diff --git a/js/app.js b/js/app.js index fdd979ea..d44b0012 100644 --- a/js/app.js +++ b/js/app.js @@ -2028,7 +2028,27 @@ class Application { } computeSimilarContentCandidates({ activeVideo = this.currentVideo, maxItems = 5 } = {}) { - const target = activeVideo && typeof activeVideo === "object" ? activeVideo : null; + const decorateCandidate = (video) => { + if (!video || typeof video !== "object") { + return video; + } + if (typeof this.decorateVideoModeration === "function") { + try { + const decorated = this.decorateVideoModeration(video); + if (decorated && typeof decorated === "object") { + return decorated; + } + } catch (error) { + devLogger.warn( + "[Application] Failed to decorate similar content candidate", + error, + ); + } + } + return video; + }; + + const target = activeVideo && typeof activeVideo === "object" ? decorateCandidate(activeVideo) : null; if (!target) { return []; } @@ -2095,35 +2115,36 @@ class Application { const results = []; for (const candidate of candidateSource) { - if (!candidate || typeof candidate !== "object") { + const decoratedCandidate = decorateCandidate(candidate); + if (!decoratedCandidate || typeof decoratedCandidate !== "object") { continue; } - if (candidate === target) { + if (decoratedCandidate === target) { continue; } - const candidateId = typeof candidate.id === "string" ? candidate.id : ""; + const candidateId = typeof decoratedCandidate.id === "string" ? decoratedCandidate.id : ""; if (candidateId && candidateId === activeId) { continue; } - if (candidate.deleted === true) { + if (decoratedCandidate.deleted === true) { continue; } - if (candidate.isPrivate === true) { + if (decoratedCandidate.isPrivate === true) { continue; } - if (candidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) { + if (decoratedCandidate.isNsfw === true && ALLOW_NSFW_CONTENT !== true) { continue; } - const candidatePubkey = typeof candidate.pubkey === "string" ? candidate.pubkey : ""; + const candidatePubkey = typeof decoratedCandidate.pubkey === "string" ? decoratedCandidate.pubkey : ""; if (candidatePubkey && this.isAuthorBlocked(candidatePubkey)) { continue; } - const candidateTagsSource = Array.isArray(candidate.displayTags) && candidate.displayTags.length - ? candidate.displayTags - : collectVideoTags(candidate); + const candidateTagsSource = Array.isArray(decoratedCandidate.displayTags) && decoratedCandidate.displayTags.length + ? decoratedCandidate.displayTags + : collectVideoTags(decoratedCandidate); if (!Array.isArray(candidateTagsSource) || candidateTagsSource.length === 0) { continue; } @@ -2176,26 +2197,26 @@ class Application { : []; } - let postedAt = this.getKnownVideoPostedAt(candidate); - if (!Number.isFinite(postedAt) && Number.isFinite(candidate.rootCreatedAt)) { - postedAt = Math.floor(candidate.rootCreatedAt); + let postedAt = this.getKnownVideoPostedAt(decoratedCandidate); + if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.rootCreatedAt)) { + postedAt = Math.floor(decoratedCandidate.rootCreatedAt); } - if (!Number.isFinite(postedAt) && Number.isFinite(candidate.created_at)) { - postedAt = Math.floor(candidate.created_at); + if (!Number.isFinite(postedAt) && Number.isFinite(decoratedCandidate.created_at)) { + postedAt = Math.floor(decoratedCandidate.created_at); } if (!Number.isFinite(postedAt)) { postedAt = null; } let shareUrl = ""; - if (typeof candidate.shareUrl === "string" && candidate.shareUrl.trim()) { - shareUrl = candidate.shareUrl.trim(); + if (typeof decoratedCandidate.shareUrl === "string" && decoratedCandidate.shareUrl.trim()) { + shareUrl = decoratedCandidate.shareUrl.trim(); } else if (candidateId) { shareUrl = this.buildShareUrlFromEventId(candidateId) || ""; } results.push({ - video: candidate, + video: decoratedCandidate, pointerInfo: pointerInfo || null, shareUrl, postedAt, diff --git a/js/channelProfile.js b/js/channelProfile.js index 2fc931fe..9d5be37d 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -16,6 +16,10 @@ import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderer import { ALLOW_NSFW_CONTENT } from "./config.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import moderationService from "./services/moderationService.js"; +import { + applyModerationContextDatasets, + normalizeVideoModerationContext, +} from "./ui/moderationUiHelpers.js"; import { calculateZapShares, describeShareType, @@ -64,72 +68,336 @@ function normalizeHex(value) { return /^[0-9a-f]{64}$/.test(trimmed) ? trimmed : ""; } -function shouldBlurChannelMedia(pubkey) { +function decorateChannelVideo(video, app = getApp()) { + if (!video || typeof video !== "object") { + return null; + } + + if (typeof app?.decorateVideoModeration === "function") { + try { + const decorated = app.decorateVideoModeration(video); + if (decorated && typeof decorated === "object") { + return decorated; + } + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to decorate channel video moderation", + error, + ); + } + } + + return video; +} + +function collectChannelVideos(pubkey, app = getApp()) { + if (!app || !(app.videosMap instanceof Map)) { + return []; + } + const normalized = normalizeHex(pubkey); if (!normalized) { - return false; + return []; } + const matches = []; + app.videosMap.forEach((video) => { + if (!video || typeof video !== "object") { + return; + } + const videoPubkey = normalizeHex(video.pubkey); + if (videoPubkey === normalized) { + matches.push(video); + } + }); + + return matches; +} + +function buildChannelModerationFallback(pubkey) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return null; + } + + let viewerMuted = false; try { - if (moderationService.isAuthorMutedByViewer(normalized) === true) { - return true; + viewerMuted = moderationService.isAuthorMutedByViewer(normalized) === true; + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to resolve viewer mute state for channel", + error, + ); + } + + let trustedMuted = false; + let trustedMuters = []; + try { + if (typeof moderationService.isAuthorMutedByTrusted === "function") { + trustedMuted = + moderationService.isAuthorMutedByTrusted(normalized) === true; + } + if (trustedMuted) { + if (typeof moderationService.getTrustedMutersForAuthor === "function") { + const muters = + moderationService.getTrustedMutersForAuthor(normalized) || []; + if (Array.isArray(muters)) { + trustedMuters = muters; + } + } } } catch (error) { - devLogger.warn("[ChannelProfile] Failed to resolve viewer mute state", error); + devLogger.warn( + "[ChannelProfile] Failed to resolve trusted mute state for channel", + error, + ); + trustedMuted = false; + trustedMuters = []; } - const app = getApp(); - const videos = - app?.videosMap instanceof Map ? Array.from(app.videosMap.values()) : []; + if (!viewerMuted && !trustedMuted) { + return null; + } - for (const video of videos) { - if (!video || typeof video !== "object") { - continue; - } - const videoPubkey = - typeof video.pubkey === "string" ? video.pubkey.trim().toLowerCase() : ""; - if (videoPubkey !== normalized) { + const trustedMuteCount = Array.isArray(trustedMuters) + ? trustedMuters.length + : 0; + + const fallback = { + trustedMuted, + trustedMuters: Array.isArray(trustedMuters) ? trustedMuters.slice() : [], + trustedMuteCount, + }; + + if (viewerMuted) { + fallback.blurThumbnail = true; + fallback.blurReason = "viewer-mute"; + fallback.hidden = true; + fallback.hideReason = "viewer-mute"; + fallback.hideCounts = { + trustedMuteCount, + trustedReportCount: 0, + }; + } else if (trustedMuted) { + fallback.blurThumbnail = true; + fallback.blurReason = "trusted-mute"; + } + + fallback.original = { + blockAutoplay: fallback.blockAutoplay === true, + blurThumbnail: fallback.blurThumbnail === true, + hidden: fallback.hidden === true, + hideReason: fallback.hideReason || "", + hideCounts: fallback.hideCounts ? { ...fallback.hideCounts } : undefined, + hideBypass: fallback.hideBypass || "", + hideTriggered: + fallback.hidden === true || Boolean(fallback.hideReason) || false, + blurReason: fallback.blurReason || "", + }; + + return fallback; +} + +function resolveChannelModeration(pubkey, app = getApp()) { + const normalized = normalizeHex(pubkey); + if (!normalized) { + return null; + } + + const matches = collectChannelVideos(normalized, app); + let fallbackModeration = null; + + for (const candidate of matches) { + const decorated = decorateChannelVideo(candidate, app); + if (!decorated || typeof decorated !== "object") { continue; } const moderation = - video.moderation && typeof video.moderation === "object" - ? video.moderation + decorated.moderation && typeof decorated.moderation === "object" + ? decorated.moderation : null; if (!moderation) { continue; } - if (moderation.hidden === true) { - return true; + + if (moderation.viewerOverride?.showAnyway === true) { + return moderation; } - if (moderation.blurThumbnail === true) { - const reason = - typeof moderation.blurReason === "string" - ? moderation.blurReason.trim().toLowerCase() - : ""; - if (reason.startsWith("trusted-")) { - return true; - } + + if (moderation.hidden === true || moderation.blurThumbnail === true) { + return moderation; + } + + if (!fallbackModeration) { + fallbackModeration = moderation; } } - return false; + if (fallbackModeration) { + return fallbackModeration; + } + + return buildChannelModerationFallback(normalized); } -function applyChannelVisualBlur({ bannerEl = null, avatarEl = null, pubkey = "" } = {}) { - const shouldBlur = shouldBlurChannelMedia(pubkey); - const applyState = (element) => { - if (!element) { - return; - } - if (shouldBlur) { - element.dataset.visualState = "blurred"; - } else if (element.dataset.visualState) { - delete element.dataset.visualState; - } - }; +export function applyChannelVisualBlur({ + bannerEl = null, + avatarEl = null, + pubkey = "", + app = null, +} = {}) { + const resolvedApp = app || getApp(); + const moderation = resolveChannelModeration(pubkey, resolvedApp) || {}; + const context = normalizeVideoModerationContext(moderation); - applyState(bannerEl); - applyState(avatarEl); + const resolvedBanner = bannerEl || + (typeof document !== "undefined" + ? document.getElementById("channelBanner") + : null); + const resolvedAvatar = avatarEl || + (typeof document !== "undefined" + ? document.getElementById("channelAvatar") + : null); + + const avatarElements = [resolvedAvatar, resolvedBanner].filter(Boolean); + + applyModerationContextDatasets(context, { + root: resolvedBanner || null, + avatar: resolvedAvatar || null, + avatars: avatarElements.length > 1 ? avatarElements : undefined, + }); +} + +function updateChannelModerationVisuals({ + pubkey = "", + app = null, + bannerEl = null, + avatarEl = null, +} = {}) { + if (typeof document === "undefined") { + return; + } + + const resolvedApp = app || getApp(); + const targetPubkey = pubkey || currentChannelHex || ""; + if (!targetPubkey) { + applyChannelVisualBlur({ + bannerEl, + avatarEl, + pubkey: "", + app: resolvedApp, + }); + return; + } + + const resolvedBanner = + bannerEl || document.getElementById("channelBanner"); + const resolvedAvatar = + avatarEl || document.getElementById("channelAvatar"); + + applyChannelVisualBlur({ + bannerEl: resolvedBanner, + avatarEl: resolvedAvatar, + pubkey: targetPubkey, + app: resolvedApp, + }); +} + +let channelModerationEventsBound = false; +const channelModerationUnsubscribes = []; + +function handleTrustedMuteSignal() { + if (!currentChannelHex) { + return; + } + updateChannelModerationVisuals({ pubkey: currentChannelHex }); +} + +function handleModerationSummarySignal(detail = {}) { + if (!detail || typeof detail !== "object") { + return; + } + + const eventId = typeof detail.eventId === "string" ? detail.eventId : ""; + if (!eventId) { + return; + } + + const app = getApp(); + const video = app?.videosMap instanceof Map ? app.videosMap.get(eventId) : null; + if (!video || typeof video !== "object") { + return; + } + + const videoPubkey = normalizeHex(video.pubkey); + if (!videoPubkey) { + return; + } + + const channelPubkey = normalizeHex(currentChannelHex); + if (!channelPubkey || videoPubkey !== channelPubkey) { + return; + } + + decorateChannelVideo(video, app); + updateChannelModerationVisuals({ + pubkey: channelPubkey, + app, + }); +} + +function handleVideoModerationEvent(event) { + const detail = event?.detail; + const video = detail && typeof detail === "object" ? detail.video : null; + if (!video || typeof video !== "object") { + return; + } + + const videoPubkey = normalizeHex(video.pubkey); + const channelPubkey = normalizeHex(currentChannelHex); + if (!videoPubkey || !channelPubkey || videoPubkey !== channelPubkey) { + return; + } + + const app = getApp(); + decorateChannelVideo(video, app); + if (app?.videosMap instanceof Map && typeof video.id === "string") { + app.videosMap.set(video.id, video); + } + updateChannelModerationVisuals({ + pubkey: channelPubkey, + app, + }); +} + +function ensureChannelModerationEvents() { + if (channelModerationEventsBound) { + return; + } + channelModerationEventsBound = true; + + if (typeof document !== "undefined") { + document.addEventListener("video:moderation-override", handleVideoModerationEvent); + document.addEventListener("video:moderation-hide", handleVideoModerationEvent); + } + + if (moderationService && typeof moderationService.on === "function") { + const trustedUnsub = moderationService.on( + "trusted-mutes", + handleTrustedMuteSignal, + ); + if (typeof trustedUnsub === "function") { + channelModerationUnsubscribes.push(trustedUnsub); + } + + const summaryUnsub = moderationService.on( + "summary", + handleModerationSummarySignal, + ); + if (typeof summaryUnsub === "function") { + channelModerationUnsubscribes.push(summaryUnsub); + } + } } const summarizeZapTracker = (tracker) => @@ -2461,6 +2729,10 @@ export async function initChannelProfileView() { const app = getApp(); + ensureChannelModerationEvents(); + + updateChannelModerationVisuals({ app, pubkey: currentChannelHex }); + setupChannelShareButton(); setupChannelMoreMenu(); @@ -2512,6 +2784,28 @@ export async function initChannelProfileView() { await Promise.allSettled(pendingTasks); } +export function __setChannelProfileTestState({ pubkey, npub } = {}) { + if (typeof pubkey !== "undefined") { + if (typeof pubkey === "string" && pubkey) { + currentChannelHex = pubkey; + } else { + currentChannelHex = null; + } + } + + if (typeof npub !== "undefined") { + if (typeof npub === "string" && npub) { + currentChannelNpub = npub; + } else { + currentChannelNpub = null; + } + } +} + +export function __ensureChannelModerationEventsForTests() { + ensureChannelModerationEvents(); +} + function setupZapButton({ force = false } = {}) { const zapButton = getChannelZapButton(); if (!zapButton) { @@ -3123,7 +3417,7 @@ function buildRenderableChannelVideos({ events = [], app } = {}) { return videos; } -function renderChannelVideosFromList({ +export function renderChannelVideosFromList({ videos = [], container, app, @@ -3254,11 +3548,14 @@ function renderChannelVideosFromList({ const allowNsfw = ALLOW_NSFW_CONTENT === true; - videos.forEach((video) => { - if (!video || !video.id || !video.title) { - return; + for (const originalVideo of videos) { + if (!originalVideo || !originalVideo.id || !originalVideo.title) { + continue; } + const decoratedVideo = decorateChannelVideo(originalVideo, app) || originalVideo; + const video = decoratedVideo; + const normalizedVideoPubkey = normalizePubkey(video.pubkey); const canEdit = Boolean(normalizedVideoPubkey) && @@ -3266,11 +3563,11 @@ function renderChannelVideosFromList({ normalizedVideoPubkey === normalizedViewerPubkey; if (video.isPrivate && !canEdit) { - return; + continue; } if (!allowNsfw && video?.isNsfw === true && !canEdit) { - return; + continue; } app?.videosMap?.set(video.id, video); @@ -3447,7 +3744,7 @@ function renderChannelVideosFromList({ if (cardEl) { fragment.appendChild(cardEl); } - }); + } if (renderIndex === 0) { if (allowEmptyMessage) { @@ -3473,6 +3770,8 @@ function renderChannelVideosFromList({ app?.attachMoreMenuHandlers?.(container); + updateChannelModerationVisuals({ app }); + return true; } @@ -3561,7 +3860,11 @@ function applyChannelProfileMetadata({ } const blurPubkey = pubkey || currentChannelHex || ""; - applyChannelVisualBlur({ bannerEl, avatarEl, pubkey: blurPubkey }); + updateChannelModerationVisuals({ + bannerEl, + avatarEl, + pubkey: blurPubkey, + }); const nameEl = document.getElementById("channelName"); if (nameEl) { diff --git a/tests/app/channel-profile-moderation.test.mjs b/tests/app/channel-profile-moderation.test.mjs new file mode 100644 index 00000000..d1241a68 --- /dev/null +++ b/tests/app/channel-profile-moderation.test.mjs @@ -0,0 +1,225 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import { + renderChannelVideosFromList, + applyChannelVisualBlur, + __setChannelProfileTestState, + __ensureChannelModerationEventsForTests, +} from "../../js/channelProfile.js"; +import { nostrClient } from "../../js/nostrClientFacade.js"; +import moderationService from "../../js/services/moderationService.js"; +import { withMockedNostrTools, createModerationAppHarness } from "../helpers/moderation-test-helpers.mjs"; + +function setupDom(t) { + const dom = new JSDOM( + "" + + '
' + + '' + + '
' + + "", + { url: "https://example.invalid/channel" }, + ); + + const { window } = dom; + const { document } = window; + + const previousGlobals = { + window: globalThis.window, + document: globalThis.document, + HTMLElement: globalThis.HTMLElement, + CustomEvent: globalThis.CustomEvent, + Event: globalThis.Event, + Node: globalThis.Node, + navigator: globalThis.navigator, + location: globalThis.location, + MutationObserver: globalThis.MutationObserver, + IntersectionObserver: globalThis.IntersectionObserver, + }; + + globalThis.window = window; + globalThis.document = document; + globalThis.HTMLElement = window.HTMLElement; + globalThis.CustomEvent = window.CustomEvent; + globalThis.Event = window.Event; + globalThis.Node = window.Node; + globalThis.navigator = window.navigator; + globalThis.location = window.location; + globalThis.MutationObserver = window.MutationObserver; + globalThis.IntersectionObserver = + window.IntersectionObserver || + class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + t.after(() => { + Object.entries(previousGlobals).forEach(([key, value]) => { + if (typeof value === "undefined") { + delete globalThis[key]; + } else { + globalThis[key] = value; + } + }); + }); + + return { document, window }; +} + +function createVideoFixture(overrides = {}) { + return { + id: overrides.id || "v".repeat(64), + title: overrides.title || "Fixture Video", + pubkey: overrides.pubkey || "a".repeat(64), + created_at: overrides.created_at || Math.floor(Date.now() / 1000), + url: overrides.url || "https://example.invalid/video.mp4", + moderation: overrides.moderation || {}, + ...overrides, + }; +} + +test("renderChannelVideosFromList decorates videos before storing", 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 = () => {}; + + const originalDecorate = app.decorateVideoModeration; + let decoratedCount = 0; + app.decorateVideoModeration = function decorated(video) { + decoratedCount += 1; + if (!video.moderation || typeof video.moderation !== "object") { + video.moderation = {}; + } + video.moderation.decoratedByTest = true; + return originalDecorate.call(this, video); + }; + + const container = document.getElementById("channelVideoList"); + const video = createVideoFixture(); + + const rendered = renderChannelVideosFromList({ + videos: [video], + container, + app, + loadToken: 0, + }); + + assert.equal(rendered, true); + assert.equal(decoratedCount, 1); + const stored = app.videosMap.get(video.id); + assert.ok(stored, "decorated video stored in videosMap"); + assert.equal(stored.moderation.decoratedByTest, true); +}); + +test("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => { + const { document } = setupDom(t); + withMockedNostrTools(t); + + const bannerEl = document.getElementById("channelBanner"); + const avatarEl = document.getElementById("channelAvatar"); + const pubkey = "b".repeat(64); + + const originalMute = moderationService.isAuthorMutedByViewer; + moderationService.isAuthorMutedByViewer = () => true; + t.after(() => { + moderationService.isAuthorMutedByViewer = originalMute; + }); + + applyChannelVisualBlur({ + bannerEl, + avatarEl, + pubkey, + app: { videosMap: new Map() }, + }); + + assert.equal(bannerEl.dataset.visualState, "blurred"); + assert.equal(avatarEl.dataset.visualState, "blurred"); +}); + +test("moderation override clears channel blur via event wiring", 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, + }); + + const channelPubkey = "c".repeat(64); + __setChannelProfileTestState({ pubkey: channelPubkey }); + __ensureChannelModerationEventsForTests(); + + const container = document.getElementById("channelVideoList"); + const moderationState = { + trustedCount: 2, + summary: { + types: { nudity: { trusted: 2 } }, + }, + }; + const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState }); + + renderChannelVideosFromList({ + videos: [video], + container, + app, + loadToken: 0, + }); + + const bannerEl = document.getElementById("channelBanner"); + const avatarEl = document.getElementById("channelAvatar"); + assert.equal(bannerEl.dataset.visualState, "blurred"); + assert.equal(avatarEl.dataset.visualState, "blurred"); + + const cardStub = { refreshModerationUi() {} }; + app.handleModerationOverride({ video, card: cardStub }); + + assert.equal(bannerEl.dataset.visualState, undefined); + assert.equal(avatarEl.dataset.visualState, undefined); +});