Add moderation overrides to video cards

This commit is contained in:
thePR0M3TH3AN committed 2025-10-17 16:24:42 -04:00
1 parent 08916a8f0d
commit 0ec7c5e76c
6 files changed
+1004 -29

No files matched your search

+264 -4
View File
@@ -112,6 +112,9 @@ import {
storeUrlHealth as persistUrlHealth,
setInFlightUrlProbe,
getInFlightUrlProbe,
getModerationOverride,
setModerationOverride,
loadModerationOverridesFromStorage,
URL_PROBE_TIMEOUT_MS,
urlHealthConstants,
} from "./state/cache.js";
@@ -942,6 +945,10 @@ class Application {
};
this.videoListView.setDeleteHandler(this.videoListViewDeleteHandler);
this.videoListView.setModerationOverrideHandler((detail = {}) =>
this.handleModerationOverride(detail)
);
if (this.moreMenuController) {
this.moreMenuController.attachVideoListView(this.videoListView);
}
@@ -1095,6 +1102,8 @@ class Application {
this.authService.hydrateFromStorage();
this.renderSavedProfiles();
loadModerationOverridesFromStorage();
const videoModalPromise = this.videoModal.load().then(() => {
const modalRoot = this.videoModal.getRoot();
if (modalRoot) {
@@ -4379,9 +4388,9 @@ class Application {
if (Array.isArray(payload.videos)) {
videos = payload.videos;
}
if (payload.metadata && typeof payload.metadata === "object") {
metadata = { ...payload.metadata };
}
if (payload.metadata && typeof payload.metadata === "object") {
metadata = { ...payload.metadata };
}
}
this.latestFeedMetadata = metadata;
@@ -4389,7 +4398,11 @@ class Application {
this.videoListView.state.feedMetadata = metadata;
}
this.videoListView.render(videos, metadata);
const decoratedVideos = Array.isArray(videos)
? videos.map((video) => this.decorateVideoModeration(video))
: [];
this.videoListView.render(decoratedVideos, metadata);
}
refreshVideoDiscussionCounts(videos = [], options = {}) {
@@ -4405,6 +4418,247 @@ class Application {
});
}
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.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 `${encoded.slice(0, 8)}…${encoded.slice(-4)}`;
}
}
} catch (error) {
userLogger.warn("[Application] Failed to encode reporter npub", error);
}
if (trimmed.length > 12) {
return `${trimmed.slice(0, 8)}…${trimmed.slice(-4)}`;
}
return trimmed;
}
decorateVideoModeration(video) {
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 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 trustedCount = Number.isFinite(existingModeration.trustedCount)
? Math.max(0, Math.floor(existingModeration.trustedCount))
: this.deriveModerationTrustedCount(summary, reportType);
const overrideEntry = getModerationOverride(video.id);
const overrideActive = overrideEntry?.showAnyway === true;
const overrideUpdatedAt = Number.isFinite(overrideEntry?.updatedAt)
? Math.floor(overrideEntry.updatedAt)
: Date.now();
const originalState =
existingModeration.original && typeof existingModeration.original === "object"
? {
blockAutoplay: existingModeration.original.blockAutoplay === true,
blurThumbnail: existingModeration.original.blurThumbnail === true,
}
: {
blockAutoplay: existingModeration.blockAutoplay === true,
blurThumbnail: existingModeration.blurThumbnail === true,
};
const decoratedModeration = {
...existingModeration,
reportType,
trustedCount,
trustedReporters: sanitizedReporters,
reporterPubkeys,
reporterDisplayNames,
original: {
blockAutoplay: originalState.blockAutoplay,
blurThumbnail: originalState.blurThumbnail,
},
};
if (overrideActive) {
decoratedModeration.blockAutoplay = false;
decoratedModeration.blurThumbnail = false;
decoratedModeration.viewerOverride = {
showAnyway: true,
updatedAt: overrideUpdatedAt,
};
} else {
decoratedModeration.blockAutoplay = originalState.blockAutoplay;
decoratedModeration.blurThumbnail = originalState.blurThumbnail;
if (decoratedModeration.viewerOverride) {
delete decoratedModeration.viewerOverride;
}
}
video.moderation = decoratedModeration;
return video;
}
handleModerationOverride({ video, card }) {
if (!video || typeof video !== "object" || !video.id) {
return false;
}
try {
setModerationOverride(video.id, {
showAnyway: true,
updatedAt: Date.now(),
});
} catch (error) {
devLogger.warn("[Application] Failed to persist moderation override:", error);
}
const storedVideo =
this.videosMap instanceof Map && video.id ? this.videosMap.get(video.id) : null;
const target = storedVideo || video;
if (target) {
this.decorateVideoModeration(target);
}
if (this.currentVideo && this.currentVideo.id === video.id) {
this.decorateVideoModeration(this.currentVideo);
}
if (card && typeof card.refreshModerationUi === "function") {
try {
card.refreshModerationUi();
} catch (error) {
devLogger.warn("[Application] Failed to refresh moderation UI:", error);
}
}
return true;
}
getVideoAddressPointer(video) {
if (
this.discussionCountService &&
@@ -6200,6 +6454,8 @@ class Application {
return;
}
this.decorateVideoModeration(video);
let trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
if (!trimmedUrl && fallbackUrl) {
trimmedUrl = fallbackUrl;
@@ -6255,6 +6511,8 @@ class Application {
lastEditedAt: normalizedEditedAt,
};
this.decorateVideoModeration(this.currentVideo);
if (Number.isFinite(knownPostedAt)) {
this.cacheVideoRootCreatedAt(this.currentVideo, knownPostedAt);
} else if (this.currentVideo.rootCreatedAt) {
@@ -6631,6 +6889,8 @@ class Application {
pointerKey: null,
};
this.decorateVideoModeration(this.currentVideo);
this.syncModalMoreMenuData();
this.currentMagnetUri = sanitizedMagnet || null;
+37
View File
@@ -263,6 +263,7 @@ export function createModerationStage({
let summary = null;
let trustedCount = 0;
let trustedReporters = [];
if (videoId) {
try {
if (typeof resolvedService.getTrustedReportSummary === "function") {
@@ -271,10 +272,34 @@ export function createModerationStage({
if (typeof resolvedService.trustedReportCount === "function") {
trustedCount = resolvedService.trustedReportCount(videoId, normalizedReportType) || 0;
}
if (typeof resolvedService.getTrustedReporters === "function") {
const reporterEntries = resolvedService.getTrustedReporters(
videoId,
normalizedReportType
);
if (Array.isArray(reporterEntries) && reporterEntries.length) {
trustedReporters = reporterEntries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const pubkey = typeof entry.pubkey === "string" ? entry.pubkey.trim() : "";
if (!pubkey) {
return null;
}
const latest = Number.isFinite(entry.latest)
? Math.floor(entry.latest)
: 0;
return { pubkey, latest };
})
.filter(Boolean);
}
}
} catch (error) {
context?.log?.(`[${stageName}] Failed to fetch moderation summary`, error);
summary = null;
trustedCount = 0;
trustedReporters = [];
}
}
@@ -293,6 +318,11 @@ export function createModerationStage({
metadataModeration.blockAutoplay = blockAutoplay;
metadataModeration.blurThumbnail = blurThumbnail;
metadataModeration.summary = summary;
metadataModeration.trustedCount = trustedCount;
metadataModeration.reportType = normalizedReportType;
metadataModeration.trustedReporters = Array.isArray(trustedReporters)
? trustedReporters.slice()
: [];
item.metadata.moderation = metadataModeration;
if (!video.moderation || typeof video.moderation !== "object") {
@@ -301,6 +331,13 @@ export function createModerationStage({
video.moderation.blockAutoplay = blockAutoplay;
video.moderation.blurThumbnail = blurThumbnail;
video.moderation.trustedCount = trustedCount;
video.moderation.reportType = normalizedReportType;
if (Array.isArray(trustedReporters) && trustedReporters.length) {
video.moderation.trustedReporters = trustedReporters.slice();
} else if (video.moderation.trustedReporters) {
delete video.moderation.trustedReporters;
}
if (summary) {
video.moderation.summary = summary;
} else if (video.moderation.summary) {
+52
View File
@@ -664,6 +664,58 @@ export class ModerationService {
return cloneSummary(summary);
}
getTrustedReporters(eventId, type) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
return [];
}
const eventReports = this.reportEvents.get(normalizedEventId);
if (!eventReports || !eventReports.size) {
return [];
}
const normalizedType = normalizeReportType(type);
const results = [];
for (const [reporter, typeMap] of eventReports.entries()) {
if (!this.trustedContacts.has(reporter)) {
continue;
}
if (normalizedType) {
const detail = typeMap.get(normalizedType);
if (!detail) {
continue;
}
results.push({
pubkey: reporter,
latest: ensureNumber(detail?.created_at),
});
continue;
}
let latest = 0;
for (const entry of typeMap.values()) {
const candidate = ensureNumber(entry?.created_at);
if (candidate > latest) {
latest = candidate;
}
}
results.push({ pubkey: reporter, latest });
}
if (!results.length) {
return [];
}
results.sort((a, b) => ensureNumber(b.latest) - ensureNumber(a.latest));
return results.map((entry) => ({
pubkey: entry.pubkey,
latest: ensureNumber(entry.latest),
}));
}
trustedReportCount(eventId, type) {
const normalizedEventId = normalizeEventId(eventId);
if (!normalizedEventId) {
+189
View File
@@ -20,6 +20,9 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1";
const MODERATION_OVERRIDE_STORAGE_VERSION = 1;
function sanitizeProfileString(value) {
if (typeof value !== "string") {
return "";
@@ -34,6 +37,7 @@ let activeProfilePubkey = null;
const profileCache = new Map();
const urlHealthCache = new Map();
const urlHealthInFlight = new Map();
const moderationOverrides = new Map();
function hasSavedProfilesChanged(previousProfiles, nextProfiles) {
if (previousProfiles === nextProfiles) {
@@ -113,6 +117,19 @@ function normalizeHexPubkey(pubkey) {
return null;
}
function normalizeEventId(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null;
}
return HEX64_REGEX.test(trimmed) ? trimmed : null;
}
export function getSavedProfiles() {
return savedProfiles;
}
@@ -637,6 +654,178 @@ export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) {
return entry;
}
export function getModerationOverridesMap() {
return moderationOverrides;
}
export function getModerationOverride(eventId) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return null;
}
const entry = moderationOverrides.get(normalized);
if (!entry) {
return null;
}
return { ...entry };
}
export function loadModerationOverridesFromStorage() {
if (typeof localStorage === "undefined") {
return;
}
const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY);
if (!raw) {
return;
}
try {
const payload = JSON.parse(raw);
if (!payload || typeof payload !== "object") {
return;
}
if (payload.version !== MODERATION_OVERRIDE_STORAGE_VERSION) {
return;
}
const entries = payload.entries && typeof payload.entries === "object"
? payload.entries
: {};
moderationOverrides.clear();
for (const [eventId, entry] of Object.entries(entries)) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
continue;
}
if (!entry || entry.showAnyway !== true) {
continue;
}
const updatedAt = Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now();
moderationOverrides.set(normalized, {
showAnyway: true,
updatedAt,
});
}
} catch (error) {
moderationOverrides.clear();
userLogger.warn(
"[cache.loadModerationOverridesFromStorage] Failed to parse payload:",
error,
);
}
}
export function persistModerationOverridesToStorage() {
if (typeof localStorage === "undefined") {
return;
}
const entries = {};
for (const [eventId, entry] of moderationOverrides.entries()) {
if (!entry || entry.showAnyway !== true) {
continue;
}
const updatedAt = Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: Date.now();
entries[eventId] = {
showAnyway: true,
updatedAt,
};
}
if (Object.keys(entries).length === 0) {
try {
localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY);
} catch (error) {
userLogger.warn(
"[cache.persistModerationOverridesToStorage] Failed to clear overrides:",
error,
);
}
return;
}
const payload = {
version: MODERATION_OVERRIDE_STORAGE_VERSION,
savedAt: Date.now(),
entries,
};
try {
localStorage.setItem(
MODERATION_OVERRIDE_STORAGE_KEY,
JSON.stringify(payload),
);
} catch (error) {
userLogger.warn(
"[cache.persistModerationOverridesToStorage] Failed to persist overrides:",
error,
);
}
}
export function setModerationOverride(
eventId,
override = {},
{ persist = true } = {},
) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return null;
}
const showAnyway = override?.showAnyway === true;
if (!showAnyway) {
const removed = moderationOverrides.delete(normalized);
if (removed && persist) {
persistModerationOverridesToStorage();
}
return null;
}
const updatedAt = Number.isFinite(override?.updatedAt)
? Math.floor(override.updatedAt)
: Date.now();
const existing = moderationOverrides.get(normalized);
const nextEntry = { showAnyway: true, updatedAt };
const changed =
!existing ||
existing.showAnyway !== nextEntry.showAnyway ||
existing.updatedAt !== nextEntry.updatedAt;
moderationOverrides.set(normalized, nextEntry);
if (persist && changed) {
persistModerationOverridesToStorage();
}
return { ...nextEntry };
}
export function clearModerationOverride(eventId, { persist = true } = {}) {
const normalized = normalizeEventId(eventId);
if (!normalized) {
return false;
}
const removed = moderationOverrides.delete(normalized);
if (removed && persist) {
persistModerationOverridesToStorage();
}
return removed;
}
function buildUrlProbeKey(url, options = {}) {
const trimmed = typeof url === "string" ? url : "";
const mode = options?.confirmPlayable ? "playable" : "basic";
+434 -25
View File
@@ -105,9 +105,17 @@ export class VideoCard {
onRequestMoreMenu: null,
onCloseMoreMenu: null,
onRequestSettingsMenu: null,
onCloseSettingsMenu: null
onCloseSettingsMenu: null,
onModerationOverride: null,
};
this.moderationBadgeEl = null;
this.moderationBadgeTextEl = null;
this.moderationActionButton = null;
this.moderationBadgeId = "";
this.badgesContainerEl = null;
this.boundShowAnywayHandler = (event) => this.handleShowAnywayClick(event);
this.root = null;
this.anchorEl = null;
this.titleEl = null;
@@ -178,6 +186,10 @@ export class VideoCard {
this.callbacks.onDelete = typeof fn === "function" ? fn : null;
}
set onModerationOverride(fn) {
this.callbacks.onModerationOverride = typeof fn === "function" ? fn : null;
}
set onMoreAction(fn) {
this.callbacks.onMoreAction = typeof fn === "function" ? fn : null;
}
@@ -356,6 +368,7 @@ export class VideoCard {
const content = this.createElement("div", {
classNames: ["p-md", "bv-stack", "bv-stack--tight"]
});
this.contentEl = content;
const title = this.createElement("h3", {
classNames: [
@@ -863,6 +876,11 @@ export class VideoCard {
pieces.push(badge);
}
const moderationBadge = this.buildModerationBadge();
if (moderationBadge) {
pieces.push(moderationBadge);
}
if (!pieces.length) {
return null;
}
@@ -871,9 +889,407 @@ export class VideoCard {
classNames: ["flex", "flex-wrap", "items-center", "gap-sm"]
});
pieces.forEach((el) => container.appendChild(el));
this.badgesContainerEl = container;
this.updateModerationAria();
return container;
}
getModerationBadgeId() {
if (this.moderationBadgeId) {
return this.moderationBadgeId;
}
const baseId =
typeof this.video?.id === "string" && this.video.id
? this.video.id
: `index-${this.index}`;
const sanitized = baseId.replace(/[^a-zA-Z0-9_-]/g, "");
const id = `video-card-${sanitized}-moderation`;
this.moderationBadgeId = id;
return id;
}
getModerationContext() {
const moderation =
this.video?.moderation && typeof this.video.moderation === "object"
? this.video.moderation
: null;
const summary =
moderation?.summary && typeof moderation.summary === "object"
? moderation.summary
: null;
let reportType = "";
if (typeof moderation?.reportType === "string" && moderation.reportType.trim()) {
reportType = moderation.reportType.trim().toLowerCase();
}
if (!reportType && summary && summary.types && typeof summary.types === "object") {
for (const [type, stats] of Object.entries(summary.types)) {
if (stats && Number.isFinite(stats.trusted) && Math.floor(stats.trusted) > 0) {
reportType = String(type).toLowerCase();
break;
}
}
}
let trustedCount = Number.isFinite(moderation?.trustedCount)
? Math.max(0, Math.floor(moderation.trustedCount))
: 0;
if (!trustedCount && summary && summary.types && typeof summary.types === "object") {
for (const stats of Object.values(summary.types)) {
if (stats && Number.isFinite(stats.trusted)) {
trustedCount = Math.max(trustedCount, Math.floor(stats.trusted));
}
}
}
const reporterDisplayNames = Array.isArray(moderation?.reporterDisplayNames)
? moderation.reporterDisplayNames
.map((name) => (typeof name === "string" ? name.trim() : ""))
.filter(Boolean)
: [];
const original =
moderation?.original && typeof moderation.original === "object"
? moderation.original
: {};
const context = {
reportType,
friendlyType: reportType ? reportType.replace(/[_-]+/g, " ").trim() : "",
trustedCount,
reporterDisplayNames,
originalBlur: original.blurThumbnail === true,
originalBlockAutoplay: original.blockAutoplay === true,
activeBlur: moderation?.blurThumbnail === true,
activeBlockAutoplay: moderation?.blockAutoplay === true,
overrideActive: moderation?.viewerOverride?.showAnyway === true,
};
context.shouldShow =
context.originalBlur ||
context.originalBlockAutoplay ||
context.trustedCount > 0 ||
context.overrideActive;
return context;
}
buildModerationReasonText(context) {
if (!context) {
return "";
}
const typeLabel = context.friendlyType || "this video";
const count = Math.max(0, Number(context.trustedCount) || 0);
if (count <= 0) {
return context.friendlyType ? `reports of ${typeLabel}` : "reports";
}
const friendLabel = count === 1 ? "friend" : "friends";
return `${count} ${friendLabel} reported ${typeLabel}`;
}
buildModerationBadgeText(context) {
if (!context) {
return "";
}
const reason = this.buildModerationReasonText(context);
if (context.overrideActive) {
if (reason) {
return `Showing despite ${reason}`;
}
return "Showing despite reports";
}
const parts = [];
if (context.originalBlur) {
parts.push("Blurred");
}
if (context.originalBlockAutoplay) {
parts.push("Autoplay blocked");
}
if (reason) {
parts.push(reason);
}
return parts.join(" · ");
}
createModerationOverrideButton() {
const button = this.createElement("button", {
classNames: [
"inline-flex",
"items-center",
"rounded-full",
"border",
"border-status-warning-border",
"px-3",
"py-1",
"text-2xs",
"font-semibold",
"uppercase",
"tracking-extra-wide",
"text-status-warning-on",
"bg-transparent",
"hover:bg-status-warning-surface",
"transition",
"duration-150",
"focus-visible:outline",
"focus-visible:outline-2",
"focus-visible:outline-offset-2",
"focus-visible:outline-status-warning-border",
],
attrs: {
type: "button",
"data-moderation-action": "override",
"aria-pressed": "false",
"aria-describedby": this.getModerationBadgeId(),
},
textContent: "Show anyway",
});
button.addEventListener("click", this.boundShowAnywayHandler);
return button;
}
handleShowAnywayClick(event) {
if (event) {
if (typeof event.preventDefault === "function") {
event.preventDefault();
}
if (typeof event.stopPropagation === "function") {
event.stopPropagation();
}
}
const button = this.moderationActionButton;
if (button) {
button.disabled = true;
button.setAttribute("aria-busy", "true");
}
if (!this.callbacks.onModerationOverride) {
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
let result;
try {
result = this.callbacks.onModerationOverride({
event,
video: this.video,
card: this,
});
} catch (error) {
userLogger.warn("[VideoCard] onModerationOverride callback threw", error);
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
Promise.resolve(result)
.then((handled) => {
if (handled === false) {
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
return;
}
this.refreshModerationUi();
})
.catch((error) => {
userLogger.warn("[VideoCard] Moderation override failed", error);
if (button) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
});
}
buildModerationBadge() {
const context = this.getModerationContext();
if (!context.shouldShow) {
this.moderationBadgeEl = null;
this.moderationBadgeTextEl = null;
if (this.moderationActionButton) {
this.moderationActionButton.removeEventListener(
"click",
this.boundShowAnywayHandler,
);
}
this.moderationActionButton = null;
return null;
}
const badge = this.createElement("div", {
classNames: ["badge", "flex", "flex-wrap", "items-center", "gap-sm"],
});
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
badge.dataset.moderationBadge = "true";
badge.dataset.moderationState = context.overrideActive ? "override" : "blocked";
const badgeId = this.getModerationBadgeId();
badge.id = badgeId;
badge.setAttribute("role", "status");
badge.setAttribute("aria-live", "polite");
badge.setAttribute("aria-atomic", "true");
const text = this.createElement("span", {
classNames: ["whitespace-nowrap"],
textContent: this.buildModerationBadgeText(context),
});
badge.appendChild(text);
const names = context.reporterDisplayNames;
if (names.length) {
const joined = names.join(", ");
badge.title = `Reported by ${joined}`;
badge.setAttribute(
"aria-label",
`${text.textContent}. Reported by ${joined}.`,
);
} else {
badge.removeAttribute("title");
badge.setAttribute("aria-label", `${text.textContent}.`);
}
this.moderationBadgeEl = badge;
this.moderationBadgeTextEl = text;
if (!context.overrideActive) {
const button = this.createModerationOverrideButton();
badge.appendChild(button);
this.moderationActionButton = button;
} else {
this.moderationActionButton = null;
}
return badge;
}
updateModerationBadge() {
const context = this.getModerationContext();
const badge = this.moderationBadgeEl;
if (!context.shouldShow) {
if (badge && badge.parentElement) {
badge.parentElement.removeChild(badge);
}
if (this.moderationActionButton) {
this.moderationActionButton.removeEventListener(
"click",
this.boundShowAnywayHandler,
);
}
this.moderationBadgeEl = null;
this.moderationBadgeTextEl = null;
this.moderationActionButton = null;
this.updateModerationAria();
return;
}
if (!badge) {
const nextBadge = this.buildModerationBadge();
if (nextBadge) {
if (!this.badgesContainerEl) {
this.badgesContainerEl = this.createElement("div", {
classNames: ["flex", "flex-wrap", "items-center", "gap-sm"],
});
if (this.contentEl) {
if (
this.discussionCountEl &&
this.discussionCountEl.parentElement === this.contentEl
) {
this.contentEl.insertBefore(
this.badgesContainerEl,
this.discussionCountEl,
);
} else {
this.contentEl.appendChild(this.badgesContainerEl);
}
}
}
this.badgesContainerEl.appendChild(nextBadge);
}
this.updateModerationAria();
return;
}
badge.dataset.variant = context.overrideActive ? "neutral" : "warning";
badge.dataset.moderationState = context.overrideActive ? "override" : "blocked";
const textContent = this.buildModerationBadgeText(context);
if (this.moderationBadgeTextEl) {
this.moderationBadgeTextEl.textContent = textContent;
}
const names = context.reporterDisplayNames;
if (names.length) {
const joined = names.join(", ");
badge.title = `Reported by ${joined}`;
badge.setAttribute("aria-label", `${textContent}. Reported by ${joined}.`);
} else {
badge.removeAttribute("title");
badge.setAttribute("aria-label", `${textContent}.`);
}
if (context.overrideActive) {
if (this.moderationActionButton) {
this.moderationActionButton.removeEventListener(
"click",
this.boundShowAnywayHandler,
);
this.moderationActionButton.remove();
}
this.moderationActionButton = null;
} else if (!this.moderationActionButton) {
const button = this.createModerationOverrideButton();
badge.appendChild(button);
this.moderationActionButton = button;
} else {
this.moderationActionButton.disabled = false;
this.moderationActionButton.removeAttribute("aria-busy");
}
}
updateModerationAria() {
const badgeId = this.moderationBadgeEl ? this.getModerationBadgeId() : "";
const elements = [this.anchorEl, this.titleEl].filter(Boolean);
elements.forEach((el) => {
if (!el || typeof el.getAttribute !== "function") {
return;
}
const attr = el.getAttribute("aria-describedby") || "";
const tokens = attr.split(/\s+/).filter(Boolean);
const filtered = tokens.filter((token) => token !== this.moderationBadgeId);
if (badgeId) {
filtered.push(badgeId);
}
if (filtered.length) {
el.setAttribute("aria-describedby", Array.from(new Set(filtered)).join(" "));
} else {
el.removeAttribute("aria-describedby");
}
});
}
refreshModerationUi() {
this.applyModerationDatasets();
this.updateModerationBadge();
this.updateModerationAria();
}
getCachedUrlHealthEntry() {
if (!this.video?.id || !(this.state.urlHealthByVideoId instanceof Map)) {
return null;
@@ -1325,43 +1741,36 @@ export class VideoCard {
return;
}
const moderation =
this.video?.moderation && typeof this.video.moderation === "object"
? this.video.moderation
: null;
const moderationContext = this.getModerationContext();
const blockAutoplay = moderation?.blockAutoplay === true;
const blurThumbnail = moderation?.blurThumbnail === true;
const summary =
moderation && typeof moderation.summary === "object"
? moderation.summary
: null;
if (blockAutoplay) {
if (moderationContext.originalBlockAutoplay && !moderationContext.overrideActive) {
this.root.dataset.autoplayPolicy = "blocked";
} else if (this.root.dataset.autoplayPolicy === "blocked") {
} else if (this.root.dataset.autoplayPolicy) {
delete this.root.dataset.autoplayPolicy;
}
if (moderationContext.overrideActive) {
this.root.dataset.moderationOverride = "show-anyway";
} else if (this.root.dataset.moderationOverride) {
delete this.root.dataset.moderationOverride;
}
if (this.thumbnailEl && !this.shouldMaskNsfwForOwner) {
if (blurThumbnail) {
if (moderationContext.activeBlur) {
this.thumbnailEl.dataset.thumbnailState = "blurred";
} else if (this.thumbnailEl.dataset.thumbnailState === "blurred") {
delete this.thumbnailEl.dataset.thumbnailState;
}
}
let nudityTrustedCount = 0;
if (summary && summary.types && typeof summary.types === "object") {
const nudityEntry = summary.types.nudity || summary.types["nudity"];
if (nudityEntry && Number.isFinite(nudityEntry.trusted)) {
nudityTrustedCount = nudityEntry.trusted;
const reportCount = Math.max(0, Number(moderationContext.trustedCount) || 0);
if (reportCount > 0) {
this.root.dataset.moderationReportCount = String(reportCount);
if (moderationContext.reportType) {
this.root.dataset.moderationReportType = moderationContext.reportType;
} else if (this.root.dataset.moderationReportType) {
delete this.root.dataset.moderationReportType;
}
}
if (nudityTrustedCount > 0) {
this.root.dataset.moderationReportType = "nudity";
this.root.dataset.moderationReportCount = String(nudityTrustedCount);
} else {
if (this.root.dataset.moderationReportType) {
delete this.root.dataset.moderationReportType;
+28
View File
@@ -181,6 +181,7 @@ export class VideoListView {
revert: null,
delete: null,
blacklist: null,
moderationOverride: null,
};
this.allowNsfw = allowNsfw !== false;
@@ -277,6 +278,10 @@ export class VideoListView {
this.handlers.blacklist = typeof handler === "function" ? handler : null;
}
setModerationOverrideHandler(handler) {
this.handlers.moderationOverride = typeof handler === "function" ? handler : null;
}
render(videos, metadata = null) {
if (!this.container) {
return [];
@@ -365,6 +370,15 @@ export class VideoListView {
authorSet.add(video.pubkey);
const reporterPubkeys = Array.isArray(video?.moderation?.reporterPubkeys)
? video.moderation.reporterPubkeys
: [];
reporterPubkeys.forEach((pubkey) => {
if (typeof pubkey === "string" && pubkey.trim()) {
authorSet.add(pubkey.trim());
}
});
const shareUrl = this.buildShareUrl(video, shareBase);
const canEdit = this.utils.canEditVideo(video);
const canDelete = this.utils.canDeleteVideo(video);
@@ -455,6 +469,20 @@ export class VideoListView {
this.emitSelected(detail);
};
videoCard.onModerationOverride = ({ event: overrideEvent }) => {
if (!this.handlers.moderationOverride) {
return false;
}
const trigger =
overrideEvent?.currentTarget || overrideEvent?.target || null;
return this.handlers.moderationOverride({
event: overrideEvent,
video,
card: videoCard,
trigger,
});
};
videoCard.onEdit = ({ event: editEvent, video: editVideo, index: editIndex }) => {
if (this.handlers.edit) {
const trigger = editEvent?.currentTarget || editEvent?.target || null;