From 15b94cfff31e2188dfbc935d22a0a54e0101db3d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 19:26:25 -0400 Subject: [PATCH] Adjust moderation defaults and store per-profile settings --- js/app.js | 4 +- js/services/moderationService.js | 4 +- js/state/cache.js | 259 +++++++++++++----- js/ui/profileModalController.js | 4 +- tests/moderation-service.test.mjs | 38 ++- tests/moderation-stage.test.mjs | 18 +- ...profile-modal-moderation-settings.test.mjs | 34 +-- 7 files changed, 240 insertions(+), 121 deletions(-) diff --git a/js/app.js b/js/app.js index a17b9556..7aceefbb 100644 --- a/js/app.js +++ b/js/app.js @@ -5364,10 +5364,10 @@ class Application { const defaults = this.defaultModerationSettings || getDefaultModerationSettings(); const defaultBlur = Number.isFinite(defaults?.blurThreshold) ? Math.max(0, Math.floor(defaults.blurThreshold)) - : 3; + : 1; const defaultAutoplay = Number.isFinite(defaults?.autoplayBlockThreshold) ? Math.max(0, Math.floor(defaults.autoplayBlockThreshold)) - : 2; + : 1; const defaultTrustedMuteHide = Number.isFinite( defaults?.trustedMuteHideThreshold, ) diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 9a1f1ec9..beb94fad 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -4,8 +4,8 @@ import { accessControl } from "../accessControl.js"; import { userBlocks, USER_BLOCK_EVENTS } from "../userBlocks.js"; import logger from "../utils/logger.js"; -const AUTOPLAY_TRUST_THRESHOLD = 2; -const BLUR_TRUST_THRESHOLD = 3; +const AUTOPLAY_TRUST_THRESHOLD = 1; +const BLUR_TRUST_THRESHOLD = 1; function normalizeUserLogger(candidate) { if ( diff --git a/js/state/cache.js b/js/state/cache.js index 40db7512..22c46b7d 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -11,6 +11,7 @@ import { removeUrlHealthFromStorage, writeUrlHealthToStorage, } from "../utils/storage.js"; +import createProfileSettingsStore from "./profileSettingsStore.js"; const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1"; const PROFILE_CACHE_VERSION = 1; @@ -29,7 +30,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 = 1; +const MODERATION_SETTINGS_STORAGE_VERSION = 2; function computeDefaultModerationSettings() { let runtimeMuteHide = DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD; @@ -58,8 +59,8 @@ function computeDefaultModerationSettings() { } return { - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: sanitizeModerationThreshold( runtimeMuteHide, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, @@ -71,12 +72,19 @@ function computeDefaultModerationSettings() { }; } -const DEFAULT_MODERATION_SETTINGS = Object.freeze(computeDefaultModerationSettings()); - function createDefaultModerationSettings() { return { ...computeDefaultModerationSettings() }; } +const GLOBAL_MODERATION_PROFILE_KEY = "__global__"; +const moderationSettingsStore = createProfileSettingsStore({ + clone: (value) => ({ ...value }), +}); +moderationSettingsStore.set( + GLOBAL_MODERATION_PROFILE_KEY, + createDefaultModerationSettings(), +); + function sanitizeModerationThreshold(value, fallback) { if (value === null || value === undefined) { return fallback; @@ -111,7 +119,6 @@ const profileCache = new Map(); const urlHealthCache = new Map(); const urlHealthInFlight = new Map(); const moderationOverrides = new Map(); -let moderationSettings = createDefaultModerationSettings(); function hasSavedProfilesChanged(previousProfiles, nextProfiles) { if (previousProfiles === nextProfiles) { @@ -228,6 +235,7 @@ export function setActiveProfilePubkey(pubkey, { persist = true } = {}) { } activeProfilePubkey = nextValue; + ensureModerationSettingsForKey(getActiveModerationStoreKey()); if (persist) { persistSavedProfiles({ persistActive: true }); } @@ -809,68 +817,152 @@ function haveModerationSettingsChanged(previous, next) { ); } +function getModerationStoreKeyForPubkey(pubkey) { + const normalized = normalizeHexPubkey(pubkey); + return normalized || GLOBAL_MODERATION_PROFILE_KEY; +} + +function getActiveModerationStoreKey() { + return getModerationStoreKeyForPubkey(activeProfilePubkey); +} + +function ensureModerationSettingsForKey(key) { + const storeKey = key || GLOBAL_MODERATION_PROFILE_KEY; + if (!moderationSettingsStore.has(storeKey)) { + moderationSettingsStore.set(storeKey, createDefaultModerationSettings()); + } + return moderationSettingsStore.get(storeKey); +} + export function getDefaultModerationSettings() { return createDefaultModerationSettings(); } +export function getModerationSettingsForPubkey(pubkey) { + const key = getModerationStoreKeyForPubkey(pubkey); + const settings = ensureModerationSettingsForKey(key); + return { ...settings }; +} + export function getModerationSettings() { - return { ...moderationSettings }; + return getModerationSettingsForPubkey(activeProfilePubkey); } export function loadModerationSettingsFromStorage() { + moderationSettingsStore.clear(); + moderationSettingsStore.set( + GLOBAL_MODERATION_PROFILE_KEY, + createDefaultModerationSettings(), + ); + if (typeof localStorage === "undefined") { - moderationSettings = createDefaultModerationSettings(); return getModerationSettings(); } const raw = localStorage.getItem(MODERATION_SETTINGS_STORAGE_KEY); if (!raw) { - moderationSettings = createDefaultModerationSettings(); return getModerationSettings(); } + let shouldRewrite = false; + try { const payload = JSON.parse(raw); if (!payload || typeof payload !== "object") { - moderationSettings = createDefaultModerationSettings(); - return getModerationSettings(); + shouldRewrite = true; + } else if (payload.version === 1) { + const defaults = createDefaultModerationSettings(); + const overrides = + payload.overrides && typeof payload.overrides === "object" + ? payload.overrides + : {}; + + const legacySettings = { + blurThreshold: sanitizeModerationThreshold( + overrides.blurThreshold, + defaults.blurThreshold, + ), + autoplayBlockThreshold: sanitizeModerationThreshold( + overrides.autoplayBlockThreshold, + defaults.autoplayBlockThreshold, + ), + trustedMuteHideThreshold: sanitizeModerationThreshold( + overrides.trustedMuteHideThreshold, + defaults.trustedMuteHideThreshold, + ), + trustedSpamHideThreshold: sanitizeModerationThreshold( + overrides.trustedSpamHideThreshold, + defaults.trustedSpamHideThreshold, + ), + }; + + moderationSettingsStore.set( + GLOBAL_MODERATION_PROFILE_KEY, + legacySettings, + ); + shouldRewrite = true; + } else if (payload.version === MODERATION_SETTINGS_STORAGE_VERSION) { + const defaults = createDefaultModerationSettings(); + const entries = + payload.entries && typeof payload.entries === "object" + ? payload.entries + : {}; + + for (const [key, entry] of Object.entries(entries)) { + if (!entry || typeof entry !== "object") { + shouldRewrite = true; + continue; + } + + const overrides = + entry.overrides && typeof entry.overrides === "object" + ? entry.overrides + : {}; + + const storeKey = + key === "default" + ? GLOBAL_MODERATION_PROFILE_KEY + : getModerationStoreKeyForPubkey(key); + + if (!storeKey) { + shouldRewrite = true; + continue; + } + + const settings = { + blurThreshold: sanitizeModerationThreshold( + overrides.blurThreshold, + defaults.blurThreshold, + ), + autoplayBlockThreshold: sanitizeModerationThreshold( + overrides.autoplayBlockThreshold, + defaults.autoplayBlockThreshold, + ), + trustedMuteHideThreshold: sanitizeModerationThreshold( + overrides.trustedMuteHideThreshold, + defaults.trustedMuteHideThreshold, + ), + trustedSpamHideThreshold: sanitizeModerationThreshold( + overrides.trustedSpamHideThreshold, + defaults.trustedSpamHideThreshold, + ), + }; + + moderationSettingsStore.set(storeKey, settings); + } + } else { + shouldRewrite = true; } - - if (payload.version !== MODERATION_SETTINGS_STORAGE_VERSION) { - moderationSettings = createDefaultModerationSettings(); - return getModerationSettings(); - } - - const defaults = createDefaultModerationSettings(); - const overrides = - payload.overrides && typeof payload.overrides === "object" - ? payload.overrides - : {}; - - moderationSettings = { - blurThreshold: sanitizeModerationThreshold( - overrides.blurThreshold, - defaults.blurThreshold, - ), - autoplayBlockThreshold: sanitizeModerationThreshold( - overrides.autoplayBlockThreshold, - defaults.autoplayBlockThreshold, - ), - trustedMuteHideThreshold: sanitizeModerationThreshold( - overrides.trustedMuteHideThreshold, - defaults.trustedMuteHideThreshold, - ), - trustedSpamHideThreshold: sanitizeModerationThreshold( - overrides.trustedSpamHideThreshold, - defaults.trustedSpamHideThreshold, - ), - }; } catch (error) { - moderationSettings = createDefaultModerationSettings(); userLogger.warn( "[cache.loadModerationSettingsFromStorage] Failed to parse payload:", error, ); + shouldRewrite = true; + } + + if (shouldRewrite) { + persistModerationSettingsToStorage(); } return getModerationSettings(); @@ -882,31 +974,46 @@ function persistModerationSettingsToStorage() { } const defaults = createDefaultModerationSettings(); - const overrides = {}; + const entries = {}; - if (moderationSettings.blurThreshold !== defaults.blurThreshold) { - overrides.blurThreshold = moderationSettings.blurThreshold; + for (const [key, value] of moderationSettingsStore.entries()) { + const settings = + value && typeof value === "object" + ? value + : createDefaultModerationSettings(); + + const overrides = {}; + + if (settings.blurThreshold !== defaults.blurThreshold) { + overrides.blurThreshold = settings.blurThreshold; + } + + if (settings.autoplayBlockThreshold !== defaults.autoplayBlockThreshold) { + overrides.autoplayBlockThreshold = settings.autoplayBlockThreshold; + } + + if (settings.trustedMuteHideThreshold !== defaults.trustedMuteHideThreshold) { + overrides.trustedMuteHideThreshold = settings.trustedMuteHideThreshold; + } + + if (settings.trustedSpamHideThreshold !== defaults.trustedSpamHideThreshold) { + overrides.trustedSpamHideThreshold = settings.trustedSpamHideThreshold; + } + + if (Object.keys(overrides).length === 0) { + continue; + } + + const storageKey = + key === GLOBAL_MODERATION_PROFILE_KEY ? "default" : key; + + entries[storageKey] = { + overrides, + savedAt: Date.now(), + }; } - if (moderationSettings.autoplayBlockThreshold !== defaults.autoplayBlockThreshold) { - overrides.autoplayBlockThreshold = moderationSettings.autoplayBlockThreshold; - } - - if ( - moderationSettings.trustedMuteHideThreshold !== - defaults.trustedMuteHideThreshold - ) { - overrides.trustedMuteHideThreshold = moderationSettings.trustedMuteHideThreshold; - } - - if ( - moderationSettings.trustedSpamHideThreshold !== - defaults.trustedSpamHideThreshold - ) { - overrides.trustedSpamHideThreshold = moderationSettings.trustedSpamHideThreshold; - } - - if (Object.keys(overrides).length === 0) { + if (Object.keys(entries).length === 0) { try { localStorage.removeItem(MODERATION_SETTINGS_STORAGE_KEY); } catch (error) { @@ -920,7 +1027,7 @@ function persistModerationSettingsToStorage() { const payload = { version: MODERATION_SETTINGS_STORAGE_VERSION, - overrides, + entries, savedAt: Date.now(), }; @@ -938,8 +1045,10 @@ function persistModerationSettingsToStorage() { } export function setModerationSettings(partial = {}, { persist = true } = {}) { + const key = getActiveModerationStoreKey(); const defaults = createDefaultModerationSettings(); - const next = { ...moderationSettings }; + const current = ensureModerationSettingsForKey(key); + const next = { ...current }; let changed = false; if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) { @@ -994,33 +1103,35 @@ export function setModerationSettings(partial = {}, { persist = true } = {}) { if (persist) { persistModerationSettingsToStorage(); } - return { ...moderationSettings }; + return ensureModerationSettingsForKey(key); } - moderationSettings = next; + moderationSettingsStore.set(key, next); if (persist) { persistModerationSettingsToStorage(); } - return { ...moderationSettings }; + return ensureModerationSettingsForKey(key); } export function resetModerationSettings({ persist = true } = {}) { - const previous = moderationSettings; - moderationSettings = createDefaultModerationSettings(); + const key = getActiveModerationStoreKey(); + const previous = ensureModerationSettingsForKey(key); + const defaults = createDefaultModerationSettings(); + moderationSettingsStore.set(key, defaults); - const changed = haveModerationSettingsChanged(previous, moderationSettings); + const changed = haveModerationSettingsChanged(previous, defaults); if (persist) { persistModerationSettingsToStorage(); } if (!changed) { - return { ...moderationSettings }; + return ensureModerationSettingsForKey(key); } - return { ...moderationSettings }; + return ensureModerationSettingsForKey(key); } export function getModerationOverridesMap() { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 7dcc33f6..11dda5ba 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -59,8 +59,8 @@ function createInternalDefaultNwcSettings() { } const DEFAULT_INTERNAL_MODERATION_SETTINGS = Object.freeze({ - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: getTrustedMuteHideThreshold(), trustedSpamHideThreshold: getTrustedSpamHideThreshold(), }); diff --git a/tests/moderation-service.test.mjs b/tests/moderation-service.test.mjs index 7610541d..0f0e888b 100644 --- a/tests/moderation-service.test.mjs +++ b/tests/moderation-service.test.mjs @@ -244,14 +244,16 @@ test("moderation thresholds emit logger hooks only when crossing", async (t) => const createReporterEntry = (timestamp) => new Map([[reportType, { created_at: timestamp }]]); - const expectSingleAction = (expectedAction, expectedCount) => { - assert.equal(calls.length, 1); - const [message, detail] = calls[0]; - assert.equal(message, "[moderationService] moderation threshold crossed"); - assert.equal(detail.action, expectedAction); - assert.equal(detail.eventId, eventId); - assert.equal(detail.reportType, reportType); - assert.equal(detail.trustedCount, expectedCount); + const expectActions = (...expected) => { + assert.equal(calls.length, expected.length); + expected.forEach((entry, index) => { + const [message, detail] = calls[index]; + assert.equal(message, "[moderationService] moderation threshold crossed"); + assert.equal(detail.action, entry.action); + assert.equal(detail.eventId, eventId); + assert.equal(detail.reportType, reportType); + assert.equal(detail.trustedCount, entry.count); + }); calls.length = 0; }; @@ -260,34 +262,40 @@ test("moderation thresholds emit logger hooks only when crossing", async (t) => }; reports.set(trustedReporters[0], createReporterEntry(100)); + service.recomputeSummaryForEvent(eventId); + expectActions( + { action: "autoplay-block-enabled", count: 1 }, + { action: "blur-enabled", count: 1 }, + ); + service.recomputeSummaryForEvent(eventId); expectNoAction(); reports.set(trustedReporters[1], createReporterEntry(200)); - service.recomputeSummaryForEvent(eventId); - expectSingleAction("autoplay-block-enabled", 2); - service.recomputeSummaryForEvent(eventId); expectNoAction(); reports.set(trustedReporters[2], createReporterEntry(300)); service.recomputeSummaryForEvent(eventId); - expectSingleAction("blur-enabled", 3); + expectNoAction(); service.recomputeSummaryForEvent(eventId); expectNoAction(); reports.delete(trustedReporters[2]); service.recomputeSummaryForEvent(eventId); - expectSingleAction("blur-cleared", 2); + expectNoAction(); reports.delete(trustedReporters[1]); service.recomputeSummaryForEvent(eventId); - expectSingleAction("autoplay-block-cleared", 1); + expectNoAction(); reports.delete(trustedReporters[0]); service.recomputeSummaryForEvent(eventId); - expectNoAction(); + expectActions( + { action: "autoplay-block-cleared", count: 0 }, + { action: "blur-cleared", count: 0 }, + ); service.recomputeSummaryForEvent(eventId); expectNoAction(); diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs index cb24b4e8..1b0cbfe9 100644 --- a/tests/moderation-stage.test.mjs +++ b/tests/moderation-stage.test.mjs @@ -55,8 +55,8 @@ test("moderation stage enforces admin lists without whitelist bypass", async () const stage = createModerationStage({ service, reportType: "nudity", - autoplayThreshold: 2, - blurThreshold: 3, + autoplayThreshold: 1, + blurThreshold: 1, }); const items = [ @@ -199,8 +199,8 @@ test("moderation stage applies provided thresholds", async () => { const relaxedStage = createModerationStage({ service, - autoplayThreshold: 2, - blurThreshold: 3, + autoplayThreshold: 1, + blurThreshold: 1, }); const strictResult = await strictStage(items, {}); @@ -281,8 +281,8 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy const stage = createModerationStage({ service, - autoplayThreshold: 2, - blurThreshold: 3, + autoplayThreshold: 1, + blurThreshold: 1, reportType: "nudity", }); @@ -322,11 +322,11 @@ test("moderation stage propagates whitelist, muters, and threshold updates", asy state.summaryById.set("muted-video", { eventId: "muted-video", - totalTrusted: 1, - types: { nudity: { trusted: 1, total: 1, latest: 1_700_000_500 } }, + totalTrusted: 0, + types: { nudity: { trusted: 0, total: 0, latest: 1_700_000_500 } }, updatedAt: 1_700_000_600, }); - state.trustedCounts.set("muted-video", 1); + state.trustedCounts.set("muted-video", 0); state.mutersByAuthor.set(mutedHex, []); state.mutedAuthors.delete(mutedHex); diff --git a/tests/ui/profile-modal-moderation-settings.test.mjs b/tests/ui/profile-modal-moderation-settings.test.mjs index ee70ae0b..b59f99ab 100644 --- a/tests/ui/profile-modal-moderation-settings.test.mjs +++ b/tests/ui/profile-modal-moderation-settings.test.mjs @@ -67,16 +67,16 @@ beforeEach(async () => { }; storedSettings = { - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: 1, trustedSpamHideThreshold: 3, }; moderationService = { getDefaultModerationSettings() { return { - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: 1, trustedSpamHideThreshold: 3, }; @@ -87,7 +87,7 @@ beforeEach(async () => { updateModerationSettings(partial = {}) { if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) { const value = partial.blurThreshold; - storedSettings.blurThreshold = value === null ? 3 : value; + storedSettings.blurThreshold = value === null ? 1 : value; } if ( Object.prototype.hasOwnProperty.call( @@ -96,7 +96,7 @@ beforeEach(async () => { ) ) { const value = partial.autoplayBlockThreshold; - storedSettings.autoplayBlockThreshold = value === null ? 2 : value; + storedSettings.autoplayBlockThreshold = value === null ? 1 : value; } if ( Object.prototype.hasOwnProperty.call( @@ -120,8 +120,8 @@ beforeEach(async () => { }, resetModerationSettings() { storedSettings = { - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: 1, trustedSpamHideThreshold: 3, }; @@ -185,8 +185,8 @@ afterEach(() => { test("moderation settings save updates service and disables control", async () => { await controller.load(); - assert.equal(controller.moderationBlurInput.value, "3"); - assert.equal(controller.moderationAutoplayInput.value, "2"); + assert.equal(controller.moderationBlurInput.value, "1"); + assert.equal(controller.moderationAutoplayInput.value, "1"); assert.equal(controller.moderationMuteHideInput.value, "1"); assert.equal(controller.moderationSpamHideInput.value, "3"); assert.equal(controller.moderationSaveButton.disabled, true); @@ -249,13 +249,13 @@ test("moderation reset restores defaults and clearing inputs uses defaults", asy const resetContext = await controller.handleModerationSettingsReset(); assert.equal(resetContext.success, true); assert.deepEqual(storedSettings, { - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: 1, trustedSpamHideThreshold: 3, }); - assert.equal(controller.moderationBlurInput.value, "3"); - assert.equal(controller.moderationAutoplayInput.value, "2"); + assert.equal(controller.moderationBlurInput.value, "1"); + assert.equal(controller.moderationAutoplayInput.value, "1"); assert.equal(controller.moderationMuteHideInput.value, "1"); assert.equal(controller.moderationSpamHideInput.value, "3"); assert.equal( @@ -276,7 +276,7 @@ test("moderation reset restores defaults and clearing inputs uses defaults", asy assert.equal(saveResult.success, true); assert.deepEqual(storedSettings, { blurThreshold: 7, - autoplayBlockThreshold: 2, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: 1, trustedSpamHideThreshold: 8, }); @@ -302,8 +302,8 @@ test("moderation reset restores defaults and clearing inputs uses defaults", asy const revertContext = await controller.handleModerationSettingsSave(); assert.equal(revertContext.success, true); assert.deepEqual(storedSettings, { - blurThreshold: 3, - autoplayBlockThreshold: 2, + blurThreshold: 1, + autoplayBlockThreshold: 1, trustedMuteHideThreshold: 1, trustedSpamHideThreshold: 3, });