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 };