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");
+ });
});