diff --git a/js/app.js b/js/app.js index db262d29..947ad27d 100644 --- a/js/app.js +++ b/js/app.js @@ -4369,6 +4369,8 @@ class Application { getService: () => this.nostrService.getModerationService(), autoplayThreshold: thresholds.autoplayBlockThreshold, blurThreshold: thresholds.blurThreshold, + trustedMuteHideThreshold: thresholds.trustedMuteHideThreshold, + trustedReportHideThreshold: thresholds.trustedSpamHideThreshold, }), ], sorter: createChronologicalSorter(), @@ -4408,6 +4410,8 @@ class Application { getService: () => this.nostrService.getModerationService(), autoplayThreshold: thresholds.autoplayBlockThreshold, blurThreshold: thresholds.blurThreshold, + trustedMuteHideThreshold: thresholds.trustedMuteHideThreshold, + trustedReportHideThreshold: thresholds.trustedSpamHideThreshold, }), ], sorter: createChronologicalSorter(), diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 47484c31..4b7bdc5c 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -8,6 +8,8 @@ import logger from "../utils/logger.js"; import { dedupeToNewestByRoot } from "../utils/videoDeduper.js"; import { isPlainObject, toSet } from "./utils.js"; +const FEED_HIDE_BYPASS_NAMES = new Set(["home", "recent"]); + function resolveDedupeFunction(customDedupe) { if (typeof customDedupe === "function") { return customDedupe; @@ -199,13 +201,30 @@ export function createModerationStage({ reportType = "nudity", service = null, getService, + trustedMuteHideThreshold = Number.POSITIVE_INFINITY, + trustedReportHideThreshold = Number.POSITIVE_INFINITY, } = {}) { + const sanitizeThreshold = (value, fallback) => { + if (Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + return fallback; + }; + const normalizedAutoplayThreshold = Number.isFinite(autoplayThreshold) ? Math.max(0, Math.floor(autoplayThreshold)) : 2; const normalizedBlurThreshold = Number.isFinite(blurThreshold) ? Math.max(0, Math.floor(blurThreshold)) : 3; + const normalizedMuteHideThreshold = sanitizeThreshold( + trustedMuteHideThreshold, + Number.POSITIVE_INFINITY, + ); + const normalizedReportHideThreshold = sanitizeThreshold( + trustedReportHideThreshold, + Number.POSITIVE_INFINITY, + ); const normalizedReportType = typeof reportType === "string" ? reportType.trim().toLowerCase() : "nudity"; return async function moderationStage(items = [], context = {}) { @@ -319,6 +338,104 @@ export function createModerationStage({ } : () => ({ hex: "", whitelisted: false, blacklisted: false }); + const normalizedFeedName = + typeof context?.feedName === "string" ? context.feedName.trim().toLowerCase() : ""; + const normalizedFeedVariant = + typeof context?.runtime?.feedVariant === "string" + ? context.runtime.feedVariant.trim().toLowerCase() + : ""; + const runtimeDisableHardHide = context?.runtime?.disableHardHide === true; + const feedPolicyBypass = + runtimeDisableHardHide || + (normalizedFeedName && FEED_HIDE_BYPASS_NAMES.has(normalizedFeedName)) || + (normalizedFeedVariant && FEED_HIDE_BYPASS_NAMES.has(normalizedFeedVariant)); + + const runtimeForOverride = context?.runtime && typeof context.runtime === "object" + ? context.runtime + : null; + + const overrideChecker = (() => { + if (!runtimeForOverride) { + return () => false; + } + + const overrideMap = runtimeForOverride.moderationOverrides; + const overrideIds = runtimeForOverride.moderationOverrideIds; + const hasFn = + typeof runtimeForOverride.hasModerationOverride === "function" + ? runtimeForOverride.hasModerationOverride.bind(runtimeForOverride) + : null; + const getFn = + typeof runtimeForOverride.getModerationOverride === "function" + ? runtimeForOverride.getModerationOverride.bind(runtimeForOverride) + : null; + + const checkEntry = (entry) => { + if (!entry) { + return false; + } + if (entry === true) { + return true; + } + if (typeof entry === "object" && entry.showAnyway === true) { + return true; + } + return false; + }; + + return (videoId) => { + if (typeof videoId !== "string") { + return false; + } + const trimmed = videoId.trim(); + if (!trimmed) { + return false; + } + const normalizedId = trimmed.toLowerCase(); + + if (overrideMap instanceof Map) { + const entry = overrideMap.get(normalizedId) ?? overrideMap.get(trimmed); + if (checkEntry(entry)) { + return true; + } + } else if (overrideMap && typeof overrideMap === "object") { + const entry = overrideMap[normalizedId] ?? overrideMap[trimmed]; + if (checkEntry(entry)) { + return true; + } + } + + if (overrideIds instanceof Set) { + if (overrideIds.has(normalizedId) || overrideIds.has(trimmed)) { + return true; + } + } + + if (hasFn) { + try { + if (hasFn(normalizedId) || (normalizedId !== trimmed && hasFn(trimmed))) { + return true; + } + } catch (error) { + context?.log?.(`[${stageName}] hasModerationOverride threw`, error); + } + } + + if (getFn) { + try { + const entry = getFn(normalizedId) ?? (normalizedId !== trimmed ? getFn(trimmed) : null); + if (checkEntry(entry)) { + return true; + } + } catch (error) { + context?.log?.(`[${stageName}] getModerationOverride threw`, error); + } + } + + return false; + }; + })(); + const results = []; for (const item of items) { @@ -489,18 +606,6 @@ export function createModerationStage({ metadataModeration.trustedMuted = trustedMuted; metadataModeration.trustedMuters = trustedMuters.slice(); metadataModeration.trustedMuteCount = trustedMuteCount; - item.metadata.moderation = metadataModeration; - - if (IS_DEV_MODE) { - logger.dev.debug("[feedEngine/moderation] metadata.moderation updated", { - stage: stageName, - videoId: videoId || null, - blockAutoplay, - blurThumbnail, - trustedCount, - reportType: normalizedReportType, - }); - } if (!video.moderation || typeof video.moderation !== "object") { video.moderation = {}; @@ -535,6 +640,93 @@ export function createModerationStage({ delete video.moderation.summary; } + const hideCounts = { + trustedMuteCount, + trustedReportCount: trustedCount, + }; + + let hideReason = ""; + let hideTriggered = false; + + if (trustedMuted && trustedMuteCount >= normalizedMuteHideThreshold) { + hideReason = "trusted-mute-hide"; + hideTriggered = true; + } else if (trustedCount >= normalizedReportHideThreshold) { + hideReason = "trusted-report-hide"; + hideTriggered = true; + } + + const viewerOverrideActive = overrideChecker(videoId); + + let hideBypass = ""; + if (hideTriggered) { + if (adminWhitelist) { + hideBypass = "admin-whitelist"; + } else if (viewerOverrideActive) { + hideBypass = "viewer-override"; + } else if (feedPolicyBypass) { + hideBypass = "feed-policy"; + } + } + + const hidden = hideTriggered && !hideBypass; + + if (hideTriggered) { + metadataModeration.hidden = hidden; + metadataModeration.hideReason = hideReason; + metadataModeration.hideCounts = { ...hideCounts }; + if (hideBypass) { + metadataModeration.hideBypass = hideBypass; + } else if (metadataModeration.hideBypass) { + delete metadataModeration.hideBypass; + } + video.moderation.hidden = hidden; + video.moderation.hideReason = hideReason; + video.moderation.hideCounts = { ...hideCounts }; + if (hideBypass) { + video.moderation.hideBypass = hideBypass; + } else if (video.moderation.hideBypass) { + delete video.moderation.hideBypass; + } + } else { + metadataModeration.hidden = false; + if (metadataModeration.hideReason) { + delete metadataModeration.hideReason; + } + if (metadataModeration.hideCounts) { + delete metadataModeration.hideCounts; + } + if (metadataModeration.hideBypass) { + delete metadataModeration.hideBypass; + } + video.moderation.hidden = false; + if (video.moderation.hideReason) { + delete video.moderation.hideReason; + } + if (video.moderation.hideCounts) { + delete video.moderation.hideCounts; + } + if (video.moderation.hideBypass) { + delete video.moderation.hideBypass; + } + } + + item.metadata.moderation = metadataModeration; + + if (IS_DEV_MODE) { + logger.dev.debug("[feedEngine/moderation] metadata.moderation updated", { + stage: stageName, + videoId: videoId || null, + blockAutoplay, + blurThumbnail, + trustedCount, + reportType: normalizedReportType, + hidden, + hideReason: hideTriggered ? hideReason : null, + hideBypass: hideBypass || null, + }); + } + if (blockAutoplay || blurThumbnail) { context?.addWhy?.({ stage: stageName, @@ -557,6 +749,35 @@ export function createModerationStage({ }); } + if (hideTriggered) { + const hideDetail = { + stage: stageName, + type: "moderation", + reason: hideReason, + videoId: videoId || null, + trustedMuteCount, + trustedReportCount: trustedCount, + hidden, + }; + if (hideBypass) { + hideDetail.hideBypass = hideBypass; + } + if (adminWhitelist) { + hideDetail.adminWhitelist = true; + } + if (viewerOverrideActive) { + hideDetail.viewerOverride = true; + } + if (feedPolicyBypass) { + hideDetail.feedPolicyBypass = true; + } + context?.addWhy?.(hideDetail); + } + + if (hidden) { + continue; + } + results.push(item); } diff --git a/tests/moderation/hide-thresholds.test.mjs b/tests/moderation/hide-thresholds.test.mjs new file mode 100644 index 00000000..10ae7cbe --- /dev/null +++ b/tests/moderation/hide-thresholds.test.mjs @@ -0,0 +1,260 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createModerationStage } from "../../js/feedEngine/stages.js"; + +const hex = (value) => value.repeat(64).slice(0, 64); + +test("moderation stage hides videos muted by trusted when threshold met", async () => { + const muterHex = hex("a"); + const mutedHex = hex("b"); + const videoId = hex("1"); + + 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, + trustedMuteHideThreshold: 1, + trustedReportHideThreshold: 99, + }); + + const items = [{ video: { id: videoId, pubkey: mutedHex }, metadata: {} }]; + const why = []; + const context = { + runtime: {}, + addWhy(detail) { + why.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 0); + + const metadata = items[0].metadata.moderation; + assert.equal(metadata.hidden, true); + assert.equal(metadata.hideReason, "trusted-mute-hide"); + assert.deepEqual(metadata.hideCounts, { trustedMuteCount: 1, trustedReportCount: 0 }); + assert.equal(metadata.hideBypass ?? null, null); + + const videoModeration = items[0].video.moderation; + assert.equal(videoModeration.hidden, true); + assert.equal(videoModeration.hideReason, "trusted-mute-hide"); + assert.deepEqual(videoModeration.hideCounts, { trustedMuteCount: 1, trustedReportCount: 0 }); + + const hideWhy = why.find((entry) => entry.reason === "trusted-mute-hide"); + assert.ok(hideWhy); + assert.equal(hideWhy.hidden, true); + assert.equal(hideWhy.trustedMuteCount, 1); + assert.equal(hideWhy.trustedReportCount, 0); +}); + +test("moderation stage hides videos when trusted reports exceed threshold", async () => { + const reporterHex = hex("c"); + const videoId = hex("2"); + + 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 5; + }, + getTrustedReporters() { + return [{ pubkey: reporterHex, latest: 1_700_000_000 }]; + }, + }; + + const stage = createModerationStage({ + service, + trustedMuteHideThreshold: 50, + trustedReportHideThreshold: 3, + }); + + const items = [{ video: { id: videoId, pubkey: hex("d") }, metadata: {} }]; + const why = []; + const context = { + runtime: {}, + addWhy(detail) { + why.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 0); + + const metadata = items[0].metadata.moderation; + assert.equal(metadata.hidden, true); + assert.equal(metadata.hideReason, "trusted-report-hide"); + assert.deepEqual(metadata.hideCounts, { trustedMuteCount: 0, trustedReportCount: 5 }); + + const videoModeration = items[0].video.moderation; + assert.equal(videoModeration.hidden, true); + assert.equal(videoModeration.hideReason, "trusted-report-hide"); + assert.deepEqual(videoModeration.hideCounts, { trustedMuteCount: 0, trustedReportCount: 5 }); + + const hideWhy = why.find((entry) => entry.reason === "trusted-report-hide"); + assert.ok(hideWhy); + assert.equal(hideWhy.hidden, true); + assert.equal(hideWhy.trustedReportCount, 5); + assert.equal(hideWhy.hideBypass ?? null, null); +}); + +test("moderation stage bypasses hard hides on home feed", async () => { + const videoId = hex("3"); + + 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 4; + }, + getTrustedReporters() { + return []; + }, + }; + + const stage = createModerationStage({ + service, + trustedMuteHideThreshold: 10, + trustedReportHideThreshold: 1, + }); + + const items = [{ video: { id: videoId, pubkey: hex("e") }, metadata: {} }]; + const why = []; + const context = { + feedName: "home", + runtime: { feedVariant: "home" }, + addWhy(detail) { + why.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 1); + + const metadata = result[0].metadata.moderation; + assert.equal(metadata.hidden, false); + assert.equal(metadata.hideReason, "trusted-report-hide"); + assert.deepEqual(metadata.hideCounts, { trustedMuteCount: 0, trustedReportCount: 4 }); + assert.equal(metadata.hideBypass, "feed-policy"); + + const hideWhy = why.find((entry) => entry.reason === "trusted-report-hide"); + assert.ok(hideWhy); + assert.equal(hideWhy.hidden, false); + assert.equal(hideWhy.hideBypass, "feed-policy"); +}); + +test("moderation stage allows admin-whitelisted videos despite hide thresholds", async () => { + const whitelistedHex = hex("f"); + const videoId = hex("4"); + + const service = { + async refreshViewerFromClient() {}, + async setActiveEventIds() {}, + getAdminListSnapshot() { + return { + whitelist: new Set([`npub${whitelistedHex}`]), + whitelistHex: new Set([whitelistedHex]), + blacklist: new Set(), + blacklistHex: new Set(), + }; + }, + getAccessControlStatus(identifier) { + const normalized = typeof identifier === "string" ? identifier.trim().toLowerCase() : ""; + return { hex: normalized, whitelisted: normalized === whitelistedHex, blacklisted: false }; + }, + getTrustedReportSummary() { + return null; + }, + trustedReportCount() { + return 6; + }, + getTrustedReporters() { + return []; + }, + }; + + const stage = createModerationStage({ + service, + trustedMuteHideThreshold: 1, + trustedReportHideThreshold: 2, + }); + + const items = [{ video: { id: videoId, pubkey: whitelistedHex }, metadata: {} }]; + const why = []; + const context = { + runtime: {}, + addWhy(detail) { + why.push(detail); + return detail; + }, + log() {}, + }; + + const result = await stage(items, context); + + assert.equal(result.length, 1); + + const metadata = result[0].metadata.moderation; + assert.equal(metadata.hidden, false); + assert.equal(metadata.hideReason, "trusted-report-hide"); + assert.equal(metadata.hideBypass, "admin-whitelist"); + assert.deepEqual(metadata.hideCounts, { trustedMuteCount: 0, trustedReportCount: 6 }); + + const hideWhy = why.find((entry) => entry.reason === "trusted-report-hide"); + assert.ok(hideWhy); + assert.equal(hideWhy.hidden, false); + assert.equal(hideWhy.hideBypass, "admin-whitelist"); + assert.equal(hideWhy.adminWhitelist, true); +}); +