Merge pull request #916 from PR0M3TH3AN/codex/enhance-moderation-stage-for-dynamic-thresholds

Resolve moderation thresholds from runtime context
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-26 10:20:03 -04:00
commit 78d5dc42d5
8 files changed
+522 -26

No files matched your search

+1
View File
@@ -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.
+66 -11
View File
@@ -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,
};
}
+98 -15
View File
@@ -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 : "";
+8
View File
@@ -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,
};
}
@@ -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);
});
+10
View File
@@ -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;
+136
View File
@@ -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);
+135
View File
@@ -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");