From 0ec7c5e76c60e27737ce2c8d873f43d7d07685c0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:24:42 -0400 Subject: [PATCH] Add moderation overrides to video cards --- js/app.js | 268 +++++++++++++++++- js/feedEngine/stages.js | 37 +++ js/services/moderationService.js | 52 ++++ js/state/cache.js | 189 +++++++++++++ js/ui/components/VideoCard.js | 459 +++++++++++++++++++++++++++++-- js/ui/views/VideoListView.js | 28 ++ 6 files changed, 1004 insertions(+), 29 deletions(-) diff --git a/js/app.js b/js/app.js index 358b8ca6..ad4d46cf 100644 --- a/js/app.js +++ b/js/app.js @@ -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; diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 804d1ece..d05a2cf6 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -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) { diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 061b5178..4f838596 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -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) { diff --git a/js/state/cache.js b/js/state/cache.js index c4e6a37a..b34206c4 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -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"; diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 55c614d5..46cc5c3a 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -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; diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index efa33ab2..e4085948 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -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;