mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 02:12:26 +00:00
Merge pull request #711 from PR0M3TH3AN/codex/implement-moderation-settings-and-controls
Add configurable moderation threshold settings
This commit is contained in:
7 files changed
+1139
-10
No files matched your search
@@ -233,6 +233,75 @@
|
||||
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="profileModerationSettings" class="card space-y-5 p-5">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-base font-semibold text-text">Safety & Moderation</h3>
|
||||
<p class="text-sm text-muted">
|
||||
Choose how many trusted reports it takes to blur thumbnails or block autoplay previews.
|
||||
Lower numbers are stricter.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationBlurThreshold"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Blur after trusted reports</span>
|
||||
<input
|
||||
id="profileModerationBlurThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Thumbnails blur when trusted contacts report nudity at or above this count.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="block text-sm text-muted-strong"
|
||||
for="profileModerationAutoplayThreshold"
|
||||
>
|
||||
<span class="mb-2 block font-medium">Block autoplay after reports</span>
|
||||
<input
|
||||
id="profileModerationAutoplayThreshold"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
/>
|
||||
<span class="mt-1 block text-xs text-muted">
|
||||
Autoplay previews pause once trusted reports meet this threshold.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
id="profileModerationSave"
|
||||
type="button"
|
||||
class="btn focus-ring"
|
||||
disabled
|
||||
>
|
||||
Save moderation settings
|
||||
</button>
|
||||
<button
|
||||
id="profileModerationReset"
|
||||
type="button"
|
||||
class="btn-ghost focus-ring"
|
||||
>
|
||||
Restore defaults
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="profileModerationStatus"
|
||||
class="text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -35,6 +35,10 @@ Thread new moderation behaviors through the same service → stage → app → U
|
||||
|
||||
> 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = `
|
||||
<div id="profileModal">
|
||||
<div class="bv-modal__panel"></div>
|
||||
<div class="bv-modal-backdrop"></div>
|
||||
<div data-profile-layout>
|
||||
<div data-profile-mobile-menu></div>
|
||||
<div data-profile-mobile-pane></div>
|
||||
</div>
|
||||
<button id="closeProfileModal" type="button"></button>
|
||||
<button id="profileModalBack" type="button"></button>
|
||||
<button id="profileLogoutBtn" type="button"></button>
|
||||
<a id="profileChannelLink" href="#"></a>
|
||||
<button id="profileAddAccountBtn" type="button"></button>
|
||||
<div id="profileNavAccount"></div>
|
||||
<div id="profilePaneAccount"></div>
|
||||
<div id="profileModerationSettings">
|
||||
<input id="profileModerationBlurThreshold" type="number" />
|
||||
<input id="profileModerationAutoplayThreshold" type="number" />
|
||||
<button id="profileModerationSave" type="button">Save</button>
|
||||
<button id="profileModerationReset" type="button">Reset</button>
|
||||
<p id="profileModerationStatus"></p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let dom;
|
||||
let documentRef;
|
||||
let windowRef;
|
||||
let controller;
|
||||
let storedSettings;
|
||||
let moderationService;
|
||||
let callbackPayloads;
|
||||
|
||||
beforeEach(async () => {
|
||||
dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
||||
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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user