Safety: account-level (per-creator) web-of-trust overrides

Add "trusted creator" overrides that suppress the web-of-trust warning/blur
for ALL of a creator's videos until reset — the account-level companion to the
existing per-video "Show anyway".

- cache.js: per-author override store (bitvid:moderationAuthorOverrides:v1),
  keyed by author hex, with get/set/has/clear/list + persist/load, mirroring
  the per-video store.
- moderationDecorator: overrideActive now also honors an account-level override
  for the video's author, so the whole class of WoT warnings clears for every
  video by that creator.
- app.js: load the author overrides on startup.
- Services + contract: getAuthorModerationOverrides / setAuthorModerationOverride
  / clearAuthorModerationOverride; set/clear refresh the feed + fire the shared
  moderation-override event so the Safety tab list updates.
- Safety tab: a "Trusted creators" section — add by npub, list each with a
  Reset, mirroring the Active-overrides section.
- tests: storage set/get/has/clear/list, hex normalization, persist→load,
  invalid-input guard.

Next: the "Always show this creator" button on the video warning overlay
(second entry point) — a larger multi-component UI change, done separately.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-08 15:58:10 -04:00
1 parent 18e7197bdc
commit a0131c718e
9 files changed
+537 -4

No files matched your search

+50
View File
@@ -1612,6 +1612,56 @@
data-orientation="vertical"
></ul>
</div>
<div class="space-y-3 border-t border-border/60 pt-4">
<div class="space-y-1">
<h4 class="text-sm font-semibold text-text">
Trusted creators
</h4>
<p class="text-xs text-muted">
Turn off web-of-trust warnings for a creator across all
their videos. They stay listed here until you reset them.
</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="min-w-0 flex-1 space-y-1">
<span class="text-xs font-medium text-muted"
>Creator npub</span
>
<input
id="profileAuthorOverrideInput"
type="text"
class="form-control"
placeholder="npub1…"
autocomplete="off"
spellcheck="false"
/>
</label>
<button
id="profileAuthorOverrideAddBtn"
type="button"
class="btn focus-ring"
>
Always show
</button>
</div>
<p
id="profileAuthorOverrideStatus"
class="text-xs text-muted"
role="status"
aria-live="polite"
></p>
<div
id="profileAuthorOverridesEmpty"
class="card border border-dashed border-border/60 p-4 text-center text-xs text-muted"
>
No trusted creators yet.
</div>
<ul
id="profileAuthorOverridesList"
class="grid-stack hidden"
data-orientation="vertical"
></ul>
</div>
</div>
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
+5
View File
@@ -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(
+11 -3
View File
@@ -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,13 +403,21 @@ 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)
: Number.isFinite(authorOverrideEntry?.updatedAt)
? Math.floor(authorOverrideEntry.updatedAt)
: Date.now();
const originalHideCounts = hideCounts
+170
View File
@@ -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";
+43
View File
@@ -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) =>
@@ -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 });
}
+21
View File
@@ -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",
+1 -1
View File
@@ -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,
+62
View File
@@ -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);
});