mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
516 lines
18 KiB
JavaScript
516 lines
18 KiB
JavaScript
// js/services/moderationDecorator.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 { getModerationOverride, getAuthorModerationOverride, getModerationSettings, getDefaultModerationSettings } from "../state/cache.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
|
|
/**
|
|
* Service class that handles decorating video objects with moderation state.
|
|
* Extracts complex logic from Application class.
|
|
*/
|
|
export class ModerationDecorator {
|
|
constructor(services = {}) {
|
|
this.services = {
|
|
getProfileCacheEntry: services.getProfileCacheEntry || (() => null),
|
|
};
|
|
this.moderationSettings = this.normalizeModerationSettings(getModerationSettings());
|
|
}
|
|
|
|
deriveModerationReportType(summary) {
|
|
if (!summary || typeof summary !== "object") {
|
|
return "";
|
|
}
|
|
|
|
const types = summary.types && typeof summary.types === "object" ? summary.types : null;
|
|
if (!types) {
|
|
return "";
|
|
}
|
|
|
|
let bestType = "";
|
|
let bestScore = -1;
|
|
for (const [type, stats] of Object.entries(types)) {
|
|
if (!stats || typeof stats !== "object") {
|
|
continue;
|
|
}
|
|
const trusted = Number.isFinite(stats.trusted) ? Math.floor(stats.trusted) : 0;
|
|
if (trusted > bestScore) {
|
|
bestScore = trusted;
|
|
bestType = typeof type === "string" ? type : bestType;
|
|
}
|
|
}
|
|
|
|
return typeof bestType === "string" ? bestType.toLowerCase() : "";
|
|
}
|
|
|
|
deriveModerationTrustedCount(summary, reportType) {
|
|
if (!summary || typeof summary !== "object") {
|
|
return 0;
|
|
}
|
|
|
|
const normalizedType = typeof reportType === "string" ? reportType.toLowerCase() : "";
|
|
const types = summary.types && typeof summary.types === "object" ? summary.types : {};
|
|
|
|
if (normalizedType && types[normalizedType]) {
|
|
const entry = types[normalizedType];
|
|
if (entry && Number.isFinite(entry.trusted)) {
|
|
return Math.max(0, Math.floor(entry.trusted));
|
|
}
|
|
}
|
|
|
|
if (Number.isFinite(summary.totalTrusted)) {
|
|
return Math.max(0, Math.floor(summary.totalTrusted));
|
|
}
|
|
|
|
for (const stats of Object.values(types)) {
|
|
if (stats && Number.isFinite(stats.trusted)) {
|
|
return Math.max(0, Math.floor(stats.trusted));
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
getReporterDisplayName(pubkey) {
|
|
if (typeof pubkey !== "string") {
|
|
return "";
|
|
}
|
|
|
|
const trimmed = pubkey.trim();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
|
|
const cachedProfile = this.services.getProfileCacheEntry(trimmed);
|
|
const cachedName = cachedProfile?.profile?.name;
|
|
if (typeof cachedName === "string" && cachedName.trim()) {
|
|
return cachedName.trim();
|
|
}
|
|
|
|
try {
|
|
if (typeof window !== "undefined" && window.NostrTools?.nip19?.npubEncode) {
|
|
const encoded = window.NostrTools.nip19.npubEncode(trimmed);
|
|
if (encoded && typeof encoded === "string") {
|
|
return formatShortNpub(encoded);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
userLogger.warn("[Application] Failed to encode reporter npub", error);
|
|
}
|
|
|
|
return formatShortNpub(trimmed);
|
|
}
|
|
|
|
normalizeModerationSettings(settings = null) {
|
|
const defaults = this.defaultModerationSettings || getDefaultModerationSettings();
|
|
const sanitizeThresholdMap = (value, fallback = {}) => {
|
|
const fallbackMap = fallback && typeof fallback === "object" ? fallback : {};
|
|
if (!value || typeof value !== "object") {
|
|
return { ...fallbackMap };
|
|
}
|
|
const sanitized = {};
|
|
for (const [key, entry] of Object.entries(value)) {
|
|
if (typeof key !== "string") {
|
|
continue;
|
|
}
|
|
const normalizedKey = key.trim().toLowerCase();
|
|
if (!normalizedKey) {
|
|
continue;
|
|
}
|
|
const numeric = Number(entry);
|
|
if (Number.isFinite(numeric)) {
|
|
sanitized[normalizedKey] = Math.max(0, Math.floor(numeric));
|
|
}
|
|
}
|
|
return { ...fallbackMap, ...sanitized };
|
|
};
|
|
const defaultBlur = Number.isFinite(defaults?.blurThreshold)
|
|
? Math.max(0, Math.floor(defaults.blurThreshold))
|
|
: DEFAULT_BLUR_THRESHOLD;
|
|
const defaultAutoplay = Number.isFinite(defaults?.autoplayBlockThreshold)
|
|
? Math.max(0, Math.floor(defaults.autoplayBlockThreshold))
|
|
: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD;
|
|
|
|
const runtimeMuteSource = getTrustedMuteHideThreshold();
|
|
const runtimeTrustedMute = Number.isFinite(runtimeMuteSource)
|
|
? Math.max(0, Math.floor(runtimeMuteSource))
|
|
: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD;
|
|
const defaultTrustedMuteHide = Number.isFinite(
|
|
defaults?.trustedMuteHideThreshold,
|
|
)
|
|
? Math.max(0, Math.floor(defaults.trustedMuteHideThreshold))
|
|
: runtimeTrustedMute;
|
|
|
|
const runtimeSpamSource = getTrustedSpamHideThreshold();
|
|
const runtimeTrustedSpam = Number.isFinite(runtimeSpamSource)
|
|
? Math.max(0, Math.floor(runtimeSpamSource))
|
|
: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD;
|
|
const defaultTrustedSpamHide = Number.isFinite(
|
|
defaults?.trustedSpamHideThreshold,
|
|
)
|
|
? Math.max(0, Math.floor(defaults.trustedSpamHideThreshold))
|
|
: runtimeTrustedSpam;
|
|
|
|
const defaultTrustedMuteHideThresholds = sanitizeThresholdMap(
|
|
defaults?.trustedMuteHideThresholds,
|
|
{},
|
|
);
|
|
|
|
const blurSource = Number.isFinite(settings?.blurThreshold)
|
|
? Math.max(0, Math.floor(settings.blurThreshold))
|
|
: defaultBlur;
|
|
const autoplaySource = Number.isFinite(settings?.autoplayBlockThreshold)
|
|
? Math.max(0, Math.floor(settings.autoplayBlockThreshold))
|
|
: defaultAutoplay;
|
|
const muteHideSource = Number.isFinite(settings?.trustedMuteHideThreshold)
|
|
? Math.max(0, Math.floor(settings.trustedMuteHideThreshold))
|
|
: defaultTrustedMuteHide;
|
|
const spamHideSource = Number.isFinite(settings?.trustedSpamHideThreshold)
|
|
? Math.max(0, Math.floor(settings.trustedSpamHideThreshold))
|
|
: defaultTrustedSpamHide;
|
|
const muteHideThresholdsSource = sanitizeThresholdMap(
|
|
settings?.trustedMuteHideThresholds,
|
|
defaultTrustedMuteHideThresholds,
|
|
);
|
|
|
|
return {
|
|
blurThreshold: blurSource,
|
|
autoplayBlockThreshold: autoplaySource,
|
|
trustedMuteHideThreshold: muteHideSource,
|
|
trustedMuteHideThresholds: muteHideThresholdsSource,
|
|
trustedSpamHideThreshold: spamHideSource,
|
|
};
|
|
}
|
|
|
|
getActiveModerationThresholds() {
|
|
this.moderationSettings = this.normalizeModerationSettings(this.moderationSettings);
|
|
return { ...this.moderationSettings };
|
|
}
|
|
|
|
updateSettings(settings) {
|
|
this.moderationSettings = this.normalizeModerationSettings(settings);
|
|
}
|
|
|
|
decorateVideo(video, feedContext = {}) {
|
|
if (!video || typeof video !== "object") {
|
|
return video;
|
|
}
|
|
|
|
const existingModeration =
|
|
video.moderation && typeof video.moderation === "object"
|
|
? { ...video.moderation }
|
|
: {};
|
|
|
|
const summary =
|
|
existingModeration.summary && typeof existingModeration.summary === "object"
|
|
? existingModeration.summary
|
|
: null;
|
|
|
|
const rawReportType =
|
|
typeof existingModeration.reportType === "string" &&
|
|
existingModeration.reportType.trim()
|
|
? existingModeration.reportType.trim().toLowerCase()
|
|
: "";
|
|
|
|
const reportType = rawReportType || this.deriveModerationReportType(summary) || "";
|
|
|
|
const sanitizedReporters = Array.isArray(existingModeration.trustedReporters)
|
|
? existingModeration.trustedReporters
|
|
.map((entry) => {
|
|
if (!entry || typeof entry !== "object") {
|
|
return null;
|
|
}
|
|
const pubkey =
|
|
typeof entry.pubkey === "string" ? entry.pubkey.trim().toLowerCase() : "";
|
|
if (!pubkey) {
|
|
return null;
|
|
}
|
|
const latest = Number.isFinite(entry.latest)
|
|
? Math.floor(entry.latest)
|
|
: 0;
|
|
return { pubkey, latest };
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const reporterPubkeys = sanitizedReporters.map((entry) => entry.pubkey);
|
|
|
|
const rawTrustedMuters = Array.isArray(existingModeration.trustedMuters)
|
|
? existingModeration.trustedMuters
|
|
.map((value) => (typeof value === "string" ? value.trim().toLowerCase() : ""))
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const trustedMuteCount = Number.isFinite(existingModeration.trustedMuteCount)
|
|
? Math.max(0, Math.floor(existingModeration.trustedMuteCount))
|
|
: rawTrustedMuters.length;
|
|
|
|
const trustedMuted = existingModeration.trustedMuted === true || trustedMuteCount > 0;
|
|
|
|
const reporterDisplayNames = [];
|
|
const seenNames = new Set();
|
|
for (const reporterPubkey of reporterPubkeys) {
|
|
const name = this.getReporterDisplayName(reporterPubkey);
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const normalizedName = name.trim();
|
|
if (!normalizedName) {
|
|
continue;
|
|
}
|
|
const key = normalizedName.toLowerCase();
|
|
if (seenNames.has(key)) {
|
|
continue;
|
|
}
|
|
seenNames.add(key);
|
|
reporterDisplayNames.push(normalizedName);
|
|
}
|
|
|
|
const trustedMuterDisplayNames = [];
|
|
if (trustedMuted) {
|
|
const seenMuteNames = new Set();
|
|
for (const muterPubkey of rawTrustedMuters) {
|
|
const name = this.getReporterDisplayName(muterPubkey);
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const normalizedName = name.trim();
|
|
if (!normalizedName) {
|
|
continue;
|
|
}
|
|
const key = normalizedName.toLowerCase();
|
|
if (seenMuteNames.has(key)) {
|
|
continue;
|
|
}
|
|
seenMuteNames.add(key);
|
|
trustedMuterDisplayNames.push(normalizedName);
|
|
}
|
|
}
|
|
|
|
const trustedCount = Number.isFinite(existingModeration.trustedCount)
|
|
? Math.max(0, Math.floor(existingModeration.trustedCount))
|
|
: this.deriveModerationTrustedCount(summary, reportType);
|
|
|
|
const viewerMuted = existingModeration.viewerMuted === true;
|
|
const existingBlockAutoplay = existingModeration.blockAutoplay === true;
|
|
const existingBlurThumbnail = existingModeration.blurThumbnail === true;
|
|
const existingBlurReason =
|
|
typeof existingModeration.blurReason === "string"
|
|
? existingModeration.blurReason.trim()
|
|
: "";
|
|
|
|
const thresholds = this.getActiveModerationThresholds();
|
|
const computedBlockAutoplayBase =
|
|
trustedCount >= thresholds.autoplayBlockThreshold || trustedMuted;
|
|
const computedBlockAutoplay =
|
|
computedBlockAutoplayBase || viewerMuted || existingBlockAutoplay;
|
|
|
|
const blurFromReports = trustedCount >= thresholds.blurThreshold;
|
|
let computedBlurThumbnail =
|
|
blurFromReports || trustedMuted || viewerMuted || existingBlurThumbnail;
|
|
let computedBlurReason = "";
|
|
|
|
if (blurFromReports) {
|
|
computedBlurReason = "trusted-report";
|
|
} else if (trustedMuted) {
|
|
computedBlurReason = "trusted-mute";
|
|
} else if (viewerMuted) {
|
|
computedBlurReason = "viewer-mute";
|
|
} else if (existingBlurThumbnail && existingBlurReason) {
|
|
computedBlurReason = existingBlurReason;
|
|
}
|
|
|
|
const muteHideThreshold = Number.isFinite(thresholds.trustedMuteHideThreshold)
|
|
? Math.max(0, Math.floor(thresholds.trustedMuteHideThreshold))
|
|
: Number.POSITIVE_INFINITY;
|
|
const reportHideThreshold = Number.isFinite(thresholds.trustedSpamHideThreshold)
|
|
? Math.max(0, Math.floor(thresholds.trustedSpamHideThreshold))
|
|
: Number.POSITIVE_INFINITY;
|
|
|
|
const existingHideReason =
|
|
typeof existingModeration.hideReason === "string"
|
|
? existingModeration.hideReason.trim() : "";
|
|
const existingHideBypass =
|
|
typeof existingModeration.hideBypass === "string"
|
|
? existingModeration.hideBypass.trim() : "";
|
|
const existingHideCounts =
|
|
existingModeration.hideCounts && typeof existingModeration.hideCounts === "object"
|
|
? existingModeration.hideCounts
|
|
: null;
|
|
|
|
let hideReason = "";
|
|
let hideTriggered = false;
|
|
|
|
if (trustedMuted && trustedMuteCount >= muteHideThreshold) {
|
|
hideReason = "trusted-mute-hide";
|
|
hideTriggered = true;
|
|
} else if (trustedCount >= reportHideThreshold) {
|
|
hideReason = "trusted-report-hide";
|
|
hideTriggered = true;
|
|
} else if (existingHideReason && existingHideCounts) {
|
|
hideReason = existingHideReason;
|
|
hideTriggered = true;
|
|
}
|
|
|
|
if (!computedBlurThumbnail && (viewerMuted || trustedMuted || hideTriggered)) {
|
|
computedBlurThumbnail = true;
|
|
if (hideTriggered) {
|
|
computedBlurReason = hideReason || "trusted-hide";
|
|
} else if (viewerMuted) {
|
|
computedBlurReason = "viewer-mute";
|
|
} else if (trustedMuted) {
|
|
computedBlurReason = "trusted-mute";
|
|
}
|
|
} else if (computedBlurThumbnail) {
|
|
if (hideTriggered) {
|
|
computedBlurReason = hideReason || "trusted-hide";
|
|
} else if (viewerMuted && !blurFromReports && !trustedMuted) {
|
|
computedBlurReason = "viewer-mute";
|
|
} else if (trustedMuted && !blurFromReports) {
|
|
computedBlurReason = "trusted-mute";
|
|
} else if (!computedBlurReason && blurFromReports) {
|
|
computedBlurReason = "trusted-report";
|
|
}
|
|
}
|
|
|
|
if (computedBlurThumbnail && !computedBlurReason && existingBlurReason) {
|
|
computedBlurReason = existingBlurReason;
|
|
}
|
|
|
|
const hideCounts = hideTriggered
|
|
? {
|
|
trustedMuteCount,
|
|
trustedReportCount: trustedCount,
|
|
}
|
|
: null;
|
|
|
|
const FEED_HIDE_BYPASS_NAMES = new Set(["home", "recent"]);
|
|
const normalizedFeedName =
|
|
typeof feedContext?.feedName === "string" ? feedContext.feedName.trim().toLowerCase() : "";
|
|
const normalizedFeedVariant =
|
|
typeof feedContext?.feedVariant === "string"
|
|
? feedContext.feedVariant.trim().toLowerCase()
|
|
: "";
|
|
const feedPolicyBypass =
|
|
(normalizedFeedName && FEED_HIDE_BYPASS_NAMES.has(normalizedFeedName)) ||
|
|
(normalizedFeedVariant && FEED_HIDE_BYPASS_NAMES.has(normalizedFeedVariant));
|
|
|
|
let hideBypass = hideTriggered ? existingHideBypass : "";
|
|
|
|
if (hideTriggered && !hideBypass && feedPolicyBypass) {
|
|
hideBypass = "feed-policy";
|
|
}
|
|
|
|
const computedHidden = hideTriggered && !hideBypass;
|
|
|
|
const authorPubkey = video.pubkey || video.author?.pubkey || "";
|
|
const overrideEntry = getModerationOverride({
|
|
eventId: video.id,
|
|
authorPubkey,
|
|
});
|
|
// 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
|
|
? {
|
|
trustedMuteCount: Math.max(0, Math.floor(hideCounts.trustedMuteCount)),
|
|
trustedReportCount: Math.max(0, Math.floor(hideCounts.trustedReportCount)),
|
|
}
|
|
: null;
|
|
|
|
const originalState = {
|
|
blockAutoplay: computedBlockAutoplay,
|
|
blurThumbnail: computedBlurThumbnail,
|
|
hidden: computedHidden,
|
|
hideReason: hideTriggered ? hideReason : "",
|
|
hideCounts: originalHideCounts,
|
|
hideBypass,
|
|
hideTriggered,
|
|
blurReason: computedBlurThumbnail ? computedBlurReason : "",
|
|
};
|
|
|
|
const decoratedModeration = {
|
|
...existingModeration,
|
|
reportType,
|
|
trustedCount,
|
|
trustedReporters: sanitizedReporters,
|
|
reporterPubkeys,
|
|
reporterDisplayNames,
|
|
trustedMuted,
|
|
trustedMuters: rawTrustedMuters,
|
|
trustedMuteCount,
|
|
trustedMuterDisplayNames,
|
|
blurReason: computedBlurThumbnail ? computedBlurReason : "",
|
|
original: {
|
|
blockAutoplay: originalState.blockAutoplay,
|
|
blurThumbnail: originalState.blurThumbnail,
|
|
hidden: originalState.hidden,
|
|
hideReason: originalState.hideReason,
|
|
hideCounts: originalState.hideCounts,
|
|
hideBypass: originalState.hideBypass,
|
|
hideTriggered: originalState.hideTriggered,
|
|
blurReason: originalState.blurReason,
|
|
},
|
|
};
|
|
|
|
if (!computedBlurThumbnail && decoratedModeration.blurReason) {
|
|
delete decoratedModeration.blurReason;
|
|
}
|
|
|
|
if (overrideActive) {
|
|
decoratedModeration.blockAutoplay = false;
|
|
decoratedModeration.blurThumbnail = false;
|
|
decoratedModeration.hidden = false;
|
|
if (decoratedModeration.hideReason) {
|
|
delete decoratedModeration.hideReason;
|
|
}
|
|
if (decoratedModeration.hideCounts) {
|
|
delete decoratedModeration.hideCounts;
|
|
}
|
|
if (decoratedModeration.hideBypass) {
|
|
delete decoratedModeration.hideBypass;
|
|
}
|
|
decoratedModeration.viewerOverride = {
|
|
showAnyway: true,
|
|
updatedAt: overrideUpdatedAt,
|
|
};
|
|
} else {
|
|
decoratedModeration.blockAutoplay = originalState.blockAutoplay;
|
|
decoratedModeration.blurThumbnail = originalState.blurThumbnail;
|
|
decoratedModeration.hidden = originalState.hidden;
|
|
if (originalState.hideReason) {
|
|
decoratedModeration.hideReason = originalState.hideReason;
|
|
} else if (decoratedModeration.hideReason) {
|
|
delete decoratedModeration.hideReason;
|
|
}
|
|
if (originalState.hideCounts) {
|
|
decoratedModeration.hideCounts = { ...originalState.hideCounts };
|
|
} else if (decoratedModeration.hideCounts) {
|
|
delete decoratedModeration.hideCounts;
|
|
}
|
|
if (originalState.hideBypass) {
|
|
decoratedModeration.hideBypass = originalState.hideBypass;
|
|
} else if (decoratedModeration.hideBypass) {
|
|
delete decoratedModeration.hideBypass;
|
|
}
|
|
if (decoratedModeration.viewerOverride) {
|
|
delete decoratedModeration.viewerOverride;
|
|
}
|
|
}
|
|
|
|
video.moderation = decoratedModeration;
|
|
return video;
|
|
}
|
|
}
|
|
|
|
export default ModerationDecorator;
|