From 5c3230074e7ec3a32f7d35e26e1c91e857c69840 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:50:32 -0500 Subject: [PATCH] Add moderation override tracking by author and content --- components/profile-modal.html | 19 ++ js/app.js | 5 +- js/services/moderationActionController.js | 55 ++++- js/state/cache.js | 183 +++++++++++++--- js/ui/applicationBootstrap.js | 5 + js/ui/profileModalController.js | 241 ++++++++++++++++++++++ 6 files changed, 467 insertions(+), 41 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 64216637..30ce10ab 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -414,6 +414,25 @@ role="status" aria-live="polite" >

+
+
+

Active overrides

+

+ Videos you unblocked with “Show anyway” stay listed here until reset. +

+
+
+ No moderation overrides yet. +
+ +
diff --git a/js/app.js b/js/app.js index 1075008d..0268c1e1 100644 --- a/js/app.js +++ b/js/app.js @@ -6299,7 +6299,10 @@ class Application { const computedHidden = hideTriggered && !hideBypass; - const overrideEntry = getModerationOverride(video.id); + const overrideEntry = getModerationOverride({ + eventId: video.id, + authorPubkey: video.pubkey || video.author?.pubkey || "", + }); const overrideActive = overrideEntry?.showAnyway === true; const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt) ? Math.floor(overrideEntry.updatedAt) diff --git a/js/services/moderationActionController.js b/js/services/moderationActionController.js index 9b88e8c1..5df72924 100644 --- a/js/services/moderationActionController.js +++ b/js/services/moderationActionController.js @@ -97,7 +97,7 @@ export default class ModerationActionController { return false; } - this.persistModerationOverride(video.id); + this.persistModerationOverride(video); const target = this.resolveTargetVideo(video); this.clearHideState(target); @@ -199,7 +199,7 @@ export default class ModerationActionController { this.showStatus(statusMessage, { showSpinner: false }); } - this.clearModerationOverride(video.id); + this.clearModerationOverride(video); const target = this.resolveTargetVideo(video); this.clearViewerOverride(target); @@ -233,7 +233,7 @@ export default class ModerationActionController { return false; } - this.clearModerationOverride(video.id); + this.clearModerationOverride(video); const target = this.resolveTargetVideo(video); this.clearViewerOverride(target); @@ -427,13 +427,45 @@ export default class ModerationActionController { } } - persistModerationOverride(eventId) { - if (!eventId || !this.services.setModerationOverride) { + getModerationOverrideDescriptor(video) { + if (!video || typeof video !== "object") { + return null; + } + + const eventId = video.id; + if (!eventId) { + return null; + } + + const rawAuthor = + typeof video.pubkey === "string" + ? video.pubkey + : typeof video.author?.pubkey === "string" + ? video.author.pubkey + : ""; + const normalizedAuthor = + rawAuthor && this.auth.normalizePubkey + ? this.auth.normalizePubkey(rawAuthor) + : rawAuthor; + + return { + eventId, + authorPubkey: normalizedAuthor || "", + }; + } + + persistModerationOverride(video) { + if (!this.services.setModerationOverride) { + return; + } + + const descriptor = this.getModerationOverrideDescriptor(video); + if (!descriptor) { return; } try { - this.services.setModerationOverride(eventId, { + this.services.setModerationOverride(descriptor, { showAnyway: true, updatedAt: Date.now(), }); @@ -445,13 +477,18 @@ export default class ModerationActionController { } } - clearModerationOverride(eventId) { - if (!eventId || !this.services.clearModerationOverride) { + clearModerationOverride(video) { + if (!this.services.clearModerationOverride) { + return; + } + + const descriptor = this.getModerationOverrideDescriptor(video); + if (!descriptor) { return; } try { - this.services.clearModerationOverride(eventId); + this.services.clearModerationOverride(descriptor); } catch (error) { devLogger.warn( "[ModerationActionController] Failed to clear moderation override:", diff --git a/js/state/cache.js b/js/state/cache.js index 4d9d631a..8105c883 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -29,8 +29,9 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds const HEX64_REGEX = /^[0-9a-f]{64}$/i; -const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1"; -const MODERATION_OVERRIDE_STORAGE_VERSION = 1; +const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2"; +const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1"; +const MODERATION_OVERRIDE_STORAGE_VERSION = 2; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; const MODERATION_SETTINGS_STORAGE_VERSION = 3; @@ -270,6 +271,50 @@ function normalizeEventId(value) { return HEX64_REGEX.test(trimmed) ? trimmed : null; } +function buildModerationOverrideKey(authorPubkey, eventId) { + const normalizedAuthor = + typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : ""; + return `${normalizedAuthor}:${eventId}`; +} + +function normalizeModerationOverrideDescriptor(eventIdOrDescriptor, authorPubkey) { + let eventId = null; + let author = null; + + if (eventIdOrDescriptor && typeof eventIdOrDescriptor === "object") { + eventId = + eventIdOrDescriptor.eventId || + eventIdOrDescriptor.id || + eventIdOrDescriptor.eventID || + null; + author = + eventIdOrDescriptor.authorPubkey || + eventIdOrDescriptor.pubkey || + eventIdOrDescriptor.author || + null; + } else if (typeof eventIdOrDescriptor === "string") { + eventId = eventIdOrDescriptor; + } + + if (!author && typeof authorPubkey === "string") { + author = authorPubkey; + } + + const normalizedEventId = normalizeEventId(eventId); + if (!normalizedEventId) { + return null; + } + + const normalizedAuthor = normalizeHexPubkey(author); + const authorKey = normalizedAuthor || ""; + + return { + eventId: normalizedEventId, + authorPubkey: normalizedAuthor || "", + key: buildModerationOverrideKey(authorKey, normalizedEventId), + }; +} + export function getSavedProfiles() { return savedProfiles; } @@ -1270,13 +1315,31 @@ export function getModerationOverridesMap() { return moderationOverrides; } -export function getModerationOverride(eventId) { - const normalized = normalizeEventId(eventId); +export function getModerationOverridesList() { + return Array.from(moderationOverrides.values()).map((entry) => ({ ...entry })); +} + +function findModerationOverrideByEventId(eventId) { + for (const entry of moderationOverrides.values()) { + if (entry?.eventId === eventId) { + return entry; + } + } + return null; +} + +export function getModerationOverride(eventIdOrDescriptor, authorPubkey) { + const normalized = normalizeModerationOverrideDescriptor( + eventIdOrDescriptor, + authorPubkey, + ); if (!normalized) { return null; } - const entry = moderationOverrides.get(normalized); + const entry = + moderationOverrides.get(normalized.key) || + findModerationOverrideByEventId(normalized.eventId); if (!entry) { return null; } @@ -1289,39 +1352,63 @@ export function loadModerationOverridesFromStorage() { return; } - const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY); + const raw = + localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY) || + localStorage.getItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); if (!raw) { return; } + let shouldRewrite = false; + try { const payload = JSON.parse(raw); if (!payload || typeof payload !== "object") { return; } - if (payload.version !== MODERATION_OVERRIDE_STORAGE_VERSION) { + const entries = []; + + if (payload.version === MODERATION_OVERRIDE_STORAGE_VERSION) { + if (Array.isArray(payload.entries)) { + entries.push(...payload.entries); + } else { + shouldRewrite = true; + } + } else if (payload.version === 1) { + const legacyEntries = + payload.entries && typeof payload.entries === "object" + ? payload.entries + : {}; + Object.entries(legacyEntries).forEach(([eventId, entry]) => { + entries.push({ + eventId, + authorPubkey: "", + showAnyway: entry?.showAnyway === true, + updatedAt: entry?.updatedAt, + }); + }); + shouldRewrite = true; + } else { return; } - const entries = payload.entries && typeof payload.entries === "object" - ? payload.entries - : {}; - moderationOverrides.clear(); - for (const [eventId, entry] of Object.entries(entries)) { - const normalized = normalizeEventId(eventId); - if (!normalized) { + for (const entry of entries) { + if (!entry || entry.showAnyway !== true) { continue; } - if (!entry || entry.showAnyway !== true) { + const normalized = normalizeModerationOverrideDescriptor(entry); + if (!normalized) { continue; } const updatedAt = Number.isFinite(entry.updatedAt) ? Math.floor(entry.updatedAt) : Date.now(); - moderationOverrides.set(normalized, { + moderationOverrides.set(normalized.key, { + eventId: normalized.eventId, + authorPubkey: normalized.authorPubkey, showAnyway: true, updatedAt, }); @@ -1332,6 +1419,11 @@ export function loadModerationOverridesFromStorage() { "[cache.loadModerationOverridesFromStorage] Failed to parse payload:", error, ); + return; + } + + if (shouldRewrite) { + persistModerationOverridesToStorage(); } } @@ -1340,23 +1432,30 @@ export function persistModerationOverridesToStorage() { return; } - const entries = {}; - for (const [eventId, entry] of moderationOverrides.entries()) { + const entries = []; + for (const entry of moderationOverrides.values()) { if (!entry || entry.showAnyway !== true) { continue; } + const normalized = normalizeModerationOverrideDescriptor(entry); + if (!normalized) { + continue; + } const updatedAt = Number.isFinite(entry.updatedAt) ? Math.floor(entry.updatedAt) : Date.now(); - entries[eventId] = { + entries.push({ + eventId: normalized.eventId, + authorPubkey: normalized.authorPubkey, showAnyway: true, updatedAt, - }; + }); } - if (Object.keys(entries).length === 0) { + if (entries.length === 0) { try { localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY); + localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); } catch (error) { userLogger.warn( "[cache.persistModerationOverridesToStorage] Failed to clear overrides:", @@ -1377,6 +1476,7 @@ export function persistModerationOverridesToStorage() { MODERATION_OVERRIDE_STORAGE_KEY, JSON.stringify(payload), ); + localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); } catch (error) { userLogger.warn( "[cache.persistModerationOverridesToStorage] Failed to persist overrides:", @@ -1386,18 +1486,21 @@ export function persistModerationOverridesToStorage() { } export function setModerationOverride( - eventId, + eventIdOrDescriptor, override = {}, { persist = true } = {}, ) { - const normalized = normalizeEventId(eventId); + const normalized = normalizeModerationOverrideDescriptor( + eventIdOrDescriptor, + override?.authorPubkey, + ); if (!normalized) { return null; } const showAnyway = override?.showAnyway === true; if (!showAnyway) { - const removed = moderationOverrides.delete(normalized); + const removed = moderationOverrides.delete(normalized.key); if (removed && persist) { persistModerationOverridesToStorage(); } @@ -1408,14 +1511,21 @@ export function setModerationOverride( ? Math.floor(override.updatedAt) : Date.now(); - const existing = moderationOverrides.get(normalized); - const nextEntry = { showAnyway: true, updatedAt }; + const existing = moderationOverrides.get(normalized.key); + const nextEntry = { + eventId: normalized.eventId, + authorPubkey: normalized.authorPubkey, + showAnyway: true, + updatedAt, + }; const changed = !existing || existing.showAnyway !== nextEntry.showAnyway || - existing.updatedAt !== nextEntry.updatedAt; + existing.updatedAt !== nextEntry.updatedAt || + existing.eventId !== nextEntry.eventId || + existing.authorPubkey !== nextEntry.authorPubkey; - moderationOverrides.set(normalized, nextEntry); + moderationOverrides.set(normalized.key, nextEntry); if (persist && changed) { persistModerationOverridesToStorage(); @@ -1424,13 +1534,24 @@ export function setModerationOverride( return { ...nextEntry }; } -export function clearModerationOverride(eventId, { persist = true } = {}) { - const normalized = normalizeEventId(eventId); +export function clearModerationOverride( + eventIdOrDescriptor, + { persist = true } = {}, +) { + const normalized = normalizeModerationOverrideDescriptor(eventIdOrDescriptor); if (!normalized) { return false; } - const removed = moderationOverrides.delete(normalized); + let removed = moderationOverrides.delete(normalized.key); + + for (const [key, entry] of moderationOverrides.entries()) { + if (entry?.eventId === normalized.eventId) { + moderationOverrides.delete(key); + removed = true; + } + } + if (removed && persist) { persistModerationOverridesToStorage(); } diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 04c4ba6f..a7f606f3 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -37,9 +37,11 @@ import { isWatchHistoryDebugEnabled } from "../watchHistoryDebug.js"; import { splitAndZap as splitAndZapDefault } from "../payments/zapSplit.js"; import { getDefaultModerationSettings, + getModerationOverridesList, getModerationSettings, setModerationSettings, resetModerationSettings, + clearModerationOverride, persistSavedProfiles, getSavedProfiles, getActiveProfilePubkey, @@ -443,6 +445,9 @@ export default class ApplicationBootstrap { setModerationSettings(partial), resetModerationSettings: () => resetModerationSettings(), }, + getModerationOverrides: () => getModerationOverridesList(), + clearModerationOverride: (descriptor) => + clearModerationOverride(descriptor), loadVideos: (forceFetch, context) => app.loadVideos(forceFetch, context), onVideosShouldRefresh: (context) => diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index d8b8d8b6..c3670352 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -503,6 +503,20 @@ const SERVICE_CONTRACT = [ return service; }, }, + { + key: "getModerationOverrides", + type: "function", + description: + "Returns a list of stored moderation overrides for the active viewer.", + fallback: () => () => [], + }, + { + key: "clearModerationOverride", + type: "function", + description: + "Clears a specific moderation override when the viewer resets a decision.", + fallback: () => () => false, + }, { key: "loadVideos", type: "function", @@ -1099,8 +1113,11 @@ export class ProfileModalController { this.moderationSaveButton = null; this.moderationResetButton = null; this.moderationStatusText = null; + this.moderationOverridesList = null; + this.moderationOverridesEmpty = null; this.moderationHideControlsGroup = null; this.moderationHideControlElements = []; + this.boundModerationOverridesUpdate = null; this.moderatorSection = null; this.moderatorEmpty = null; this.adminModeratorList = null; @@ -1415,6 +1432,10 @@ export class ProfileModalController { document.getElementById("profileModerationReset") || null; this.moderationStatusText = document.getElementById("profileModerationStatus") || null; + this.moderationOverridesList = + document.getElementById("profileModerationOverridesList") || null; + this.moderationOverridesEmpty = + document.getElementById("profileModerationOverridesEmpty") || null; this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null; this.moderationTrustedMuteCount = @@ -2688,6 +2709,24 @@ export class ProfileModalController { }); } + if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") { + this.boundModerationOverridesUpdate = () => { + this.refreshModerationOverridesUi(); + }; + document.addEventListener( + "video:moderation-override", + this.boundModerationOverridesUpdate, + ); + document.addEventListener( + "video:moderation-hide", + this.boundModerationOverridesUpdate, + ); + document.addEventListener( + "video:moderation-block", + this.boundModerationOverridesUpdate, + ); + } + if (this.addModeratorButton instanceof HTMLElement) { this.addModeratorButton.addEventListener("click", () => { void this.handleAddModerator(); @@ -7084,6 +7123,206 @@ export class ProfileModalController { helper.textContent = TRUSTED_MUTE_HIDE_HELPER_TEXT; } + getModerationOverrideEntries() { + if (typeof this.services.getModerationOverrides !== "function") { + return []; + } + + try { + const entries = this.services.getModerationOverrides(); + return Array.isArray(entries) ? entries : []; + } catch (error) { + devLogger.info( + "[profileModal] moderation overrides fallback used", + error, + ); + return []; + } + } + + normalizeModerationOverrideEntries(entries = []) { + const normalized = []; + const seen = new Set(); + + entries.forEach((entry) => { + if (!entry || typeof entry !== "object") { + return; + } + const eventId = + typeof entry.eventId === "string" + ? entry.eventId.trim().toLowerCase() + : ""; + if (!eventId) { + return; + } + const author = + typeof entry.authorPubkey === "string" + ? entry.authorPubkey.trim() + : ""; + const normalizedAuthor = author ? this.normalizeHexPubkey(author) || author : ""; + const key = `${normalizedAuthor || ""}:${eventId}`; + if (seen.has(key)) { + return; + } + seen.add(key); + normalized.push({ + eventId, + authorPubkey: normalizedAuthor || "", + updatedAt: Number.isFinite(entry.updatedAt) + ? Math.floor(entry.updatedAt) + : 0, + }); + }); + + normalized.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + + return normalized; + } + + formatModerationOverrideTimestamp(updatedAt) { + const numeric = Number(updatedAt); + if (!Number.isFinite(numeric) || numeric <= 0) { + return { display: "", iso: "" }; + } + + try { + const date = new Date(numeric); + return { + display: date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + iso: date.toISOString(), + }; + } catch (error) { + return { display: "", iso: "" }; + } + } + + async handleModerationOverrideReset(entry) { + if (!entry || typeof entry !== "object") { + return false; + } + + if (typeof this.services.clearModerationOverride !== "function") { + return false; + } + + try { + await this.services.clearModerationOverride({ + eventId: entry.eventId, + authorPubkey: entry.authorPubkey, + }); + this.refreshModerationOverridesUi(); + this.showSuccess("Moderation override reset."); + return true; + } catch (error) { + this.showError("Unable to reset this moderation override."); + return false; + } + } + + refreshModerationOverridesUi() { + if ( + !(this.moderationOverridesList instanceof HTMLElement) || + !(this.moderationOverridesEmpty instanceof HTMLElement) + ) { + return; + } + + const entries = this.normalizeModerationOverrideEntries( + this.getModerationOverrideEntries(), + ); + + this.moderationOverridesList.innerHTML = ""; + + if (!entries.length) { + this.moderationOverridesEmpty.classList.remove("hidden"); + this.moderationOverridesList.classList.add("hidden"); + return; + } + + this.moderationOverridesEmpty.classList.add("hidden"); + this.moderationOverridesList.classList.remove("hidden"); + + const entriesNeedingFetch = new Set(); + + entries.forEach((entry) => { + const item = document.createElement("li"); + item.className = "card space-y-2 p-4"; + + const row = document.createElement("div"); + row.className = "flex items-center justify-between gap-4"; + + const authorKey = entry.authorPubkey; + let profileSummary = null; + if (authorKey) { + const cacheEntry = this.services.getProfileCacheEntry(authorKey); + if (!cacheEntry) { + entriesNeedingFetch.add(authorKey); + } + } + + const summaryData = this.resolveProfileSummaryForPubkey(authorKey); + profileSummary = this.createCompactProfileSummary(summaryData); + + const actions = document.createElement("div"); + actions.className = "flex flex-wrap items-center justify-end gap-2"; + + const resetButton = this.createRemoveButton({ + label: "Reset", + onRemove: () => this.handleModerationOverrideReset(entry), + }); + if (resetButton) { + actions.appendChild(resetButton); + } + + if (profileSummary) { + row.appendChild(profileSummary); + } + if (actions.childElementCount > 0) { + row.appendChild(actions); + } + + const meta = document.createElement("div"); + meta.className = "flex flex-wrap items-center gap-3 text-2xs text-muted"; + + const contentId = document.createElement("span"); + contentId.className = "font-mono text-2xs text-muted"; + const shortId = + typeof this.truncateMiddle === "function" + ? this.truncateMiddle(entry.eventId, 16) + : entry.eventId; + contentId.textContent = `Content ${shortId}`; + contentId.title = entry.eventId; + meta.appendChild(contentId); + + const timestamp = this.formatModerationOverrideTimestamp(entry.updatedAt); + if (timestamp.display) { + const time = document.createElement("time"); + time.className = "text-2xs text-muted"; + time.dateTime = timestamp.iso; + time.textContent = `Updated ${timestamp.display}`; + meta.appendChild(time); + } + + item.appendChild(row); + item.appendChild(meta); + + this.moderationOverridesList.appendChild(item); + }); + + if ( + entriesNeedingFetch.size && + typeof this.services.batchFetchProfiles === "function" + ) { + this.services.batchFetchProfiles(entriesNeedingFetch); + } + } + refreshModerationSettingsUi() { const service = this.getModerationSettingsService(); if (!service) { @@ -7091,6 +7330,7 @@ export class ProfileModalController { this.currentModerationSettings = createInternalDefaultModerationSettings(); this.updateTrustedHideControlsVisibility(); this.updateModerationTrustStats(); + this.refreshModerationOverridesUi(); this.applyModerationSettingsControlState({ resetStatus: true }); return; } @@ -7133,6 +7373,7 @@ export class ProfileModalController { this.updateTrustedHideControlsVisibility(); this.updateModerationTrustStats(); + this.refreshModerationOverridesUi(); this.applyModerationSettingsControlState({ resetStatus: true }); }