From d9774063865b18eba384f6d65deabcc6e81c5bb8 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 20 Oct 2025 12:45:39 -0400
Subject: [PATCH] Add configurable moderation thresholds in profile settings
---
components/profile-modal.html | 69 +++
docs/moderation/README.md | 4 +
js/app.js | 125 ++++-
js/state/cache.js | 209 ++++++++
js/ui/profileModalController.js | 463 ++++++++++++++++++
tests/moderation-stage.test.mjs | 47 ++
...profile-modal-moderation-settings.test.mjs | 232 +++++++++
7 files changed, 1139 insertions(+), 10 deletions(-)
create mode 100644 tests/ui/profile-modal-moderation-settings.test.mjs
diff --git a/components/profile-modal.html b/components/profile-modal.html
index 019750f7..d1c0766b 100644
--- a/components/profile-modal.html
+++ b/components/profile-modal.html
@@ -233,6 +233,75 @@
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
+
+
+
+
Safety & Moderation
+
+ Choose how many trusted reports it takes to blur thumbnails or block autoplay previews.
+ Lower numbers are stricter.
+
+
+
+
+
+
+
+
+
+
+
+
You can override all defaults in **Settings → Safety & Moderation**.
+### Safety & Moderation controls
+
+The profile modal now exposes the blur and autoplay thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values above. Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load.
+
## Files in this folder
- `web-of-trust.md` — how we compute trust signals and thresholds
- `nips.md` — exact NIPs and kinds bitvid uses
diff --git a/js/app.js b/js/app.js
index 14514290..6cd8c47e 100644
--- a/js/app.js
+++ b/js/app.js
@@ -119,6 +119,11 @@ import {
getModerationOverride,
setModerationOverride,
loadModerationOverridesFromStorage,
+ getModerationSettings,
+ getDefaultModerationSettings,
+ setModerationSettings,
+ resetModerationSettings,
+ loadModerationSettingsFromStorage,
URL_PROBE_TIMEOUT_MS,
urlHealthConstants,
} from "./state/cache.js";
@@ -191,6 +196,8 @@ class Application {
onUpdate: this.boundStreamHealthBadgeHandler,
});
};
+ this.defaultModerationSettings = getDefaultModerationSettings();
+ this.moderationSettings = { ...this.defaultModerationSettings };
this.relayManager = relayManager;
this.activeIntervals = [];
this.watchHistoryTelemetry = null;
@@ -538,6 +545,13 @@ class Application {
accessControl,
getCurrentUserNpub: () => this.getCurrentUserNpub(),
nwcSettings: this.nwcSettingsService,
+ moderationSettings: {
+ getDefaultModerationSettings: () => getDefaultModerationSettings(),
+ getActiveModerationSettings: () => getModerationSettings(),
+ updateModerationSettings: (partial = {}) =>
+ setModerationSettings(partial),
+ resetModerationSettings: () => resetModerationSettings(),
+ },
loadVideos: (forceFetch, context) =>
this.loadVideos(forceFetch, context),
onVideosShouldRefresh: (context) =>
@@ -596,6 +610,8 @@ class Application {
this.handleProfileAdminNotifyError(payload),
onHistoryReady: (payload) =>
this.handleProfileHistoryEvent(payload),
+ onModerationSettingsChange: (payload) =>
+ this.handleModerationSettingsChange(payload),
};
this.profileController = new ProfileModalController({
@@ -1205,6 +1221,10 @@ class Application {
this.renderSavedProfiles();
loadModerationOverridesFromStorage();
+ loadModerationSettingsFromStorage();
+ this.moderationSettings = this.normalizeModerationSettings(
+ getModerationSettings(),
+ );
const videoModalPromise = this.videoModal.load().then(() => {
const modalRoot = this.videoModal.getRoot();
@@ -3023,6 +3043,55 @@ class Application {
return null;
}
+ handleModerationSettingsChange({ settings } = {}) {
+ const normalized = this.normalizeModerationSettings(settings);
+ this.moderationSettings = normalized;
+
+ if (this.videosMap instanceof Map) {
+ for (const video of this.videosMap.values()) {
+ if (video && typeof video === "object") {
+ this.decorateVideoModeration(video);
+ }
+ }
+ }
+
+ if (
+ this.videoListView &&
+ Array.isArray(this.videoListView.videoCardInstances)
+ ) {
+ for (const card of this.videoListView.videoCardInstances) {
+ if (!card || typeof card.refreshModerationUi !== "function") {
+ continue;
+ }
+ if (card.video && typeof card.video === "object") {
+ this.decorateVideoModeration(card.video);
+ }
+ try {
+ card.refreshModerationUi();
+ } catch (error) {
+ devLogger.warn(
+ "[Application] Failed to refresh moderation UI:",
+ error,
+ );
+ }
+ }
+ }
+
+ if (this.videoListView && Array.isArray(this.videoListView.currentVideos)) {
+ for (const video of this.videoListView.currentVideos) {
+ if (video && typeof video === "object") {
+ this.decorateVideoModeration(video);
+ }
+ }
+ }
+
+ if (this.currentVideo && typeof this.currentVideo === "object") {
+ this.decorateVideoModeration(this.currentVideo);
+ }
+
+ return normalized;
+ }
+
updateActiveProfileUI(pubkey, profile = {}) {
if (this.profileController) {
this.profileController.handleProfileUpdated({
@@ -4111,6 +4180,7 @@ class Application {
}
try {
+ const thresholds = this.getActiveModerationThresholds();
return this.feedEngine.registerFeed("recent", {
source: createActiveNostrSource({ service: this.nostrService }),
stages: [
@@ -4123,6 +4193,8 @@ class Application {
}),
createModerationStage({
getService: () => this.nostrService.getModerationService(),
+ autoplayThreshold: thresholds.autoplayBlockThreshold,
+ blurThreshold: thresholds.blurThreshold,
}),
],
sorter: createChronologicalSorter(),
@@ -4147,6 +4219,7 @@ class Application {
}
try {
+ const thresholds = this.getActiveModerationThresholds();
return this.feedEngine.registerFeed("subscriptions", {
source: createSubscriptionAuthorsSource({ service: this.nostrService }),
stages: [
@@ -4159,6 +4232,8 @@ class Application {
}),
createModerationStage({
getService: () => this.nostrService.getModerationService(),
+ autoplayThreshold: thresholds.autoplayBlockThreshold,
+ blurThreshold: thresholds.blurThreshold,
}),
],
sorter: createChronologicalSorter(),
@@ -4746,6 +4821,33 @@ class Application {
return formatShortNpub(trimmed);
}
+ normalizeModerationSettings(settings = null) {
+ const defaults = this.defaultModerationSettings || getDefaultModerationSettings();
+ const defaultBlur = Number.isFinite(defaults?.blurThreshold)
+ ? Math.max(0, Math.floor(defaults.blurThreshold))
+ : 3;
+ const defaultAutoplay = Number.isFinite(defaults?.autoplayBlockThreshold)
+ ? Math.max(0, Math.floor(defaults.autoplayBlockThreshold))
+ : 2;
+
+ const blurSource = Number.isFinite(settings?.blurThreshold)
+ ? Math.max(0, Math.floor(settings.blurThreshold))
+ : defaultBlur;
+ const autoplaySource = Number.isFinite(settings?.autoplayBlockThreshold)
+ ? Math.max(0, Math.floor(settings.autoplayBlockThreshold))
+ : defaultAutoplay;
+
+ return {
+ blurThreshold: blurSource,
+ autoplayBlockThreshold: autoplaySource,
+ };
+ }
+
+ getActiveModerationThresholds() {
+ this.moderationSettings = this.normalizeModerationSettings(this.moderationSettings);
+ return { ...this.moderationSettings };
+ }
+
decorateVideoModeration(video) {
if (!video || typeof video !== "object") {
return video;
@@ -4846,22 +4948,25 @@ class Application {
? Math.max(0, Math.floor(existingModeration.trustedCount))
: this.deriveModerationTrustedCount(summary, reportType);
+ const thresholds = this.getActiveModerationThresholds();
+ const adminWhitelistBypass = existingModeration.adminWhitelistBypass === true;
+ const computedBlockAutoplay = adminWhitelistBypass
+ ? false
+ : trustedCount >= thresholds.autoplayBlockThreshold;
+ const computedBlurThumbnail = adminWhitelistBypass
+ ? false
+ : trustedCount >= thresholds.blurThreshold;
+
const overrideEntry = getModerationOverride(video.id);
const overrideActive = overrideEntry?.showAnyway === true;
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
? Math.floor(overrideEntry.updatedAt)
: Date.now();
- const originalState =
- existingModeration.original && typeof existingModeration.original === "object"
- ? {
- blockAutoplay: existingModeration.original.blockAutoplay === true,
- blurThumbnail: existingModeration.original.blurThumbnail === true,
- }
- : {
- blockAutoplay: existingModeration.blockAutoplay === true,
- blurThumbnail: existingModeration.blurThumbnail === true,
- };
+ const originalState = {
+ blockAutoplay: computedBlockAutoplay,
+ blurThumbnail: computedBlurThumbnail,
+ };
const decoratedModeration = {
...existingModeration,
diff --git a/js/state/cache.js b/js/state/cache.js
index fc30d477..e8f1c6ae 100644
--- a/js/state/cache.js
+++ b/js/state/cache.js
@@ -22,6 +22,35 @@ 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 DEFAULT_MODERATION_SETTINGS = Object.freeze({
+ blurThreshold: 3,
+ autoplayBlockThreshold: 2,
+});
+
+function createDefaultModerationSettings() {
+ return { ...DEFAULT_MODERATION_SETTINGS };
+}
+
+function sanitizeModerationThreshold(value, fallback) {
+ if (value === null || value === undefined) {
+ return fallback;
+ }
+
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return fallback;
+ }
+
+ const clamped = Math.max(0, Math.floor(numeric));
+ if (!Number.isFinite(clamped)) {
+ return fallback;
+ }
+
+ return clamped;
+}
function sanitizeProfileString(value) {
if (typeof value !== "string") {
@@ -39,6 +68,7 @@ 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) {
@@ -679,6 +709,185 @@ export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
return entry;
}
+function haveModerationSettingsChanged(previous, next) {
+ if (!previous || !next) {
+ return true;
+ }
+
+ return (
+ previous.blurThreshold !== next.blurThreshold ||
+ previous.autoplayBlockThreshold !== next.autoplayBlockThreshold
+ );
+}
+
+export function getDefaultModerationSettings() {
+ return createDefaultModerationSettings();
+}
+
+export function getModerationSettings() {
+ return { ...moderationSettings };
+}
+
+export function loadModerationSettingsFromStorage() {
+ if (typeof localStorage === "undefined") {
+ moderationSettings = createDefaultModerationSettings();
+ return getModerationSettings();
+ }
+
+ const raw = localStorage.getItem(MODERATION_SETTINGS_STORAGE_KEY);
+ if (!raw) {
+ moderationSettings = createDefaultModerationSettings();
+ return getModerationSettings();
+ }
+
+ try {
+ const payload = JSON.parse(raw);
+ if (!payload || typeof payload !== "object") {
+ moderationSettings = createDefaultModerationSettings();
+ return getModerationSettings();
+ }
+
+ 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,
+ ),
+ };
+ } catch (error) {
+ moderationSettings = createDefaultModerationSettings();
+ userLogger.warn(
+ "[cache.loadModerationSettingsFromStorage] Failed to parse payload:",
+ error,
+ );
+ }
+
+ return getModerationSettings();
+}
+
+function persistModerationSettingsToStorage() {
+ if (typeof localStorage === "undefined") {
+ return;
+ }
+
+ const defaults = createDefaultModerationSettings();
+ const overrides = {};
+
+ if (moderationSettings.blurThreshold !== defaults.blurThreshold) {
+ overrides.blurThreshold = moderationSettings.blurThreshold;
+ }
+
+ if (moderationSettings.autoplayBlockThreshold !== defaults.autoplayBlockThreshold) {
+ overrides.autoplayBlockThreshold = moderationSettings.autoplayBlockThreshold;
+ }
+
+ if (Object.keys(overrides).length === 0) {
+ try {
+ localStorage.removeItem(MODERATION_SETTINGS_STORAGE_KEY);
+ } catch (error) {
+ userLogger.warn(
+ "[cache.persistModerationSettingsToStorage] Failed to clear overrides:",
+ error,
+ );
+ }
+ return;
+ }
+
+ const payload = {
+ version: MODERATION_SETTINGS_STORAGE_VERSION,
+ overrides,
+ savedAt: Date.now(),
+ };
+
+ try {
+ localStorage.setItem(
+ MODERATION_SETTINGS_STORAGE_KEY,
+ JSON.stringify(payload),
+ );
+ } catch (error) {
+ userLogger.warn(
+ "[cache.persistModerationSettingsToStorage] Failed to persist overrides:",
+ error,
+ );
+ }
+}
+
+export function setModerationSettings(partial = {}, { persist = true } = {}) {
+ const defaults = createDefaultModerationSettings();
+ const next = { ...moderationSettings };
+ let changed = false;
+
+ if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) {
+ const value = partial.blurThreshold;
+ const sanitized =
+ value === null
+ ? defaults.blurThreshold
+ : sanitizeModerationThreshold(value, defaults.blurThreshold);
+ if (next.blurThreshold !== sanitized) {
+ next.blurThreshold = sanitized;
+ changed = true;
+ }
+ }
+
+ if (Object.prototype.hasOwnProperty.call(partial, "autoplayBlockThreshold")) {
+ const value = partial.autoplayBlockThreshold;
+ const sanitized =
+ value === null
+ ? defaults.autoplayBlockThreshold
+ : sanitizeModerationThreshold(value, defaults.autoplayBlockThreshold);
+ if (next.autoplayBlockThreshold !== sanitized) {
+ next.autoplayBlockThreshold = sanitized;
+ changed = true;
+ }
+ }
+
+ if (!changed) {
+ if (persist) {
+ persistModerationSettingsToStorage();
+ }
+ return { ...moderationSettings };
+ }
+
+ moderationSettings = next;
+
+ if (persist) {
+ persistModerationSettingsToStorage();
+ }
+
+ return { ...moderationSettings };
+}
+
+export function resetModerationSettings({ persist = true } = {}) {
+ const previous = moderationSettings;
+ moderationSettings = createDefaultModerationSettings();
+
+ const changed = haveModerationSettingsChanged(previous, moderationSettings);
+
+ if (persist) {
+ persistModerationSettingsToStorage();
+ }
+
+ if (!changed) {
+ return { ...moderationSettings };
+ }
+
+ return { ...moderationSettings };
+}
+
export function getModerationOverridesMap() {
return moderationOverrides;
}
diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js
index c94b5c50..6ad6a6a0 100644
--- a/js/ui/profileModalController.js
+++ b/js/ui/profileModalController.js
@@ -27,6 +27,31 @@ function createInternalDefaultNwcSettings() {
return { ...DEFAULT_INTERNAL_NWC_SETTINGS };
}
+const DEFAULT_INTERNAL_MODERATION_SETTINGS = Object.freeze({
+ blurThreshold: 3,
+ autoplayBlockThreshold: 2,
+});
+
+function createInternalDefaultModerationSettings() {
+ return { ...DEFAULT_INTERNAL_MODERATION_SETTINGS };
+}
+
+function ensureInternalModerationSettings(internalState) {
+ if (!internalState || typeof internalState !== "object") {
+ return createInternalDefaultModerationSettings();
+ }
+
+ if (
+ !internalState.moderationSettings ||
+ typeof internalState.moderationSettings !== "object" ||
+ internalState.moderationSettings === null
+ ) {
+ internalState.moderationSettings = createInternalDefaultModerationSettings();
+ }
+
+ return internalState.moderationSettings;
+}
+
function ensureInternalWalletSettings(internalState) {
if (!internalState || typeof internalState !== "object") {
return createInternalDefaultNwcSettings();
@@ -262,6 +287,79 @@ const SERVICE_CONTRACT = [
return service;
},
},
+ {
+ key: "moderationSettings",
+ type: "object",
+ description:
+ "Service that manages Safety & Moderation threshold overrides for the active profile.",
+ fallback: ({ internalState }) => {
+ const ensure = () => ensureInternalModerationSettings(internalState);
+ const sanitize = (value, fallback) => {
+ if (value === null || value === undefined) {
+ return fallback;
+ }
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) {
+ return fallback;
+ }
+ return Math.max(0, Math.floor(numeric));
+ };
+
+ const service = {};
+
+ service.getDefaultModerationSettings = () =>
+ createInternalDefaultModerationSettings();
+
+ service.getActiveModerationSettings = () => {
+ const current = ensure();
+ const defaults = service.getDefaultModerationSettings();
+ return {
+ blurThreshold: sanitize(current.blurThreshold, defaults.blurThreshold),
+ autoplayBlockThreshold: sanitize(
+ current.autoplayBlockThreshold,
+ defaults.autoplayBlockThreshold,
+ ),
+ };
+ };
+
+ service.updateModerationSettings = (partial = {}) => {
+ const defaults = service.getDefaultModerationSettings();
+ const current = ensure();
+ const next = { ...current };
+
+ if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) {
+ const value = partial.blurThreshold;
+ next.blurThreshold =
+ value === null
+ ? defaults.blurThreshold
+ : sanitize(value, defaults.blurThreshold);
+ }
+
+ if (
+ Object.prototype.hasOwnProperty.call(
+ partial,
+ "autoplayBlockThreshold",
+ )
+ ) {
+ const value = partial.autoplayBlockThreshold;
+ next.autoplayBlockThreshold =
+ value === null
+ ? defaults.autoplayBlockThreshold
+ : sanitize(value, defaults.autoplayBlockThreshold);
+ }
+
+ internalState.moderationSettings = next;
+ return service.getActiveModerationSettings();
+ };
+
+ service.resetModerationSettings = () => {
+ internalState.moderationSettings = createInternalDefaultModerationSettings();
+ return service.getActiveModerationSettings();
+ };
+
+ return service;
+ },
+ },
{
key: "loadVideos",
type: "function",
@@ -615,6 +713,7 @@ export class ProfileModalController {
activePane: "account",
walletBusy: false,
walletSettings: createInternalDefaultNwcSettings(),
+ moderationSettings: createInternalDefaultModerationSettings(),
};
this.services = buildServicesContract(services, this.internalState);
@@ -625,6 +724,8 @@ export class ProfileModalController {
this.safeDecodeNpub = this.services.safeDecodeNpub;
this.truncateMiddle = this.services.truncateMiddle;
this.formatShortNpub = this.services.formatShortNpub;
+ this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
+ this.currentModerationSettings = createInternalDefaultModerationSettings();
this.sendAdminListNotificationService =
typeof this.services.sendAdminListNotification === "function"
? this.services.sendAdminListNotification
@@ -664,6 +765,8 @@ export class ProfileModalController {
callbacks.onWalletDisconnectRequest || callbacks.onWalletDisconnect || noop,
onAdminMutation: callbacks.onAdminMutation || noop,
onAdminNotifyError: callbacks.onAdminNotifyError || noop,
+ onModerationSettingsChange:
+ callbacks.onModerationSettingsChange || noop,
};
this.profileModal = null;
@@ -728,6 +831,12 @@ export class ProfileModalController {
this.walletDisconnectButton = null;
this.walletStatusText = null;
this.profileWalletStatusText = null;
+ this.moderationSettingsCard = null;
+ this.moderationBlurInput = null;
+ this.moderationAutoplayInput = null;
+ this.moderationSaveButton = null;
+ this.moderationResetButton = null;
+ this.moderationStatusText = null;
this.moderatorSection = null;
this.moderatorEmpty = null;
this.adminModeratorList = null;
@@ -809,6 +918,7 @@ export class ProfileModalController {
this.setupLayoutBreakpointObserver();
this.applyModalStackingOverrides();
this.registerEventListeners();
+ this.refreshModerationSettingsUi();
const preserveMenu = this.isMobileLayoutActive();
this.selectPane(this.getActivePane(), { keepMenuView: preserveMenu });
@@ -903,6 +1013,18 @@ export class ProfileModalController {
this.panes.blocked?.querySelector("[data-role=\"blocked-list-status\"]") ||
null;
this.profileWalletStatusText = this.walletStatusText;
+ this.moderationSettingsCard =
+ document.getElementById("profileModerationSettings") || null;
+ this.moderationBlurInput =
+ document.getElementById("profileModerationBlurThreshold") || null;
+ this.moderationAutoplayInput =
+ document.getElementById("profileModerationAutoplayThreshold") || null;
+ this.moderationSaveButton =
+ document.getElementById("profileModerationSave") || null;
+ this.moderationResetButton =
+ document.getElementById("profileModerationReset") || null;
+ this.moderationStatusText =
+ document.getElementById("profileModerationStatus") || null;
this.moderatorSection =
document.getElementById("adminModeratorsSection") || null;
@@ -1120,6 +1242,30 @@ export class ProfileModalController {
});
}
+ if (this.moderationBlurInput instanceof HTMLElement) {
+ this.moderationBlurInput.addEventListener("input", () => {
+ this.applyModerationSettingsControlState();
+ });
+ }
+
+ if (this.moderationAutoplayInput instanceof HTMLElement) {
+ this.moderationAutoplayInput.addEventListener("input", () => {
+ this.applyModerationSettingsControlState();
+ });
+ }
+
+ if (this.moderationSaveButton instanceof HTMLElement) {
+ this.moderationSaveButton.addEventListener("click", () => {
+ void this.handleModerationSettingsSave();
+ });
+ }
+
+ if (this.moderationResetButton instanceof HTMLElement) {
+ this.moderationResetButton.addEventListener("click", () => {
+ void this.handleModerationSettingsReset();
+ });
+ }
+
if (this.addModeratorButton instanceof HTMLElement) {
this.addModeratorButton.addEventListener("click", () => {
void this.handleAddModerator();
@@ -2989,6 +3135,322 @@ export class ProfileModalController {
return context;
}
+ getModerationSettingsService() {
+ const service = this.services.moderationSettings;
+ if (!service || typeof service !== "object") {
+ return null;
+ }
+ return service;
+ }
+
+ getModerationSettingsDefaults() {
+ const service = this.getModerationSettingsService();
+ let defaults = null;
+
+ if (service && typeof service.getDefaultModerationSettings === "function") {
+ try {
+ defaults = service.getDefaultModerationSettings();
+ } catch (error) {
+ devLogger.info("[profileModal] moderation defaults fallback used", error);
+ }
+ }
+
+ if (!defaults || typeof defaults !== "object") {
+ defaults = createInternalDefaultModerationSettings();
+ }
+
+ const sanitized = {
+ blurThreshold: Math.max(
+ 0,
+ Math.floor(
+ Number(
+ defaults.blurThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.blurThreshold,
+ ),
+ ),
+ ),
+ autoplayBlockThreshold: Math.max(
+ 0,
+ Math.floor(
+ Number(
+ defaults.autoplayBlockThreshold ??
+ DEFAULT_INTERNAL_MODERATION_SETTINGS.autoplayBlockThreshold,
+ ),
+ ),
+ ),
+ };
+
+ return sanitized;
+ }
+
+ normalizeModerationSettings(settings = null) {
+ const defaults = this.getModerationSettingsDefaults();
+ const blur = Number.isFinite(settings?.blurThreshold)
+ ? Math.max(0, Math.floor(settings.blurThreshold))
+ : defaults.blurThreshold;
+ const autoplay = Number.isFinite(settings?.autoplayBlockThreshold)
+ ? Math.max(0, Math.floor(settings.autoplayBlockThreshold))
+ : defaults.autoplayBlockThreshold;
+
+ return {
+ blurThreshold: blur,
+ autoplayBlockThreshold: autoplay,
+ };
+ }
+
+ readModerationInputs() {
+ const defaults = this.getModerationSettingsDefaults();
+
+ const parse = (input, fallback) => {
+ if (!(input instanceof HTMLInputElement)) {
+ return { value: fallback, override: null, valid: true };
+ }
+
+ const raw = typeof input.value === "string" ? input.value.trim() : "";
+ if (!raw) {
+ return { value: fallback, override: null, valid: true };
+ }
+
+ const numeric = Number(raw);
+ if (!Number.isFinite(numeric)) {
+ return { value: fallback, override: null, valid: false };
+ }
+
+ const sanitized = Math.max(0, Math.floor(numeric));
+ return { value: sanitized, override: sanitized, valid: true };
+ };
+
+ const blur = parse(this.moderationBlurInput, defaults.blurThreshold);
+ const autoplay = parse(
+ this.moderationAutoplayInput,
+ defaults.autoplayBlockThreshold,
+ );
+
+ const valid = blur.valid && autoplay.valid;
+ const values = {
+ blurThreshold: blur.value,
+ autoplayBlockThreshold: autoplay.value,
+ };
+ const overrides = {
+ blurThreshold: blur.override,
+ autoplayBlockThreshold: autoplay.override,
+ };
+
+ return { defaults, values, overrides, valid };
+ }
+
+ applyModerationSettingsControlState({ resetStatus = false } = {}) {
+ const result = this.readModerationInputs();
+
+ const button = this.moderationSaveButton;
+ if (button instanceof HTMLElement) {
+ const baseline = this.currentModerationSettings || this.normalizeModerationSettings();
+ const isDirty =
+ result.valid &&
+ (baseline.blurThreshold !== result.values.blurThreshold ||
+ baseline.autoplayBlockThreshold !== result.values.autoplayBlockThreshold);
+ button.disabled = !(result.valid && isDirty);
+ if (button.disabled) {
+ button.setAttribute("aria-disabled", "true");
+ } else {
+ button.removeAttribute("aria-disabled");
+ }
+ }
+
+ if (resetStatus) {
+ this.updateModerationSettingsStatus("", "info");
+ }
+
+ return result;
+ }
+
+ updateModerationSettingsStatus(message = "", variant = "info") {
+ if (!(this.moderationStatusText instanceof HTMLElement)) {
+ return;
+ }
+
+ const text = typeof message === "string" ? message : "";
+ this.moderationStatusText.textContent = text;
+
+ if (text) {
+ this.moderationStatusText.dataset.variant = variant || "info";
+ } else if (this.moderationStatusText.dataset.variant) {
+ delete this.moderationStatusText.dataset.variant;
+ }
+ }
+
+ refreshModerationSettingsUi() {
+ const service = this.getModerationSettingsService();
+ if (!service) {
+ this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
+ this.currentModerationSettings = createInternalDefaultModerationSettings();
+ this.applyModerationSettingsControlState({ resetStatus: true });
+ return;
+ }
+
+ let active = null;
+ if (typeof service.getActiveModerationSettings === "function") {
+ try {
+ active = service.getActiveModerationSettings();
+ } catch (error) {
+ devLogger.info("[profileModal] moderation settings fallback used", error);
+ }
+ }
+
+ const defaults = this.getModerationSettingsDefaults();
+ this.moderationSettingsDefaults = defaults;
+ const normalized = this.normalizeModerationSettings(active);
+ this.currentModerationSettings = normalized;
+
+ if (this.moderationBlurInput instanceof HTMLInputElement) {
+ this.moderationBlurInput.value = String(normalized.blurThreshold);
+ }
+
+ if (this.moderationAutoplayInput instanceof HTMLInputElement) {
+ this.moderationAutoplayInput.value = String(
+ normalized.autoplayBlockThreshold,
+ );
+ }
+
+ this.applyModerationSettingsControlState({ resetStatus: true });
+ }
+
+ async handleModerationSettingsSave() {
+ const service = this.getModerationSettingsService();
+ const context = {
+ success: false,
+ reason: null,
+ error: null,
+ settings: null,
+ };
+
+ if (!service) {
+ return context;
+ }
+
+ const inputState = this.applyModerationSettingsControlState();
+ if (!inputState.valid) {
+ const message =
+ "Enter non-negative whole numbers for moderation thresholds.";
+ this.updateModerationSettingsStatus(message, "error");
+ this.showError(message);
+ context.reason = "invalid-input";
+ context.error = message;
+ return context;
+ }
+
+ const payload = {};
+ if (Object.prototype.hasOwnProperty.call(inputState.overrides, "blurThreshold")) {
+ payload.blurThreshold = inputState.overrides.blurThreshold;
+ }
+ if (
+ Object.prototype.hasOwnProperty.call(
+ inputState.overrides,
+ "autoplayBlockThreshold",
+ )
+ ) {
+ payload.autoplayBlockThreshold = inputState.overrides.autoplayBlockThreshold;
+ }
+
+ try {
+ const updated =
+ typeof service.updateModerationSettings === "function"
+ ? await service.updateModerationSettings(payload)
+ : inputState.values;
+
+ const normalized = this.normalizeModerationSettings(updated);
+ this.currentModerationSettings = normalized;
+ if (this.moderationBlurInput instanceof HTMLInputElement) {
+ this.moderationBlurInput.value = String(normalized.blurThreshold);
+ }
+ if (this.moderationAutoplayInput instanceof HTMLInputElement) {
+ this.moderationAutoplayInput.value = String(
+ normalized.autoplayBlockThreshold,
+ );
+ }
+ this.applyModerationSettingsControlState();
+ this.updateModerationSettingsStatus("Moderation settings saved.", "success");
+ this.showSuccess("Moderation settings saved.");
+ context.success = true;
+ context.reason = "saved";
+ context.settings = normalized;
+ this.callbacks.onModerationSettingsChange({
+ settings: normalized,
+ controller: this,
+ reason: "saved",
+ });
+ } catch (error) {
+ const fallbackMessage = "Failed to update moderation settings.";
+ const detail =
+ error && typeof error.message === "string" && error.message.trim()
+ ? error.message.trim()
+ : fallbackMessage;
+ this.updateModerationSettingsStatus(detail, "error");
+ this.showError(detail);
+ context.error = detail;
+ context.reason = error?.code || "service-error";
+ }
+
+ return context;
+ }
+
+ async handleModerationSettingsReset() {
+ const service = this.getModerationSettingsService();
+ const context = {
+ success: false,
+ reason: null,
+ error: null,
+ settings: null,
+ };
+
+ if (!service) {
+ return context;
+ }
+
+ try {
+ const updated =
+ typeof service.resetModerationSettings === "function"
+ ? await service.resetModerationSettings()
+ : createInternalDefaultModerationSettings();
+
+ const normalized = this.normalizeModerationSettings(updated);
+ this.currentModerationSettings = normalized;
+ if (this.moderationBlurInput instanceof HTMLInputElement) {
+ this.moderationBlurInput.value = String(normalized.blurThreshold);
+ }
+ if (this.moderationAutoplayInput instanceof HTMLInputElement) {
+ this.moderationAutoplayInput.value = String(
+ normalized.autoplayBlockThreshold,
+ );
+ }
+ this.applyModerationSettingsControlState({ resetStatus: true });
+ this.updateModerationSettingsStatus(
+ "Moderation defaults restored.",
+ "success",
+ );
+ this.showSuccess("Moderation defaults restored.");
+ context.success = true;
+ context.reason = "reset";
+ context.settings = normalized;
+ this.callbacks.onModerationSettingsChange({
+ settings: normalized,
+ controller: this,
+ reason: "reset",
+ });
+ } catch (error) {
+ const fallbackMessage = "Failed to restore moderation defaults.";
+ const detail =
+ error && typeof error.message === "string" && error.message.trim()
+ ? error.message.trim()
+ : fallbackMessage;
+ this.updateModerationSettingsStatus(detail, "error");
+ this.showError(detail);
+ context.error = detail;
+ context.reason = error?.code || "service-error";
+ }
+
+ return context;
+ }
+
storeAdminEmptyMessages() {
const capture = (element) => {
if (element instanceof HTMLElement && !element.dataset.defaultMessage) {
@@ -4377,6 +4839,7 @@ export class ProfileModalController {
this.renderSavedProfiles();
this.refreshWalletPaneState();
+ this.refreshModerationSettingsUi();
const hasBlockHydrator =
this.services.userBlocks &&
typeof this.services.userBlocks.ensureLoaded === "function";
diff --git a/tests/moderation-stage.test.mjs b/tests/moderation-stage.test.mjs
index b7005564..0b62e44d 100644
--- a/tests/moderation-stage.test.mjs
+++ b/tests/moderation-stage.test.mjs
@@ -167,6 +167,53 @@ test("moderation stage annotates trusted mute metadata", async () => {
assert.equal(trustedMuteReason.videoId, "muted");
});
+test("moderation stage applies provided thresholds", async () => {
+ const videoId = "custom-threshold";
+ 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 items = [{ video: { id: videoId, pubkey: "a".repeat(64) }, metadata: {} }];
+
+ const strictStage = createModerationStage({
+ service,
+ autoplayThreshold: 5,
+ blurThreshold: 6,
+ });
+
+ const relaxedStage = createModerationStage({
+ service,
+ autoplayThreshold: 2,
+ blurThreshold: 3,
+ });
+
+ const strictResult = await strictStage(items, {});
+ assert.equal(strictResult.length, 1);
+ assert.equal(strictResult[0].video.moderation.blockAutoplay, false);
+ assert.equal(strictResult[0].video.moderation.blurThumbnail, false);
+
+ const relaxedResult = await relaxedStage(items, {});
+ assert.equal(relaxedResult.length, 1);
+ assert.equal(relaxedResult[0].video.moderation.blockAutoplay, true);
+ assert.equal(relaxedResult[0].video.moderation.blurThumbnail, true);
+});
+
test("moderation stage propagates whitelist, muters, and threshold updates", async () => {
const whitelistedHex = "a".repeat(64);
const mutedHex = "b".repeat(64);
diff --git a/tests/ui/profile-modal-moderation-settings.test.mjs b/tests/ui/profile-modal-moderation-settings.test.mjs
new file mode 100644
index 00000000..a210b62e
--- /dev/null
+++ b/tests/ui/profile-modal-moderation-settings.test.mjs
@@ -0,0 +1,232 @@
+import test, { beforeEach, afterEach } from "node:test";
+import assert from "node:assert/strict";
+import { JSDOM } from "jsdom";
+
+import ProfileModalController from "../../js/ui/profileModalController.js";
+
+const TEMPLATE_HTML = `
+
+`;
+
+let dom;
+let documentRef;
+let windowRef;
+let controller;
+let storedSettings;
+let moderationService;
+let callbackPayloads;
+
+beforeEach(async () => {
+ dom = new JSDOM("", {
+ pretendToBeVisual: true,
+ url: "https://example.com",
+ });
+
+ windowRef = dom.window;
+ documentRef = windowRef.document;
+
+ global.window = windowRef;
+ global.document = documentRef;
+ global.HTMLElement = windowRef.HTMLElement;
+ global.HTMLInputElement = windowRef.HTMLInputElement;
+ global.Event = windowRef.Event;
+
+ global.fetch = async (url) => {
+ assert.equal(url, "components/profile-modal.html");
+ return {
+ ok: true,
+ async text() {
+ return TEMPLATE_HTML;
+ },
+ };
+ };
+
+ storedSettings = { blurThreshold: 3, autoplayBlockThreshold: 2 };
+ moderationService = {
+ getDefaultModerationSettings() {
+ return { blurThreshold: 3, autoplayBlockThreshold: 2 };
+ },
+ getActiveModerationSettings() {
+ return { ...storedSettings };
+ },
+ updateModerationSettings(partial = {}) {
+ if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) {
+ const value = partial.blurThreshold;
+ storedSettings.blurThreshold = value === null ? 3 : value;
+ }
+ if (
+ Object.prototype.hasOwnProperty.call(
+ partial,
+ "autoplayBlockThreshold",
+ )
+ ) {
+ const value = partial.autoplayBlockThreshold;
+ storedSettings.autoplayBlockThreshold = value === null ? 2 : value;
+ }
+ return { ...storedSettings };
+ },
+ resetModerationSettings() {
+ storedSettings = { blurThreshold: 3, autoplayBlockThreshold: 2 };
+ return { ...storedSettings };
+ },
+ };
+
+ callbackPayloads = [];
+
+ controller = new ProfileModalController({
+ modalContainer: documentRef.body,
+ removeTrackingScripts: () => {},
+ services: {
+ relayManager: {},
+ userBlocks: {},
+ nostrClient: {},
+ accessControl: {},
+ getCurrentUserNpub: () => null,
+ moderationSettings: moderationService,
+ loadVideos: async () => undefined,
+ onVideosShouldRefresh: async () => undefined,
+ onAccessControlUpdated: async () => undefined,
+ persistSavedProfiles: () => {},
+ watchHistoryService: {},
+ authService: {},
+ log: () => {},
+ closeAllMoreMenus: () => {},
+ },
+ state: {
+ getSavedProfiles: () => [],
+ setSavedProfiles: () => ({ changed: false, profiles: [] }),
+ persistSavedProfiles: () => {},
+ getActivePubkey: () => null,
+ setActivePubkey: () => null,
+ },
+ callbacks: {
+ onModerationSettingsChange: (payload) => callbackPayloads.push(payload),
+ },
+ });
+});
+
+afterEach(() => {
+ if (dom) {
+ dom.window.close();
+ }
+ dom = null;
+ documentRef = null;
+ windowRef = null;
+ controller = null;
+ storedSettings = null;
+ moderationService = null;
+ callbackPayloads = null;
+ delete global.window;
+ delete global.document;
+ delete global.HTMLElement;
+ delete global.HTMLInputElement;
+ delete global.Event;
+ delete global.fetch;
+});
+
+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.moderationSaveButton.disabled, true);
+
+ controller.moderationBlurInput.value = "5";
+ controller.moderationBlurInput.dispatchEvent(
+ new windowRef.Event("input", { bubbles: true }),
+ );
+ controller.moderationAutoplayInput.value = "4";
+ controller.moderationAutoplayInput.dispatchEvent(
+ new windowRef.Event("input", { bubbles: true }),
+ );
+
+ assert.equal(controller.moderationSaveButton.disabled, false);
+
+ const result = await controller.handleModerationSettingsSave();
+ assert.equal(result.success, true);
+ assert.deepEqual(storedSettings, {
+ blurThreshold: 5,
+ autoplayBlockThreshold: 4,
+ });
+ assert.equal(controller.moderationSaveButton.disabled, true);
+ assert.equal(
+ controller.moderationStatusText.textContent,
+ "Moderation settings saved.",
+ );
+ assert.equal(callbackPayloads.length, 1);
+ assert.deepEqual(callbackPayloads[0].settings, {
+ blurThreshold: 5,
+ autoplayBlockThreshold: 4,
+ });
+});
+
+test("moderation reset restores defaults and clearing inputs uses defaults", async () => {
+ storedSettings = { blurThreshold: 6, autoplayBlockThreshold: 4 };
+ await controller.load();
+
+ assert.equal(controller.moderationBlurInput.value, "6");
+ assert.equal(controller.moderationAutoplayInput.value, "4");
+
+ const resetContext = await controller.handleModerationSettingsReset();
+ assert.equal(resetContext.success, true);
+ assert.deepEqual(storedSettings, {
+ blurThreshold: 3,
+ autoplayBlockThreshold: 2,
+ });
+ assert.equal(controller.moderationBlurInput.value, "3");
+ assert.equal(controller.moderationAutoplayInput.value, "2");
+ assert.equal(
+ controller.moderationStatusText.textContent,
+ "Moderation defaults restored.",
+ );
+ assert.equal(controller.moderationSaveButton.disabled, true);
+
+ controller.moderationBlurInput.value = "7.8";
+ controller.moderationBlurInput.dispatchEvent(
+ new windowRef.Event("input", { bubbles: true }),
+ );
+ const saveResult = await controller.handleModerationSettingsSave();
+ assert.equal(saveResult.success, true);
+ assert.deepEqual(storedSettings, {
+ blurThreshold: 7,
+ autoplayBlockThreshold: 2,
+ });
+
+ controller.moderationBlurInput.value = "";
+ controller.moderationAutoplayInput.value = "";
+ controller.moderationBlurInput.dispatchEvent(
+ new windowRef.Event("input", { bubbles: true }),
+ );
+ controller.moderationAutoplayInput.dispatchEvent(
+ new windowRef.Event("input", { bubbles: true }),
+ );
+ assert.equal(controller.moderationSaveButton.disabled, false);
+
+ const revertContext = await controller.handleModerationSettingsSave();
+ assert.equal(revertContext.success, true);
+ assert.deepEqual(storedSettings, {
+ blurThreshold: 3,
+ autoplayBlockThreshold: 2,
+ });
+});