Files
bitvid/js/services/moderationDecorator.js
T
google-labs-jules[bot] 8015edf6b3 refactor(ai): extract ModerationDecorator from js/app.js (agent)
Extracts `decorateVideoModeration` and related helper methods from `js/app.js` into a new `js/services/moderationDecorator.js` service. This improves separation of concerns by moving complex moderation logic out of the main application class.

AGENTS.md Checklist:
1. Identify Logic: Extracted `decorateVideoModeration`, `deriveModerationReportType`, `deriveModerationTrustedCount`, `getReporterDisplayName`, `normalizeModerationSettings`, `getActiveModerationThresholds`.
2. Create Controller Method: Created `ModerationDecorator` class with `decorateVideo` method.
3. Create `bitvidApp` Wrapper: Replaced `decorateVideoModeration` in `js/app.js` with a wrapper that delegates to `this.moderationDecorator.decorateVideo`.
4. Wire Up Callbacks: Passed `getProfileCacheEntry` to `ModerationDecorator` constructor.
5. Remove Old Code: Removed extracted methods from `js/app.js`.

Also added unit tests for `ModerationDecorator` and ensured runtime settings updates are propagated.
2026-01-21 03:20:17 +00:00

508 lines
17 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, 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 overrideEntry = getModerationOverride({
eventId: video.id,
authorPubkey: video.pubkey || video.author?.pubkey || "",
});
const overrideActive = overrideEntry?.showAnyway === true;
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
? Math.floor(overrideEntry.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;