mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
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:
1 parent
18e7197bdc
commit
a0131c718e
9 files changed
+538
-5
No files matched your search
@@ -1612,6 +1612,56 @@
|
|||||||
data-orientation="vertical"
|
data-orientation="vertical"
|
||||||
></ul>
|
></ul>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
|
<div id="profileLinkPreviewSettings" class="card space-y-4 p-5">
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ import {
|
|||||||
setModerationOverride,
|
setModerationOverride,
|
||||||
clearModerationOverride,
|
clearModerationOverride,
|
||||||
loadModerationOverridesFromStorage,
|
loadModerationOverridesFromStorage,
|
||||||
|
getAuthorModerationOverridesList,
|
||||||
|
setAuthorModerationOverride,
|
||||||
|
clearAuthorModerationOverride,
|
||||||
|
loadAuthorModerationOverridesFromStorage,
|
||||||
getModerationSettings,
|
getModerationSettings,
|
||||||
getDefaultModerationSettings,
|
getDefaultModerationSettings,
|
||||||
setModerationSettings,
|
setModerationSettings,
|
||||||
@@ -918,6 +922,7 @@ class Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadModerationOverridesFromStorage();
|
loadModerationOverridesFromStorage();
|
||||||
|
loadAuthorModerationOverridesFromStorage();
|
||||||
loadModerationSettingsFromStorage();
|
loadModerationSettingsFromStorage();
|
||||||
loadDmPrivacySettingsFromStorage();
|
loadDmPrivacySettingsFromStorage();
|
||||||
this.moderationSettings = this.normalizeModerationSettings(
|
this.moderationSettings = this.normalizeModerationSettings(
|
||||||
|
|||||||
@@ -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 { 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 { 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";
|
import { userLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,14 +403,22 @@ export class ModerationDecorator {
|
|||||||
|
|
||||||
const computedHidden = hideTriggered && !hideBypass;
|
const computedHidden = hideTriggered && !hideBypass;
|
||||||
|
|
||||||
|
const authorPubkey = video.pubkey || video.author?.pubkey || "";
|
||||||
const overrideEntry = getModerationOverride({
|
const overrideEntry = getModerationOverride({
|
||||||
eventId: video.id,
|
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)
|
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
|
||||||
? Math.floor(overrideEntry.updatedAt)
|
? Math.floor(overrideEntry.updatedAt)
|
||||||
: Date.now();
|
: Number.isFinite(authorOverrideEntry?.updatedAt)
|
||||||
|
? Math.floor(authorOverrideEntry.updatedAt)
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
const originalHideCounts = hideCounts
|
const originalHideCounts = hideCounts
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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_KEY = "bitvid:moderationOverrides:v2";
|
||||||
const MODERATION_OVERRIDE_STORAGE_VERSION = 2;
|
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_KEY = "bitvid:moderationSettings:v1";
|
||||||
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
|
const MODERATION_SETTINGS_STORAGE_VERSION = 3;
|
||||||
const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1";
|
const DM_PRIVACY_SETTINGS_STORAGE_KEY = "bitvid:dmPrivacySettings:v1";
|
||||||
@@ -198,6 +204,7 @@ const profileCache = new Map();
|
|||||||
const urlHealthCache = new Map();
|
const urlHealthCache = new Map();
|
||||||
const urlHealthInFlight = new Map();
|
const urlHealthInFlight = new Map();
|
||||||
const moderationOverrides = new Map();
|
const moderationOverrides = new Map();
|
||||||
|
const authorModerationOverrides = new Map();
|
||||||
|
|
||||||
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
|
||||||
if (previousProfiles === nextProfiles) {
|
if (previousProfiles === nextProfiles) {
|
||||||
@@ -1720,6 +1727,169 @@ export function clearModerationOverride(
|
|||||||
return removed;
|
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 = {}) {
|
function buildUrlProbeKey(url, options = {}) {
|
||||||
const trimmed = typeof url === "string" ? url : "";
|
const trimmed = typeof url === "string" ? url : "";
|
||||||
const mode = options?.confirmPlayable ? "playable" : "basic";
|
const mode = options?.confirmPlayable ? "playable" : "basic";
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ import {
|
|||||||
getDmPrivacySettings,
|
getDmPrivacySettings,
|
||||||
setDmPrivacySettings,
|
setDmPrivacySettings,
|
||||||
clearModerationOverride,
|
clearModerationOverride,
|
||||||
|
getAuthorModerationOverridesList,
|
||||||
|
setAuthorModerationOverride,
|
||||||
|
clearAuthorModerationOverride,
|
||||||
persistSavedProfiles,
|
persistSavedProfiles,
|
||||||
getSavedProfiles,
|
getSavedProfiles,
|
||||||
getActiveProfilePubkey,
|
getActiveProfilePubkey,
|
||||||
@@ -93,6 +96,30 @@ import {
|
|||||||
} from "./components/staticModalAccessibility.js";
|
} from "./components/staticModalAccessibility.js";
|
||||||
import ModalManager from "./ModalManager.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 {
|
export default class ApplicationBootstrap {
|
||||||
constructor({
|
constructor({
|
||||||
app,
|
app,
|
||||||
@@ -550,6 +577,22 @@ export default class ApplicationBootstrap {
|
|||||||
getModerationOverrides: () => getModerationOverridesList(),
|
getModerationOverrides: () => getModerationOverridesList(),
|
||||||
clearModerationOverride: (descriptor) =>
|
clearModerationOverride: (descriptor) =>
|
||||||
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) =>
|
loadVideos: (forceFetch, context) =>
|
||||||
app.loadVideos(forceFetch, context),
|
app.loadVideos(forceFetch, context),
|
||||||
onVideosShouldRefresh: (context) =>
|
onVideosShouldRefresh: (context) =>
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export class ProfileModerationController {
|
|||||||
this.moderationStatusText = document.getElementById("profileModerationStatus") || null;
|
this.moderationStatusText = document.getElementById("profileModerationStatus") || null;
|
||||||
this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null;
|
this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null;
|
||||||
this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || 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.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null;
|
||||||
this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null;
|
this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null;
|
||||||
this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || 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") {
|
if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") {
|
||||||
this.boundModerationOverridesUpdate = () => {
|
this.boundModerationOverridesUpdate = () => {
|
||||||
this.refreshModerationOverridesUi();
|
this.refreshModerationOverridesUi();
|
||||||
|
this.refreshAuthorOverridesUi();
|
||||||
};
|
};
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"video:moderation-override",
|
"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() {
|
refreshModerationSettingsUi() {
|
||||||
const service = this.getModerationSettingsService();
|
const service = this.getModerationSettingsService();
|
||||||
if (!service) {
|
if (!service) {
|
||||||
@@ -620,6 +793,7 @@ export class ProfileModerationController {
|
|||||||
this.updateTrustedHideControlsVisibility();
|
this.updateTrustedHideControlsVisibility();
|
||||||
this.updateModerationTrustStats();
|
this.updateModerationTrustStats();
|
||||||
this.refreshModerationOverridesUi();
|
this.refreshModerationOverridesUi();
|
||||||
|
this.refreshAuthorOverridesUi();
|
||||||
|
|
||||||
this.applyModerationSettingsControlState({ resetStatus: true });
|
this.applyModerationSettingsControlState({ resetStatus: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -510,6 +510,27 @@ const SERVICE_CONTRACT = [
|
|||||||
"Clears a specific moderation override when the viewer resets a decision.",
|
"Clears a specific moderation override when the viewer resets a decision.",
|
||||||
fallback: () => () => false,
|
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",
|
key: "loadVideos",
|
||||||
type: "function",
|
type: "function",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const GRANDFATHERED = {
|
|||||||
"js/services/authService.js": 1335,
|
"js/services/authService.js": 1335,
|
||||||
"js/ui/zapController.js": 1206,
|
"js/ui/zapController.js": 1206,
|
||||||
"js/services/r2Service.js": 1192,
|
"js/services/r2Service.js": 1192,
|
||||||
"js/ui/applicationBootstrap.js": 1217,
|
"js/ui/applicationBootstrap.js": 1451,
|
||||||
"js/ui/components/EditModal.js": 1130,
|
"js/ui/components/EditModal.js": 1130,
|
||||||
"js/services/commentThreadService.js": 1100,
|
"js/services/commentThreadService.js": 1100,
|
||||||
"js/nostr/managers/SignerManager.js": 1160,
|
"js/nostr/managers/SignerManager.js": 1160,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user