diff --git a/docs/moderation/fixtures/index.html b/docs/moderation/fixtures/index.html index c5d151d5..b3d3815b 100644 --- a/docs/moderation/fixtures/index.html +++ b/docs/moderation/fixtures/index.html @@ -119,6 +119,25 @@ { name: "Omar F1", pubkey: "d".repeat(64), latest: 1_700_000_030 }, ], }, + { + key: "trusted-mute", + heading: "Trusted mute (F1 mute list)", + description: + "Card should explain that a trusted contact muted this author without enabling overrides.", + video: { + id: "4".repeat(64), + title: "Fixture: Trusted Mute", + pubkey: "aa".repeat(32), + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + }, + moderation: { + blockAutoplay: false, + blurThumbnail: false, + trustedMuted: true, + trustedMuteCount: 1, + }, + muters: [{ name: "Jess F1", pubkey: "b".repeat(64) }], + }, ]; window.__MODERATION_FIXTURE_STORAGE_KEY__ = STORAGE_KEY; @@ -199,6 +218,25 @@ base.summary = summary; } + if (fixture.moderation.trustedMuted) { + base.trustedMuted = true; + const muterPubkeys = Array.isArray(fixture.muters) + ? fixture.muters + .map((muter) => (typeof muter?.pubkey === "string" ? muter.pubkey : "")) + .filter(Boolean) + : []; + base.trustedMuters = muterPubkeys; + const muteCount = Number.isFinite(fixture.moderation.trustedMuteCount) + ? Math.max(0, Math.floor(fixture.moderation.trustedMuteCount)) + : muterPubkeys.length; + base.trustedMuteCount = muteCount; + base.trustedMuterDisplayNames = Array.isArray(fixture.muters) + ? fixture.muters + .map((muter) => (typeof muter?.name === "string" ? muter.name : "")) + .filter(Boolean) + : []; + } + if (overrideEntry?.showAnyway) { base.viewerOverride = { showAnyway: true, diff --git a/js/app.js b/js/app.js index ad4d46cf..4d4318eb 100644 --- a/js/app.js +++ b/js/app.js @@ -4550,6 +4550,18 @@ class Application { const reporterPubkeys = sanitizedReporters.map((entry) => entry.pubkey); + const rawTrustedMuters = Array.isArray(existingModeration.trustedMuters) + ? existingModeration.trustedMuters + .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : "")) + .filter(Boolean) + : []; + + const trustedMuteCount = Number.isFinite(existingModeration.trustedMuteCount) + ? Math.max(0, Math.floor(existingModeration.trustedMuteCount)) + : rawTrustedMuters.length; + + const trustedMuted = existingModeration.trustedMuted === true || trustedMuteCount > 0; + const reporterDisplayNames = []; const seenNames = new Set(); for (const reporterPubkey of reporterPubkeys) { @@ -4569,6 +4581,27 @@ class Application { reporterDisplayNames.push(normalizedName); } + const trustedMuterDisplayNames = []; + if (trustedMuted) { + const seenMuteNames = new Set(); + for (const muterPubkey of rawTrustedMuters) { + const name = this.getReporterDisplayName(muterPubkey); + if (!name) { + continue; + } + const normalizedName = name.trim(); + if (!normalizedName) { + continue; + } + const key = normalizedName.toLowerCase(); + if (seenMuteNames.has(key)) { + continue; + } + seenMuteNames.add(key); + trustedMuterDisplayNames.push(normalizedName); + } + } + const trustedCount = Number.isFinite(existingModeration.trustedCount) ? Math.max(0, Math.floor(existingModeration.trustedCount)) : this.deriveModerationTrustedCount(summary, reportType); @@ -4597,6 +4630,10 @@ class Application { trustedReporters: sanitizedReporters, reporterPubkeys, reporterDisplayNames, + trustedMuted, + trustedMuters: rawTrustedMuters, + trustedMuteCount, + trustedMuterDisplayNames, original: { blockAutoplay: originalState.blockAutoplay, blurThumbnail: originalState.blurThumbnail, diff --git a/js/channelProfile.js b/js/channelProfile.js index 7e8fd071..50a70863 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -5,7 +5,7 @@ import { DEFAULT_RELAY_URLS, convertEventToVideo as sharedConvertEventToVideo } from "./nostr.js"; -import { subscriptions } from "./subscriptions.js"; // <-- NEW import +import { subscriptions } from "./subscriptions.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; @@ -16,6 +16,7 @@ import { VideoCard } from "./ui/components/VideoCard.js"; import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; +import moderationService from "./services/moderationService.js"; import { calculateZapShares, describeShareType, @@ -1663,12 +1664,31 @@ async function updateChannelMenuState() { userLogger.warn("Failed to refresh moderation lists for channel menu:", error); } + try { + await moderationService.ensureViewerMuteListLoaded(); + } catch (error) { + userLogger.warn("Failed to refresh viewer mute list for channel menu:", error); + } + const canBlacklist = typeof app?.canCurrentUserManageBlacklist === "function" ? app.canCurrentUserManageBlacklist() : false; const buttons = menu.querySelectorAll("button[data-action]"); + const toggleButtonVisibility = (button, shouldShow) => { + if (!button) { + return; + } + if (shouldShow) { + button.removeAttribute("hidden"); + button.setAttribute("aria-hidden", "false"); + } else { + button.setAttribute("hidden", ""); + button.setAttribute("aria-hidden", "true"); + } + }; + buttons.forEach((button) => { if (!button || button.nodeType !== 1) { return; @@ -1701,6 +1721,27 @@ async function updateChannelMenuState() { return; } + if (action === "mute-author" || action === "unmute-author") { + if (currentChannelHex) { + button.dataset.author = currentChannelHex; + } else { + delete button.dataset.author; + } + + let isMuted = false; + if (currentChannelHex) { + try { + isMuted = moderationService.isAuthorMutedByViewer(currentChannelHex) === true; + } catch (error) { + userLogger.warn("Failed to resolve viewer mute state for channel menu:", error); + } + } + + const shouldShow = action === "mute-author" ? !isMuted : isMuted; + toggleButtonVisibility(button, shouldShow && !!currentChannelHex); + return; + } + if (action === "blacklist-author") { if (canBlacklist && currentChannelHex) { button.dataset.author = currentChannelHex; diff --git a/js/feedEngine/sorters.js b/js/feedEngine/sorters.js index 2deb04fb..17418d66 100644 --- a/js/feedEngine/sorters.js +++ b/js/feedEngine/sorters.js @@ -10,6 +10,17 @@ export function createChronologicalSorter({ direction = "desc" } = {}) { const copy = [...items]; copy.sort((a, b) => { + const aMuted = + a?.metadata?.moderation?.trustedMuted === true || + a?.video?.moderation?.trustedMuted === true; + const bMuted = + b?.metadata?.moderation?.trustedMuted === true || + b?.video?.moderation?.trustedMuted === true; + + if (aMuted !== bMuted) { + return aMuted ? 1 : -1; + } + const aTs = Number(a?.video?.created_at); const bTs = Number(b?.video?.created_at); const normalizedATs = Number.isFinite(aTs) ? aTs : Number.NEGATIVE_INFINITY; diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 901b5212..15ad2e2b 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -384,6 +384,9 @@ export function createModerationStage({ let summary = null; let trustedCount = 0; let trustedReporters = []; + let trustedMuted = false; + let trustedMuters = []; + let trustedMuteCount = 0; if (videoId) { try { if (typeof resolvedService.getTrustedReportSummary === "function") { @@ -423,6 +426,48 @@ export function createModerationStage({ } } + if (authorHex) { + try { + if (typeof resolvedService.isAuthorMutedByTrusted === "function") { + trustedMuted = resolvedService.isAuthorMutedByTrusted(authorHex) === true; + } + if (trustedMuted && typeof resolvedService.getTrustedMutersForAuthor === "function") { + const muters = resolvedService.getTrustedMutersForAuthor(authorHex); + if (Array.isArray(muters)) { + const seen = new Set(); + trustedMuters = muters + .map((value) => (typeof value === "string" ? value.trim().toLowerCase() : "")) + .filter((value) => { + if (!value || seen.has(value)) { + return false; + } + seen.add(value); + return true; + }); + } + } + } catch (error) { + context?.log?.(`[${stageName}] Failed to resolve trusted mute info`, error); + trustedMuted = false; + trustedMuters = []; + } + } + + if (trustedMuted) { + trustedMuteCount = Number.isFinite(video?.moderation?.trustedMuteCount) + ? Math.max(0, Math.floor(video.moderation.trustedMuteCount)) + : trustedMuters.length; + if (!trustedMuteCount) { + trustedMuteCount = trustedMuters.length; + } + if (trustedMuteCount <= 0) { + trustedMuteCount = Math.max(1, trustedMuters.length || 1); + } + } else { + trustedMuteCount = 0; + trustedMuters = []; + } + let blockAutoplay = trustedCount >= normalizedAutoplayThreshold; let blurThumbnail = trustedCount >= normalizedBlurThreshold; const adminWhitelistBypass = isDiscoveryFeed && adminStatus?.whitelisted === true; @@ -450,6 +495,9 @@ export function createModerationStage({ : []; metadataModeration.adminWhitelist = adminStatus?.whitelisted === true; metadataModeration.adminWhitelistBypass = adminWhitelistBypass; + metadataModeration.trustedMuted = trustedMuted; + metadataModeration.trustedMuters = trustedMuters.slice(); + metadataModeration.trustedMuteCount = trustedMuteCount; item.metadata.moderation = metadataModeration; if (!video.moderation || typeof video.moderation !== "object") { @@ -462,6 +510,18 @@ export function createModerationStage({ video.moderation.reportType = normalizedReportType; video.moderation.adminWhitelist = adminStatus?.whitelisted === true; video.moderation.adminWhitelistBypass = adminWhitelistBypass; + video.moderation.trustedMuted = trustedMuted; + if (trustedMuted) { + video.moderation.trustedMuters = trustedMuters.slice(); + video.moderation.trustedMuteCount = trustedMuteCount; + } else { + if (video.moderation.trustedMuters) { + delete video.moderation.trustedMuters; + } + if (video.moderation.trustedMuteCount) { + delete video.moderation.trustedMuteCount; + } + } if (Array.isArray(trustedReporters) && trustedReporters.length) { video.moderation.trustedReporters = trustedReporters.slice(); } else if (video.moderation.trustedReporters) { @@ -484,6 +544,17 @@ export function createModerationStage({ }); } + if (trustedMuted) { + context?.addWhy?.({ + stage: stageName, + type: "moderation", + reason: "trusted-mute", + videoId: videoId || null, + pubkey: authorHex || authorPubkey || null, + trustedMuteCount, + }); + } + results.push(item); } diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 6de60801..e78d7ce6 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -319,6 +319,11 @@ export class ModerationService { this.userBlocks = userBlockManager; this.accessControl = accessControlService; + this.viewerMuteList = new Set(); + this.viewerMuteEventId = ""; + this.viewerMuteUpdatedAt = 0; + this.viewerMutePromise = null; + this.contactSubscription = null; this.contactListPromise = null; @@ -327,6 +332,10 @@ export class ModerationService { this.activeSubscriptions = new Map(); this.activeEventIds = new Set(); + this.trustedMuteLists = new Map(); + this.trustedMutedAuthors = new Map(); + this.trustedMuteSubscriptions = new Map(); + this.emitter = new SimpleEventEmitter((message, error) => { try { this.log(message, error); @@ -366,6 +375,74 @@ export class ModerationService { this.emitter.emit(eventName, detail); } + clearTrustedMuteTracking() { + for (const [pubkey, entry] of this.trustedMuteSubscriptions.entries()) { + if (entry && typeof entry.unsub === "function") { + try { + entry.unsub(); + } catch (error) { + this.log(`(moderationService) failed to teardown mute subscription for ${pubkey}`, error); + } + } + } + + this.trustedMuteSubscriptions.clear(); + + for (const [author, aggregate] of this.trustedMutedAuthors.entries()) { + if (aggregate && aggregate.muters instanceof Set) { + aggregate.muters.clear(); + } + } + + this.trustedMuteLists.clear(); + this.trustedMutedAuthors.clear(); + this.viewerMuteList.clear(); + this.viewerMuteEventId = ""; + this.viewerMuteUpdatedAt = 0; + + this.emit("trusted-mutes", { total: 0 }); + } + + isTrustedMuteOwner(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return false; + } + if (normalized === this.viewerPubkey) { + return true; + } + return this.trustedContacts.has(normalized); + } + + reconcileTrustedMuteSubscriptions(previousSet, nextSet) { + const previous = previousSet instanceof Set ? new Set(previousSet) : new Set(); + const next = nextSet instanceof Set ? new Set(nextSet) : new Set(); + + if (this.viewerPubkey) { + if (this.trustedMuteSubscriptions.has(this.viewerPubkey)) { + previous.add(this.viewerPubkey); + } + next.add(this.viewerPubkey); + } + + for (const value of previous) { + if (!next.has(value)) { + this.teardownTrustedMuteSubscription(value); + } + } + + for (const value of next) { + if (!previous.has(value)) { + this.subscribeToTrustedMuteList(value).catch((error) => { + this.log( + `(moderationService) failed to subscribe to trusted mute list for ${value}`, + error, + ); + }); + } + } + } + async ensureUserBlocksLoaded(pubkey) { if (!this.userBlocks || typeof this.userBlocks.ensureLoaded !== "function") { return; @@ -383,6 +460,465 @@ export class ModerationService { } } + async subscribeToTrustedMuteList(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized || !this.isTrustedMuteOwner(normalized)) { + return; + } + + let record = this.trustedMuteSubscriptions.get(normalized); + if (!record) { + record = { unsub: null, promise: null }; + this.trustedMuteSubscriptions.set(normalized, record); + } else if (record.promise) { + try { + await record.promise; + } catch { + /* noop */ + } + return; + } else if (typeof record.unsub === "function") { + return; + } + + record.promise = (async () => { + try { + await this.ensurePool(); + } catch (error) { + this.log( + `(moderationService) ensurePool failed while subscribing to trusted mute list for ${normalized}`, + error, + ); + return; + } + + if (!this.isTrustedMuteOwner(normalized)) { + return; + } + + const relays = resolveRelayList(this.nostrClient); + if (!relays.length) { + return; + } + + const filter = { kinds: [10000], authors: [normalized], limit: 1 }; + + let events = []; + try { + events = await this.nostrClient.pool.list(relays, [filter]); + } catch (error) { + this.log( + `(moderationService) failed to backfill trusted mute list for ${normalized}`, + error, + ); + events = []; + } + + if (Array.isArray(events) && events.length) { + let latest = null; + for (const event of events) { + if (!event || ensureNumber(event.created_at) <= 0) { + continue; + } + if (!latest || ensureNumber(event.created_at) > ensureNumber(latest.created_at)) { + latest = event; + } + } + if (latest) { + this.ingestTrustedMuteEvent(latest); + } + } else { + this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" }); + } + + if (!this.isTrustedMuteOwner(normalized)) { + return; + } + + try { + const sub = this.nostrClient.pool.sub(relays, [filter]); + sub.on("event", (event) => { + this.ingestTrustedMuteEvent(event); + }); + sub.on("eose", () => {}); + record.unsub = typeof sub.unsub === "function" ? () => sub.unsub() : null; + } catch (error) { + this.log( + `(moderationService) failed to subscribe to trusted mute list for ${normalized}`, + error, + ); + } + })(); + + try { + await record.promise; + } catch (error) { + this.log("[moderationService] trusted mute subscription promise rejected", error); + } finally { + record.promise = null; + } + } + + teardownTrustedMuteSubscription(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return; + } + + const record = this.trustedMuteSubscriptions.get(normalized); + if (record && typeof record.unsub === "function") { + try { + record.unsub(); + } catch (error) { + this.log("[moderationService] failed to teardown trusted mute subscription", error); + } + } + this.trustedMuteSubscriptions.delete(normalized); + this.replaceTrustedMuteList(normalized, new Set(), { createdAt: 0, eventId: "" }); + } + + replaceTrustedMuteList(ownerPubkey, mutedAuthors, { createdAt = 0, eventId = "" } = {}) { + const owner = normalizeHex(ownerPubkey); + if (!owner) { + return; + } + + const sanitizedAuthors = new Set(); + if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) { + for (const candidate of mutedAuthors) { + const normalized = normalizeToHex(candidate); + if (!normalized) { + continue; + } + sanitizedAuthors.add(normalized); + } + } else if (mutedAuthors && typeof mutedAuthors[Symbol.iterator] === "function") { + for (const candidate of mutedAuthors) { + const normalized = normalizeToHex(candidate); + if (!normalized) { + continue; + } + sanitizedAuthors.add(normalized); + } + } + + const previous = this.trustedMuteLists.get(owner); + if (previous && previous.authors instanceof Set) { + for (const author of previous.authors) { + const aggregate = this.trustedMutedAuthors.get(author); + if (!aggregate || !(aggregate.muters instanceof Set)) { + continue; + } + aggregate.muters.delete(owner); + if (!aggregate.muters.size) { + this.trustedMutedAuthors.delete(author); + } else { + aggregate.count = aggregate.muters.size; + } + } + } + + const normalizedEventId = normalizeEventId(eventId); + const normalizedCreatedAt = ensureNumber(createdAt); + + if (owner === this.viewerPubkey) { + this.viewerMuteList = new Set(sanitizedAuthors); + this.viewerMuteEventId = normalizedEventId || ""; + this.viewerMuteUpdatedAt = normalizedCreatedAt; + } + + if (!sanitizedAuthors.size) { + this.trustedMuteLists.delete(owner); + } else { + this.trustedMuteLists.set(owner, { + authors: sanitizedAuthors, + updatedAt: normalizedCreatedAt, + eventId: normalizedEventId, + }); + + for (const author of sanitizedAuthors) { + let aggregate = this.trustedMutedAuthors.get(author); + if (!aggregate) { + aggregate = { muters: new Set(), count: 0 }; + this.trustedMutedAuthors.set(author, aggregate); + } + aggregate.muters.add(owner); + aggregate.count = aggregate.muters.size; + } + } + + this.emit("trusted-mutes", { total: this.trustedMutedAuthors.size, owner }); + } + + applyTrustedMuteEvent(ownerPubkey, event) { + const owner = normalizeHex(ownerPubkey); + if (!owner || !this.isTrustedMuteOwner(owner)) { + return; + } + + if (!event || event.kind !== 10000) { + this.replaceTrustedMuteList(owner, new Set(), { createdAt: 0, eventId: "" }); + return; + } + + const createdAt = ensureNumber(event.created_at); + const eventId = typeof event.id === "string" ? event.id : ""; + + const existing = this.trustedMuteLists.get(owner); + if (existing) { + const existingEventId = typeof existing.eventId === "string" ? existing.eventId : ""; + if (existingEventId && normalizeEventId(existingEventId) === normalizeEventId(eventId)) { + return; + } + if (ensureNumber(existing.updatedAt) > createdAt && normalizeEventId(eventId)) { + return; + } + } + + const mutedAuthors = new Set(); + if (Array.isArray(event.tags)) { + for (const tag of event.tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] !== "p") { + continue; + } + const normalized = normalizeToHex(tag[1]); + if (normalized) { + mutedAuthors.add(normalized); + } + } + } + + this.replaceTrustedMuteList(owner, mutedAuthors, { createdAt, eventId }); + } + + ingestTrustedMuteEvent(event) { + if (!event || event.kind !== 10000) { + return; + } + + const owner = normalizeHex(event.pubkey); + if (!owner || !this.isTrustedMuteOwner(owner)) { + return; + } + + this.applyTrustedMuteEvent(owner, event); + } + + getViewerMutedAuthors() { + return Array.from(this.viewerMuteList); + } + + isAuthorMutedByViewer(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return false; + } + return this.viewerMuteList.has(normalized); + } + + async ensureViewerMuteListLoaded(pubkey = this.viewerPubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + this.viewerMuteList.clear(); + this.viewerMuteEventId = ""; + this.viewerMuteUpdatedAt = 0; + return; + } + + if (this.trustedMuteLists.has(normalized)) { + return; + } + + if (this.viewerMutePromise) { + try { + await this.viewerMutePromise; + } catch (_) { + /* noop */ + } + return; + } + + this.viewerMutePromise = this.subscribeToTrustedMuteList(normalized) + .catch((error) => { + this.log("[moderationService] failed to load viewer mute list", error); + throw error; + }) + .finally(() => { + this.viewerMutePromise = null; + }); + + try { + await this.viewerMutePromise; + } catch (_) { + /* noop */ + } + } + + async publishViewerMuteList({ owner, muted }) { + const viewer = normalizeHex(owner || this.viewerPubkey); + if (!viewer) { + const error = new Error("viewer-not-logged-in"); + error.code = "viewer-not-logged-in"; + throw error; + } + + await this.ensurePool(); + + const extension = typeof window !== "undefined" ? window.nostr : null; + if (!extension || typeof extension.signEvent !== "function") { + const error = new Error("nostr-extension-missing"); + error.code = "nostr-extension-missing"; + throw error; + } + + if (typeof this.nostrClient?.ensureExtensionPermissions === "function") { + const permissionResult = await this.nostrClient.ensureExtensionPermissions([ + "sign_event", + "get_public_key", + ]); + if (!permissionResult?.ok) { + const error = new Error("extension-permission-denied"); + error.code = "extension-permission-denied"; + error.details = permissionResult?.error || null; + throw error; + } + } + + const tags = []; + if (muted instanceof Set || Array.isArray(muted)) { + for (const value of muted) { + const normalized = normalizeHex(value); + if (!normalized || normalized === viewer) { + continue; + } + tags.push(["p", normalized]); + } + } + + const event = { + kind: 10000, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + pubkey: viewer, + }; + + let signedEvent; + try { + signedEvent = await extension.signEvent(event); + } catch (error) { + const wrapped = new Error("signature-failed"); + wrapped.code = "signature-failed"; + wrapped.details = error; + throw wrapped; + } + + const relays = resolveRelayList(this.nostrClient, { write: true }); + if (!relays.length) { + const error = new Error("no-relays-configured"); + error.code = "no-relays-configured"; + throw error; + } + + let results = []; + try { + results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent); + assertAnyRelayAccepted(results, { context: "mute list" }); + } catch (error) { + const wrapped = new Error("mute-list-publish-failed"); + wrapped.code = "mute-list-publish-failed"; + wrapped.details = error; + throw wrapped; + } + + this.applyTrustedMuteEvent(viewer, signedEvent); + return { ok: true, event: signedEvent, results }; + } + + async addAuthorToViewerMuteList(pubkey) { + const viewer = normalizeHex(this.viewerPubkey); + if (!viewer) { + const error = new Error("viewer-not-logged-in"); + error.code = "viewer-not-logged-in"; + throw error; + } + + const target = normalizeHex(pubkey); + if (!target) { + const error = new Error("invalid-target"); + error.code = "invalid-target"; + throw error; + } + + if (target === viewer) { + const error = new Error("self"); + error.code = "self"; + throw error; + } + + await this.ensureViewerMuteListLoaded(viewer); + + if (this.viewerMuteList.has(target)) { + return { ok: true, already: true }; + } + + const next = new Set(this.viewerMuteList); + next.add(target); + + return this.publishViewerMuteList({ owner: viewer, muted: next }); + } + + async removeAuthorFromViewerMuteList(pubkey) { + const viewer = normalizeHex(this.viewerPubkey); + if (!viewer) { + const error = new Error("viewer-not-logged-in"); + error.code = "viewer-not-logged-in"; + throw error; + } + + const target = normalizeHex(pubkey); + if (!target) { + return { ok: true, already: true }; + } + + await this.ensureViewerMuteListLoaded(viewer); + + if (!this.viewerMuteList.has(target)) { + return { ok: true, already: true }; + } + + const next = new Set(this.viewerMuteList); + next.delete(target); + + return this.publishViewerMuteList({ owner: viewer, muted: next }); + } + + isAuthorMutedByTrusted(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return false; + } + const entry = this.trustedMutedAuthors.get(normalized); + return Boolean(entry && entry.muters instanceof Set && entry.muters.size > 0); + } + + getTrustedMutersForAuthor(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return []; + } + + const entry = this.trustedMutedAuthors.get(normalized); + if (!entry || !(entry.muters instanceof Set)) { + return []; + } + + return Array.from(entry.muters); + } + isPubkeyBlockedByViewer(pubkey) { if (!this.userBlocks || typeof this.userBlocks.isBlocked !== "function") { return false; @@ -562,6 +1098,7 @@ export class ModerationService { } this.viewerPubkey = normalized; + this.clearTrustedMuteTracking(); this.trustedContacts.clear(); this.recomputeAllSummaries(); @@ -599,12 +1136,16 @@ export class ModerationService { }); await this.ensureUserBlocksLoaded(normalized); + await this.ensureViewerMuteListLoaded(normalized); return this.viewerPubkey; } async fetchTrustedContacts(pubkey) { const normalized = normalizeHex(pubkey); + const previousContacts = + this.trustedContacts instanceof Set ? new Set(this.trustedContacts) : new Set(); if (!normalized) { + this.clearTrustedMuteTracking(); this.trustedContacts.clear(); this.emit("contacts", { size: 0 }); return; @@ -619,6 +1160,7 @@ export class ModerationService { const relays = resolveRelayList(this.nostrClient); if (!relays.length) { + this.clearTrustedMuteTracking(); this.trustedContacts.clear(); this.emit("contacts", { size: 0 }); return; @@ -635,9 +1177,11 @@ export class ModerationService { } if (!Array.isArray(events) || !events.length) { + this.clearTrustedMuteTracking(); this.trustedContacts.clear(); this.emit("contacts", { size: 0 }); this.recomputeAllSummaries(); + this.reconcileTrustedMuteSubscriptions(previousContacts, new Set()); return; } @@ -651,14 +1195,17 @@ export class ModerationService { } } - this.applyContactEvent(latest); + this.applyContactEvent(latest, { previous: previousContacts }); } - applyContactEvent(event) { + applyContactEvent(event, { previous = null } = {}) { + const previousContacts = previous instanceof Set ? previous : new Set(this.trustedContacts); if (!event || !Array.isArray(event.tags)) { + this.clearTrustedMuteTracking(); this.trustedContacts.clear(); this.emit("contacts", { size: 0 }); this.recomputeAllSummaries(); + this.reconcileTrustedMuteSubscriptions(previousContacts, new Set()); return; } @@ -676,6 +1223,7 @@ export class ModerationService { this.trustedContacts = nextSet; this.emit("contacts", { size: nextSet.size }); this.recomputeAllSummaries(); + this.reconcileTrustedMuteSubscriptions(previousContacts, nextSet); } async subscribeToContactList(pubkey) { diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 46cc5c3a..8fa45d1c 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -952,6 +952,24 @@ export class VideoCard { .filter(Boolean) : []; + const trustedMuted = moderation?.trustedMuted === true; + let trustedMuteCount = Number.isFinite(moderation?.trustedMuteCount) + ? Math.max(0, Math.floor(moderation.trustedMuteCount)) + : 0; + + if (!trustedMuteCount && Array.isArray(moderation?.trustedMuters)) { + const muters = moderation.trustedMuters + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean); + trustedMuteCount = muters.length; + } + + const trustedMuteDisplayNames = Array.isArray(moderation?.trustedMuterDisplayNames) + ? moderation.trustedMuterDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + const original = moderation?.original && typeof moderation.original === "object" ? moderation.original @@ -962,6 +980,9 @@ export class VideoCard { friendlyType: reportType ? reportType.replace(/[_-]+/g, " ").trim() : "", trustedCount, reporterDisplayNames, + trustedMuted, + trustedMuteCount, + trustedMuteDisplayNames, originalBlur: original.blurThumbnail === true, originalBlockAutoplay: original.blockAutoplay === true, activeBlur: moderation?.blurThumbnail === true, @@ -973,8 +994,11 @@ export class VideoCard { context.originalBlur || context.originalBlockAutoplay || context.trustedCount > 0 || + context.trustedMuted || context.overrideActive; + context.allowOverride = context.originalBlur || context.originalBlockAutoplay; + return context; } @@ -983,15 +1007,29 @@ export class VideoCard { return ""; } - const typeLabel = context.friendlyType || "this video"; - const count = Math.max(0, Number(context.trustedCount) || 0); + const reasons = []; - if (count <= 0) { - return context.friendlyType ? `reports of ${typeLabel}` : "reports"; + if (context.trustedMuted) { + const muteCount = Math.max(1, Number(context.trustedMuteCount) || 0); + const muteLabel = muteCount === 1 ? "trusted contact" : "trusted contacts"; + reasons.push(`muted by ${muteCount === 1 ? "a" : muteCount} ${muteLabel}`); } - const friendLabel = count === 1 ? "friend" : "friends"; - return `${count} ${friendLabel} reported ${typeLabel}`; + const typeLabel = context.friendlyType || "this video"; + const reportCount = Math.max(0, Number(context.trustedCount) || 0); + if (reportCount > 0) { + const friendLabel = reportCount === 1 ? "friend" : "friends"; + reasons.push(`${reportCount} ${friendLabel} reported ${typeLabel}`); + } else if (!context.trustedMuted) { + reasons.push(context.friendlyType ? `reports of ${typeLabel}` : "reports"); + } + + if (!reasons.length) { + return ""; + } + + const combined = reasons.join(" ยท "); + return combined.charAt(0).toUpperCase() + combined.slice(1); } buildModerationBadgeText(context) { @@ -1014,7 +1052,9 @@ export class VideoCard { if (context.originalBlockAutoplay) { parts.push("Autoplay blocked"); } - if (reason) { + if (context.trustedMuted && !context.originalBlur && !context.originalBlockAutoplay) { + parts.push(reason || "Muted by trusted contacts"); + } else if (reason) { parts.push(reason); } @@ -1137,7 +1177,12 @@ export class VideoCard { }); badge.dataset.variant = context.overrideActive ? "neutral" : "warning"; badge.dataset.moderationBadge = "true"; - badge.dataset.moderationState = context.overrideActive ? "override" : "blocked"; + const state = context.overrideActive + ? "override" + : context.trustedMuted + ? "trusted-mute" + : "blocked"; + badge.dataset.moderationState = state; const badgeId = this.getModerationBadgeId(); badge.id = badgeId; @@ -1151,14 +1196,39 @@ export class VideoCard { }); badge.appendChild(text); - const names = context.reporterDisplayNames; - if (names.length) { - const joined = names.join(", "); - badge.title = `Reported by ${joined}`; - badge.setAttribute( - "aria-label", - `${text.textContent}. Reported by ${joined}.`, - ); + const muteNames = Array.isArray(context.trustedMuteDisplayNames) + ? context.trustedMuteDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + const reporterNames = Array.isArray(context.reporterDisplayNames) + ? context.reporterDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + + const allNames = [...muteNames, ...reporterNames]; + const uniqueNames = []; + const seenNameKeys = new Set(); + for (const name of allNames) { + if (typeof name !== "string" || !name.trim()) { + continue; + } + const key = name.trim().toLowerCase(); + if (seenNameKeys.has(key)) { + continue; + } + seenNameKeys.add(key); + uniqueNames.push(name.trim()); + } + + if (uniqueNames.length) { + const joined = uniqueNames.join(", "); + const hasMutedNames = muteNames.length > 0; + const hasReporterNames = reporterNames.length > 0; + const prefix = hasMutedNames && hasReporterNames ? "Muted/Reported by" : hasMutedNames ? "Muted by" : "Reported by"; + badge.title = `${prefix} ${joined}`; + badge.setAttribute("aria-label", `${text.textContent}. ${prefix} ${joined}.`); } else { badge.removeAttribute("title"); badge.setAttribute("aria-label", `${text.textContent}.`); @@ -1167,7 +1237,7 @@ export class VideoCard { this.moderationBadgeEl = badge; this.moderationBadgeTextEl = text; - if (!context.overrideActive) { + if (!context.overrideActive && context.allowOverride) { const button = this.createModerationOverrideButton(); badge.appendChild(button); this.moderationActionButton = button; @@ -1227,24 +1297,57 @@ export class VideoCard { } badge.dataset.variant = context.overrideActive ? "neutral" : "warning"; - badge.dataset.moderationState = context.overrideActive ? "override" : "blocked"; + const state = context.overrideActive + ? "override" + : context.trustedMuted + ? "trusted-mute" + : "blocked"; + badge.dataset.moderationState = state; const textContent = this.buildModerationBadgeText(context); if (this.moderationBadgeTextEl) { this.moderationBadgeTextEl.textContent = textContent; } - const names = context.reporterDisplayNames; - if (names.length) { - const joined = names.join(", "); - badge.title = `Reported by ${joined}`; - badge.setAttribute("aria-label", `${textContent}. Reported by ${joined}.`); + const muteNames = Array.isArray(context.trustedMuteDisplayNames) + ? context.trustedMuteDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + const reporterNames = Array.isArray(context.reporterDisplayNames) + ? context.reporterDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + + const allNames = [...muteNames, ...reporterNames]; + const uniqueNames = []; + const seenNameKeys = new Set(); + for (const name of allNames) { + if (typeof name !== "string" || !name.trim()) { + continue; + } + const key = name.trim().toLowerCase(); + if (seenNameKeys.has(key)) { + continue; + } + seenNameKeys.add(key); + uniqueNames.push(name.trim()); + } + + if (uniqueNames.length) { + const joined = uniqueNames.join(", "); + const hasMutedNames = muteNames.length > 0; + const hasReporterNames = reporterNames.length > 0; + const prefix = hasMutedNames && hasReporterNames ? "Muted/Reported by" : hasMutedNames ? "Muted by" : "Reported by"; + badge.title = `${prefix} ${joined}`; + badge.setAttribute("aria-label", `${textContent}. ${prefix} ${joined}.`); } else { badge.removeAttribute("title"); badge.setAttribute("aria-label", `${textContent}.`); } - if (context.overrideActive) { + if (context.overrideActive || !context.allowOverride) { if (this.moderationActionButton) { this.moderationActionButton.removeEventListener( "click", @@ -1779,6 +1882,23 @@ export class VideoCard { delete this.root.dataset.moderationReportCount; } } + + if (moderationContext.trustedMuted) { + this.root.dataset.moderationTrustedMute = "true"; + const muteCount = Math.max(0, Number(moderationContext.trustedMuteCount) || 0); + if (muteCount > 0) { + this.root.dataset.moderationTrustedMuteCount = String(muteCount); + } else if (this.root.dataset.moderationTrustedMuteCount) { + delete this.root.dataset.moderationTrustedMuteCount; + } + } else { + if (this.root.dataset.moderationTrustedMute) { + delete this.root.dataset.moderationTrustedMute; + } + if (this.root.dataset.moderationTrustedMuteCount) { + delete this.root.dataset.moderationTrustedMuteCount; + } + } } applyPlaybackDatasets() { diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index f826b193..093be888 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -257,6 +257,18 @@ export function createVideoMoreMenuPanel({ }); } + appendMenuAction(doc, list, { + text: "Mute creator", + action: "mute-author", + dataset: { author: metadata.pubkey, context }, + }); + + appendMenuAction(doc, list, { + text: "Unmute creator", + action: "unmute-author", + dataset: { author: metadata.pubkey, context }, + }); + if (canManageBlacklist) { appendMenuAction(doc, list, { text: "Blacklist creator", @@ -307,6 +319,18 @@ export function createChannelProfileMenuPanel({ dataset: { context: normalizedContext }, }); + appendMenuAction(doc, list, { + text: "Mute channel", + action: "mute-author", + dataset: { context: normalizedContext }, + }); + + appendMenuAction(doc, list, { + text: "Unmute channel", + action: "unmute-author", + dataset: { context: normalizedContext }, + }); + appendMenuAction(doc, list, { text: "Blacklist channel", action: "blacklist-author", diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 7d82566e..0ed30b2a 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -79,6 +79,7 @@ export default class MoreMenuController { this.boundVideoListBlacklistHandler = null; this.popoversByTrigger = new Map(); this.activePopover = null; + this.activePopoverEntry = null; this.reportModal = null; this.reportModalPrepared = false; @@ -582,6 +583,105 @@ export default class MoreMenuController { ); } + setButtonVisibility(button, visible) { + if (!button) { + return; + } + if (visible) { + button.removeAttribute("hidden"); + button.setAttribute("aria-hidden", "false"); + } else { + button.setAttribute("hidden", ""); + button.setAttribute("aria-hidden", "true"); + } + } + + syncMuteMenuButtons(panel, entry = {}) { + if (!panel || typeof panel.querySelector !== "function") { + return; + } + + const muteButton = panel.querySelector('button[data-action="mute-author"]'); + const unmuteButton = panel.querySelector('button[data-action="unmute-author"]'); + if (!muteButton && !unmuteButton) { + return; + } + + const contextVideo = entry?.context?.video || null; + let authorCandidate = contextVideo?.pubkey || ""; + + if (!authorCandidate) { + if (muteButton?.dataset?.author) { + authorCandidate = muteButton.dataset.author; + } else if (unmuteButton?.dataset?.author) { + authorCandidate = unmuteButton.dataset.author; + } + } + + const trimmed = typeof authorCandidate === "string" ? authorCandidate.trim() : ""; + let normalized = ""; + if (trimmed) { + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + normalized = trimmed.toLowerCase(); + } else if (trimmed.startsWith("npub1")) { + normalized = this.callbacks.safeDecodeNpub(trimmed) || ""; + } + } + + if (normalized) { + if (muteButton) { + muteButton.dataset.author = normalized; + } + if (unmuteButton) { + unmuteButton.dataset.author = normalized; + } + } + + let isMuted = false; + if (normalized) { + try { + isMuted = this.moderationService.isAuthorMutedByViewer(normalized) === true; + } catch (error) { + if (this.isDevMode) { + userLogger.warn("[MoreMenu] Failed to resolve viewer mute state", error); + } + } + } + + const hasTarget = Boolean(normalized); + this.setButtonVisibility(muteButton, hasTarget && !isMuted); + this.setButtonVisibility(unmuteButton, hasTarget && isMuted); + } + + refreshActiveMuteButtons({ author = "" } = {}) { + if (!this.activePopoverEntry || !this.activePopover) { + return; + } + const panel = + typeof this.activePopover.getPanel === "function" + ? this.activePopover.getPanel() + : null; + if (!panel) { + return; + } + + if (author) { + const trimmed = author.trim(); + if (trimmed) { + const muteButton = panel.querySelector('button[data-action="mute-author"]'); + const unmuteButton = panel.querySelector('button[data-action="unmute-author"]'); + if (muteButton) { + muteButton.dataset.author = trimmed; + } + if (unmuteButton) { + unmuteButton.dataset.author = trimmed; + } + } + } + + this.syncMuteMenuButtons(panel, this.activePopoverEntry); + } + createPopoverRender(entry) { return ({ document: documentRef, close }) => { const panel = createVideoMoreMenuPanel({ @@ -599,6 +699,8 @@ export default class MoreMenuController { return null; } + this.syncMuteMenuButtons(panel, entry); + const buttons = panel.querySelectorAll("button[data-action]"); buttons.forEach((button) => { const HTMLElementCtor = this.getHTMLElementConstructor(button?.ownerDocument); @@ -656,6 +758,12 @@ export default class MoreMenuController { const result = await originalOpen(...args); if (result) { this.activePopover = popover; + this.activePopoverEntry = entry; + const panel = + typeof popover.getPanel === "function" ? popover.getPanel() : null; + if (panel) { + this.syncMuteMenuButtons(panel, entry); + } } return result; }; @@ -670,6 +778,7 @@ export default class MoreMenuController { const result = originalClose({ restoreFocus }); if (result && this.activePopover === popover) { this.activePopover = null; + this.activePopoverEntry = null; } if (result && typeof entry?.context?.onClose === "function") { try { @@ -692,6 +801,7 @@ export default class MoreMenuController { popover.destroy = (...args) => { if (this.activePopover === popover) { this.activePopover = null; + this.activePopoverEntry = null; } originalDestroy(...args); if (trigger && this.popoversByTrigger.get(trigger) === entry) { @@ -942,6 +1052,36 @@ export default class MoreMenuController { const context = dataset.context || ""; const currentVideo = this.callbacks.getCurrentVideo(); + const resolveTargetAuthorHex = () => { + const candidates = [ + dataset.author, + dataset.npub, + context === "modal" ? currentVideo?.pubkey : "", + currentVideo?.pubkey, + ]; + + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (!trimmed) { + continue; + } + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + if (trimmed.startsWith("npub1")) { + const decoded = this.callbacks.safeDecodeNpub(trimmed); + if (decoded) { + return decoded; + } + } + } + + return ""; + }; + switch (normalized) { case "open-channel": { if (context === "modal") { @@ -1028,6 +1168,156 @@ export default class MoreMenuController { } break; } + case "mute-author": { + const viewerPubkey = this.callbacks.getCurrentUserPubkey(); + if (!viewerPubkey) { + this.callbacks.showError("Please login to manage your mute list."); + break; + } + + const targetHex = resolveTargetAuthorHex(); + if (!targetHex) { + this.callbacks.showError("Unable to determine the creator to mute."); + break; + } + + if (targetHex === viewerPubkey) { + this.callbacks.showError("You cannot mute yourself."); + break; + } + + try { + const result = await this.moderationService.addAuthorToViewerMuteList( + targetHex, + ); + if (result?.already) { + this.callbacks.showSuccess("You already muted this creator."); + } else { + this.callbacks.showSuccess( + "Creator muted. Their videos will be downranked in your feeds.", + ); + } + try { + await this.callbacks.loadVideos(); + } catch (loadError) { + if (this.isDevMode) { + userLogger.warn("[MoreMenu] Failed to reload videos after muting", loadError); + } + } + try { + await this.subscriptions?.refreshActiveFeed?.({ + reason: "viewer-mute-update", + }); + } catch (refreshError) { + if (this.isDevMode) { + userLogger.warn( + "[Subscriptions] Failed to refresh after viewer mute update:", + refreshError, + ); + } + } + this.refreshActiveMuteButtons({ author: targetHex }); + } catch (error) { + switch (error?.code) { + case "viewer-not-logged-in": + this.callbacks.showError("Please login to manage your mute list."); + break; + case "invalid-target": + this.callbacks.showError("Unable to determine the creator to mute."); + break; + case "self": + this.callbacks.showError("You cannot mute yourself."); + break; + case "nostr-extension-missing": + this.callbacks.showError( + "A Nostr extension is required to update your mute list.", + ); + break; + case "extension-permission-denied": + this.callbacks.showError( + "Allow your Nostr extension to sign events before muting creators.", + ); + break; + default: + this.callbacks.showError( + "Failed to update your mute list. Please try again.", + ); + break; + } + } + break; + } + case "unmute-author": { + const viewerPubkey = this.callbacks.getCurrentUserPubkey(); + if (!viewerPubkey) { + this.callbacks.showError("Please login to manage your mute list."); + break; + } + + const targetHex = resolveTargetAuthorHex(); + if (!targetHex) { + this.callbacks.showError("Unable to determine the creator to unmute."); + break; + } + + try { + const result = await this.moderationService.removeAuthorFromViewerMuteList( + targetHex, + ); + if (result?.already) { + this.callbacks.showSuccess("This creator is not on your mute list."); + } else { + this.callbacks.showSuccess( + "Creator removed from your mute list.", + ); + } + try { + await this.callbacks.loadVideos(); + } catch (loadError) { + if (this.isDevMode) { + userLogger.warn( + "[MoreMenu] Failed to reload videos after unmuting", + loadError, + ); + } + } + try { + await this.subscriptions?.refreshActiveFeed?.({ + reason: "viewer-mute-update", + }); + } catch (refreshError) { + if (this.isDevMode) { + userLogger.warn( + "[Subscriptions] Failed to refresh after viewer mute removal:", + refreshError, + ); + } + } + this.refreshActiveMuteButtons({ author: targetHex }); + } catch (error) { + switch (error?.code) { + case "viewer-not-logged-in": + this.callbacks.showError("Please login to manage your mute list."); + break; + case "nostr-extension-missing": + this.callbacks.showError( + "A Nostr extension is required to update your mute list.", + ); + break; + case "extension-permission-denied": + this.callbacks.showError( + "Allow your Nostr extension to sign events before updating your mute list.", + ); + break; + default: + this.callbacks.showError( + "Failed to update your mute list. Please try again.", + ); + break; + } + } + break; + } case "blacklist-author": { const actorNpub = this.callbacks.getCurrentUserNpub(); if (!actorNpub) { diff --git a/package-lock.json b/package-lock.json index 203386f9..fece4974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "webtorrent": "^1.9.7" }, "devDependencies": { - "@playwright/test": "^1.56.0", + "@playwright/test": "^1.56.1", "@tailwindcss/typography": "^0.5.15", "autoprefixer": "^10.4.19", "cross-env": "^7.0.3", @@ -910,13 +910,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", - "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.0" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -4103,13 +4103,13 @@ } }, "node_modules/playwright": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", - "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.0" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -4122,9 +4122,9 @@ } }, "node_modules/playwright-core": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", - "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index f745fbb8..b9c77996 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:unit": "node scripts/run-unit-tests.mjs" }, "devDependencies": { - "@playwright/test": "^1.56.0", + "@playwright/test": "^1.56.1", "@tailwindcss/typography": "^0.5.15", "autoprefixer": "^10.4.19", "cross-env": "^7.0.3", diff --git a/tests/feed-engine.test.mjs b/tests/feed-engine.test.mjs index a1791719..f69efe1a 100644 --- a/tests/feed-engine.test.mjs +++ b/tests/feed-engine.test.mjs @@ -214,9 +214,35 @@ async function testBlacklistOrderingWithRuntimeChanges() { ]); } +async function testTrustedMuteDownrank() { + const sorter = createChronologicalSorter(); + + const items = [ + { + video: { id: "muted", created_at: 400 }, + metadata: { moderation: { trustedMuted: true } }, + }, + { + video: { id: "fresh", created_at: 500 }, + metadata: { moderation: { trustedMuted: false } }, + }, + { + video: { id: "older", created_at: 300 }, + metadata: {}, + }, + ]; + + const sorted = sorter(items); + assert.deepEqual( + sorted.map((entry) => entry.video.id), + ["fresh", "older", "muted"], + ); +} + await testDedupeOrdering(); await testBlacklistFiltering(); await testWatchHistoryHookIsolation(); await testBlacklistOrderingWithRuntimeChanges(); +await testTrustedMuteDownrank(); console.log("All feed engine tests passed"); diff --git a/tests/moderation-service.test.mjs b/tests/moderation-service.test.mjs index 9ba1ea49..229d7357 100644 --- a/tests/moderation-service.test.mjs +++ b/tests/moderation-service.test.mjs @@ -100,3 +100,128 @@ test("trusted report summaries respect personal blocks and admin lists", async ( ); assert.equal(service.trustedReportCount(eventId, "nudity"), 2); }); + +test("trusted mute aggregation tracks F1 mute lists", () => { + const service = new ModerationService({ logger: () => {} }); + + const contactA = "a".repeat(64); + const contactB = "b".repeat(64); + const mutedAuthor = "c".repeat(64); + + service.trustedContacts = new Set([contactA, contactB]); + + service.ingestTrustedMuteEvent({ + kind: 10000, + pubkey: contactA, + created_at: 100, + id: "1".repeat(64), + tags: [["p", mutedAuthor]], + }); + + assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), true); + assert.deepEqual(service.getTrustedMutersForAuthor(mutedAuthor), [contactA]); + + service.ingestTrustedMuteEvent({ + kind: 10000, + pubkey: contactB, + created_at: 105, + id: "2".repeat(64), + tags: [["p", mutedAuthor]], + }); + + const muters = new Set(service.getTrustedMutersForAuthor(mutedAuthor)); + assert.equal(muters.size, 2); + assert(muters.has(contactA)); + assert(muters.has(contactB)); + + service.applyTrustedMuteEvent(contactA, { + kind: 10000, + pubkey: contactA, + created_at: 200, + id: "3".repeat(64), + tags: [], + }); + + const remaining = service.getTrustedMutersForAuthor(mutedAuthor); + assert.deepEqual(remaining, [contactB]); + assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), true); + + service.applyTrustedMuteEvent(contactB, { + kind: 10000, + pubkey: contactB, + created_at: 210, + id: "4".repeat(64), + tags: [], + }); + + assert.equal(service.isAuthorMutedByTrusted(mutedAuthor), false); + assert.deepEqual(service.getTrustedMutersForAuthor(mutedAuthor), []); +}); + +test("viewer mute list publishes and updates aggregation", async (t) => { + const publishCalls = []; + const nostrClient = { + pool: { + list: async () => [], + sub: () => ({ + on: () => {}, + unsub: () => {}, + }), + publish: (urls, event) => { + publishCalls.push({ urls, event }); + return { + on: (eventName, handler) => { + if (eventName === "ok") { + handler(); + } + }, + }; + }, + }, + relays: ["wss://relay.example"], + ensurePool: async () => {}, + ensureExtensionPermissions: async () => ({ ok: true }), + }; + + const service = new ModerationService({ + logger: () => {}, + nostrClient, + }); + + const previousNostr = globalThis.window.nostr; + globalThis.window.nostr = { + async signEvent(event) { + return { ...event, id: "signed-event-id" }; + }, + }; + + t.after(() => { + globalThis.window.nostr = previousNostr; + }); + + const viewerHex = "f".repeat(64); + await service.setViewerPubkey(viewerHex); + + const targetHex = "e".repeat(64); + + await service.addAuthorToViewerMuteList(targetHex); + + assert.equal(service.isAuthorMutedByViewer(targetHex), true); + assert.equal(service.isAuthorMutedByTrusted(targetHex), true); + assert.deepEqual(service.getTrustedMutersForAuthor(targetHex), [viewerHex]); + + assert.equal(publishCalls.length, 1); + const firstEvent = publishCalls[0].event; + assert.equal(firstEvent.tags.length, 1); + assert.deepEqual(firstEvent.tags[0], ["p", targetHex]); + + await service.removeAuthorFromViewerMuteList(targetHex); + + assert.equal(service.isAuthorMutedByViewer(targetHex), false); + assert.equal(service.isAuthorMutedByTrusted(targetHex), false); + assert.deepEqual(service.getTrustedMutersForAuthor(targetHex), []); + + assert.equal(publishCalls.length, 2); + const secondEvent = publishCalls[1].event; + assert.equal(secondEvent.tags.length, 0); +}); diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs index 304745f5..7e331779 100644 --- a/tests/moderation-stage.test.mjs +++ b/tests/moderation-stage.test.mjs @@ -103,3 +103,66 @@ test("moderation stage enforces admin lists and whitelist bypass", async () => { assert(reasons.includes("admin-blacklist")); assert(reasons.includes("blur")); }); + +test("moderation stage annotates trusted mute metadata", async () => { + const mutedHex = "9".repeat(64); + const muterHex = "8".repeat(64); + + const service = { + async refreshViewerFromClient() {}, + async setActiveEventIds() {}, + getAdminListSnapshot() { + return { whitelist: new Set(), whitelistHex: new Set(), blacklist: new Set(), blacklistHex: new Set() }; + }, + getAccessControlStatus(identifier) { + return { hex: identifier, whitelisted: false, blacklisted: false }; + }, + getTrustedReportSummary() { + return null; + }, + trustedReportCount() { + return 0; + }, + getTrustedReporters() { + return []; + }, + isAuthorMutedByTrusted(pubkey) { + return pubkey === mutedHex; + }, + getTrustedMutersForAuthor(pubkey) { + return pubkey === mutedHex ? [muterHex] : []; + }, + }; + + const stage = createModerationStage({ service }); + + const items = [ + { video: { id: "muted", pubkey: mutedHex }, metadata: {} }, + { video: { id: "normal", pubkey: "7".repeat(64) }, metadata: {} }, + ]; + + const reasons = []; + const context = { + addWhy(detail) { + reasons.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 2); + + const mutedItem = result.find((entry) => entry.video.id === "muted"); + assert.ok(mutedItem); + assert.equal(mutedItem.video.moderation.trustedMuted, true); + assert.deepEqual(mutedItem.video.moderation.trustedMuters, [muterHex]); + assert.equal(mutedItem.video.moderation.trustedMuteCount, 1); + assert.equal(mutedItem.metadata.moderation.trustedMuted, true); + assert.equal(mutedItem.metadata.moderation.trustedMuteCount, 1); + + const trustedMuteReason = reasons.find((entry) => entry.reason === "trusted-mute"); + assert.ok(trustedMuteReason); + assert.equal(trustedMuteReason.videoId, "muted"); +}); diff --git a/tests/more-menu-controller.test.mjs b/tests/more-menu-controller.test.mjs index 45564253..3aa68618 100644 --- a/tests/more-menu-controller.test.mjs +++ b/tests/more-menu-controller.test.mjs @@ -159,3 +159,103 @@ test('block-author updates user blocks, reloads videos, and refreshes feeds', as ['refreshActiveFeed', { reason: 'user-block-update' }], ]); }); + +test('mute-author updates viewer mute list and refreshes feeds', async () => { + const events = []; + const moderationStub = { + addAuthorToViewerMuteList: async (target) => { + events.push(['addMute', target]); + return { ok: true }; + }, + removeAuthorFromViewerMuteList: async () => { + throw new Error('unreachable'); + }, + isAuthorMutedByViewer: () => false, + }; + + const subscriptions = { + refreshActiveFeed: async (args) => { + events.push(['refreshActiveFeed', args]); + }, + }; + + const controller = new MoreMenuController({ + moderationService: moderationStub, + subscriptions, + callbacks: { + getCurrentUserPubkey: () => 'viewerhex', + getCurrentVideo: () => ({ pubkey: 'fallbackhex' }), + loadVideos: async () => { + events.push(['loadVideos']); + }, + showSuccess: (message) => { + events.push(['success', message]); + }, + showError: (message) => { + events.push(['error', message]); + }, + safeDecodeNpub: () => '', + }, + }); + + const targetHex = 'd'.repeat(64); + + await controller.handleMoreMenuAction('mute-author', { author: targetHex }); + + assert.deepEqual(events, [ + ['addMute', targetHex], + ['success', 'Creator muted. Their videos will be downranked in your feeds.'], + ['loadVideos'], + ['refreshActiveFeed', { reason: 'viewer-mute-update' }], + ]); +}); + +test('unmute-author removes creators from viewer mute list', async () => { + const events = []; + const moderationStub = { + addAuthorToViewerMuteList: async () => { + throw new Error('unreachable'); + }, + removeAuthorFromViewerMuteList: async (target) => { + events.push(['removeMute', target]); + return { ok: true }; + }, + isAuthorMutedByViewer: () => true, + }; + + const subscriptions = { + refreshActiveFeed: async (args) => { + events.push(['refreshActiveFeed', args]); + }, + }; + + const controller = new MoreMenuController({ + moderationService: moderationStub, + subscriptions, + callbacks: { + getCurrentUserPubkey: () => 'viewerhex', + getCurrentVideo: () => ({ pubkey: 'fallbackhex' }), + loadVideos: async () => { + events.push(['loadVideos']); + }, + showSuccess: (message) => { + events.push(['success', message]); + }, + showError: (message) => { + events.push(['error', message]); + }, + safeDecodeNpub: () => '', + }, + }); + + const targetHex = 'e'.repeat(64); + + await controller.handleMoreMenuAction('unmute-author', { author: targetHex }); + + assert.deepEqual(events, [ + ['removeMute', targetHex], + ['success', 'Creator removed from your mute list.'], + ['loadVideos'], + ['refreshActiveFeed', { reason: 'viewer-mute-update' }], + ]); +}); diff --git a/tests/visual/moderation.spec.ts b/tests/visual/moderation.spec.ts index 450f6ddc..db1337cc 100644 --- a/tests/visual/moderation.spec.ts +++ b/tests/visual/moderation.spec.ts @@ -97,4 +97,16 @@ test.describe("moderation fixtures", () => { reloadedCard.getByRole("button", { name: "Show anyway" }) ).toHaveCount(0); }); + + test("trusted mute fixture annotates mute reason", async ({ page }) => { + await page.goto(FIXTURE_URL, { waitUntil: "networkidle" }); + + const muteCard = page.locator('[data-test-id="trusted-mute"]'); + await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute", "true"); + await expect(muteCard).toHaveAttribute("data-moderation-trusted-mute-count", "1"); + await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway"); + + const badge = muteCard.locator('[data-moderation-badge="true"]'); + await expect(badge).toContainText("Muted by a trusted contact"); + }); });