diff --git a/js/channelProfile.js b/js/channelProfile.js index 56411378..7c780199 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -16,6 +16,13 @@ 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 { createModerationStage } from "./feedEngine/stages.js"; +import { + DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + DEFAULT_BLUR_THRESHOLD, + DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, + DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, +} from "./constants.js"; import { applyModerationContextDatasets, normalizeVideoModerationContext, @@ -134,6 +141,112 @@ function collectChannelVideos(pubkey, app = getApp()) { return matches; } +async function applyChannelModerationToVideos(videos = [], app = getApp()) { + if (!Array.isArray(videos) || videos.length === 0) { + return Array.isArray(videos) ? videos : []; + } + + const resolvedApp = app || getApp(); + + const resolveThreshold = (value, fallback) => { + if (value === Number.POSITIVE_INFINITY) { + return Number.POSITIVE_INFINITY; + } + if (Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + return fallback; + }; + + const thresholds = + typeof resolvedApp?.getActiveModerationThresholds === "function" + ? resolvedApp.getActiveModerationThresholds() + : null; + + const stage = createModerationStage({ + getService: () => { + try { + if ( + resolvedApp?.nostrService && + typeof resolvedApp.nostrService.getModerationService === "function" + ) { + return resolvedApp.nostrService.getModerationService(); + } + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to resolve channel moderation service", + error, + ); + } + return null; + }, + autoplayThreshold: resolveThreshold( + thresholds?.autoplayBlockThreshold, + DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + ), + blurThreshold: resolveThreshold( + thresholds?.blurThreshold, + DEFAULT_BLUR_THRESHOLD, + ), + trustedMuteHideThreshold: resolveThreshold( + thresholds?.trustedMuteHideThreshold, + DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, + ), + trustedReportHideThreshold: resolveThreshold( + thresholds?.trustedSpamHideThreshold, + DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, + ), + }); + + let stageResult; + try { + const items = videos.map((video) => ({ video, metadata: {} })); + stageResult = await stage(items, { feedName: "channel-profile" }); + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to apply moderation stage to channel videos", + error, + ); + return videos; + } + + if (!Array.isArray(stageResult)) { + return videos; + } + + const decoratedVideos = []; + + for (const item of stageResult) { + if (!item || typeof item !== "object") { + continue; + } + + const video = item.video; + if (!video || typeof video !== "object") { + continue; + } + + const metadataModeration = + item.metadata && + typeof item.metadata === "object" && + item.metadata.moderation && + typeof item.metadata.moderation === "object" + ? item.metadata.moderation + : null; + + if (metadataModeration) { + if (!video.moderation || typeof video.moderation !== "object") { + video.moderation = {}; + } + Object.assign(video.moderation, metadataModeration); + } + + decoratedVideos.push(decorateChannelVideo(video, resolvedApp) || video); + } + + return decoratedVideos; +} + function buildChannelModerationFallback(pubkey) { const normalized = normalizeHex(pubkey); if (!normalized) { @@ -3819,7 +3932,7 @@ function buildRenderableChannelVideos({ events = [], app } = {}) { return videos; } -export function renderChannelVideosFromList({ +export async function renderChannelVideosFromList({ videos = [], container, app, @@ -3839,6 +3952,34 @@ export function renderChannelVideosFromList({ return false; } + let preparedVideos = videos; + try { + preparedVideos = await applyChannelModerationToVideos(videos, app); + } catch (error) { + devLogger.warn( + "[ChannelProfile] Failed to prepare channel videos for moderation", + error, + ); + preparedVideos = videos; + } + + if (loadToken !== currentVideoLoadToken) { + return false; + } + + const moderationApplied = + Array.isArray(preparedVideos) && preparedVideos !== videos; + const renderableVideos = Array.isArray(preparedVideos) ? preparedVideos : videos; + + if (!renderableVideos.length) { + if (allowEmptyMessage) { + container.dataset.hasChannelVideos = "false"; + container.innerHTML = `

No videos to display.

`; + return true; + } + return false; + } + container.dataset.hasChannelVideos = "true"; container.innerHTML = ""; @@ -3950,13 +4091,15 @@ export function renderChannelVideosFromList({ const allowNsfw = ALLOW_NSFW_CONTENT === true; - for (const originalVideo of videos) { + for (const originalVideo of renderableVideos) { if (!originalVideo || !originalVideo.id || !originalVideo.title) { continue; } - const decoratedVideo = decorateChannelVideo(originalVideo, app) || originalVideo; - const video = decoratedVideo; + const video = + moderationApplied + ? originalVideo + : decorateChannelVideo(originalVideo, app) || originalVideo; const normalizedVideoPubkey = normalizePubkey(video.pubkey); const canEdit = @@ -4549,7 +4692,7 @@ async function loadUserVideos(pubkey) { app }); if (cachedVideos.length) { - const rendered = renderChannelVideosFromList({ + const rendered = await renderChannelVideosFromList({ videos: cachedVideos, container, app, @@ -4632,7 +4775,7 @@ async function loadUserVideos(pubkey) { } const videos = buildRenderableChannelVideos({ events, app }); - const rendered = renderChannelVideosFromList({ + const rendered = await renderChannelVideosFromList({ videos, container, app, diff --git a/tests/app/channel-profile-moderation.test.mjs b/tests/app/channel-profile-moderation.test.mjs index 39275c7d..152189ad 100644 --- a/tests/app/channel-profile-moderation.test.mjs +++ b/tests/app/channel-profile-moderation.test.mjs @@ -129,7 +129,7 @@ test("renderChannelVideosFromList decorates videos before storing", async (t) => const container = document.getElementById("channelVideoList"); const video = createVideoFixture(); - const rendered = renderChannelVideosFromList({ + const rendered = await renderChannelVideosFromList({ videos: [video], container, app, @@ -143,6 +143,93 @@ test("renderChannelVideosFromList decorates videos before storing", async (t) => assert.equal(stored.moderation.decoratedByTest, true); }); +test("renderChannelVideosFromList applies moderation blur without existing metadata", 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: Number.POSITIVE_INFINITY, + trustedMuteHideThreshold: Number.POSITIVE_INFINITY, + trustedSpamHideThreshold: Number.POSITIVE_INFINITY, + }); + + const stubModerationService = { + async refreshViewerFromClient() {}, + async setActiveEventIds() {}, + getAdminListSnapshot() { + return null; + }, + getAccessControlStatus() { + return { hex: "", whitelisted: false, blacklisted: false }; + }, + getTrustedReportSummary() { + return { types: { nudity: { trusted: 2 } } }; + }, + trustedReportCount() { + return 2; + }, + getTrustedReporters() { + return []; + }, + isAuthorMutedByTrusted() { + return false; + }, + }; + + app.nostrService = { + getModerationService() { + return stubModerationService; + }, + }; + + const container = document.getElementById("channelVideoList"); + const channelPubkey = "e".repeat(64); + __setChannelProfileTestState({ pubkey: channelPubkey }); + + const video = createVideoFixture({ pubkey: channelPubkey }); + delete video.moderation; + + const rendered = await renderChannelVideosFromList({ + videos: [video], + container, + app, + loadToken: 0, + }); + + assert.equal(rendered, true, "channel render succeeded with moderation stage"); + const stored = app.videosMap.get(video.id); + assert.ok(stored, "video stored in app map after moderation"); + assert.equal(stored.moderation?.blurThumbnail, true, "moderation blur applied"); + + const thumbnail = container.querySelector('[data-video-thumbnail="true"]'); + assert.ok(thumbnail, "video card thumbnail rendered"); + assert.equal(thumbnail.dataset.thumbnailState, "blurred"); + + const bannerEl = document.getElementById("channelBanner"); + assert.equal(bannerEl.dataset.visualState, "blurred"); +}); + test("applyChannelVisualBlur blurs banner and avatar when viewer mutes author", async (t) => { const { document } = setupDom(t); withMockedNostrTools(t); @@ -216,7 +303,7 @@ test("moderation override clears channel blur via event wiring", async (t) => { moderation: moderationState, }); - renderChannelVideosFromList({ + await renderChannelVideosFromList({ videos: [video], container, app, @@ -290,7 +377,7 @@ test("channel header moderation badge reflects blur state and override actions", }; const video = createVideoFixture({ pubkey: channelPubkey, moderation: moderationState }); - renderChannelVideosFromList({ + await renderChannelVideosFromList({ videos: [video], container, app,