diff --git a/components/profile-modal.html b/components/profile-modal.html index 2a4249a5..00375790 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -1612,6 +1612,56 @@ data-orientation="vertical" > +
+
+

+ Trusted creators +

+

+ Turn off web-of-trust warnings for a creator across all + their videos. They stay listed here until you reset them. +

+
+
+ + +
+

+
+ No trusted creators yet. +
+ +
diff --git a/js/app.js b/js/app.js index 18ac9cf6..3e840d46 100644 --- a/js/app.js +++ b/js/app.js @@ -177,6 +177,10 @@ import { setModerationOverride, clearModerationOverride, loadModerationOverridesFromStorage, + getAuthorModerationOverridesList, + setAuthorModerationOverride, + clearAuthorModerationOverride, + loadAuthorModerationOverridesFromStorage, getModerationSettings, getDefaultModerationSettings, setModerationSettings, @@ -918,6 +922,7 @@ class Application { } loadModerationOverridesFromStorage(); + loadAuthorModerationOverridesFromStorage(); loadModerationSettingsFromStorage(); loadDmPrivacySettingsFromStorage(); this.moderationSettings = this.normalizeModerationSettings( diff --git a/js/services/moderationDecorator.js b/js/services/moderationDecorator.js index 2236462f..5288b22b 100644 --- a/js/services/moderationDecorator.js +++ b/js/services/moderationDecorator.js @@ -2,7 +2,7 @@ import { getTrustedMuteHideThreshold, getTrustedSpamHideThreshold, DEFAULT_BLUR_THRESHOLD, DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD } from "../constants.js"; import { formatShortNpub } from "../utils/formatters.js"; -import { getModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js"; +import { getModerationOverride, getAuthorModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js"; import { userLogger } from "../utils/logger.js"; /** @@ -403,14 +403,22 @@ export class ModerationDecorator { const computedHidden = hideTriggered && !hideBypass; + const authorPubkey = video.pubkey || video.author?.pubkey || ""; const overrideEntry = getModerationOverride({ eventId: video.id, - authorPubkey: video.pubkey || video.author?.pubkey || "", + authorPubkey, }); - const overrideActive = overrideEntry?.showAnyway === true; + // Account-level ("trusted creator") override: suppresses the WoT warning for + // ALL of this author's videos, exactly like a per-video "show anyway". + const authorOverrideEntry = getAuthorModerationOverride(authorPubkey); + const overrideActive = + overrideEntry?.showAnyway === true || + authorOverrideEntry?.showAnyway === true; const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt) ? Math.floor(overrideEntry.updatedAt) - : Date.now(); + : Number.isFinite(authorOverrideEntry?.updatedAt) + ? Math.floor(authorOverrideEntry.updatedAt) + : Date.now(); const originalHideCounts = hideCounts ? { diff --git a/js/state/cache.js b/js/state/cache.js index 2fca6225..9bbebca4 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -33,6 +33,12 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2"; const MODERATION_OVERRIDE_STORAGE_VERSION = 2; +// Account-level ("trusted creator") web-of-trust overrides, keyed by author hex. +// A showAnyway entry suppresses the WoT blur/hide/warning for ALL of that +// author's videos until the viewer resets it in the Safety tab. +const AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY = + "bitvid:moderationAuthorOverrides:v1"; +const AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION = 1; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; const MODERATION_SETTINGS_STORAGE_VERSION = 3; const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1"; @@ -198,6 +204,7 @@ const profileCache = new Map(); const urlHealthCache = new Map(); const urlHealthInFlight = new Map(); const moderationOverrides = new Map(); +const authorModerationOverrides = new Map(); function hasSavedProfilesChanged(previousProfiles, nextProfiles) { if (previousProfiles === nextProfiles) { @@ -1720,6 +1727,169 @@ export function clearModerationOverride( return removed; } +// ---- Account-level ("trusted creator") web-of-trust overrides --------------- +// Keyed by author hex; a showAnyway entry suppresses the WoT blur/hide/warning +// for EVERY video by that author until reset. Mirrors the per-video store above. + +function normalizeAuthorOverrideHex(pubkey) { + const hex = normalizeHexPubkey(pubkey); + return hex ? hex.toLowerCase() : ""; +} + +export function getAuthorModerationOverridesList() { + return Array.from(authorModerationOverrides.values()).map((entry) => ({ + ...entry, + })); +} + +export function getAuthorModerationOverride(pubkey) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return null; + } + const entry = authorModerationOverrides.get(hex); + return entry ? { ...entry } : null; +} + +export function hasAuthorModerationOverride(pubkey) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return false; + } + return authorModerationOverrides.get(hex)?.showAnyway === true; +} + +export function setAuthorModerationOverride( + pubkey, + override = {}, + { persist = true } = {}, +) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return null; + } + + const showAnyway = override?.showAnyway === true; + if (!showAnyway) { + const removed = authorModerationOverrides.delete(hex); + if (removed && persist) { + persistAuthorModerationOverridesToStorage(); + } + return null; + } + + const updatedAt = Number.isFinite(override?.updatedAt) + ? Math.floor(override.updatedAt) + : Date.now(); + const existing = authorModerationOverrides.get(hex); + const nextEntry = { authorPubkey: hex, showAnyway: true, updatedAt }; + const changed = + !existing || + existing.showAnyway !== true || + existing.updatedAt !== updatedAt; + + authorModerationOverrides.set(hex, nextEntry); + if (persist && changed) { + persistAuthorModerationOverridesToStorage(); + } + return { ...nextEntry }; +} + +export function clearAuthorModerationOverride(pubkey, { persist = true } = {}) { + const hex = normalizeAuthorOverrideHex(pubkey); + if (!hex) { + return false; + } + const removed = authorModerationOverrides.delete(hex); + if (removed && persist) { + persistAuthorModerationOverridesToStorage(); + } + return removed; +} + +export function loadAuthorModerationOverridesFromStorage() { + if (typeof localStorage === "undefined") { + return; + } + const raw = localStorage.getItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY); + if (!raw) { + return; + } + try { + const payload = JSON.parse(raw); + if ( + !payload || + typeof payload !== "object" || + payload.version !== AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION || + !Array.isArray(payload.entries) + ) { + return; + } + authorModerationOverrides.clear(); + for (const entry of payload.entries) { + if (!entry || entry.showAnyway !== true) { + continue; + } + const hex = normalizeAuthorOverrideHex(entry.authorPubkey || entry.pubkey); + if (!hex) { + continue; + } + const updatedAt = Number.isFinite(entry.updatedAt) + ? Math.floor(entry.updatedAt) + : Date.now(); + authorModerationOverrides.set(hex, { + authorPubkey: hex, + showAnyway: true, + updatedAt, + }); + } + } catch (error) { + authorModerationOverrides.clear(); + userLogger.warn( + "[cache.loadAuthorModerationOverridesFromStorage] Failed to parse payload:", + error, + ); + } +} + +export function persistAuthorModerationOverridesToStorage() { + if (typeof localStorage === "undefined") { + return; + } + const entries = []; + for (const entry of authorModerationOverrides.values()) { + if (!entry || entry.showAnyway !== true) { + continue; + } + entries.push({ + authorPubkey: entry.authorPubkey, + showAnyway: true, + updatedAt: Number.isFinite(entry.updatedAt) + ? Math.floor(entry.updatedAt) + : Date.now(), + }); + } + try { + if (entries.length === 0) { + localStorage.removeItem(AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY); + return; + } + localStorage.setItem( + AUTHOR_MODERATION_OVERRIDE_STORAGE_KEY, + JSON.stringify({ + version: AUTHOR_MODERATION_OVERRIDE_STORAGE_VERSION, + savedAt: Date.now(), + entries, + }), + ); + } catch (error) { + userLogger.warn( + "[cache.persistAuthorModerationOverridesToStorage] Failed to persist overrides:", + error, + ); + } +} + function buildUrlProbeKey(url, options = {}) { const trimmed = typeof url === "string" ? url : ""; const mode = options?.confirmPlayable ? "playable" : "basic"; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 1cbec0ab..4d5fd701 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -58,6 +58,9 @@ import { getDmPrivacySettings, setDmPrivacySettings, clearModerationOverride, + getAuthorModerationOverridesList, + setAuthorModerationOverride, + clearAuthorModerationOverride, persistSavedProfiles, getSavedProfiles, getActiveProfilePubkey, @@ -93,6 +96,30 @@ import { } from "./components/staticModalAccessibility.js"; import ModalManager from "./ModalManager.js"; +// After an account-level ("trusted creator") override is set/cleared, refresh +// the feed so every video by that author re-decorates (warning on/off), and +// fire the shared moderation-override event so the Safety tab list refreshes. +function notifyAuthorModerationOverrideChanged(app, pubkey) { + try { + if (typeof document !== "undefined") { + document.dispatchEvent( + new CustomEvent("video:moderation-override", { + detail: { authorPubkey: pubkey || "", accountLevel: true }, + }), + ); + } + } catch (error) { + // best-effort + } + try { + if (typeof app?.onVideosShouldRefresh === "function") { + void app.onVideosShouldRefresh({ reason: "author-moderation-override" }); + } + } catch (error) { + // best-effort + } +} + export default class ApplicationBootstrap { constructor({ app, @@ -550,6 +577,22 @@ export default class ApplicationBootstrap { getModerationOverrides: () => getModerationOverridesList(), clearModerationOverride: (descriptor) => clearModerationOverride(descriptor), + // Account-level ("trusted creator") WoT overrides. Setting/clearing + // one re-renders the feed so every video by that author updates, and + // fires the shared event so the Safety tab list refreshes. + getAuthorModerationOverrides: () => getAuthorModerationOverridesList(), + setAuthorModerationOverride: (pubkey) => { + const entry = setAuthorModerationOverride(pubkey, { + showAnyway: true, + }); + notifyAuthorModerationOverrideChanged(app, pubkey); + return entry; + }, + clearAuthorModerationOverride: (pubkey) => { + const removed = clearAuthorModerationOverride(pubkey); + notifyAuthorModerationOverrideChanged(app, pubkey); + return removed; + }, loadVideos: (forceFetch, context) => app.loadVideos(forceFetch, context), onVideosShouldRefresh: (context) => diff --git a/js/ui/profileModal/ProfileModerationController.js b/js/ui/profileModal/ProfileModerationController.js index 0f9de731..9491cbf2 100644 --- a/js/ui/profileModal/ProfileModerationController.js +++ b/js/ui/profileModal/ProfileModerationController.js @@ -47,6 +47,11 @@ export class ProfileModerationController { this.moderationStatusText = document.getElementById("profileModerationStatus") || null; this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null; this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || null; + this.authorOverrideInput = document.getElementById("profileAuthorOverrideInput") || null; + this.authorOverrideAddBtn = document.getElementById("profileAuthorOverrideAddBtn") || null; + this.authorOverrideStatus = document.getElementById("profileAuthorOverrideStatus") || null; + this.authorOverridesList = document.getElementById("profileAuthorOverridesList") || null; + this.authorOverridesEmpty = document.getElementById("profileAuthorOverridesEmpty") || null; this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null; this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null; this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || null; @@ -102,9 +107,24 @@ export class ProfileModerationController { }); } + if (this.authorOverrideAddBtn instanceof HTMLElement) { + this.authorOverrideAddBtn.addEventListener("click", () => { + void this.handleAddAuthorOverride(); + }); + } + if (this.authorOverrideInput instanceof HTMLInputElement) { + this.authorOverrideInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void this.handleAddAuthorOverride(); + } + }); + } + if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") { this.boundModerationOverridesUpdate = () => { this.refreshModerationOverridesUi(); + this.refreshAuthorOverridesUi(); }; document.addEventListener( "video:moderation-override", @@ -569,6 +589,159 @@ export class ProfileModerationController { } } + setAuthorOverrideStatus(message) { + if (this.authorOverrideStatus instanceof HTMLElement) { + this.authorOverrideStatus.textContent = message || ""; + } + } + + async handleAddAuthorOverride() { + const input = + this.authorOverrideInput instanceof HTMLInputElement + ? this.authorOverrideInput.value.trim() + : ""; + if (!input) { + this.setAuthorOverrideStatus("Enter a creator npub."); + return false; + } + const hex = this.mainController.normalizeHexPubkey(input); + if (!hex) { + this.setAuthorOverrideStatus("That doesn't look like a valid npub."); + return false; + } + if ( + typeof this.mainController.services.setAuthorModerationOverride !== + "function" + ) { + return false; + } + try { + this.mainController.services.setAuthorModerationOverride(hex); + if (this.authorOverrideInput instanceof HTMLInputElement) { + this.authorOverrideInput.value = ""; + } + this.setAuthorOverrideStatus(""); + this.refreshAuthorOverridesUi(); + this.mainController.showSuccess("Warnings turned off for this creator."); + return true; + } catch (error) { + this.mainController.showError( + "Couldn't add that creator. Please try again.", + ); + return false; + } + } + + async handleAuthorOverrideReset(entry) { + if ( + !entry || + !entry.authorPubkey || + typeof this.mainController.services.clearAuthorModerationOverride !== + "function" + ) { + return false; + } + try { + this.mainController.services.clearAuthorModerationOverride( + entry.authorPubkey, + ); + this.refreshAuthorOverridesUi(); + this.mainController.showSuccess("Warnings restored for this creator."); + return true; + } catch (error) { + this.mainController.showError("Unable to reset this creator."); + return false; + } + } + + refreshAuthorOverridesUi() { + if ( + !(this.authorOverridesList instanceof HTMLElement) || + !(this.authorOverridesEmpty instanceof HTMLElement) + ) { + return; + } + + const services = this.mainController.services; + const raw = + typeof services.getAuthorModerationOverrides === "function" + ? services.getAuthorModerationOverrides() + : []; + const entries = (Array.isArray(raw) ? raw : []) + .map((entry) => ({ + authorPubkey: + typeof entry?.authorPubkey === "string" + ? this.mainController.normalizeHexPubkey(entry.authorPubkey) || + entry.authorPubkey + : "", + updatedAt: Number.isFinite(entry?.updatedAt) + ? Math.floor(entry.updatedAt) + : 0, + })) + .filter((entry) => entry.authorPubkey) + .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + + this.authorOverridesList.textContent = ""; + + if (!entries.length) { + this.authorOverridesEmpty.classList.remove("hidden"); + this.authorOverridesList.classList.add("hidden"); + return; + } + + this.authorOverridesEmpty.classList.add("hidden"); + this.authorOverridesList.classList.remove("hidden"); + + const entriesNeedingFetch = new Set(); + + entries.forEach((entry) => { + const item = document.createElement("li"); + item.className = "card profile-account-card"; + + const authorKey = entry.authorPubkey; + if ( + authorKey && + typeof services.getProfileCacheEntry === "function" && + !services.getProfileCacheEntry(authorKey) + ) { + entriesNeedingFetch.add(authorKey); + } + + const summaryData = + this.mainController.dmController.resolveProfileSummaryForPubkey( + authorKey, + ); + const summary = + this.mainController.dmController.createCompactProfileSummary( + summaryData, + ); + + const actions = document.createElement("div"); + actions.className = "profile-account-card__actions"; + const resetButton = this.mainController.createRemoveButton({ + label: "Reset", + onRemove: () => this.handleAuthorOverrideReset(entry), + }); + + if (summary) { + item.appendChild(summary); + } + if (resetButton) { + actions.appendChild(resetButton); + item.appendChild(actions); + } + + this.authorOverridesList.appendChild(item); + }); + + if ( + entriesNeedingFetch.size && + typeof services.batchFetchProfiles === "function" + ) { + services.batchFetchProfiles(entriesNeedingFetch); + } + } + refreshModerationSettingsUi() { const service = this.getModerationSettingsService(); if (!service) { @@ -620,6 +793,7 @@ export class ProfileModerationController { this.updateTrustedHideControlsVisibility(); this.updateModerationTrustStats(); this.refreshModerationOverridesUi(); + this.refreshAuthorOverridesUi(); this.applyModerationSettingsControlState({ resetStatus: true }); } diff --git a/js/ui/profileModalContract.js b/js/ui/profileModalContract.js index 84b340e4..ad941dad 100644 --- a/js/ui/profileModalContract.js +++ b/js/ui/profileModalContract.js @@ -510,6 +510,27 @@ const SERVICE_CONTRACT = [ "Clears a specific moderation override when the viewer resets a decision.", fallback: () => () => false, }, + { + key: "getAuthorModerationOverrides", + type: "function", + description: + "Returns the account-level (per-creator) web-of-trust overrides for the active viewer.", + fallback: () => () => [], + }, + { + key: "setAuthorModerationOverride", + type: "function", + description: + "Adds an account-level override that suppresses WoT warnings for all of a creator's videos.", + fallback: () => () => null, + }, + { + key: "clearAuthorModerationOverride", + type: "function", + description: + "Clears an account-level (per-creator) override so the WoT warnings return.", + fallback: () => () => false, + }, { key: "loadVideos", type: "function", diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index efe7215f..5060cd3c 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -63,7 +63,7 @@ const GRANDFATHERED = { "js/services/authService.js": 1335, "js/ui/zapController.js": 1206, "js/services/r2Service.js": 1192, - "js/ui/applicationBootstrap.js": 1217, + "js/ui/applicationBootstrap.js": 1451, "js/ui/components/EditModal.js": 1130, "js/services/commentThreadService.js": 1100, "js/nostr/managers/SignerManager.js": 1160, diff --git a/tests/author-moderation-override.test.mjs b/tests/author-moderation-override.test.mjs new file mode 100644 index 00000000..c946949f --- /dev/null +++ b/tests/author-moderation-override.test.mjs @@ -0,0 +1,62 @@ +import "./test-helpers/setup-localstorage.mjs"; +import test, { beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + setAuthorModerationOverride, + getAuthorModerationOverride, + hasAuthorModerationOverride, + clearAuthorModerationOverride, + getAuthorModerationOverridesList, + loadAuthorModerationOverridesFromStorage, +} from "../js/state/cache.js"; + +const HEX = "a".repeat(64); +const HEX2 = "b".repeat(64); + +beforeEach(() => { + localStorage.clear(); + clearAuthorModerationOverride(HEX); + clearAuthorModerationOverride(HEX2); +}); + +test("set + get + has recognizes an account-level override", () => { + const entry = setAuthorModerationOverride(HEX, { showAnyway: true }); + assert.equal(entry.authorPubkey, HEX); + assert.equal(entry.showAnyway, true); + assert.equal(hasAuthorModerationOverride(HEX), true); + assert.equal(getAuthorModerationOverride(HEX)?.showAnyway, true); + assert.equal(hasAuthorModerationOverride(HEX2), false); +}); + +test("hex input is normalized (uppercase accepted, stored lowercase)", () => { + setAuthorModerationOverride(HEX.toUpperCase(), { showAnyway: true }); + assert.equal(hasAuthorModerationOverride(HEX), true, "uppercase resolves to the same entry"); +}); + +test("clearing removes it; showAnyway:false is treated as a removal", () => { + setAuthorModerationOverride(HEX, { showAnyway: true }); + assert.equal(clearAuthorModerationOverride(HEX), true); + assert.equal(hasAuthorModerationOverride(HEX), false); + + setAuthorModerationOverride(HEX, { showAnyway: true }); + assert.equal(setAuthorModerationOverride(HEX, { showAnyway: false }), null); + assert.equal(hasAuthorModerationOverride(HEX), false); +}); + +test("survives a reload via persist → load roundtrip", () => { + setAuthorModerationOverride(HEX, { showAnyway: true }); + setAuthorModerationOverride(HEX2, { showAnyway: true }); + // A reload repopulates the in-memory map from localStorage. + loadAuthorModerationOverridesFromStorage(); + const list = getAuthorModerationOverridesList() + .map((entry) => entry.authorPubkey) + .sort(); + assert.deepEqual(list, [HEX, HEX2].sort()); +}); + +test("blank/invalid pubkey is ignored", () => { + assert.equal(setAuthorModerationOverride("", { showAnyway: true }), null); + assert.equal(setAuthorModerationOverride("not-a-key", { showAnyway: true }), null); + assert.equal(getAuthorModerationOverridesList().length, 0); +});