diff --git a/docs/moderation/web-of-trust.md b/docs/moderation/web-of-trust.md index 039c1678..01b27884 100644 --- a/docs/moderation/web-of-trust.md +++ b/docs/moderation/web-of-trust.md @@ -17,10 +17,10 @@ - `blurThumbnail = trustedReportCount(event,'nudity') >= DEFAULT_BLUR_THRESHOLD` - `hideAutoplay = trustedReportCount(event,'nudity') >= DEFAULT_AUTOPLAY_BLOCK_THRESHOLD` - `downrankIfMutedByF1 = true` -- `hideIfTrustedMuteCount(author) >= DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` +- `hideIfTrustedMuteCount(author, category) >= trustedMuteHideThresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` - `hideIfTrustedSpamReports(event) >= DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` -Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements. +Threshold constants are exported from [`config/instance-config.js`](../../config/instance-config.js) so operators can change the defaults without touching moderation code. Inspect the `DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, and `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD` exports to set your policy. The upstream repo includes example values (blur at 1, autoplay block at 1, trusted mute hide at 20, trusted spam hide at 1), but treat those as guidance rather than hard-coded requirements. Per-category trusted mute hide thresholds now live in moderation settings as `trustedMuteHideThresholds` (e.g., `{ spam: 5, nudity: 10 }`), and any category not present falls back to the default trusted mute hide threshold. When evaluating trusted mutes we use the content's report type if available, otherwise we fall back to any category metadata attached to trusted mute list entries. ### Trusted mute decay window - Trusted mute counts are **time-bounded**: only mute lists updated within the rolling window (currently 60 days) contribute to `trustedMuteCount`. @@ -67,8 +67,10 @@ function shouldHideAutoplay(eventId: string, ctx: Ctx): boolean { return trustedReportCount(eventId, 'nudity', ctx.viewerFollows, ctx.reports) >= DEFAULT_AUTOPLAY_BLOCK_THRESHOLD; } -function shouldHideForTrustedMute(authorHex: string, ctx: Ctx): boolean { - return ctx.trustedMuteCount(authorHex) >= DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD; +function shouldHideForTrustedMute(authorHex: string, ctx: Ctx, category: Report['type']): boolean { + const thresholds = ctx.trustedMuteHideThresholds || {}; + const threshold = thresholds[category] ?? DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD; + return ctx.trustedMuteCount(authorHex, category) >= threshold; } function shouldHideForTrustedSpam(eventId: string, ctx: Ctx): boolean { diff --git a/js/app.js b/js/app.js index 6328dfd5..1075008d 100644 --- a/js/app.js +++ b/js/app.js @@ -6001,6 +6001,27 @@ class Application { normalizeModerationSettings(settings = null) { const defaults = this.defaultModerationSettings || getDefaultModerationSettings(); + const sanitizeThresholdMap = (value, fallback = {}) => { + const fallbackMap = fallback && typeof fallback === "object" ? fallback : {}; + if (!value || typeof value !== "object") { + return { ...fallbackMap }; + } + const sanitized = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof key !== "string") { + continue; + } + const normalizedKey = key.trim().toLowerCase(); + if (!normalizedKey) { + continue; + } + const numeric = Number(entry); + if (Number.isFinite(numeric)) { + sanitized[normalizedKey] = Math.max(0, Math.floor(numeric)); + } + } + return { ...fallbackMap, ...sanitized }; + }; const defaultBlur = Number.isFinite(defaults?.blurThreshold) ? Math.max(0, Math.floor(defaults.blurThreshold)) : DEFAULT_BLUR_THRESHOLD; @@ -6028,6 +6049,11 @@ class Application { ? Math.max(0, Math.floor(defaults.trustedSpamHideThreshold)) : runtimeTrustedSpam; + const defaultTrustedMuteHideThresholds = sanitizeThresholdMap( + defaults?.trustedMuteHideThresholds, + {}, + ); + const blurSource = Number.isFinite(settings?.blurThreshold) ? Math.max(0, Math.floor(settings.blurThreshold)) : defaultBlur; @@ -6040,11 +6066,16 @@ class Application { const spamHideSource = Number.isFinite(settings?.trustedSpamHideThreshold) ? Math.max(0, Math.floor(settings.trustedSpamHideThreshold)) : defaultTrustedSpamHide; + const muteHideThresholdsSource = sanitizeThresholdMap( + settings?.trustedMuteHideThresholds, + defaultTrustedMuteHideThresholds, + ); return { blurThreshold: blurSource, autoplayBlockThreshold: autoplaySource, trustedMuteHideThreshold: muteHideSource, + trustedMuteHideThresholds: muteHideThresholdsSource, trustedSpamHideThreshold: spamHideSource, }; } diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 5f0e78c6..38c56beb 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -483,7 +483,7 @@ export function createModerationStage({ defaultValue: DEFAULT_BLUR_THRESHOLD, fallbackValue: DEFAULT_BLUR_THRESHOLD, }); - const resolveMuteHideThreshold = createThresholdResolver( + const resolveMuteHideThresholdBase = createThresholdResolver( trustedMuteHideThreshold, { runtimeKey: "trustedMuteHideThreshold", @@ -492,6 +492,35 @@ export function createModerationStage({ allowInfinity: true, }, ); + const resolveMuteHideThreshold = (context, category) => { + const base = resolveMuteHideThresholdBase(context); + const normalizedCategory = + typeof category === "string" ? category.trim().toLowerCase() : ""; + if (!normalizedCategory) { + return base; + } + + const runtimeThresholds = + context?.runtime && typeof context.runtime === "object" + ? context.runtime.moderationThresholds + : null; + + const thresholdMap = + runtimeThresholds && typeof runtimeThresholds === "object" + ? runtimeThresholds.trustedMuteHideThresholds + : null; + + if (!thresholdMap || typeof thresholdMap !== "object") { + return base; + } + + const override = thresholdMap[normalizedCategory]; + if (Number.isFinite(override)) { + return Math.max(0, Math.floor(override)); + } + + return base; + }; const resolveReportHideThreshold = createThresholdResolver( trustedReportHideThreshold, { @@ -529,7 +558,6 @@ export function createModerationStage({ const normalizedAutoplayThreshold = resolveAutoplayThreshold(context); const normalizedBlurThreshold = resolveBlurThreshold(context); - const normalizedMuteHideThreshold = resolveMuteHideThreshold(context); const normalizedReportHideThreshold = resolveReportHideThreshold(context); const activeIds = new Set(); @@ -779,6 +807,9 @@ export function createModerationStage({ let trustedMuted = false; let trustedMuters = []; let trustedMuteCount = 0; + let trustedMuteCountTotal = 0; + let trustedMuteCategory = ""; + let trustedMuteCountsByCategory = null; let viewerMuted = false; if (videoId) { try { @@ -839,6 +870,21 @@ export function createModerationStage({ }); } } + if ( + trustedMuted && + typeof resolvedService.getTrustedMuteCountsForAuthor === "function" + ) { + const summary = resolvedService.getTrustedMuteCountsForAuthor(authorHex); + if (summary && typeof summary === "object") { + trustedMuteCountTotal = Number.isFinite(summary.total) + ? Math.max(0, Math.floor(summary.total)) + : 0; + trustedMuteCountsByCategory = + summary.categories && typeof summary.categories === "object" + ? { ...summary.categories } + : null; + } + } if (typeof resolvedService.isAuthorMutedByViewer === "function") { viewerMuted = resolvedService.isAuthorMutedByViewer(authorHex) === true; } @@ -851,20 +897,76 @@ export function createModerationStage({ } if (trustedMuted) { - trustedMuteCount = Number.isFinite(video?.moderation?.trustedMuteCount) + trustedMuteCountTotal = Number.isFinite(video?.moderation?.trustedMuteCount) ? Math.max(0, Math.floor(video.moderation.trustedMuteCount)) - : trustedMuters.length; - if (!trustedMuteCount) { - trustedMuteCount = trustedMuters.length; + : trustedMuteCountTotal || trustedMuters.length; + if (!trustedMuteCountTotal) { + trustedMuteCountTotal = trustedMuters.length; + } + if (trustedMuteCountTotal <= 0) { + trustedMuteCountTotal = Math.max(1, trustedMuters.length || 1); + } + trustedMuteCategory = normalizedReportType; + if ( + trustedMuteCountsByCategory && + typeof trustedMuteCountsByCategory === "object" + ) { + const categoryCount = + trustedMuteCategory && + Number.isFinite(trustedMuteCountsByCategory[trustedMuteCategory]) + ? Math.max( + 0, + Math.floor(trustedMuteCountsByCategory[trustedMuteCategory]), + ) + : null; + if (categoryCount !== null && categoryCount !== undefined) { + trustedMuteCount = categoryCount; + } else if (!trustedMuteCategory) { + let bestCategory = ""; + let bestCount = 0; + for (const [category, count] of Object.entries( + trustedMuteCountsByCategory, + )) { + if (!Number.isFinite(count)) { + continue; + } + const normalizedCategory = category.trim().toLowerCase(); + const normalizedCount = Math.max(0, Math.floor(count)); + if (!normalizedCategory) { + continue; + } + if (normalizedCount > bestCount) { + bestCount = normalizedCount; + bestCategory = normalizedCategory; + } + } + if (bestCategory) { + trustedMuteCategory = bestCategory; + trustedMuteCount = bestCount; + } else { + trustedMuteCount = trustedMuteCountTotal; + } + } else { + trustedMuteCount = trustedMuteCountTotal; + } + } else { + trustedMuteCount = trustedMuteCountTotal; } if (trustedMuteCount <= 0) { - trustedMuteCount = Math.max(1, trustedMuters.length || 1); + trustedMuteCount = trustedMuteCountTotal || Math.max(1, trustedMuters.length || 1); } } else { trustedMuteCount = 0; + trustedMuteCountTotal = 0; + trustedMuteCategory = ""; trustedMuters = []; } + const normalizedMuteHideThreshold = resolveMuteHideThreshold( + context, + trustedMuteCategory, + ); + const blockAutoplay = trustedCount >= normalizedAutoplayThreshold || trustedMuted || viewerMuted; const blurFromReports = trustedCount >= normalizedBlurThreshold; diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 31c0c563..a05b46c7 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -109,6 +109,39 @@ function normalizeReportType(value) { return trimmed ? trimmed : ""; } +function isRelayHint(value) { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim().toLowerCase(); + return ( + trimmed.startsWith("wss://") || + trimmed.startsWith("ws://") || + trimmed.startsWith("https://") || + trimmed.startsWith("http://") + ); +} + +function normalizeMuteCategory(value) { + return normalizeReportType(value); +} + +function extractMuteCategoryFromTag(tag) { + if (!Array.isArray(tag)) { + return ""; + } + + const direct = normalizeMuteCategory(tag[3]); + if (direct) { + return direct; + } + + const fallback = typeof tag[2] === "string" && !isRelayHint(tag[2]) + ? normalizeMuteCategory(tag[2]) + : ""; + return fallback; +} + function cloneSummary(summary) { if (!summary || typeof summary !== "object") { return { eventId: "", totalTrusted: 0, types: {}, updatedAt: 0 }; @@ -794,9 +827,21 @@ export class ModerationService { if (!aggregate || !(aggregate.muters instanceof Map)) { continue; } + const expiredOwners = new Set(); for (const [owner, updatedAt] of aggregate.muters.entries()) { if (ensureNumber(updatedAt) < cutoff) { aggregate.muters.delete(owner); + expiredOwners.add(owner); + } + } + if (aggregate.categories instanceof Map && expiredOwners.size) { + for (const [category, muters] of aggregate.categories.entries()) { + for (const owner of expiredOwners) { + muters.delete(owner); + } + if (!muters.size) { + aggregate.categories.delete(category); + } } } aggregate.count = aggregate.muters.size; @@ -826,6 +871,39 @@ export class ModerationService { return Array.from(refreshed.muters.keys()); } + getTrustedMuteCountsForAuthor(author) { + const normalized = normalizeHex(author); + if (!normalized) { + return { total: 0, categories: {} }; + } + + const entry = this.trustedMutedAuthors.get(normalized); + if (!entry || !(entry.muters instanceof Map)) { + return { total: 0, categories: {} }; + } + + this.pruneTrustedMuteAggregates([normalized]); + const refreshed = this.trustedMutedAuthors.get(normalized); + if (!refreshed || !(refreshed.muters instanceof Map)) { + return { total: 0, categories: {} }; + } + + const categories = {}; + if (refreshed.categories instanceof Map) { + for (const [category, muters] of refreshed.categories.entries()) { + if (typeof category !== "string" || !category.trim()) { + continue; + } + categories[category] = muters instanceof Map ? muters.size : 0; + } + } + + return { + total: refreshed.muters.size, + categories, + }; + } + setLogger(newLogger) { this.log = normalizeLogger(newLogger); } @@ -1143,34 +1221,71 @@ export class ModerationService { return; } - const sanitizedAuthors = new Set(); - if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) { + const sanitizedAuthors = new Map(); + const addAuthor = (candidate, category = "") => { + const normalized = normalizeToHex(candidate); + if (!normalized) { + return; + } + const normalizedCategory = normalizeMuteCategory(category); + sanitizedAuthors.set(normalized, normalizedCategory); + }; + + if (mutedAuthors instanceof Map) { + for (const [candidate, category] of mutedAuthors.entries()) { + addAuthor(candidate, category); + } + } else if (mutedAuthors instanceof Set || Array.isArray(mutedAuthors)) { for (const candidate of mutedAuthors) { - const normalized = normalizeToHex(candidate); - if (!normalized) { + if (!candidate) { continue; } - sanitizedAuthors.add(normalized); + if (typeof candidate === "object") { + addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason); + continue; + } + addAuthor(candidate); } } else if (mutedAuthors && typeof mutedAuthors[Symbol.iterator] === "function") { for (const candidate of mutedAuthors) { - const normalized = normalizeToHex(candidate); - if (!normalized) { + if (!candidate) { continue; } - sanitizedAuthors.add(normalized); + if (typeof candidate === "object") { + addAuthor(candidate.pubkey || candidate.author, candidate.category || candidate.reason); + continue; + } + addAuthor(candidate); } } const touchedAuthors = new Set(); const previous = this.trustedMuteLists.get(owner); - if (previous && previous.authors instanceof Set) { - for (const author of previous.authors) { + if (previous && previous.authors instanceof Map) { + for (const [author, category] of previous.authors.entries()) { const aggregate = this.trustedMutedAuthors.get(author); if (!aggregate || !(aggregate.muters instanceof Map)) { continue; } aggregate.muters.delete(owner); + if (aggregate.categories instanceof Map) { + if (category) { + const categoryMuters = aggregate.categories.get(category); + if (categoryMuters instanceof Map) { + categoryMuters.delete(owner); + if (!categoryMuters.size) { + aggregate.categories.delete(category); + } + } + } else { + for (const [existingCategory, muters] of aggregate.categories.entries()) { + muters.delete(owner); + if (!muters.size) { + aggregate.categories.delete(existingCategory); + } + } + } + } touchedAuthors.add(author); } } @@ -1193,13 +1308,21 @@ export class ModerationService { eventId: normalizedEventId, }); - for (const author of sanitizedAuthors) { + for (const [author, category] of sanitizedAuthors.entries()) { let aggregate = this.trustedMutedAuthors.get(author); if (!aggregate) { - aggregate = { muters: new Map(), count: 0 }; + aggregate = { muters: new Map(), categories: new Map(), count: 0 }; this.trustedMutedAuthors.set(author, aggregate); } aggregate.muters.set(owner, normalizedCreatedAt); + if (category) { + let categoryMuters = aggregate.categories.get(category); + if (!categoryMuters) { + categoryMuters = new Map(); + aggregate.categories.set(category, categoryMuters); + } + categoryMuters.set(owner, normalizedCreatedAt); + } touchedAuthors.add(author); } } @@ -1233,7 +1356,7 @@ export class ModerationService { } } - const mutedAuthors = new Set(); + const mutedAuthors = new Map(); if (Array.isArray(event.tags)) { for (const tag of event.tags) { if (!Array.isArray(tag) || tag.length < 2) { @@ -1244,7 +1367,8 @@ export class ModerationService { } const normalized = normalizeToHex(tag[1]); if (normalized) { - mutedAuthors.add(normalized); + const category = extractMuteCategoryFromTag(tag); + mutedAuthors.set(normalized, category); } } } diff --git a/js/state/cache.js b/js/state/cache.js index a743e950..4d9d631a 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -32,7 +32,7 @@ const HEX64_REGEX = /^[0-9a-f]{64}$/i; const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1"; const MODERATION_OVERRIDE_STORAGE_VERSION = 1; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; -const MODERATION_SETTINGS_STORAGE_VERSION = 2; +const MODERATION_SETTINGS_STORAGE_VERSION = 3; function computeDefaultModerationSettings() { const defaultBlur = sanitizeModerationThreshold( @@ -92,6 +92,7 @@ function computeDefaultModerationSettings() { runtimeMuteHide, defaultTrustedMute, ), + trustedMuteHideThresholds: {}, trustedSpamHideThreshold: sanitizeModerationThreshold( runtimeSpamHide, defaultTrustedSpam, @@ -130,6 +131,37 @@ function sanitizeModerationThreshold(value, fallback) { return clamped; } +function sanitizeModerationThresholdMap( + value, + fallback = {}, + { mergeFallback = false } = {}, +) { + const fallbackMap = fallback && typeof fallback === "object" ? fallback : {}; + if (!value || typeof value !== "object") { + return mergeFallback ? { ...fallbackMap } : {}; + } + + const sanitized = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof key !== "string") { + continue; + } + const normalizedKey = key.trim().toLowerCase(); + if (!normalizedKey) { + continue; + } + const sanitizedValue = sanitizeModerationThreshold( + entry, + fallbackMap[normalizedKey], + ); + if (Number.isFinite(sanitizedValue)) { + sanitized[normalizedKey] = sanitizedValue; + } + } + + return mergeFallback ? { ...fallbackMap, ...sanitized } : sanitized; +} + function sanitizeProfileString(value) { if (typeof value !== "string") { return ""; @@ -836,6 +868,27 @@ function haveModerationSettingsChanged(previous, next) { return true; } + const prevMuteThresholds = + previous.trustedMuteHideThresholds && + typeof previous.trustedMuteHideThresholds === "object" + ? previous.trustedMuteHideThresholds + : {}; + const nextMuteThresholds = + next.trustedMuteHideThresholds && + typeof next.trustedMuteHideThresholds === "object" + ? next.trustedMuteHideThresholds + : {}; + const allMuteKeys = new Set([ + ...Object.keys(prevMuteThresholds), + ...Object.keys(nextMuteThresholds), + ]); + + for (const key of allMuteKeys) { + if (prevMuteThresholds[key] !== nextMuteThresholds[key]) { + return true; + } + } + return ( previous.blurThreshold !== next.blurThreshold || previous.autoplayBlockThreshold !== next.autoplayBlockThreshold || @@ -917,6 +970,11 @@ export function loadModerationSettingsFromStorage() { overrides.trustedMuteHideThreshold, defaults.trustedMuteHideThreshold, ), + trustedMuteHideThresholds: sanitizeModerationThresholdMap( + overrides.trustedMuteHideThresholds, + defaults.trustedMuteHideThresholds, + { mergeFallback: true }, + ), trustedSpamHideThreshold: sanitizeModerationThreshold( overrides.trustedSpamHideThreshold, defaults.trustedSpamHideThreshold, @@ -928,7 +986,7 @@ export function loadModerationSettingsFromStorage() { legacySettings, ); shouldRewrite = true; - } else if (payload.version === MODERATION_SETTINGS_STORAGE_VERSION) { + } else if (payload.version === 2 || payload.version === MODERATION_SETTINGS_STORAGE_VERSION) { const defaults = createDefaultModerationSettings(); const entries = payload.entries && typeof payload.entries === "object" @@ -969,6 +1027,11 @@ export function loadModerationSettingsFromStorage() { overrides.trustedMuteHideThreshold, defaults.trustedMuteHideThreshold, ), + trustedMuteHideThresholds: sanitizeModerationThresholdMap( + overrides.trustedMuteHideThresholds, + defaults.trustedMuteHideThresholds, + { mergeFallback: true }, + ), trustedSpamHideThreshold: sanitizeModerationThreshold( overrides.trustedSpamHideThreshold, defaults.trustedSpamHideThreshold, @@ -1023,6 +1086,16 @@ function persistModerationSettingsToStorage() { overrides.trustedMuteHideThreshold = settings.trustedMuteHideThreshold; } + const trustedMuteHideThresholds = sanitizeModerationThresholdMap( + settings.trustedMuteHideThresholds, + defaults.trustedMuteHideThresholds, + { mergeFallback: false }, + ); + + if (Object.keys(trustedMuteHideThresholds).length > 0) { + overrides.trustedMuteHideThresholds = trustedMuteHideThresholds; + } + if (settings.trustedSpamHideThreshold !== defaults.trustedSpamHideThreshold) { overrides.trustedSpamHideThreshold = settings.trustedSpamHideThreshold; } @@ -1114,6 +1187,38 @@ export function setModerationSettings(partial = {}, { persist = true } = {}) { } } + if ( + Object.prototype.hasOwnProperty.call(partial, "trustedMuteHideThresholds") + ) { + const value = partial.trustedMuteHideThresholds; + const sanitized = + value === null + ? defaults.trustedMuteHideThresholds + : sanitizeModerationThresholdMap(value, defaults.trustedMuteHideThresholds, { + mergeFallback: true, + }); + const current = + next.trustedMuteHideThresholds && + typeof next.trustedMuteHideThresholds === "object" + ? next.trustedMuteHideThresholds + : {}; + const nextKeys = new Set([ + ...Object.keys(current), + ...Object.keys(sanitized), + ]); + let mapChanged = false; + for (const key of nextKeys) { + if (current[key] !== sanitized[key]) { + mapChanged = true; + break; + } + } + if (mapChanged) { + next.trustedMuteHideThresholds = { ...sanitized }; + changed = true; + } + } + if (Object.prototype.hasOwnProperty.call(partial, "trustedSpamHideThreshold")) { const value = partial.trustedSpamHideThreshold; const sanitized =