From 849209178049d7ba95732b6a9ea8db15c93ea058 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 17 Oct 2025 22:09:02 -0400 Subject: [PATCH] Track trusted mute lists and surface UI signals --- docs/moderation/fixtures/index.html | 38 ++++ js/app.js | 37 ++++ js/feedEngine/sorters.js | 11 + js/feedEngine/stages.js | 71 ++++++ js/services/moderationService.js | 325 +++++++++++++++++++++++++++- js/ui/components/VideoCard.js | 168 ++++++++++++-- package-lock.json | 24 +- package.json | 2 +- tests/feed-engine.test.mjs | 26 +++ tests/moderation-service.test.mjs | 57 +++++ tests/moderation-stage.test.mjs | 63 ++++++ tests/visual/moderation.spec.ts | 12 + 12 files changed, 795 insertions(+), 39 deletions(-) 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/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..82f7d6bd 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -327,6 +327,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 +370,53 @@ 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.emit("trusted-mutes", { total: 0 }); + } + + reconcileTrustedMuteSubscriptions(previousSet, nextSet) { + const previous = previousSet instanceof Set ? previousSet : new Set(); + const next = nextSet instanceof Set ? nextSet : new Set(); + + 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 +434,265 @@ export class ModerationService { } } + async subscribeToTrustedMuteList(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized || !this.trustedContacts.has(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.trustedContacts.has(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); + } + } + + if (!this.trustedContacts.has(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; + } + } + } + + if (!sanitizedAuthors.size) { + this.trustedMuteLists.delete(owner); + } else { + this.trustedMuteLists.set(owner, { + authors: sanitizedAuthors, + updatedAt: ensureNumber(createdAt), + eventId: normalizeEventId(eventId), + }); + + 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.trustedContacts.has(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.trustedContacts.has(owner)) { + return; + } + + this.applyTrustedMuteEvent(owner, event); + } + + 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 +872,7 @@ export class ModerationService { } this.viewerPubkey = normalized; + this.clearTrustedMuteTracking(); this.trustedContacts.clear(); this.recomputeAllSummaries(); @@ -604,7 +915,10 @@ export class ModerationService { 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 +933,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 +950,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 +968,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 +996,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/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..0606962e 100644 --- a/tests/moderation-service.test.mjs +++ b/tests/moderation-service.test.mjs @@ -100,3 +100,60 @@ 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), []); +}); 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/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"); + }); });