From 87bde7f0222ed31a603408c86065effa3b4243bf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Oct 2025 19:39:18 -0400 Subject: [PATCH] Add moderation UI support to watch history cards --- css/tailwind.source.css | 21 +- js/historyView.js | 791 +++++++++++++++++++++++++++++++++ js/ui/components/VideoCard.js | 131 +----- js/ui/components/VideoModal.js | 65 +-- js/ui/moderationCopy.js | 154 +++++++ tests/watch-history.test.mjs | 156 ++++++- 6 files changed, 1126 insertions(+), 192 deletions(-) create mode 100644 js/ui/moderationCopy.js diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 426d5133..31aea268 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3245,6 +3245,13 @@ @apply h-full w-full object-cover; } + .watch-history-card [data-thumbnail-state="blurred"] img, + .watch-history-card [data-visual-state="blurred"] img { + @apply transition duration-base ease-in-out; + filter: blur(var(--blur-thumbnail-blur)); + transform: scale(1.05); + } + .watch-history-card__details { @apply flex min-w-0 flex-1 flex-col justify-center gap-sm; } @@ -3279,7 +3286,7 @@ } .watch-history-card__creatorAvatar { - @apply flex h-10 w-10 items-center justify-center rounded-full bg-panel transition duration-base ease-out hover:ring-2 hover:ring-opacity-50 focus-visible:ring-2 focus-visible:ring-opacity-50; + @apply flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-panel transition duration-base ease-out hover:ring-2 hover:ring-opacity-50 focus-visible:ring-2 focus-visible:ring-opacity-50; } .watch-history-card__creatorAvatar:hover, @@ -3310,6 +3317,18 @@ .watch-history-card__action--danger { @apply bg-critical text-white hover:bg-critical-strong focus-visible:bg-critical-strong; } + + .watch-history-card__moderation { + @apply mt-sm flex flex-wrap items-center gap-sm; + } + + .watch-history-card__hidden { + @apply mt-md rounded-lg border border-dashed border-border bg-panel/70 text-sm text-text; + } + + .watch-history-card__hidden .moderation-badge { + @apply self-start; + } } @layer components { diff --git a/js/historyView.js b/js/historyView.js index 1a385529..cfc73152 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -12,6 +12,11 @@ import { } from "./config.js"; import { getApplication } from "./applicationContext.js"; import { userLogger } from "./utils/logger.js"; +import { + normalizeVideoModerationContext, + applyModerationContextDatasets, +} from "./ui/moderationUiHelpers.js"; +import { buildModerationBadgeText } from "./ui/moderationCopy.js"; export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; @@ -351,6 +356,624 @@ function getPointerVideoId(video, pointer) { return ""; } +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const historyCardRegistry = new Map(); + +function sanitizeIdToken(value) { + if (typeof value !== "string") { + return ""; + } + const token = value.trim().replace(/[^a-zA-Z0-9_-]/g, ""); + return token; +} + +function getHistoryCardModerationId(pointerKey) { + const token = sanitizeIdToken(pointerKey); + return token ? `watch-history-${token}-moderation` : ""; +} + +function getHistoryCardModerationState(context) { + if (!context) { + return ""; + } + if (context.overrideActive) { + return "override"; + } + if (context.activeHidden && !context.overrideActive) { + return "hidden"; + } + if (context.trustedMuted) { + return "trusted-mute"; + } + return "blocked"; +} + +function shouldShowHistoryCardHideButton(context) { + if (!context || !context.trustedMuted) { + return false; + } + if (context.activeHidden && !context.overrideActive) { + return false; + } + return true; +} + +function getModerationBadgeIconShape(state) { + if (state === "override") { + return { + d: "M10 18a8 8 0 100-16 8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L9 11.94l-1.72-1.72a.75.75 0 10-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l3.25-3.25z", + fillRule: "evenodd", + clipRule: "evenodd" + }; + } + + return { + d: "M10 18a8 8 0 100-16 8 8 0 000 16zm-.75-11.75a.75.75 0 011.5 0v4.5a.75.75 0 01-1.5 0v-4.5zm.75 8.5a1 1 0 100-2 1 1 0 000 2z", + fillRule: "evenodd", + clipRule: "evenodd" + }; +} + +function updateHistoryCardBadgeIcon(svg, state) { + if (!svg) { + return; + } + svg.dataset.iconState = state; + const path = svg.firstElementChild; + if (!path) { + return; + } + const { d, fillRule, clipRule } = getModerationBadgeIconShape(state); + path.setAttribute("d", d); + if (fillRule) { + path.setAttribute("fill-rule", fillRule); + } else { + path.removeAttribute("fill-rule"); + } + if (clipRule) { + path.setAttribute("clip-rule", clipRule); + } else { + path.removeAttribute("clip-rule"); + } +} + +function createHistoryCardBadgeIcon(doc, state) { + const wrapper = doc.createElement("span"); + wrapper.className = "moderation-badge__icon"; + wrapper.setAttribute("aria-hidden", "true"); + const svg = doc.createElementNS(SVG_NAMESPACE, "svg"); + svg.setAttribute("viewBox", "0 0 20 20"); + svg.setAttribute("focusable", "false"); + svg.setAttribute("aria-hidden", "true"); + svg.classList.add("moderation-badge__icon-mark"); + const path = doc.createElementNS(SVG_NAMESPACE, "path"); + path.setAttribute("fill", "currentColor"); + svg.appendChild(path); + wrapper.appendChild(svg); + updateHistoryCardBadgeIcon(svg, state); + return { wrapper, svg }; +} + +function cleanupHistoryCard(pointerKey) { + if (!pointerKey || !historyCardRegistry.has(pointerKey)) { + return; + } + const ref = historyCardRegistry.get(pointerKey); + historyCardRegistry.delete(pointerKey); + if (!ref || typeof ref !== "object") { + return; + } + if (ref.overrideButton && typeof ref.overrideButton.removeEventListener === "function" && typeof ref.boundOverride === "function") { + ref.overrideButton.removeEventListener("click", ref.boundOverride); + } + if (ref.hideButton && typeof ref.hideButton.removeEventListener === "function" && typeof ref.boundHide === "function") { + ref.hideButton.removeEventListener("click", ref.boundHide); + } + if (ref.badgeEl && ref.badgeEl.parentNode) { + ref.badgeEl.parentNode.removeChild(ref.badgeEl); + } + if (ref.hiddenContainer && ref.hiddenContainer.parentNode) { + ref.hiddenContainer.parentNode.removeChild(ref.hiddenContainer); + } +} + +function registerHistoryCard(pointerKey, ref) { + if (!pointerKey || !ref) { + return; + } + cleanupHistoryCard(pointerKey); + historyCardRegistry.set(pointerKey, ref); +} + +function clearHistoryCardRegistry() { + const keys = Array.from(historyCardRegistry.keys()); + keys.forEach((key) => { + cleanupHistoryCard(key); + }); + historyCardRegistry.clear(); +} + +function ensureHiddenSummaryContainer(ref) { + const article = ref?.article; + if (!article || typeof article.insertBefore !== "function") { + return null; + } + let container = ref.hiddenContainer; + const doc = article.ownerDocument || document; + if (!container) { + container = doc.createElement("div"); + container.className = "watch-history-card__hidden bv-stack bv-stack--tight p-md"; + container.dataset.moderationHiddenContainer = "true"; + container.setAttribute("role", "group"); + container.setAttribute("aria-live", "polite"); + ref.hiddenContainer = container; + } + container.hidden = false; + container.removeAttribute("aria-hidden"); + if (container.parentNode !== article) { + article.insertBefore(container, article.firstChild || null); + } + return container; +} + +function updateHistoryCardHiddenState(ref, context) { + const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive); + const primary = ref.primary; + const meta = ref.meta; + + if (hiddenActive) { + if (primary && typeof primary.setAttribute === "function") { + primary.setAttribute("hidden", ""); + primary.setAttribute("aria-hidden", "true"); + } + if (meta && typeof meta.setAttribute === "function") { + meta.setAttribute("hidden", ""); + meta.setAttribute("aria-hidden", "true"); + } + const container = ensureHiddenSummaryContainer(ref); + if (container) { + const description = buildModerationBadgeText(context, { variant: "card" }); + if (description) { + container.setAttribute("aria-label", description); + } else { + container.removeAttribute("aria-label"); + } + } + } else { + if (primary && typeof primary.removeAttribute === "function") { + primary.removeAttribute("hidden"); + primary.removeAttribute("aria-hidden"); + } + if (meta && typeof meta.removeAttribute === "function") { + meta.removeAttribute("hidden"); + meta.removeAttribute("aria-hidden"); + } + const container = ref.hiddenContainer; + if (container) { + container.hidden = true; + container.setAttribute("aria-hidden", "true"); + container.removeAttribute("aria-label"); + container.removeAttribute("aria-labelledby"); + if (container.parentNode) { + container.parentNode.removeChild(container); + } + ref.hiddenContainer = null; + } + } +} + +function createHistoryCardBadge(ref, context) { + const article = ref?.article; + const doc = (article && article.ownerDocument) || (typeof document !== "undefined" ? document : null); + if (!doc) { + return null; + } + const badge = doc.createElement("div"); + badge.className = "moderation-badge flex flex-wrap items-center gap-sm"; + badge.dataset.moderationBadge = "true"; + const { wrapper, svg } = createHistoryCardBadgeIcon(doc, getHistoryCardModerationState(context)); + badge.appendChild(wrapper); + const text = doc.createElement("span"); + text.className = "moderation-badge__text whitespace-nowrap"; + badge.appendChild(text); + ref.badgeEl = badge; + ref.badgeTextEl = text; + ref.badgeIconSvg = svg; + ref.badgeIconWrapper = wrapper; + if (typeof ref.boundOverride !== "function") { + ref.boundOverride = (event) => handleHistoryCardModerationOverride(event, ref); + } + if (typeof ref.boundHide !== "function") { + ref.boundHide = (event) => handleHistoryCardModerationHide(event, ref); + } + if (!ref.overrideButton) { + const overrideButton = doc.createElement("button"); + overrideButton.type = "button"; + overrideButton.className = "moderation-badge__action flex-shrink-0"; + overrideButton.dataset.moderationAction = "override"; + overrideButton.textContent = "Show anyway"; + overrideButton.addEventListener("click", ref.boundOverride); + ref.overrideButton = overrideButton; + } + if (!ref.hideButton) { + const hideButton = doc.createElement("button"); + hideButton.type = "button"; + hideButton.className = "moderation-badge__action flex-shrink-0"; + hideButton.dataset.moderationAction = "hide"; + hideButton.textContent = "Hide"; + hideButton.addEventListener("click", ref.boundHide); + ref.hideButton = hideButton; + } + return badge; +} + +function updateHistoryCardBadge(ref, context) { + const hiddenActive = Boolean(context?.activeHidden && !context?.overrideActive); + if (!context?.shouldShow) { + if (ref.overrideButton && typeof ref.overrideButton.removeEventListener === "function" && typeof ref.boundOverride === "function") { + ref.overrideButton.removeEventListener("click", ref.boundOverride); + } + if (ref.hideButton && typeof ref.hideButton.removeEventListener === "function" && typeof ref.boundHide === "function") { + ref.hideButton.removeEventListener("click", ref.boundHide); + } + if (ref.badgeEl && ref.badgeEl.parentNode) { + ref.badgeEl.parentNode.removeChild(ref.badgeEl); + } + ref.badgeEl = null; + ref.badgeTextEl = null; + ref.badgeIconSvg = null; + ref.badgeIconWrapper = null; + ref.overrideButton = null; + ref.hideButton = null; + return; + } + + if (!ref.badgeEl) { + createHistoryCardBadge(ref, context); + } + + const badge = ref.badgeEl; + if (!badge) { + return; + } + + badge.dataset.variant = context.overrideActive ? "neutral" : "warning"; + const state = context.overrideActive ? "override" : getHistoryCardModerationState(context); + badge.dataset.moderationState = state; + if (hiddenActive && context.effectiveHideReason) { + badge.dataset.moderationHideReason = context.effectiveHideReason; + } else if (badge.dataset.moderationHideReason) { + delete badge.dataset.moderationHideReason; + } + + if (ref.badgeIconSvg) { + updateHistoryCardBadgeIcon(ref.badgeIconSvg, state); + } + + const textContent = buildModerationBadgeText(context, { variant: "card" }); + if (ref.badgeTextEl) { + ref.badgeTextEl.textContent = textContent; + } + + const muteNames = Array.isArray(context.trustedMuteDisplayNames) + ? context.trustedMuteDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + const reporterNames = Array.isArray(context.reporterDisplayNames) + ? context.reporterDisplayNames + .map((name) => (typeof name === "string" ? name.trim() : "")) + .filter(Boolean) + : []; + + const allNames = [...muteNames, ...reporterNames]; + const uniqueNames = []; + const seen = new Set(); + for (const name of allNames) { + if (!name) { + continue; + } + const key = name.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + uniqueNames.push(name); + } + + if (uniqueNames.length) { + const hasMuted = muteNames.length > 0; + const hasReporters = reporterNames.length > 0; + const prefix = hasMuted && hasReporters + ? "Muted/Reported by" + : hasMuted + ? "Muted by" + : "Reported by"; + const joined = uniqueNames.join(", "); + badge.title = `${prefix} ${joined}`; + badge.setAttribute("aria-label", `${textContent}. ${prefix} ${joined}.`); + } else if (textContent) { + badge.removeAttribute("title"); + badge.setAttribute("aria-label", `${textContent}.`); + } else { + badge.removeAttribute("title"); + badge.removeAttribute("aria-label"); + } + + const parent = hiddenActive ? ensureHiddenSummaryContainer(ref) : ref.badgeMount; + if (parent && badge.parentNode !== parent) { + if (badge.parentNode) { + badge.parentNode.removeChild(badge); + } + parent.appendChild(badge); + } + + if (!context.overrideActive && context.allowOverride && ref.overrideButton) { + if (ref.overrideButton.parentNode !== badge) { + badge.appendChild(ref.overrideButton); + } + ref.overrideButton.disabled = false; + ref.overrideButton.removeAttribute("aria-busy"); + } else if (ref.overrideButton && ref.overrideButton.parentNode === badge) { + badge.removeChild(ref.overrideButton); + } + + if (shouldShowHistoryCardHideButton(context) && ref.hideButton) { + if (ref.hideButton.parentNode !== badge) { + badge.appendChild(ref.hideButton); + } + ref.hideButton.disabled = false; + ref.hideButton.removeAttribute("aria-busy"); + } else if (ref.hideButton && ref.hideButton.parentNode === badge) { + badge.removeChild(ref.hideButton); + } +} + +function updateHistoryCardAria(ref) { + const badge = ref.badgeEl; + const badgeId = badge ? getHistoryCardModerationId(ref.pointerKey) : ""; + if (badge) { + if (badgeId) { + badge.id = badgeId; + } else { + badge.removeAttribute("id"); + } + } + + const targets = [ + ref.thumbnailLink, + ref.titleLink, + ref.playButton, + ref.channelButton, + ref.creatorNameButton, + ref.avatarButton + ].filter((el) => el && typeof el.getAttribute === "function"); + + targets.forEach((el) => { + const existing = el.getAttribute("aria-describedby") || ""; + const tokens = existing.split(/\s+/).filter(Boolean); + if (ref.badgeId) { + for (let index = tokens.length - 1; index >= 0; index -= 1) { + if (tokens[index] === ref.badgeId) { + tokens.splice(index, 1); + } + } + } + if (badgeId) { + tokens.push(badgeId); + } + if (tokens.length) { + el.setAttribute("aria-describedby", Array.from(new Set(tokens)).join(" ")); + } else { + el.removeAttribute("aria-describedby"); + } + }); + + if (ref.overrideButton) { + if (badgeId) { + ref.overrideButton.setAttribute("aria-describedby", badgeId); + } else { + ref.overrideButton.removeAttribute("aria-describedby"); + } + } + + if (ref.hideButton) { + if (badgeId) { + ref.hideButton.setAttribute("aria-describedby", badgeId); + } else { + ref.hideButton.removeAttribute("aria-describedby"); + } + } + + if (ref.hiddenContainer) { + if (badgeId) { + ref.hiddenContainer.setAttribute("aria-labelledby", badgeId); + } else { + ref.hiddenContainer.removeAttribute("aria-labelledby"); + } + } + + ref.badgeId = badgeId; +} + +function applyHistoryCardModeration(ref, context) { + if (!ref) { + return; + } + const normalizedContext = context || normalizeVideoModerationContext(ref.video?.moderation); + applyModerationContextDatasets(normalizedContext, { + root: ref.article, + thumbnail: ref.thumbnailInner || ref.thumbnailLink, + avatar: ref.avatarButton + }); + updateHistoryCardHiddenState(ref, normalizedContext); + updateHistoryCardBadge(ref, normalizedContext); + updateHistoryCardAria(ref); +} + +function handleHistoryCardModerationOverride(event, ref) { + if (event) { + if (typeof event.preventDefault === "function") { + event.preventDefault(); + } + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + } + + const button = ref.overrideButton; + if (button) { + button.disabled = true; + button.setAttribute("aria-busy", "true"); + } + + const video = ref.video; + if (!video || typeof video !== "object" || !video.id) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + + const app = getAppInstance(); + let result; + try { + if (typeof app?.handleModerationOverride === "function") { + result = app.handleModerationOverride({ video }); + } else { + const doc = (ref.article && ref.article.ownerDocument) || (typeof document !== "undefined" ? document : null); + if (doc && typeof doc.dispatchEvent === "function") { + doc.dispatchEvent( + new CustomEvent("video:moderation-override", { detail: { video } }) + ); + } + result = true; + } + } catch (error) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + if (isDevEnv) { + userLogger.warn("[historyView] Moderation override handler threw:", error); + } + return; + } + + Promise.resolve(result) + .then((handled) => { + if (handled === false) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + const context = normalizeVideoModerationContext(ref.video?.moderation); + applyHistoryCardModeration(ref, context); + }) + .catch((error) => { + if (isDevEnv) { + userLogger.warn("[historyView] Moderation override failed:", error); + } + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + }); +} + +function handleHistoryCardModerationHide(event, ref) { + if (event) { + if (typeof event.preventDefault === "function") { + event.preventDefault(); + } + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + } + + const button = ref.hideButton; + if (button) { + button.disabled = true; + button.setAttribute("aria-busy", "true"); + } + + const video = ref.video; + if (!video || typeof video !== "object" || !video.id) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + + const app = getAppInstance(); + let result; + try { + if (typeof app?.handleModerationHide === "function") { + result = app.handleModerationHide({ video }); + } else { + const doc = (ref.article && ref.article.ownerDocument) || (typeof document !== "undefined" ? document : null); + if (doc && typeof doc.dispatchEvent === "function") { + doc.dispatchEvent( + new CustomEvent("video:moderation-hide", { detail: { video } }) + ); + } + result = true; + } + } catch (error) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + if (isDevEnv) { + userLogger.warn("[historyView] Moderation hide handler threw:", error); + } + return; + } + + Promise.resolve(result) + .then((handled) => { + if (handled === false) { + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + const context = normalizeVideoModerationContext(ref.video?.moderation); + applyHistoryCardModeration(ref, context); + }) + .catch((error) => { + if (isDevEnv) { + userLogger.warn("[historyView] Moderation hide failed:", error); + } + if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + }); +} + +function refreshRegisteredHistoryCards(video, context, pointerKeys = null) { + const filterSet = Array.isArray(pointerKeys) && pointerKeys.length ? new Set(pointerKeys) : null; + const videoId = video && typeof video.id === "string" ? video.id : ""; + historyCardRegistry.forEach((ref, pointerKey) => { + if (!ref) { + return; + } + if (filterSet && !filterSet.has(pointerKey)) { + return; + } + if (videoId) { + ref.video = video; + } + applyHistoryCardModeration(ref, context); + }); +} + export function buildHistoryCard({ item, video, profile, metadataPreference }) { const article = document.createElement("article"); article.className = "watch-history-card"; @@ -438,6 +1061,9 @@ export function buildHistoryCard({ item, video, profile, metadataPreference }) { details.appendChild(titleLink); details.appendChild(created); + const moderationMount = document.createElement("div"); + moderationMount.className = "watch-history-card__moderation flex flex-wrap items-center gap-sm"; + details.appendChild(moderationMount); primary.appendChild(thumbnailLink); primary.appendChild(details); @@ -569,6 +1195,42 @@ export function buildHistoryCard({ item, video, profile, metadataPreference }) { article.appendChild(primary); article.appendChild(meta); + if (pointerVideoId) { + article.dataset.videoId = pointerVideoId; + } + + const moderationContext = normalizeVideoModerationContext(video?.moderation); + const cardRef = { + pointerKey: item.pointerKey, + article, + primary, + meta, + details, + badgeMount: moderationMount, + thumbnailInner, + thumbnailLink, + titleLink, + playButton, + channelButton, + removeButton, + creatorNameButton, + avatarButton: creatorAvatarButton, + video: video || null, + hiddenContainer: null, + badgeEl: null, + badgeTextEl: null, + badgeIconSvg: null, + badgeIconWrapper: null, + overrideButton: null, + hideButton: null, + badgeId: "", + boundOverride: null, + boundHide: null + }; + + registerHistoryCard(item.pointerKey, cardRef); + applyHistoryCardModeration(cardRef, moderationContext); + return article; } @@ -974,6 +1636,109 @@ export function createWatchHistoryRenderer(config = {}) { } } + function handleModerationUpdate(video) { + if (!video || typeof video !== "object" || !video.id) { + return; + } + + let workingVideo = video; + if (typeof structuredClone === "function") { + try { + workingVideo = structuredClone(video); + } catch (error) { + workingVideo = video; + } + } else { + try { + workingVideo = JSON.parse(JSON.stringify(video)); + } catch (error) { + workingVideo = { ...video }; + if (video.moderation && typeof video.moderation === "object") { + workingVideo.moderation = { ...video.moderation }; + } + } + } + + const app = getAppInstance(); + if (typeof app?.decorateVideoModeration === "function") { + try { + const decorated = app.decorateVideoModeration(workingVideo); + if (decorated) { + workingVideo = decorated; + } + } catch (error) { + if (isDevEnv) { + userLogger.warn( + "[historyView] Failed to decorate moderation update:", + error + ); + } + } + } + + const context = normalizeVideoModerationContext(workingVideo?.moderation); + const affectedPointers = []; + state.items.forEach((entry) => { + if (!entry) { + return; + } + const entryVideo = entry.metadata?.video || entry.video; + if (entryVideo && entryVideo.id === workingVideo.id) { + entry.metadata.video = workingVideo; + entry.video = workingVideo; + if (entry.pointerKey) { + const cached = state.metadataCache.get(entry.pointerKey) || {}; + state.metadataCache.set(entry.pointerKey, { + ...cached, + video: workingVideo + }); + if (state.metadataStorageEnabled) { + try { + watchHistoryService.setLocalMetadata?.(entry.pointerKey, { + ...cached, + video: workingVideo + }); + } catch (error) { + if (isDevEnv) { + userLogger.warn( + "[historyView] Failed to persist moderation metadata for pointer:", + entry.pointerKey, + error + ); + } + } + } + affectedPointers.push(entry.pointerKey); + } + } + }); + + refreshRegisteredHistoryCards( + workingVideo, + context, + affectedPointers.length ? affectedPointers : null + ); + } + + function subscribeToModerationEvents() { + if (typeof document === "undefined" || typeof document.addEventListener !== "function") { + return; + } + const handler = (event) => { + const video = event?.detail?.video; + if (!video) { + return; + } + handleModerationUpdate(video); + }; + document.addEventListener("video:moderation-override", handler); + document.addEventListener("video:moderation-hide", handler); + subscriptions.add(() => { + document.removeEventListener("video:moderation-override", handler); + document.removeEventListener("video:moderation-hide", handler); + }); + } + function setLoadingState(loading) { state.isLoading = loading; if (elements.loading) { @@ -1005,6 +1770,7 @@ export function createWatchHistoryRenderer(config = {}) { elements.grid.innerHTML = ""; setHidden(elements.grid, true); } + clearHistoryCardRegistry(); if (elements.empty) { setTextContent(elements.empty, emptyCopy); setHidden(elements.empty, false); @@ -1218,6 +1984,27 @@ export function createWatchHistoryRenderer(config = {}) { profile: profile || null }; + if (metadata.video && typeof metadata.video === "object") { + const app = getAppInstance(); + if (typeof app?.decorateVideoModeration === "function") { + try { + const decorated = app.decorateVideoModeration(metadata.video); + if (decorated) { + metadata.video = decorated; + } + } catch (error) { + if (isDevEnv) { + userLogger.warn( + "[historyView] Failed to decorate video moderation:", + error + ); + } + } + } + } + + video = metadata.video || null; + if (pointerKeyValue) { state.metadataCache.set(pointerKeyValue, { video: metadata.video, @@ -1726,6 +2513,7 @@ export function createWatchHistoryRenderer(config = {}) { state.cursor = 0; state.hasMore = state.items.length > 0; elements.grid.innerHTML = ""; + clearHistoryCardRegistry(); removeEmptyDayContainers(elements.grid); if (!state.items.length) { showEmptyState(); @@ -1754,6 +2542,7 @@ export function createWatchHistoryRenderer(config = {}) { subscribeToMetadataPreference(); subscribeToRepublishEvents(); subscribeToFingerprintUpdates(); + subscribeToModerationEvents(); updateInfoCallout(); state.metadataStorageEnabled = typeof watchHistoryService.shouldStoreMetadata === "function" @@ -1893,6 +2682,7 @@ export function createWatchHistoryRenderer(config = {}) { if (elements.grid) { elements.grid.innerHTML = ""; } + clearHistoryCardRegistry(); state.initialized = false; state.items = []; state.cursor = 0; @@ -1919,6 +2709,7 @@ export function createWatchHistoryRenderer(config = {}) { `[data-pointer-key="${escapeSelector(pointerKeyValue)}"]` ); const parentDay = card?.closest("[data-history-day]"); + cleanupHistoryCard(pointerKeyValue); if (card) { card.remove(); } diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 673bd1e2..355dbe49 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -5,6 +5,7 @@ import { applyModerationContextDatasets, normalizeVideoModerationContext, } from "../moderationUiHelpers.js"; +import { buildModerationBadgeText } from "../moderationCopy.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; @@ -981,130 +982,6 @@ export class VideoCard { return normalizeVideoModerationContext(this.video?.moderation); } - buildModerationReasonText(context) { - if (!context) { - return ""; - } - - const reasons = []; - - if (context.trustedMuted) { - const muteCount = Math.max(1, Number(context.trustedMuteCount) || 0); - const muteLabel = muteCount === 1 ? "trusted contact" : "trusted contacts"; - reasons.push(`muted by ${muteCount === 1 ? "a" : muteCount} ${muteLabel}`); - } - - const typeLabel = context.friendlyType || "this video"; - const reportCount = Math.max(0, Number(context.trustedCount) || 0); - if (reportCount > 0) { - const friendLabel = reportCount === 1 ? "friend" : "friends"; - reasons.push(`${reportCount} ${friendLabel} reported ${typeLabel}`); - } else if (!context.trustedMuted) { - reasons.push(context.friendlyType ? `reports of ${typeLabel}` : "reports"); - } - - if (!reasons.length) { - return ""; - } - - const combined = reasons.join(" · "); - return combined.charAt(0).toUpperCase() + combined.slice(1); - } - - buildHiddenSummaryLabel(context) { - if (!context) { - return ""; - } - - const reason = context.effectiveHideReason; - if (!reason) { - return ""; - } - - const countsSource = - context.effectiveHideCounts || context.originalHideCounts || context.hideCounts || null; - const getCount = (key, fallback) => { - if (countsSource && Number.isFinite(countsSource[key])) { - return Math.max(0, Number(countsSource[key])); - } - if (Number.isFinite(context[key])) { - return Math.max(0, Number(context[key])); - } - return Math.max(0, Number(fallback) || 0); - }; - - if (reason === "trusted-mute-hide") { - const count = getCount("trustedMuteCount", context.trustedMuteCount || 0); - if (count > 0) { - const label = count === 1 ? "trusted mute" : "trusted mutes"; - return `${count} ${label}`; - } - return "trusted mute"; - } - - if (reason === "trusted-report-hide") { - const count = getCount("trustedReportCount", context.trustedCount || 0); - const type = typeof context.friendlyType === "string" ? context.friendlyType.trim() : ""; - const normalizedType = type ? type.toLowerCase() : ""; - if (count > 0) { - const label = count === 1 ? "report" : "reports"; - if (normalizedType) { - return `${count} trusted ${normalizedType} ${label}`; - } - const base = count === 1 ? "trusted report" : "trusted reports"; - return `${count} ${base}`; - } - if (normalizedType) { - return `trusted ${normalizedType} reports`; - } - return "trusted reports"; - } - - return ""; - } - - buildModerationBadgeText(context) { - if (!context) { - return ""; - } - - const hiddenLabel = this.buildHiddenSummaryLabel(context); - const reason = this.buildModerationReasonText(context); - if (context.overrideActive) { - if (hiddenLabel) { - return `Showing despite ${hiddenLabel}`; - } - if (reason) { - return `Showing despite ${reason}`; - } - return "Showing despite reports"; - } - - if (context.activeHidden || (context.originalHidden && !context.overrideActive)) { - if (hiddenLabel) { - return `Hidden · ${hiddenLabel}`; - } - return "Hidden"; - } - - const blurSource = (context.originalBlurReason || context.blurReason || "").toLowerCase(); - const suppressBlurLabel = - blurSource === "trusted-mute" || blurSource === "trusted-mute-hide"; - - const parts = []; - if (context.originalBlur && !suppressBlurLabel) { - 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: ["moderation-badge__action", "flex-shrink-0"], @@ -1384,7 +1261,7 @@ export class VideoCard { const text = this.createElement("span", { classNames: ["moderation-badge__text", "whitespace-nowrap"], - textContent: this.buildModerationBadgeText(context), + textContent: buildModerationBadgeText(context, { variant: "card" }), }); badge.appendChild(text); @@ -1555,7 +1432,7 @@ export class VideoCard { delete badge.dataset.moderationHideReason; } - const textContent = this.buildModerationBadgeText(context); + const textContent = buildModerationBadgeText(context, { variant: "card" }); if (this.moderationBadgeTextEl) { this.moderationBadgeTextEl.textContent = textContent; } @@ -1715,7 +1592,7 @@ export class VideoCard { const summaryContainer = this.ensureHiddenSummaryContainer(); if (summaryContainer) { - const description = this.buildModerationBadgeText(context); + const description = buildModerationBadgeText(context, { variant: "card" }); if (description) { summaryContainer.setAttribute("aria-label", description); } else { diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index e9a98590..3678f580 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -15,6 +15,7 @@ import { formatViewCount, } from "../../viewCounter.js"; import { normalizeVideoModerationContext } from "../moderationUiHelpers.js"; +import { buildModerationBadgeText } from "../moderationCopy.js"; import { formatAbsoluteTimestamp, formatTimeAgo, @@ -3902,68 +3903,6 @@ export class VideoModal { return this.moderationBadgeId; } - buildModerationReasonText(context) { - if (!context) { - return ""; - } - - const reasons = []; - - if (context.trustedMuted) { - const muteCount = Math.max(1, Number(context.trustedMuteCount) || 0); - const muteLabel = muteCount === 1 ? "trusted contact" : "trusted contacts"; - reasons.push( - `muted by ${muteCount === 1 ? "a" : muteCount} ${muteLabel}`, - ); - } - - const typeLabel = context.friendlyType || "this video"; - const reportCount = Math.max(0, Number(context.trustedCount) || 0); - if (reportCount > 0) { - const friendLabel = reportCount === 1 ? "friend" : "friends"; - reasons.push(`${reportCount} ${friendLabel} reported ${typeLabel}`); - } else if (!context.trustedMuted) { - reasons.push( - context.friendlyType ? `reports of ${typeLabel}` : "reports", - ); - } - - if (!reasons.length) { - return ""; - } - - const combined = reasons.join(" · "); - return combined.charAt(0).toUpperCase() + combined.slice(1); - } - - buildModerationBadgeText(context) { - if (!context) { - return ""; - } - - const blurSource = (context.originalBlurReason || context.blurReason || "") - .toLowerCase() - .trim(); - const suppressBlurLabel = - blurSource === "trusted-mute" || blurSource === "trusted-mute-hide"; - - const parts = []; - if (!suppressBlurLabel) { - parts.push("Blurred"); - } - - const reason = this.buildModerationReasonText(context); - if (reason) { - parts.push(reason); - } - - if (!parts.length) { - return "Blurred due to reports"; - } - - return parts.join(" · "); - } - refreshActiveVideoModeration({ video = this.activeVideo } = {}) { if (!video) { this.activeModerationContext = null; @@ -4038,7 +3977,7 @@ export class VideoModal { badge.id = badgeId; } - const textContent = this.buildModerationBadgeText(context); + const textContent = buildModerationBadgeText(context, { variant: "modal" }); if (textEl) { textEl.textContent = textContent; } diff --git a/js/ui/moderationCopy.js b/js/ui/moderationCopy.js new file mode 100644 index 00000000..165c7c9c --- /dev/null +++ b/js/ui/moderationCopy.js @@ -0,0 +1,154 @@ +// js/ui/moderationCopy.js + +export function buildModerationReasonText(context) { + if (!context) { + return ""; + } + + const reasons = []; + + if (context.trustedMuted) { + const muteCount = Math.max(1, Number(context.trustedMuteCount) || 0); + const muteLabel = muteCount === 1 ? "trusted contact" : "trusted contacts"; + const countLabel = muteCount === 1 ? "a" : muteCount; + reasons.push(`muted by ${countLabel} ${muteLabel}`); + } + + const typeLabel = context.friendlyType || "this video"; + const reportCount = Math.max(0, Number(context.trustedCount) || 0); + if (reportCount > 0) { + const friendLabel = reportCount === 1 ? "friend" : "friends"; + reasons.push(`${reportCount} ${friendLabel} reported ${typeLabel}`); + } else if (!context.trustedMuted) { + reasons.push(context.friendlyType ? `reports of ${typeLabel}` : "reports"); + } + + if (!reasons.length) { + return ""; + } + + const combined = reasons.join(" · "); + return combined.charAt(0).toUpperCase() + combined.slice(1); +} + +export function buildHiddenSummaryLabel(context) { + if (!context) { + return ""; + } + + const reason = context.effectiveHideReason; + if (!reason) { + return ""; + } + + const countsSource = + context.effectiveHideCounts || context.originalHideCounts || context.hideCounts || null; + + const getCount = (key, fallback) => { + if (countsSource && Number.isFinite(countsSource[key])) { + return Math.max(0, Number(countsSource[key])); + } + if (Number.isFinite(context[key])) { + return Math.max(0, Number(context[key])); + } + return Math.max(0, Number(fallback) || 0); + }; + + if (reason === "trusted-mute-hide") { + const count = getCount("trustedMuteCount", context.trustedMuteCount || 0); + if (count > 0) { + const label = count === 1 ? "trusted mute" : "trusted mutes"; + return `${count} ${label}`; + } + return "trusted mute"; + } + + if (reason === "trusted-report-hide") { + const count = getCount("trustedReportCount", context.trustedCount || 0); + const type = typeof context.friendlyType === "string" ? context.friendlyType.trim() : ""; + const normalizedType = type ? type.toLowerCase() : ""; + if (count > 0) { + const label = count === 1 ? "report" : "reports"; + if (normalizedType) { + return `${count} trusted ${normalizedType} ${label}`; + } + const base = count === 1 ? "trusted report" : "trusted reports"; + return `${count} ${base}`; + } + if (normalizedType) { + return `trusted ${normalizedType} reports`; + } + return "trusted reports"; + } + + return ""; +} + +export function buildModerationBadgeText(context, options = {}) { + if (!context) { + return ""; + } + + const variant = typeof options.variant === "string" ? options.variant : "card"; + + if (variant === "modal") { + const blurSource = (context.originalBlurReason || context.blurReason || "") + .toLowerCase() + .trim(); + const suppressBlurLabel = + blurSource === "trusted-mute" || blurSource === "trusted-mute-hide"; + + const parts = []; + if (!suppressBlurLabel) { + parts.push("Blurred"); + } + + const reason = buildModerationReasonText(context); + if (reason) { + parts.push(reason); + } + + if (!parts.length) { + return "Blurred due to reports"; + } + + return parts.join(" · "); + } + + const hiddenLabel = buildHiddenSummaryLabel(context); + const reason = buildModerationReasonText(context); + + if (context.overrideActive) { + if (hiddenLabel) { + return `Showing despite ${hiddenLabel}`; + } + if (reason) { + return `Showing despite ${reason}`; + } + return "Showing despite reports"; + } + + if (context.activeHidden || (context.originalHidden && !context.overrideActive)) { + if (hiddenLabel) { + return `Hidden · ${hiddenLabel}`; + } + return "Hidden"; + } + + const blurSource = (context.originalBlurReason || context.blurReason || "").toLowerCase(); + const suppressBlurLabel = + blurSource === "trusted-mute" || blurSource === "trusted-mute-hide"; + + const parts = []; + if (context.originalBlur && !suppressBlurLabel) { + parts.push("Blurred"); + } + if (context.originalBlockAutoplay) { + parts.push("Autoplay blocked"); + } + if (reason) { + parts.push(reason); + } + + return parts.join(" · "); +} diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index d957c870..9ca19358 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -1687,6 +1687,13 @@ async function testHistoryCardsUseDecryptedPlaybackMetadata() { "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", infoHash: "0123456789abcdef0123456789abcdef01234567", legacyInfoHash: "0123456789abcdef0123456789abcdef01234567", + moderation: { + blurThumbnail: true, + original: { blurThumbnail: true }, + trustedCount: 3, + reportType: "nudity", + summary: { types: { nudity: { trusted: 3 } } } + }, }; const item = { @@ -1761,16 +1768,50 @@ async function testHistoryCardsUseDecryptedPlaybackMetadata() { this._classSet = new Set(); this._className = ""; this.classList = new FakeClassList(this); + this._listeners = new Map(); + this.ownerDocument = null; } appendChild(child) { if (child && typeof child === "object") { child.parentNode = this; + if (this.ownerDocument) { + child.ownerDocument = this.ownerDocument; + } } this.children.push(child); return child; } + insertBefore(child, reference) { + if (child && typeof child === "object") { + child.parentNode = this; + if (this.ownerDocument) { + child.ownerDocument = this.ownerDocument; + } + } + if (!reference) { + this.children.push(child); + return child; + } + const index = this.children.indexOf(reference); + if (index === -1) { + this.children.push(child); + } else { + this.children.splice(index, 0, child); + } + return child; + } + + removeChild(child) { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + child.parentNode = null; + } + return child; + } + get className() { return this._className; } @@ -1794,6 +1835,51 @@ async function testHistoryCardsUseDecryptedPlaybackMetadata() { removeAttribute(name) { delete this.attributes[name]; } + + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) + ? this.attributes[name] + : null; + } + + addEventListener(type, handler) { + if (typeof handler !== "function") { + return; + } + if (!this._listeners.has(type)) { + this._listeners.set(type, new Set()); + } + this._listeners.get(type).add(handler); + } + + removeEventListener(type, handler) { + const listeners = this._listeners.get(type); + if (listeners) { + listeners.delete(handler); + } + } + + dispatchEvent(event) { + if (!event || typeof event.type !== "string") { + return false; + } + const listeners = this._listeners.get(event.type); + if (!listeners) { + return true; + } + for (const handler of Array.from(listeners)) { + handler.call(this, event); + } + return true; + } + + closest() { + return null; + } + + get parentElement() { + return this.parentNode instanceof FakeElement ? this.parentNode : null; + } } function collectElements(root, predicate, results = []) { @@ -1811,13 +1897,38 @@ async function testHistoryCardsUseDecryptedPlaybackMetadata() { const fakeDocument = { createElement(tagName) { - return new FakeElement(tagName); + const el = new FakeElement(tagName); + el.ownerDocument = fakeDocument; + return el; }, + createElementNS(_ns, tagName) { + const el = new FakeElement(tagName); + el.ownerDocument = fakeDocument; + return el; + } }; globalThis.document = fakeDocument; globalThis.HTMLElement = FakeElement; + const originalApp = getApplication(); + const overrideCalls = []; + setApplication({ + handleModerationOverride({ video }) { + overrideCalls.push(video); + return true; + }, + handleModerationHide() { + return true; + }, + decorateVideoModeration(videoInput) { + return videoInput; + }, + safeEncodeNpub(value) { + return value; + } + }); + try { const card = buildHistoryCard({ item, @@ -1847,7 +1958,50 @@ async function testHistoryCardsUseDecryptedPlaybackMetadata() { pointerVideo.magnet, "play action should surface magnet from metadata", ); + + const blurredThumbs = collectElements( + card, + (element) => element.dataset?.thumbnailState === "blurred", + ); + assert( + blurredThumbs.length >= 1, + "card should flag blurred thumbnails when moderation requests it", + ); + + const blurredAvatars = collectElements( + card, + (element) => element.dataset?.visualState === "blurred", + ); + assert( + blurredAvatars.length >= 1, + "card should blur creator avatars when moderation requests it", + ); + + const overrideButtons = collectElements( + card, + (element) => + element.tagName === "BUTTON" && + element.dataset.moderationAction === "override", + ); + assert.equal( + overrideButtons.length, + 1, + "card should render a moderation override button", + ); + + overrideButtons[0].dispatchEvent({ + type: "click", + preventDefault() {}, + stopPropagation() {}, + }); + await Promise.resolve(); + assert.equal( + overrideCalls.length, + 1, + "override button should call the app override handler", + ); } finally { + setApplication(originalApp || null); globalThis.document = originalDocument; globalThis.HTMLElement = originalHTMLElement; }