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
+538 -5

No files matched your search

@@ -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 });
}