From 91404af8ad42a3b7d195a15f081cb03fb7f7a3a4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:19:34 -0400 Subject: [PATCH] Make moderation thresholds resolve at runtime --- docs/moderation/README.md | 1 + js/app.js | 77 ++++++++-- js/feedEngine/stages.js | 113 +++++++++++++-- js/subscriptions.js | 8 ++ .../app/moderation-settings-refresh.test.mjs | 68 +++++++++ tests/helpers/moderation-test-helpers.mjs | 10 ++ tests/moderation-stage.test.mjs | 136 ++++++++++++++++++ tests/moderation/hide-thresholds.test.mjs | 135 +++++++++++++++++ 8 files changed, 522 insertions(+), 26 deletions(-) create mode 100644 tests/app/moderation-settings-refresh.test.mjs diff --git a/docs/moderation/README.md b/docs/moderation/README.md index f8b08490..41ee66b3 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -24,6 +24,7 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc ## Runtime flow (blur, hide, override) - [`ModerationService`](../../js/services/moderationService.js) orchestrates ingest and scoring. Review the service for the trusted-report math and helper entry points. - [`createModerationStage`](../../js/feedEngine/stages.js) wires the service into the feed engine where moderation summaries decorate timeline items. +- The moderation stage now resolves blur/autoplay/hide thresholds on every execution using `context.runtime.moderationThresholds` (when provided) or dynamic resolver functions, so feed refreshes immediately respect updated viewer settings. - [`bitvidApp.decorateVideoModeration`](../../js/app.js) connects stage output to the UI layer alongside feature-flag plumbing. - [`VideoCard.refreshModerationUi`](../../js/ui/components/VideoCard.js) applies badges, blur states, hide metadata (`data-moderation-hidden`), and the "show anyway" toggles. diff --git a/js/app.js b/js/app.js index de3248af..d680a3b9 100644 --- a/js/app.js +++ b/js/app.js @@ -5403,7 +5403,7 @@ class Application { return null; } - handleModerationSettingsChange({ settings } = {}) { + async handleModerationSettingsChange({ settings } = {}) { const normalized = this.normalizeModerationSettings(settings); this.moderationSettings = normalized; @@ -5449,6 +5449,15 @@ class Application { this.decorateVideoModeration(this.currentVideo); } + try { + await this.onVideosShouldRefresh({ reason: "moderation-settings-change" }); + } catch (error) { + devLogger.warn( + "[Application] Failed to refresh videos after moderation settings change:", + error, + ); + } + return normalized; } @@ -6679,7 +6688,28 @@ class Application { } try { - const thresholds = this.getActiveModerationThresholds(); + const app = this; + const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + + if (app && typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = active && typeof active === "object" ? active[key] : undefined; + if ( + Number.isFinite(candidate) || + candidate === Number.POSITIVE_INFINITY + ) { + return candidate; + } + } + + return defaultValue; + }; return this.feedEngine.registerFeed("recent", { source: createActiveNostrSource({ service: this.nostrService }), stages: [ @@ -6695,10 +6725,10 @@ class Application { }), createModerationStage({ getService: () => this.nostrService.getModerationService(), - autoplayThreshold: thresholds.autoplayBlockThreshold, - blurThreshold: thresholds.blurThreshold, - trustedMuteHideThreshold: thresholds.trustedMuteHideThreshold, - trustedReportHideThreshold: thresholds.trustedSpamHideThreshold, + autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"), + blurThreshold: resolveThresholdFromApp("blurThreshold"), + trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"), + trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"), }), ], sorter: createChronologicalSorter(), @@ -6723,7 +6753,28 @@ class Application { } try { - const thresholds = this.getActiveModerationThresholds(); + const app = this; + const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + + if (app && typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = active && typeof active === "object" ? active[key] : undefined; + if ( + Number.isFinite(candidate) || + candidate === Number.POSITIVE_INFINITY + ) { + return candidate; + } + } + + return defaultValue; + }; return this.feedEngine.registerFeed("subscriptions", { source: createSubscriptionAuthorsSource({ service: this.nostrService }), stages: [ @@ -6738,10 +6789,10 @@ class Application { }), createModerationStage({ getService: () => this.nostrService.getModerationService(), - autoplayThreshold: thresholds.autoplayBlockThreshold, - blurThreshold: thresholds.blurThreshold, - trustedMuteHideThreshold: thresholds.trustedMuteHideThreshold, - trustedReportHideThreshold: thresholds.trustedSpamHideThreshold, + autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"), + blurThreshold: resolveThresholdFromApp("blurThreshold"), + trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"), + trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"), }), ], sorter: createChronologicalSorter(), @@ -6792,6 +6843,7 @@ class Application { ? this.getHashtagPreferences() : {}; const { interests = [], disinterests = [] } = preferenceSource || {}; + const moderationThresholds = this.getActiveModerationThresholds(); return { blacklistedEventIds: blacklist, @@ -6800,6 +6852,9 @@ class Application { interests: Array.isArray(interests) ? [...interests] : [], disinterests: Array.isArray(disinterests) ? [...disinterests] : [], }, + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, }; } diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index ff6327b7..ac654363 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -204,34 +204,112 @@ export function createWatchHistorySuppressionStage({ export function createModerationStage({ stageName = "moderation", - autoplayThreshold = DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, - blurThreshold = DEFAULT_BLUR_THRESHOLD, + autoplayThreshold, + blurThreshold, reportType = "nudity", service = null, getService, - trustedMuteHideThreshold = Number.POSITIVE_INFINITY, - trustedReportHideThreshold = Number.POSITIVE_INFINITY, + trustedMuteHideThreshold, + trustedReportHideThreshold, } = {}) { - const sanitizeThreshold = (value, fallback) => { + const sanitizeThreshold = (value, { fallback, allowInfinity = false } = {}) => { if (Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } + if (allowInfinity && value === Number.POSITIVE_INFINITY) { + return Number.POSITIVE_INFINITY; + } return fallback; }; - const normalizedAutoplayThreshold = Number.isFinite(autoplayThreshold) - ? Math.max(0, Math.floor(autoplayThreshold)) - : DEFAULT_AUTOPLAY_BLOCK_THRESHOLD; - const normalizedBlurThreshold = Number.isFinite(blurThreshold) - ? Math.max(0, Math.floor(blurThreshold)) - : DEFAULT_BLUR_THRESHOLD; - const normalizedMuteHideThreshold = sanitizeThreshold( + const createThresholdResolver = ( + candidate, + { runtimeKey, defaultValue, fallbackValue, allowInfinity = false }, + ) => { + return (context) => { + const runtimeThresholds = + context?.runtime && typeof context.runtime === "object" + ? context.runtime.moderationThresholds + : null; + + const runtimeValue = + runtimeThresholds && typeof runtimeThresholds === "object" + ? runtimeThresholds[runtimeKey] + : undefined; + + const runtimeValid = + Number.isFinite(runtimeValue) || + (allowInfinity && runtimeValue === Number.POSITIVE_INFINITY); + + let value; + + if (typeof candidate === "function") { + try { + value = candidate({ + context, + runtimeThresholds, + runtimeValue, + defaultValue, + }); + } catch (error) { + context?.log?.( + `[${stageName}] threshold resolver threw for ${runtimeKey}`, + error, + ); + value = undefined; + } + } else if (Number.isFinite(candidate)) { + value = candidate; + } else if (allowInfinity && candidate === Number.POSITIVE_INFINITY) { + value = Number.POSITIVE_INFINITY; + } + + const hasValidValue = + Number.isFinite(value) || + (allowInfinity && value === Number.POSITIVE_INFINITY); + + if (!hasValidValue) { + if (runtimeValid) { + value = runtimeValue; + } else { + value = defaultValue; + } + } + + return sanitizeThreshold(value, { + fallback: fallbackValue, + allowInfinity, + }); + }; + }; + + const resolveAutoplayThreshold = createThresholdResolver(autoplayThreshold, { + runtimeKey: "autoplayBlockThreshold", + defaultValue: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + fallbackValue: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + }); + const resolveBlurThreshold = createThresholdResolver(blurThreshold, { + runtimeKey: "blurThreshold", + defaultValue: DEFAULT_BLUR_THRESHOLD, + fallbackValue: DEFAULT_BLUR_THRESHOLD, + }); + const resolveMuteHideThreshold = createThresholdResolver( trustedMuteHideThreshold, - Number.POSITIVE_INFINITY, + { + runtimeKey: "trustedMuteHideThreshold", + defaultValue: Number.POSITIVE_INFINITY, + fallbackValue: Number.POSITIVE_INFINITY, + allowInfinity: true, + }, ); - const normalizedReportHideThreshold = sanitizeThreshold( + const resolveReportHideThreshold = createThresholdResolver( trustedReportHideThreshold, - Number.POSITIVE_INFINITY, + { + runtimeKey: "trustedSpamHideThreshold", + defaultValue: Number.POSITIVE_INFINITY, + fallbackValue: Number.POSITIVE_INFINITY, + allowInfinity: true, + }, ); const normalizedReportType = typeof reportType === "string" ? reportType.trim().toLowerCase() : "nudity"; @@ -259,6 +337,11 @@ export function createModerationStage({ context?.log?.(`[${stageName}] Failed to refresh viewer context`, error); } + const normalizedAutoplayThreshold = resolveAutoplayThreshold(context); + const normalizedBlurThreshold = resolveBlurThreshold(context); + const normalizedMuteHideThreshold = resolveMuteHideThreshold(context); + const normalizedReportHideThreshold = resolveReportHideThreshold(context); + const activeIds = new Set(); for (const item of items) { const videoId = typeof item?.video?.id === "string" ? item.video.id : ""; diff --git a/js/subscriptions.js b/js/subscriptions.js index 544da1f8..e2b6abe4 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -883,6 +883,11 @@ class SubscriptionsManager { ? app.getHashtagPreferences() : {}; + const moderationThresholds = + typeof app?.getActiveModerationThresholds === "function" + ? app.getActiveModerationThresholds() + : null; + return { subscriptionAuthors: normalizedAuthors, authors: normalizedAuthors, @@ -897,6 +902,9 @@ class SubscriptionsManager { ? [...preferenceSource.disinterests] : [], }, + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, }; } diff --git a/tests/app/moderation-settings-refresh.test.mjs b/tests/app/moderation-settings-refresh.test.mjs new file mode 100644 index 00000000..617ca2eb --- /dev/null +++ b/tests/app/moderation-settings-refresh.test.mjs @@ -0,0 +1,68 @@ +import test, { mock } from "node:test"; +import assert from "node:assert/strict"; + +import "../test-helpers/setup-localstorage.mjs"; +import { + withMockedNostrTools, + createModerationAppHarness, +} from "../helpers/moderation-test-helpers.mjs"; + +const VIDEO_HEX = "1".repeat(64); +const SECOND_VIDEO_HEX = "2".repeat(64); +const CURRENT_VIDEO_HEX = "3".repeat(64); + +function buildModerationState() { + return { + trustedMuted: false, + trustedMuteCount: 0, + trustedCount: 0, + reportType: "nudity", + }; +} + +test("handleModerationSettingsChange refreshes feeds with updated thresholds", async (t) => { + withMockedNostrTools(t); + + const app = await createModerationAppHarness(); + app.decorateVideoModeration = mock.fn(() => {}); + + const storedVideo = { id: VIDEO_HEX, moderation: buildModerationState() }; + const listVideo = { id: SECOND_VIDEO_HEX, moderation: buildModerationState() }; + const currentVideo = { id: CURRENT_VIDEO_HEX, moderation: buildModerationState() }; + + app.videosMap.set(storedVideo.id, storedVideo); + app.videoListView = { + videoCardInstances: [ + { + video: listVideo, + refreshModerationUi: mock.fn(() => {}), + }, + ], + currentVideos: [listVideo], + }; + app.currentVideo = currentVideo; + + const refreshMock = mock.fn(async () => {}); + app.onVideosShouldRefresh = refreshMock; + + const pendingSettings = { + blurThreshold: 7, + autoplayBlockThreshold: 6, + trustedMuteHideThreshold: 9, + trustedSpamHideThreshold: 11, + }; + + const expectedSettings = app.normalizeModerationSettings(pendingSettings); + const result = await app.handleModerationSettingsChange({ settings: pendingSettings }); + + assert.deepEqual(result, expectedSettings); + assert.deepEqual(app.moderationSettings, expectedSettings); + + assert.equal(refreshMock.mock.calls.length, 1); + assert.equal( + refreshMock.mock.calls[0]?.arguments?.[0]?.reason, + "moderation-settings-change", + ); + + assert.ok(app.decorateVideoModeration.mock.calls.length >= 3); +}); diff --git a/tests/helpers/moderation-test-helpers.mjs b/tests/helpers/moderation-test-helpers.mjs index 57bfce13..a03e6f0b 100644 --- a/tests/helpers/moderation-test-helpers.mjs +++ b/tests/helpers/moderation-test-helpers.mjs @@ -1,5 +1,6 @@ import "../test-helpers/setup-localstorage.mjs"; import { ModerationService } from "../../js/services/moderationService.js"; +import { getDefaultModerationSettings } from "../../js/state/cache.js"; const DEFAULT_CONTACT_OWNER = "f".repeat(64); @@ -199,6 +200,15 @@ export async function createModerationAppHarness() { app.handleModerationOverride = Application.prototype.handleModerationOverride; app.handleModerationBlock = Application.prototype.handleModerationBlock; app.handleModerationHide = Application.prototype.handleModerationHide; + app.handleModerationSettingsChange = + Application.prototype.handleModerationSettingsChange; + app.normalizeModerationSettings = + Application.prototype.normalizeModerationSettings; + app.getActiveModerationThresholds = + Application.prototype.getActiveModerationThresholds; + + app.defaultModerationSettings = getDefaultModerationSettings(); + app.moderationSettings = { ...app.defaultModerationSettings }; app.videosMap = new Map(); app.currentVideo = null; diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs index d6e8f3eb..05026ed5 100644 --- a/tests/moderation-stage.test.mjs +++ b/tests/moderation-stage.test.mjs @@ -225,6 +225,142 @@ test("moderation stage applies provided thresholds", async () => { assert.equal(relaxedResult[0].metadata.moderation.blurReason, "trusted-report"); }); +test("moderation stage respects runtime threshold changes", async () => { + const videoId = "runtime-threshold"; + const authorHex = "e".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 3; + }, + getTrustedReporters() { + return []; + }, + }; + + const stage = createModerationStage({ service }); + + const strictContext = { + runtime: { + moderationThresholds: { + autoplayBlockThreshold: 1, + blurThreshold: 1, + trustedMuteHideThreshold: Number.POSITIVE_INFINITY, + trustedSpamHideThreshold: 2, + }, + }, + addWhy() {}, + log() {}, + }; + + const relaxedContext = { + runtime: { + moderationThresholds: { + autoplayBlockThreshold: 5, + blurThreshold: 5, + trustedMuteHideThreshold: Number.POSITIVE_INFINITY, + trustedSpamHideThreshold: 10, + }, + }, + addWhy() {}, + log() {}, + }; + + const buildItem = () => ({ video: { id: videoId, pubkey: authorHex }, metadata: {} }); + + const hiddenItems = [buildItem()]; + const hiddenResult = await stage(hiddenItems, strictContext); + + assert.equal(hiddenResult.length, 0); + const hiddenMetadata = hiddenItems[0].metadata.moderation; + assert.equal(hiddenMetadata.hidden, true); + assert.equal(hiddenMetadata.hideReason, "trusted-report-hide"); + assert.equal(hiddenMetadata.blockAutoplay, true); + assert.equal(hiddenMetadata.blurThumbnail, true); + + const visibleItems = [buildItem()]; + const visibleResult = await stage(visibleItems, relaxedContext); + + assert.equal(visibleResult.length, 1); + const visibleModeration = visibleResult[0].video.moderation; + assert.equal(visibleModeration.hidden, false); + assert.equal(visibleModeration.blockAutoplay, false); + assert.equal(visibleModeration.blurThumbnail, false); +}); + +test("moderation stage supports function-based threshold resolvers", async () => { + const videoId = "resolver-threshold"; + const authorHex = "f".repeat(64); + + let autoplaySetting = 5; + let blurSetting = 6; + let hideSetting = Number.POSITIVE_INFINITY; + + 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 2; + }, + getTrustedReporters() { + return []; + }, + }; + + const stage = createModerationStage({ + service, + autoplayThreshold: () => autoplaySetting, + blurThreshold: () => blurSetting, + trustedReportHideThreshold: () => hideSetting, + }); + + const createContext = () => ({ addWhy() {}, log() {} }); + const buildItem = () => ({ video: { id: videoId, pubkey: authorHex }, metadata: {} }); + + const relaxedItems = [buildItem()]; + const relaxedResult = await stage(relaxedItems, createContext()); + + assert.equal(relaxedResult.length, 1); + const relaxedModeration = relaxedResult[0].video.moderation; + assert.equal(relaxedModeration.blockAutoplay, false); + assert.equal(relaxedModeration.blurThumbnail, false); + assert.equal(relaxedModeration.hidden, false); + + autoplaySetting = 1; + blurSetting = 1; + hideSetting = 1; + + const strictItems = [buildItem()]; + const strictResult = await stage(strictItems, createContext()); + + assert.equal(strictResult.length, 0); + const strictMetadata = strictItems[0].metadata.moderation; + assert.equal(strictMetadata.blockAutoplay, true); + assert.equal(strictMetadata.blurThumbnail, true); + assert.equal(strictMetadata.hidden, true); + assert.equal(strictMetadata.hideReason, "trusted-report-hide"); +}); + test("moderation stage propagates whitelist, muters, and threshold updates", async () => { const whitelistedHex = "a".repeat(64); const mutedHex = "b".repeat(64); diff --git a/tests/moderation/hide-thresholds.test.mjs b/tests/moderation/hide-thresholds.test.mjs index f276ffba..a806dead 100644 --- a/tests/moderation/hide-thresholds.test.mjs +++ b/tests/moderation/hide-thresholds.test.mjs @@ -137,6 +137,141 @@ test("moderation stage hides videos when trusted reports exceed threshold", asyn assert.equal(hideWhy.hideBypass ?? null, null); }); +test("moderation stage respects runtime hide threshold changes", async () => { + const reporterHex = hex("4"); + const mutedHex = hex("5"); + const videoId = hex("6"); + + 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 [{ pubkey: reporterHex, latest: 1_700_000_000 }]; + }, + isAuthorMutedByTrusted(pubkey) { + return pubkey === mutedHex; + }, + getTrustedMutersForAuthor(pubkey) { + return pubkey === mutedHex ? [reporterHex] : []; + }, + }; + + const stage = createModerationStage({ service }); + + const strictContext = { + runtime: { + moderationThresholds: { + autoplayBlockThreshold: 1, + blurThreshold: 1, + trustedMuteHideThreshold: 1, + trustedSpamHideThreshold: 2, + }, + }, + addWhy() {}, + log() {}, + }; + + const relaxedContext = { + runtime: { + moderationThresholds: { + autoplayBlockThreshold: 10, + blurThreshold: 10, + trustedMuteHideThreshold: 10, + trustedSpamHideThreshold: 10, + }, + }, + addWhy() {}, + log() {}, + }; + + const hiddenItems = [{ video: { id: videoId, pubkey: mutedHex }, metadata: {} }]; + const hiddenResult = await stage(hiddenItems, strictContext); + + assert.equal(hiddenResult.length, 0); + assert.equal(hiddenItems[0].metadata.moderation.hidden, true); + assert.equal(hiddenItems[0].metadata.moderation.hideReason, "trusted-mute-hide"); + + const visibleItems = [{ video: { id: videoId, pubkey: mutedHex }, metadata: {} }]; + const visibleResult = await stage(visibleItems, relaxedContext); + + assert.equal(visibleResult.length, 1); + const moderation = visibleResult[0].metadata.moderation; + assert.equal(moderation.hidden, false); + assert.equal(moderation.hideReason ?? null, null); +}); + +test("moderation stage supports trusted mute hide resolver functions", async () => { + const muterHex = hex("7"); + const mutedHex = hex("8"); + const videoId = hex("9"); + + let muteHideSetting = Number.POSITIVE_INFINITY; + + 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: () => muteHideSetting, + }); + + const buildItem = () => ({ video: { id: videoId, pubkey: mutedHex }, metadata: {} }); + const baseContext = { addWhy() {}, log() {} }; + + const relaxedItems = [buildItem()]; + const relaxedResult = await stage(relaxedItems, baseContext); + + assert.equal(relaxedResult.length, 1); + const relaxedModeration = relaxedResult[0].metadata.moderation; + assert.equal(relaxedModeration.hidden, false); + assert.equal(relaxedModeration.trustedMuted, true); + + muteHideSetting = 1; + + const strictItems = [buildItem()]; + const strictResult = await stage(strictItems, baseContext); + + assert.equal(strictResult.length, 0); + const strictMetadata = strictItems[0].metadata.moderation; + assert.equal(strictMetadata.hidden, true); + assert.equal(strictMetadata.hideReason, "trusted-mute-hide"); +}); + test("moderation stage bypasses hard hides on home feed", async () => { const videoId = hex("3");