diff --git a/js/app.js b/js/app.js index 2e2175ea..cd461925 100644 --- a/js/app.js +++ b/js/app.js @@ -61,7 +61,10 @@ import WatchHistoryController from "./ui/watchHistoryController.js"; import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { subscriptions } from "./subscriptions.js"; -import { refreshActiveChannelVideoGrid } from "./channelProfile.js"; +import { + refreshActiveChannelVideoGrid, + clearChannelVideoCardRegistry, +} from "./channelProfile.js"; import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js"; import { devLogger, userLogger } from "./utils/logger.js"; import createPopover from "./ui/overlay/popoverEngine.js"; @@ -1486,6 +1489,7 @@ class Application { } prepareForViewLoad() { + clearChannelVideoCardRegistry(); if (this.videoListView) { this.videoListView.destroy(); } diff --git a/js/channelProfile.js b/js/channelProfile.js index 7c780199..c974bd75 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -82,6 +82,12 @@ const channelModerationBadgeState = { app: null, }; +const channelVideoCardsById = new Map(); + +export function clearChannelVideoCardRegistry() { + channelVideoCardsById.clear(); +} + let currentChannelHex = null; let currentChannelNpub = null; let currentChannelLightningAddress = ""; @@ -828,6 +834,64 @@ function handleTrustedMuteSignal() { updateChannelModerationVisuals({ pubkey: currentChannelHex }); } +function refreshChannelVideoCardModeration( + videoId, + { app = getApp(), fallbackVideo = null, decoratedVideo = null } = {}, +) { + if (typeof videoId !== "string" || !videoId) { + return false; + } + + const resolvedApp = app || getApp(); + const sourceMap = + resolvedApp?.videosMap instanceof Map ? resolvedApp.videosMap : null; + const card = channelVideoCardsById.get(videoId) || null; + + let nextVideo = + decoratedVideo && typeof decoratedVideo === "object" + ? decoratedVideo + : null; + + if (!nextVideo) { + const baseVideo = + (sourceMap && sourceMap.get(videoId)) || + (fallbackVideo && typeof fallbackVideo === "object" + ? fallbackVideo + : null) || + (card && typeof card.video === "object" ? card.video : null); + + if (!baseVideo) { + return false; + } + + nextVideo = decorateChannelVideo(baseVideo, resolvedApp) || baseVideo; + } + + if (sourceMap && nextVideo) { + sourceMap.set(videoId, nextVideo); + } + + if (!card || !nextVideo) { + return Boolean(nextVideo); + } + + card.video = nextVideo; + + if (typeof card.refreshModerationUi === "function") { + try { + card.refreshModerationUi(); + } catch (error) { + devLogger.warn( + `[ChannelProfile] Failed to refresh moderation UI for video ${videoId}`, + error, + ); + } + } + + channelVideoCardsById.set(videoId, card); + return true; +} + function handleModerationSummarySignal(detail = {}) { if (!detail || typeof detail !== "object") { return; @@ -854,7 +918,12 @@ function handleModerationSummarySignal(detail = {}) { return; } - decorateChannelVideo(video, app); + const decoratedVideo = decorateChannelVideo(video, app) || video; + refreshChannelVideoCardModeration(eventId, { + app, + fallbackVideo: decoratedVideo, + decoratedVideo, + }); updateChannelModerationVisuals({ pubkey: channelPubkey, app, @@ -874,11 +943,22 @@ function handleVideoModerationEvent(event) { return; } - const app = getApp(); - decorateChannelVideo(video, app); - if (app?.videosMap instanceof Map && typeof video.id === "string") { - app.videosMap.set(video.id, video); + const videoId = typeof video.id === "string" ? video.id : ""; + if (!videoId) { + return; } + + const app = getApp(); + const decoratedVideo = decorateChannelVideo(video, app) || video; + if (app?.videosMap instanceof Map) { + app.videosMap.set(videoId, decoratedVideo); + } + + refreshChannelVideoCardModeration(videoId, { + app, + fallbackVideo: decoratedVideo, + decoratedVideo, + }); updateChannelModerationVisuals({ pubkey: channelPubkey, app, @@ -3939,10 +4019,19 @@ export async function renderChannelVideosFromList({ loadToken, allowEmptyMessage = true } = {}) { - if (!container || loadToken !== currentVideoLoadToken) { + if (!container) { + if (loadToken === currentVideoLoadToken) { + clearChannelVideoCardRegistry(); + } return false; } + if (loadToken !== currentVideoLoadToken) { + return false; + } + + clearChannelVideoCardRegistry(); + if (!Array.isArray(videos) || videos.length === 0) { if (allowEmptyMessage) { container.dataset.hasChannelVideos = "false"; @@ -4194,6 +4283,8 @@ export async function renderChannelVideosFromList({ } }); + channelVideoCardsById.set(video.id, videoCard); + videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => { const trigger = domEvent?.currentTarget || domEvent?.target; const detail = extractPlaybackDetail(trigger, cardVideo || video); diff --git a/tests/app/channel-profile-moderation.test.mjs b/tests/app/channel-profile-moderation.test.mjs index 152189ad..f647c6f2 100644 --- a/tests/app/channel-profile-moderation.test.mjs +++ b/tests/app/channel-profile-moderation.test.mjs @@ -431,3 +431,87 @@ test("channel header moderation badge reflects blur state and override actions", assert.equal(overrideContext.overrideActive, true); assert.ok(overrideText.startsWith("Showing despite")); }); + +test("channel video cards update moderation state in place when summary changes", 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, + }); + + setApplication(app); + t.after(() => setApplication(null)); + + const video = createVideoFixture(); + __setChannelProfileTestState({ pubkey: video.pubkey }); + __ensureChannelModerationEventsForTests(); + + const container = document.getElementById("channelVideoList"); + await renderChannelVideosFromList({ + videos: [video], + container, + app, + loadToken: 0, + }); + + const cardEl = container.querySelector('[data-component="video-card"]'); + assert.ok(cardEl, "video card rendered for channel"); + const thumbnailEl = cardEl.querySelector('[data-video-thumbnail="true"]'); + assert.ok(thumbnailEl, "card thumbnail present"); + assert.equal( + thumbnailEl.dataset.thumbnailState || "", + "", + "thumbnail should not be blurred initially", + ); + + const existing = app.videosMap.get(video.id) || video; + const updatedVideo = { + ...existing, + moderation: { + ...(existing?.moderation || {}), + summary: { + types: { + nudity: { trusted: 3 }, + }, + }, + trustedCount: 3, + blurThumbnail: true, + reportType: "nudity", + }, + }; + app.videosMap.set(video.id, updatedVideo); + + const priorCard = container.querySelector('[data-component="video-card"]'); + + moderationService.emit("summary", { eventId: video.id }); + + const currentCard = container.querySelector('[data-component="video-card"]'); + assert.strictEqual(currentCard, priorCard, "card should not rerender"); + assert.equal(thumbnailEl.dataset.thumbnailState, "blurred"); + assert.equal(cardEl.dataset.moderationReportType, "nudity"); + assert.equal(cardEl.dataset.moderationReportCount, "3"); +});