From e77418f0a8dbd5ab00ab912dbb19beb24483b982 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:29:36 +0000 Subject: [PATCH] feat: overlay moderation badge on blurred video thumbnails Moves the moderation warning badge ("Content blocked...") to overlay the blurred thumbnail image instead of sitting below it or increasing card height. This logic is applied to both the main `VideoCard` and the `SimilarContentCard` to ensure layout consistency. Changes: - VideoCard.js: Refactor `updateModerationBadge` to append the badge container as a child of the `anchorEl` (thumbnail wrapper) with absolute positioning. - SimilarContentCard.js: Implement `refreshModerationUi` logic to generate and overlay a simplified moderation badge on the thumbnail when `trustedMuted` context is present. - Fix tests/moderation/video-card.test.mjs: Patch test environment to mock `globalThis.navigator`. --- js/ui/components/SimilarContentCard.js | 90 ++++++++++++++++++++++++++ js/ui/components/VideoCard.js | 62 ++++++++++++++---- tests/moderation/video-card.test.mjs | 15 ++++- 3 files changed, 153 insertions(+), 14 deletions(-) diff --git a/js/ui/components/SimilarContentCard.js b/js/ui/components/SimilarContentCard.js index a759724d..dcb2a9ca 100644 --- a/js/ui/components/SimilarContentCard.js +++ b/js/ui/components/SimilarContentCard.js @@ -1,6 +1,8 @@ import { normalizeDesignSystemContext } from "../../designSystem.js"; import { formatShortNpub } from "../../utils/formatters.js"; import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; +import { normalizeVideoModerationContext } from "../moderationUiHelpers.js"; +import { buildModerationBadgeText } from "../moderationCopy.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const DEFAULT_PROFILE_AVATAR = "assets/svg/default-profile.svg"; @@ -69,6 +71,10 @@ export class SimilarContentCard { this.viewCountEl = null; this.discussionCountEl = null; + this.moderationBadgeEl = null; + this.moderationBadgeLabelEl = null; + this.moderationBadgeTextEl = null; + this.build(); } @@ -342,6 +348,7 @@ export class SimilarContentCard { root.appendChild(content); } + this.refreshModerationUi(); this.applyPointerDatasets(); this.bindEvents(); } @@ -361,6 +368,89 @@ export class SimilarContentCard { return anchor; } + getModerationContext() { + return normalizeVideoModerationContext(this.video?.moderation); + } + + buildModerationBadge(context = this.getModerationContext()) { + if (!context.shouldShow || !context.trustedMuted) { + return null; + } + + const badge = this.document.createElement("div"); + badge.className = "moderation-badge opacity-95"; + badge.dataset.variant = "warning"; + badge.dataset.moderationBadge = "true"; + badge.dataset.moderationState = "trusted-mute"; + + // Position it centrally over the thumbnail like in VideoCard + badge.style.pointerEvents = "auto"; + + const label = this.document.createElement("span"); + label.className = "moderation-badge__label inline-flex items-center gap-xs"; + + // Simplified icon for small card + const iconWrapper = this.document.createElement("span"); + iconWrapper.className = "moderation-badge__icon"; + iconWrapper.setAttribute("aria-hidden", "true"); + + const svg = this.document.createElementNS(SVG_NAMESPACE, "svg"); + svg.setAttribute("viewBox", "0 0 20 20"); + svg.setAttribute("focusable", "false"); + svg.classList.add("moderation-badge__icon-mark"); + + const path = this.document.createElementNS(SVG_NAMESPACE, "path"); + path.setAttribute("fill", "currentColor"); + path.setAttribute("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"); + path.setAttribute("fill-rule", "evenodd"); + path.setAttribute("clip-rule", "evenodd"); + + svg.appendChild(path); + iconWrapper.appendChild(svg); + label.appendChild(iconWrapper); + + const text = this.document.createElement("span"); + text.className = "moderation-badge__text"; + // Shorten text for small cards if needed, but keeping standard for now + text.textContent = "Blocked by trusted contact"; + + label.appendChild(text); + badge.appendChild(label); + + this.moderationBadgeEl = badge; + return badge; + } + + refreshModerationUi() { + const context = this.getModerationContext(); + + if (this.moderationBadgeEl) { + this.moderationBadgeEl.remove(); + this.moderationBadgeEl = null; + } + + // Check specifically for muted/blurred state where we want the overlay + if (context.shouldShow && context.trustedMuted && !context.overrideActive) { + // Ensure media link exists + if (!this.mediaLinkEl) return; + + const badge = this.buildModerationBadge(context); + if (badge) { + // Create a wrapper for centering if needed, or use classes on badge if possible. + // Similar to VideoCard, let's create a container for absolute positioning + const container = this.document.createElement("div"); + container.className = "absolute inset-0 flex items-center justify-center p-2 z-10 pointer-events-none"; + container.appendChild(badge); + + this.mediaLinkEl.appendChild(container); + // We track the container removal via moderationBadgeEl reference if we wanted strict cleanup, + // but for now just rebuilding is fine if we store the container. + // Let's store badge el as the container actually to make removal easier + this.moderationBadgeEl = container; + } + } + } + buildThumbnail() { const img = this.document.createElement("img"); img.decoding = "async"; diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index b068745c..5ea8fa4e 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -493,14 +493,28 @@ export class VideoCard { } const moderationBadgeSlot = this.createElement("div", { - classNames: ["video-card__moderation-slot", "px-md", "pt-md"], + classNames: [ + "video-card__moderation-overlay", + "absolute", + "inset-0", + "flex", + "items-center", + "justify-center", + "p-4", + "z-10", + "pointer-events-none", + ], }); moderationBadgeSlot.hidden = true; moderationBadgeSlot.setAttribute("aria-hidden", "true"); this.moderationBadgeSlot = moderationBadgeSlot; + // Append slot to anchor (media wrapper) instead of root to overlay thumbnail + if (this.anchorEl) { + this.anchorEl.appendChild(moderationBadgeSlot); + } + root.appendChild(anchor); - root.appendChild(moderationBadgeSlot); root.appendChild(content); this.applyPlaybackDatasets(); @@ -1632,18 +1646,24 @@ export class VideoCard { let slot = this.moderationBadgeSlot; const hiddenActive = context.activeHidden && !context.overrideActive; - if (!slot && this.root) { + if (!slot && this.anchorEl) { slot = this.createElement("div", { - classNames: ["video-card__moderation-slot", "px-md", "pt-md"], + classNames: [ + "video-card__moderation-overlay", + "absolute", + "inset-0", + "flex", + "items-center", + "justify-center", + "p-4", + "z-10", + "pointer-events-none", + ], }); slot.hidden = true; slot.setAttribute("aria-hidden", "true"); this.moderationBadgeSlot = slot; - if (this.anchorEl && this.anchorEl.parentElement === this.root) { - this.root.insertBefore(slot, this.anchorEl.nextSibling); - } else { - this.root.appendChild(slot); - } + this.anchorEl.appendChild(slot); } if (badge) { @@ -1724,6 +1744,8 @@ export class VideoCard { if (!badge) { const nextBadge = this.buildModerationBadge(context); if (nextBadge && slot) { + nextBadge.style.pointerEvents = "auto"; // Enable interaction on badge itself + nextBadge.classList.add("opacity-95"); // Slight transparency slot.appendChild(nextBadge); slot.hidden = false; slot.removeAttribute("aria-hidden"); @@ -1732,6 +1754,8 @@ export class VideoCard { return; } + badge.style.pointerEvents = "auto"; + badge.classList.add("opacity-95"); badge.dataset.variant = context.overrideActive ? "neutral" : "warning"; const state = context.overrideActive ? "override" @@ -1985,9 +2009,17 @@ export class VideoCard { this.contentEl.setAttribute("aria-hidden", "true"); } - if (badgeSlot && this.moderationBadgeEl) { - badgeSlot.hidden = false; - badgeSlot.removeAttribute("aria-hidden"); + // In hidden state (blocked), the anchor/thumbnail is hidden, so overlay slot + // won't be visible. We rely on the summary container below. + // We hide the badge slot here because we don't want it floating if anchor is hidden + // (though usually anchor hidden means children hidden too). + // However, if we move badgeSlot to be a child of anchorEl, it will be hidden automatically. + + if (badgeSlot) { + // If it's a child of anchorEl, it's hidden by parent. + // We can force it hidden to be safe. + badgeSlot.hidden = true; + badgeSlot.setAttribute("aria-hidden", "true"); } const summaryContainer = this.ensureHiddenSummaryContainer(); @@ -2008,7 +2040,11 @@ export class VideoCard { this.contentEl.removeAttribute("hidden"); this.contentEl.removeAttribute("aria-hidden"); } - if (badgeSlot && !this.moderationBadgeEl) { + // Restore badge slot visibility if badge exists and we are not hidden + if (badgeSlot && this.moderationBadgeEl) { + badgeSlot.hidden = false; + badgeSlot.removeAttribute("aria-hidden"); + } else if (badgeSlot) { badgeSlot.hidden = true; badgeSlot.setAttribute("aria-hidden", "true"); } diff --git a/tests/moderation/video-card.test.mjs b/tests/moderation/video-card.test.mjs index ad05f5fd..0ebf28f1 100644 --- a/tests/moderation/video-card.test.mjs +++ b/tests/moderation/video-card.test.mjs @@ -35,7 +35,6 @@ function setupDom(t) { "ResizeObserver", "requestAnimationFrame", "cancelAnimationFrame", - "navigator", ]; keys.forEach((key) => { @@ -55,6 +54,14 @@ function setupDom(t) { globalThis[key] = window[key]; }); + // Mock navigator safely + const originalNavigator = globalThis.navigator; + Object.defineProperty(globalThis, "navigator", { + value: window.navigator, + writable: true, + configurable: true, + }); + if (!window.requestAnimationFrame) { window.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); globalThis.requestAnimationFrame = window.requestAnimationFrame; @@ -85,6 +92,12 @@ function setupDom(t) { globalThis[key] = previousValue; } }); + // Restore navigator + Object.defineProperty(globalThis, "navigator", { + value: originalNavigator, + writable: true, + configurable: true, + }); }); return { window, document: window.document };