diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 31aea268..28f0b73c 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -1438,6 +1438,21 @@ transform: scale(1.05); } + .channel-moderation-badge-anchor { + @apply flex flex-col gap-sm; + pointer-events: none; + width: 100%; + } + + .channel-moderation-badge-anchor:empty { + display: none; + } + + .channel-moderation-badge-anchor > [data-moderation-badge="true"] { + pointer-events: auto; + max-width: min(100%, 28rem); + } + #playerModal .video-modal__video { position: relative; } diff --git a/js/channelProfile.js b/js/channelProfile.js index 9d5be37d..56411378 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -20,6 +20,7 @@ import { applyModerationContextDatasets, normalizeVideoModerationContext, } from "./ui/moderationUiHelpers.js"; +import { buildModerationBadgeText } from "./ui/moderationCopy.js"; import { calculateZapShares, describeShareType, @@ -55,6 +56,25 @@ import { setModalState as setGlobalModalState } from "./state/appState.js"; const getApp = () => getApplication(); +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge"; + +let currentChannelModerationSourceVideo = null; + +const channelModerationBadgeState = { + container: null, + badgeEl: null, + textEl: null, + iconSvg: null, + iconWrapper: null, + overrideButton: null, + hideButton: null, + boundOverride: null, + boundHide: null, + badgeId: "", + app: null, +}; + let currentChannelHex = null; let currentChannelNpub = null; let currentChannelLightningAddress = ""; @@ -198,6 +218,354 @@ function buildChannelModerationFallback(pubkey) { return fallback; } +function getChannelModerationState(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 shouldShowChannelHideButton(context) { + if (!context || !context.trustedMuted) { + return false; + } + if (context.activeHidden && !context.overrideActive) { + return false; + } + return true; +} + +function getChannelModerationBadgeIconShape(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 updateChannelModerationBadgeIcon(svg, state) { + if (!svg) { + return; + } + svg.dataset.iconState = state; + const path = svg.firstElementChild; + if (!path) { + return; + } + const { d, fillRule, clipRule } = getChannelModerationBadgeIconShape(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 createChannelModerationBadgeIcon(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); + updateChannelModerationBadgeIcon(svg, state); + return { wrapper, svg }; +} + +function getChannelModerationBadgeContainer(doc = typeof document !== "undefined" ? document : null) { + if (!doc || typeof doc.getElementById !== "function") { + return null; + } + channelModerationBadgeState.container = doc.getElementById("channelModerationBadge") || null; + return channelModerationBadgeState.container; +} + +function ensureChannelModerationBadgeResources(doc) { + const container = getChannelModerationBadgeContainer(doc); + if (!container) { + return null; + } + + if (!channelModerationBadgeState.badgeEl) { + const badge = doc.createElement("div"); + badge.className = "moderation-badge flex flex-wrap items-center gap-sm"; + badge.dataset.moderationBadge = "true"; + const { wrapper, svg } = createChannelModerationBadgeIcon(doc, "blocked"); + badge.appendChild(wrapper); + const text = doc.createElement("span"); + text.className = "moderation-badge__text whitespace-nowrap"; + badge.appendChild(text); + channelModerationBadgeState.badgeEl = badge; + channelModerationBadgeState.textEl = text; + channelModerationBadgeState.iconWrapper = wrapper; + channelModerationBadgeState.iconSvg = svg; + } + + if (typeof channelModerationBadgeState.boundOverride !== "function") { + channelModerationBadgeState.boundOverride = (event) => + handleChannelModerationOverride(event); + } + + if (typeof channelModerationBadgeState.boundHide !== "function") { + channelModerationBadgeState.boundHide = (event) => + handleChannelModerationHide(event); + } + + if (!channelModerationBadgeState.overrideButton) { + const button = doc.createElement("button"); + button.type = "button"; + button.className = "moderation-badge__action flex-shrink-0"; + button.dataset.moderationAction = "override"; + button.textContent = "Show anyway"; + button.addEventListener("click", channelModerationBadgeState.boundOverride); + channelModerationBadgeState.overrideButton = button; + } + + if (!channelModerationBadgeState.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", channelModerationBadgeState.boundHide); + channelModerationBadgeState.hideButton = hideButton; + } + + return channelModerationBadgeState; +} + +function teardownChannelModerationBadge() { + const ref = channelModerationBadgeState; + currentChannelModerationSourceVideo = null; + if (ref.badgeEl && ref.badgeEl.parentNode) { + ref.badgeEl.parentNode.removeChild(ref.badgeEl); + } + if (ref.container) { + ref.container.removeAttribute("data-moderation-active"); + } + ref.app = null; +} + +function renderChannelModerationBadge({ + context = null, + video = null, + app = null, + doc = typeof document !== "undefined" ? document : null, +} = {}) { + const container = getChannelModerationBadgeContainer(doc); + if (!container) { + teardownChannelModerationBadge(); + return; + } + + const normalizedContext = context || null; + const shouldShow = Boolean(normalizedContext?.shouldShow); + + currentChannelModerationSourceVideo = + video && typeof video === "object" && typeof video.id === "string" + ? video + : null; + + if (!shouldShow) { + teardownChannelModerationBadge(); + return; + } + + const ref = ensureChannelModerationBadgeResources(doc); + if (!ref || !ref.badgeEl || !ref.textEl) { + teardownChannelModerationBadge(); + return; + } + + const badge = ref.badgeEl; + const state = normalizedContext.overrideActive + ? "override" + : getChannelModerationState(normalizedContext); + + badge.dataset.variant = normalizedContext.overrideActive ? "neutral" : "warning"; + badge.dataset.moderationState = state; + if (normalizedContext.effectiveHideReason) { + badge.dataset.moderationHideReason = normalizedContext.effectiveHideReason; + } else if (badge.dataset.moderationHideReason) { + delete badge.dataset.moderationHideReason; + } + + updateChannelModerationBadgeIcon(ref.iconSvg, state); + + const textContent = + buildModerationBadgeText(normalizedContext, { variant: "card" }) || + "Moderated due to reports"; + ref.textEl.textContent = textContent; + + if (CHANNEL_MODERATION_BADGE_ID) { + badge.id = CHANNEL_MODERATION_BADGE_ID; + } + + if (badge.parentNode !== container) { + container.appendChild(badge); + } + + container.dataset.moderationActive = "true"; + + const hasVideoTarget = Boolean(currentChannelModerationSourceVideo?.id); + + if ( + hasVideoTarget && + !normalizedContext.overrideActive && + normalizedContext.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 (hasVideoTarget && shouldShowChannelHideButton(normalizedContext) && 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); + } + + if (ref.overrideButton) { + if (badge.id) { + ref.overrideButton.setAttribute("aria-describedby", badge.id); + } else { + ref.overrideButton.removeAttribute("aria-describedby"); + } + } + + if (ref.hideButton) { + if (badge.id) { + ref.hideButton.setAttribute("aria-describedby", badge.id); + } else { + ref.hideButton.removeAttribute("aria-describedby"); + } + } + + ref.badgeId = badge.id || ""; + ref.app = app || getApp(); +} + +function handleChannelModerationOverride(event) { + if (event) { + if (typeof event.preventDefault === "function") { + event.preventDefault(); + } + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + } + + const sourceVideo = currentChannelModerationSourceVideo; + const app = channelModerationBadgeState.app || getApp(); + const targetVideo = + app?.videosMap instanceof Map && sourceVideo?.id + ? app.videosMap.get(sourceVideo.id) || sourceVideo + : sourceVideo; + + if (!targetVideo || typeof targetVideo !== "object" || !targetVideo.id) { + return; + } + + const button = channelModerationBadgeState.overrideButton; + if (button) { + button.disabled = true; + button.setAttribute("aria-busy", "true"); + } + + let handled = false; + try { + handled = app?.handleModerationOverride?.({ video: targetVideo }) === true; + } catch (error) { + devLogger.warn("[ChannelProfile] Failed to handle channel moderation override", error); + } + + if (handled) { + updateChannelModerationVisuals({ app, pubkey: sourceVideo?.pubkey || currentChannelHex || "" }); + } else if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } +} + +function handleChannelModerationHide(event) { + if (event) { + if (typeof event.preventDefault === "function") { + event.preventDefault(); + } + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + } + + const sourceVideo = currentChannelModerationSourceVideo; + const app = channelModerationBadgeState.app || getApp(); + const targetVideo = + app?.videosMap instanceof Map && sourceVideo?.id + ? app.videosMap.get(sourceVideo.id) || sourceVideo + : sourceVideo; + + if (!targetVideo || typeof targetVideo !== "object" || !targetVideo.id) { + return; + } + + const button = channelModerationBadgeState.hideButton; + if (button) { + button.disabled = true; + button.setAttribute("aria-busy", "true"); + } + + let handled = false; + try { + handled = app?.handleModerationHide?.({ video: targetVideo }) === true; + } catch (error) { + devLogger.warn("[ChannelProfile] Failed to handle channel moderation hide", error); + } + + if (handled) { + updateChannelModerationVisuals({ app, pubkey: sourceVideo?.pubkey || currentChannelHex || "" }); + } else if (button) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } +} + function resolveChannelModeration(pubkey, app = getApp()) { const normalized = normalizeHex(pubkey); if (!normalized) { @@ -206,6 +574,7 @@ function resolveChannelModeration(pubkey, app = getApp()) { const matches = collectChannelVideos(normalized, app); let fallbackModeration = null; + let fallbackVideo = null; for (const candidate of matches) { const decorated = decorateChannelVideo(candidate, app); @@ -221,23 +590,29 @@ function resolveChannelModeration(pubkey, app = getApp()) { } if (moderation.viewerOverride?.showAnyway === true) { - return moderation; + return { moderation, video: decorated }; } if (moderation.hidden === true || moderation.blurThumbnail === true) { - return moderation; + return { moderation, video: decorated }; } if (!fallbackModeration) { fallbackModeration = moderation; + fallbackVideo = decorated; } } if (fallbackModeration) { - return fallbackModeration; + return { moderation: fallbackModeration, video: fallbackVideo }; } - return buildChannelModerationFallback(normalized); + const fallback = buildChannelModerationFallback(normalized); + if (fallback) { + return { moderation: fallback, video: null }; + } + + return null; } export function applyChannelVisualBlur({ @@ -247,7 +622,8 @@ export function applyChannelVisualBlur({ app = null, } = {}) { const resolvedApp = app || getApp(); - const moderation = resolveChannelModeration(pubkey, resolvedApp) || {}; + const resolution = resolveChannelModeration(pubkey, resolvedApp); + const moderation = resolution?.moderation || {}; const context = normalizeVideoModerationContext(moderation); const resolvedBanner = bannerEl || @@ -258,14 +634,21 @@ export function applyChannelVisualBlur({ (typeof document !== "undefined" ? document.getElementById("channelAvatar") : null); + const doc = resolvedBanner?.ownerDocument || + resolvedAvatar?.ownerDocument || + (typeof document !== "undefined" ? document : null); + const badgeContainer = doc?.getElementById?.("channelModerationBadge") || null; const avatarElements = [resolvedAvatar, resolvedBanner].filter(Boolean); applyModerationContextDatasets(context, { root: resolvedBanner || null, + roots: badgeContainer ? [badgeContainer] : undefined, avatar: resolvedAvatar || null, avatars: avatarElements.length > 1 ? avatarElements : undefined, }); + + return { context, moderation, video: resolution?.video || null }; } function updateChannelModerationVisuals({ @@ -281,12 +664,21 @@ function updateChannelModerationVisuals({ const resolvedApp = app || getApp(); const targetPubkey = pubkey || currentChannelHex || ""; if (!targetPubkey) { - applyChannelVisualBlur({ + const result = applyChannelVisualBlur({ bannerEl, avatarEl, pubkey: "", app: resolvedApp, }); + renderChannelModerationBadge({ + context: result?.context, + video: null, + app: resolvedApp, + doc: + bannerEl?.ownerDocument || + avatarEl?.ownerDocument || + (typeof document !== "undefined" ? document : null), + }); return; } @@ -295,12 +687,22 @@ function updateChannelModerationVisuals({ const resolvedAvatar = avatarEl || document.getElementById("channelAvatar"); - applyChannelVisualBlur({ + const result = applyChannelVisualBlur({ bannerEl: resolvedBanner, avatarEl: resolvedAvatar, pubkey: targetPubkey, app: resolvedApp, }); + + renderChannelModerationBadge({ + context: result?.context, + video: result?.video, + app: resolvedApp, + doc: + resolvedBanner?.ownerDocument || + resolvedAvatar?.ownerDocument || + (typeof document !== "undefined" ? document : null), + }); } let channelModerationEventsBound = false; diff --git a/tests/app/channel-profile-moderation.test.mjs b/tests/app/channel-profile-moderation.test.mjs index d1241a68..39275c7d 100644 --- a/tests/app/channel-profile-moderation.test.mjs +++ b/tests/app/channel-profile-moderation.test.mjs @@ -2,6 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { JSDOM } from "jsdom"; +import "../test-helpers/setup-localstorage.mjs"; + import { renderChannelVideosFromList, applyChannelVisualBlur, @@ -10,11 +12,15 @@ import { } from "../../js/channelProfile.js"; import { nostrClient } from "../../js/nostrClientFacade.js"; import moderationService from "../../js/services/moderationService.js"; +import { setApplication } from "../../js/applicationContext.js"; +import { normalizeVideoModerationContext } from "../../js/ui/moderationUiHelpers.js"; +import { buildModerationBadgeText } from "../../js/ui/moderationCopy.js"; import { withMockedNostrTools, createModerationAppHarness } from "../helpers/moderation-test-helpers.mjs"; function setupDom(t) { const dom = new JSDOM( "" + + '
' + '
' + '' + '
' + @@ -107,6 +113,7 @@ test("renderChannelVideosFromList decorates videos before storing", async (t) => app.mountVideoListView = () => {}; app.mediaLoader = { observe() {} }; app.attachMoreMenuHandlers = () => {}; + app.decorateVideoModeration = (video) => video; const originalDecorate = app.decorateVideoModeration; let decoratedCount = 0; @@ -203,7 +210,11 @@ test("moderation override clears channel blur via event wiring", async (t) => { types: { nudity: { trusted: 2 } }, }, }; - const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState }); + const video = createVideoFixture({ + id: "a".repeat(64), + pubkey: channelPubkey, + moderation: moderationState, + }); renderChannelVideosFromList({ videos: [video], @@ -223,3 +234,113 @@ test("moderation override clears channel blur via event wiring", async (t) => { assert.equal(bannerEl.dataset.visualState, undefined); assert.equal(avatarEl.dataset.visualState, undefined); }); + +test("channel header moderation badge reflects blur state and override actions", async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + nostrClient.allEvents = new Map(); + nostrClient.rawEvents = new Set(); + + const app = await createModerationAppHarness(); + app.loadedThumbnails = new Map(); + app.videosMap = new Map(); + app.buildShareUrlFromEventId = (id) => `https://example.invalid/watch/${id}`; + app.formatTimeAgo = () => "just now"; + app.ensureGlobalMoreMenuHandlers = () => {}; + app.closeAllMoreMenus = () => {}; + app.normalizeHexPubkey = (value) => + (typeof value === "string" ? value.trim().toLowerCase() : ""); + app.hasOlderVersion = () => false; + app.deriveVideoPointerInfo = () => null; + app.persistWatchHistoryMetadataForVideo = () => {}; + app.canCurrentUserManageBlacklist = () => false; + app.isMagnetUriSupported = () => false; + app.mountVideoListView = () => {}; + app.mediaLoader = { observe() {} }; + app.attachMoreMenuHandlers = () => {}; + app.getActiveModerationThresholds = () => ({ + blurThreshold: 1, + autoplayBlockThreshold: 1, + trustedMuteHideThreshold: 1, + trustedSpamHideThreshold: Number.POSITIVE_INFINITY, + }); + let overrideCalls = 0; + app.handleModerationOverride = () => { + overrideCalls += 1; + return true; + }; + + setApplication(app); + t.after(() => { + setApplication(null); + }); + + const channelPubkey = "d".repeat(64); + __setChannelProfileTestState({ pubkey: channelPubkey }); + __ensureChannelModerationEventsForTests(); + + const container = document.getElementById("channelVideoList"); + const moderationState = { + blurThumbnail: true, + trustedCount: 2, + reportType: "nudity", + summary: { types: { nudity: { trusted: 2 } } }, + original: { blurThumbnail: true, blockAutoplay: false }, + }; + const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState }); + + renderChannelVideosFromList({ + videos: [video], + container, + app, + loadToken: 0, + }); + + applyChannelVisualBlur({ app, pubkey: channelPubkey }); + const storedVideo = app.videosMap.get(video.id); + + const bannerEl = document.getElementById("channelBanner"); + const avatarEl = document.getElementById("channelAvatar"); + assert.equal(bannerEl.dataset.visualState, "blurred"); + assert.equal(avatarEl.dataset.visualState, "blurred"); + + const badgeContainer = document.getElementById("channelModerationBadge"); + const badge = badgeContainer.querySelector('[data-moderation-badge="true"]'); + assert.ok(badge, "badge renders in channel header"); + + const textEl = badge.querySelector(".moderation-badge__text"); + const context = normalizeVideoModerationContext(storedVideo.moderation); + const expectedText = buildModerationBadgeText(context, { variant: "card" }); + assert.equal(textEl.textContent, expectedText); + + const overrideButton = badge.querySelector('[data-moderation-action="override"]'); + assert.ok(overrideButton, "override control is available"); + + overrideButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(overrideCalls, 1); + const updatedVideo = app.videosMap.get(video.id); + if (!updatedVideo.moderation || typeof updatedVideo.moderation !== "object") { + updatedVideo.moderation = {}; + } + updatedVideo.moderation.viewerOverride = { showAnyway: true }; + updatedVideo.moderation.blurThumbnail = false; + updatedVideo.moderation.blockAutoplay = false; + const originalState = updatedVideo.moderation.original && typeof updatedVideo.moderation.original === "object" + ? { ...updatedVideo.moderation.original } + : {}; + if (typeof originalState.blurThumbnail === "undefined") { + originalState.blurThumbnail = true; + } + if (typeof originalState.blockAutoplay === "undefined") { + originalState.blockAutoplay = true; + } + updatedVideo.moderation.original = originalState; + app.videosMap.set(updatedVideo.id, updatedVideo); + const overrideContext = normalizeVideoModerationContext(updatedVideo.moderation); + const overrideText = buildModerationBadgeText(overrideContext, { variant: "card" }); + + assert.equal(overrideContext.overrideActive, true); + assert.ok(overrideText.startsWith("Showing despite")); +}); diff --git a/views/channel-profile.html b/views/channel-profile.html index 4e1506ee..73f15a62 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -16,20 +16,27 @@
-
+
-
+
+
+ Avatar
-
-

User Name

-

npub...

+
+

User Name

+

npub...

+