From 24309c1356baf44963b5fed4a60544c2eca48f06 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Oct 2025 16:58:48 -0400 Subject: [PATCH] Refactor subscriptions view to use feed engine --- js/app.js | 61 ++ js/subscriptions.js | 981 +++++++++++++----------------- tests/subscriptions-feed.test.mjs | 147 +++++ 3 files changed, 629 insertions(+), 560 deletions(-) create mode 100644 tests/subscriptions-feed.test.mjs diff --git a/js/app.js b/js/app.js index 73f53f57..e9de5332 100644 --- a/js/app.js +++ b/js/app.js @@ -24,6 +24,7 @@ import { createBlacklistFilterStage, createDedupeByRootStage, createChronologicalSorter, + createSubscriptionAuthorsSource, } from "./feedEngine/index.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; @@ -33,6 +34,7 @@ import nostrService from "./services/nostrService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; +import { subscriptions } from "./subscriptions.js"; import { initViewCounter, subscribeToVideoViewCount, @@ -183,6 +185,7 @@ class Application { this.feedEngine.run = (...args) => this.feedEngine.runFeed(...args); } this.registerRecentFeed(); + this.registerSubscriptionsFeed(); this.playbackService = services.playbackService || @@ -4956,6 +4959,44 @@ class Application { } } + registerSubscriptionsFeed() { + if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { + return null; + } + + const existingDefinition = + typeof this.feedEngine.getFeedDefinition === "function" + ? this.feedEngine.getFeedDefinition("subscriptions") + : null; + if (existingDefinition) { + return existingDefinition; + } + + try { + return this.feedEngine.registerFeed("subscriptions", { + source: createSubscriptionAuthorsSource({ service: this.nostrService }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + this.nostrService.shouldIncludeVideo(video, options), + }), + createDedupeByRootStage({ + dedupe: (videos) => this.dedupeVideosByRoot(videos), + }), + ], + sorter: createChronologicalSorter(), + hooks: { + subscriptions: { + resolveAuthors: () => subscriptions.getSubscribedAuthors(), + }, + }, + }); + } catch (error) { + console.warn("[Application] Failed to register subscriptions feed:", error); + return null; + } + } + buildRecentFeedRuntime() { const blacklist = this.blacklistedEventIds instanceof Set @@ -5905,6 +5946,16 @@ class Application { if (result?.ok) { this.showSuccess("Creator added to the blacklist."); + subscriptions + .refreshActiveFeed({ reason: "admin-blacklist-update" }) + .catch((error) => { + if (isDevMode) { + console.warn( + "[Subscriptions] Failed to refresh after blacklist update:", + error + ); + } + }); } else { const code = result?.error || "unknown"; switch (code) { @@ -5984,6 +6035,16 @@ class Application { this.populateBlockedList(); await this.loadVideos(); + subscriptions + .refreshActiveFeed({ reason: "user-block-update" }) + .catch((error) => { + if (isDevMode) { + console.warn( + "[Subscriptions] Failed to refresh after user block update:", + error + ); + } + }); } catch (error) { console.error("Failed to update personal block list:", error); const message = diff --git a/js/subscriptions.js b/js/subscriptions.js index 1486e8b4..d8e191c4 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -3,7 +3,6 @@ import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; -import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER, @@ -14,82 +13,10 @@ import { assertAnyRelayAccepted, } from "./nostrPublish.js"; import { getApplication } from "./applicationContext.js"; +import { VideoListView } from "./ui/views/VideoListView.js"; const getApp = () => getApplication(); -let attachHealthBadgesLoader = null; -let attachHealthBadgesErrorLogged = false; - -function scheduleAttachHealthBadges(container) { - if (!container) { - return; - } - - if (!attachHealthBadgesLoader) { - attachHealthBadgesLoader = import("./gridHealth.js") - .then((module) => module?.attachHealthBadges || null) - .catch((error) => { - if (!attachHealthBadgesErrorLogged) { - attachHealthBadgesErrorLogged = true; - if (typeof console !== "undefined") { - console.warn( - "[SubscriptionsManager] Failed to load grid health helpers:", - error - ); - } - } - return null; - }); - } - - attachHealthBadgesLoader - .then((fn) => { - if (typeof fn === "function") { - try { - fn(container); - } catch (error) { - if (typeof console !== "undefined") { - console.warn( - "[SubscriptionsManager] attachHealthBadges threw:", - error - ); - } - } - } - }) - .catch(() => { - // Errors are already logged above; no-op here to keep Promise chains quiet. - }); -} - -function getAbsoluteShareUrl(nevent) { - if (!nevent) { - return ""; - } - - const app = getApp(); - if (app?.buildShareUrlFromNevent) { - const candidate = app.buildShareUrlFromNevent(nevent); - if (candidate) { - return candidate; - } - } - - const origin = window.location?.origin || ""; - const pathname = window.location?.pathname || ""; - let base = origin || pathname ? `${origin}${pathname}` : ""; - if (!base) { - const href = window.location?.href || ""; - base = href ? href.split(/[?#]/)[0] : ""; - } - - if (!base) { - return `?v=${encodeURIComponent(nevent)}`; - } - - return `${base}?v=${encodeURIComponent(nevent)}`; -} - /** * Manages the user's subscription list (kind=30002) *privately*, * using NIP-04 encryption for the content field. @@ -101,6 +28,10 @@ class SubscriptionsManager { this.subscribedPubkeys = new Set(); this.subsEventId = null; this.loaded = false; + this.subscriptionListView = null; + this.lastRunOptions = null; + this.lastResult = null; + this.lastContainerId = null; } /** @@ -173,6 +104,10 @@ class SubscriptionsManager { return this.subscribedPubkeys.has(channelHex); } + getSubscribedAuthors() { + return Array.from(this.subscribedPubkeys); + } + async addChannel(channelHex, userPubkey) { if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); @@ -183,6 +118,14 @@ class SubscriptionsManager { } this.subscribedPubkeys.add(channelHex); await this.publishSubscriptionList(userPubkey); + this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { + if (typeof console !== "undefined") { + console.warn( + "[SubscriptionsManager] Failed to refresh after adding subscription:", + error + ); + } + }); } async removeChannel(channelHex, userPubkey) { @@ -195,6 +138,14 @@ class SubscriptionsManager { } this.subscribedPubkeys.delete(channelHex); await this.publishSubscriptionList(userPubkey); + this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { + if (typeof console !== "undefined") { + console.warn( + "[SubscriptionsManager] Failed to refresh after removing subscription:", + error + ); + } + }); } /** @@ -295,99 +246,187 @@ class SubscriptionsManager { */ async showSubscriptionVideos( userPubkey, - containerId = "subscriptionsVideoList" + containerId = "subscriptionsVideoList", + options = {} ) { + const limitCandidate = Number(options?.limit); + const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 + ? Math.floor(limitCandidate) + : null; + const reason = + typeof options?.reason === "string" && options.reason.trim() + ? options.reason.trim() + : undefined; + + this.lastContainerId = containerId; + + const container = document.getElementById(containerId); if (!userPubkey) { - const c = document.getElementById(containerId); - if (c) c.innerHTML = "
Please log in first.
"; - return; + if (container) { + container.innerHTML = + "Please log in first.
"; + } + this.lastRunOptions = null; + this.lastResult = null; + return null; } + if (!this.loaded) { await this.loadSubscriptions(userPubkey); } - const channelHexes = Array.from(this.subscribedPubkeys); - const container = document.getElementById(containerId); - if (!container) return; + const channelHexes = this.getSubscribedAuthors(); + if (!container) { + this.lastRunOptions = { + actorPubkey: userPubkey, + limit, + containerId, + }; + return null; + } if (!channelHexes.length) { container.innerHTML = "No subscriptions found.
"; - return; + this.lastRunOptions = { + actorPubkey: userPubkey, + limit, + containerId, + }; + this.lastResult = { items: [], metadata: { reason: "no-subscriptions" } }; + return this.lastResult; } container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…"); - // Gather all videos - const videos = await this.fetchSubscribedVideos(channelHexes); - this.renderSameGridStyle(videos, containerId); + this.lastRunOptions = { + actorPubkey: userPubkey, + limit, + containerId, + reason, + }; + + this.ensureFeedRegistered(); + const engine = this.getFeedEngine(); + if (!engine || typeof engine.run !== "function") { + container.innerHTML = + "Subscriptions are unavailable right now.
"; + return null; + } + + const app = getApp(); + const runtime = this.buildFeedRuntime({ app, authors: channelHexes }); + const runOptions = { + actorPubkey: userPubkey, + limit, + runtime, + hooks: { + subscriptions: { + resolveAuthors: () => this.getSubscribedAuthors(), + }, + }, + }; + + try { + const result = await engine.run("subscriptions", runOptions); + + const videos = Array.isArray(result?.items) + ? result.items.map((item) => item?.video).filter(Boolean) + : []; + + if (app?.videosMap instanceof Map) { + videos.forEach((video) => { + if (video && typeof video.id === "string" && video.id) { + app.videosMap.set(video.id, video); + } + }); + } + + this.lastResult = result; + this.renderSameGridStyle(result, containerId, { limit, reason }); + return result; + } catch (error) { + console.error( + "[SubscriptionsManager] Failed to run subscriptions feed:", + error + ); + container.innerHTML = + "Unable to load subscriptions right now.
"; + this.lastResult = null; + return null; + } } - /** - * Pull all events from subscribed authors, convert, dedupe => newest - */ - async fetchSubscribedVideos(authorPubkeys) { - try { - const filter = { - kinds: [30078], - "#t": ["video"], - authors: authorPubkeys, - limit: 500, - }; - - const allEvents = []; - for (const relay of nostrClient.relays) { - try { - const res = await nostrClient.pool.list([relay], [filter]); - allEvents.push(...res); - } catch (rErr) { - console.error(`[SubscriptionsManager] Error at ${relay}`, rErr); - } + ensureFeedRegistered() { + const app = getApp(); + if (typeof app?.registerSubscriptionsFeed === "function") { + try { + app.registerSubscriptionsFeed(); + } catch (error) { + console.warn( + "[SubscriptionsManager] Failed to register subscriptions feed:", + error + ); } - - const videos = []; - for (const evt of allEvents) { - const vid = this.convertEventToVideo(evt); - if (!vid.invalid && !vid.deleted) videos.push(vid); - } - - const deduped = this.dedupeToNewestByRoot(videos); - deduped.sort((a, b) => b.created_at - a.created_at); - return deduped; - } catch (err) { - console.error("fetchSubscribedVideos error:", err); - return []; } } + getFeedEngine() { + const app = getApp(); + return app?.feedEngine || null; + } + + buildFeedRuntime({ app, authors = [] } = {}) { + const normalizedAuthors = Array.isArray(authors) + ? authors.filter((author) => typeof author === "string" && author) + : []; + + const blacklist = + app?.blacklistedEventIds instanceof Set + ? new Set(app.blacklistedEventIds) + : new Set(); + + const isAuthorBlocked = + typeof app?.isAuthorBlocked === "function" + ? (pubkey) => app.isAuthorBlocked(pubkey) + : () => false; + + return { + subscriptionAuthors: normalizedAuthors, + authors: normalizedAuthors, + blacklistedEventIds: blacklist, + isAuthorBlocked, + }; + } + /** * Renders the feed in the same style as home. * This includes gear menu, time-ago, lazy load, clickable authors, etc. */ - renderSameGridStyle(videos, containerId) { + renderSameGridStyle(result, containerId, options = {}) { const container = document.getElementById(containerId); - if (!container) return; + if (!container) { + return; + } const app = getApp(); + const items = Array.isArray(result?.items) ? result.items : []; + const metadata = + result && typeof result.metadata === "object" + ? { ...result.metadata } + : {}; - const safeVideos = Array.isArray(videos) ? videos : []; - const dedupedVideos = - app?.dedupeVideosByRoot?.(safeVideos) ?? - this.dedupeToNewestByRoot(safeVideos); + const limitCandidate = Number(options?.limit); + const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 + ? Math.floor(limitCandidate) + : null; - const filteredVideos = dedupedVideos.filter((video) => { - if (!video || typeof video !== "object") { - return false; - } + const limitedItems = limit ? items.slice(0, limit) : items; + const videos = limitedItems + .map((item) => (item && typeof item === "object" ? item.video : null)) + .filter((video) => video && typeof video === "object"); - if (app?.isAuthorBlocked && app.isAuthorBlocked(video.pubkey)) { - return false; - } - - return true; - }); - - if (!filteredVideos.length) { + if (!videos.length) { container.innerHTML = `No videos available yet. @@ -395,443 +434,265 @@ class SubscriptionsManager { return; } - // Sort newest first - filteredVideos.sort((a, b) => b.created_at - a.created_at); - - const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); - const fragment = document.createDocumentFragment(); - // Only declare localAuthorSet once - const localAuthorSet = new Set(); - - filteredVideos.forEach((video, index) => { - if (!video.id || !video.title) { - console.error("Missing ID or title:", video); - return; - } - - // Keep the global videos map up to date so delegated playback handlers - // can reuse the already fetched metadata for this event. - app?.videosMap?.set(video.id, video); - - localAuthorSet.add(video.pubkey); - - const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); - const shareUrl = getAbsoluteShareUrl(nevent); - const canEdit = app?.pubkey === video.pubkey; - - const highlightClass = - video.isPrivate && canEdit - ? "border-2 border-yellow-500" - : "border-none"; - - const timeAgo = app?.formatTimeAgo - ? app.formatTimeAgo(video.created_at) - : new Date(video.created_at * 1000).toLocaleString(); - - let hasOlder = false; - if (canEdit && video.videoRootId && app?.hasOlderVersion) { - hasOlder = app.hasOlderVersion(video, fullAllEventsArray); - } - - const revertButton = hasOlder - ? ` - - ` - : ""; - - const gearMenu = canEdit - ? ` -
+ Unable to render subscriptions feed. +
`; + return; } - // Make author name/pic clickable => open channel - authorPics.forEach((pic) => { - pic.style.cursor = "pointer"; - pic.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = pic.getAttribute("data-pubkey"); - app?.goToProfile?.(pubkey); + listView.mount(container); + + if (app?.videosMap instanceof Map) { + listView.state.videosMap = app.videosMap; + } + + const enrichedMetadata = { + ...metadata, + feed: "subscriptions", + }; + if (limit) { + enrichedMetadata.limit = limit; + } + if (typeof options?.reason === "string" && options.reason) { + enrichedMetadata.reason = options.reason; + } + + listView.state.feedMetadata = enrichedMetadata; + listView.render(videos, enrichedMetadata); + } + + getListView(container, app) { + if (this.subscriptionListView) { + return this.subscriptionListView; + } + + if (!container) { + return null; + } + + const doc = container.ownerDocument || document; + const baseView = app?.videoListView || null; + + const badgeHelpers = baseView?.badgeHelpers || { + attachHealthBadges: () => {}, + attachUrlHealthBadges: () => {}, + }; + + const formatTimeAgo = (timestamp) => { + if (typeof app?.formatTimeAgo === "function") { + return app.formatTimeAgo(timestamp); + } + if (typeof baseView?.formatters?.formatTimeAgo === "function") { + return baseView.formatters.formatTimeAgo(timestamp); + } + return timestamp; + }; + + const formatViewCountLabel = (total) => { + if (typeof baseView?.formatters?.formatViewCountLabel === "function") { + return baseView.formatters.formatViewCountLabel(total); + } + return typeof total === "number" ? total.toLocaleString() : `${total}`; + }; + + const assets = baseView?.assets || { + fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg", + unsupportedBtihMessage: + "This magnet link is missing a compatible BitTorrent v1 info hash.", + }; + + const loadedThumbnails = + app?.loadedThumbnails instanceof Map + ? app.loadedThumbnails + : baseView?.state?.loadedThumbnails instanceof Map + ? baseView.state.loadedThumbnails + : new Map(); + + const videosMap = + app?.videosMap instanceof Map + ? app.videosMap + : baseView?.state?.videosMap instanceof Map + ? baseView.state.videosMap + : new Map(); + + const listViewConfig = { + document: doc, + container, + mediaLoader: app?.mediaLoader || baseView?.mediaLoader || null, + badgeHelpers, + formatters: { + formatTimeAgo, + formatViewCountLabel, + }, + helpers: { + escapeHtml: (value) => app?.escapeHTML?.(value) ?? value, + isMagnetSupported: (magnet) => + app?.isMagnetUriSupported?.(magnet) ?? false, + toLocaleString: (value) => + typeof value === "number" ? value.toLocaleString() : value, + }, + assets, + state: { + loadedThumbnails, + videosMap, + }, + utils: { + dedupeVideos: (videos) => (Array.isArray(videos) ? [...videos] : []), + getAllEvents: () => Array.from(nostrClient.allEvents.values()), + hasOlderVersion: (video, events) => + app?.hasOlderVersion?.(video, events) ?? false, + derivePointerInfo: (video) => + app?.deriveVideoPointerInfo?.(video) ?? null, + persistWatchHistoryMetadata: (video, pointerInfo) => + app?.persistWatchHistoryMetadataForVideo?.(video, pointerInfo), + getShareUrlBase: () => app?.getShareUrlBase?.() ?? "", + buildShareUrlFromNevent: (nevent) => + app?.buildShareUrlFromNevent?.(nevent) ?? "", + buildShareUrlFromEventId: (eventId) => + app?.buildShareUrlFromEventId?.(eventId) ?? "", + canManageBlacklist: () => + app?.canCurrentUserManageBlacklist?.() ?? false, + canEditVideo: (video) => video?.pubkey === app?.pubkey, + canDeleteVideo: (video) => video?.pubkey === app?.pubkey, + batchFetchProfiles: (authorSet) => + app?.batchFetchProfiles?.(authorSet), + bindThumbnailFallbacks: (target) => + app?.bindThumbnailFallbacks?.(target), + handleUrlHealthBadge: (payload) => + app?.handleUrlHealthBadge?.(payload), + refreshDiscussionCounts: (videosList) => + app?.refreshVideoDiscussionCounts?.(videosList), + ensureGlobalMoreMenuHandlers: () => + app?.ensureGlobalMoreMenuHandlers?.(), + closeAllMenus: () => app?.closeAllMoreMenus?.(), + }, + renderers: { + getLoadingMarkup: (message) => getSidebarLoadingMarkup(message), + }, + }; + + const listView = new VideoListView(listViewConfig); + + listView.setPlaybackHandler((detail) => { + if (!detail) { + return; + } + if (detail.videoId) { + Promise.resolve(app?.playVideoByEventId?.(detail.videoId)).catch( + (error) => { + console.error( + "[SubscriptionsManager] Failed to play by event id:", + error + ); + } + ); + return; + } + Promise.resolve( + app?.playVideoWithFallback?.({ url: detail.url, magnet: detail.magnet }) + ).catch((error) => { + console.error( + "[SubscriptionsManager] Failed to start playback:", + error + ); }); }); - authorNames.forEach((nameEl) => { - nameEl.style.cursor = "pointer"; - nameEl.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const pubkey = nameEl.getAttribute("data-pubkey"); - app?.goToProfile?.(pubkey); + listView.setEditHandler(({ video, index }) => { + if (!video?.id) { + return; + } + app?.handleEditVideo?.({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, }); }); + + listView.setRevertHandler(({ video, index }) => { + if (!video?.id) { + return; + } + app?.handleRevertVideo?.({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + }); + }); + + listView.setDeleteHandler(({ video, index }) => { + if (!video?.id) { + return; + } + app?.handleFullDeleteVideo?.({ + eventId: video.id, + index: Number.isFinite(index) ? index : null, + }); + }); + + listView.setBlacklistHandler(({ video, dataset }) => { + const detail = { + ...(dataset || {}), + author: dataset?.author || video?.pubkey || "", + context: dataset?.context || "subscriptions", + }; + app?.handleMoreMenuAction?.("blacklist-author", detail); + }); + + listView.addEventListener("video:share", (event) => { + const detail = event?.detail || {}; + const dataset = { + ...(detail.dataset || {}), + eventId: + detail.eventId || + detail.dataset?.eventId || + detail.video?.id || + "", + context: detail.dataset?.context || "subscriptions", + }; + app?.handleMoreMenuAction?.(detail.action || "copy-link", dataset); + }); + + listView.addEventListener("video:context-action", (event) => { + const detail = event?.detail || {}; + const dataset = { + ...(detail.dataset || {}), + eventId: detail.dataset?.eventId || detail.video?.id || "", + context: detail.dataset?.context || "subscriptions", + }; + app?.handleMoreMenuAction?.(detail.action, dataset); + }); + + this.subscriptionListView = listView; + return this.subscriptionListView; + } + + async refreshActiveFeed(options = {}) { + if (!this.lastRunOptions) { + return null; + } + + const { actorPubkey, containerId, limit } = this.lastRunOptions; + if (!actorPubkey || !containerId) { + return null; + } + + const reason = + typeof options?.reason === "string" && options.reason.trim() + ? options.reason.trim() + : this.lastRunOptions.reason; + + return this.showSubscriptionVideos(actorPubkey, containerId, { + limit, + reason, + }); } convertEventToVideo(evt) { return sharedConvertEventToVideo(evt); } - dedupeToNewestByRoot(videos) { - const map = new Map(); - for (const v of videos) { - const rootId = v.videoRootId || v.id; - const existing = map.get(rootId); - if (!existing || v.created_at > existing.created_at) { - map.set(rootId, v); - } - } - return Array.from(map.values()); - } } export const subscriptions = new SubscriptionsManager(); diff --git a/tests/subscriptions-feed.test.mjs b/tests/subscriptions-feed.test.mjs new file mode 100644 index 00000000..71cc90f4 --- /dev/null +++ b/tests/subscriptions-feed.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +if (typeof globalThis.localStorage === "undefined") { + const storage = new Map(); + globalThis.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(String(key), String(value)); + }, + removeItem(key) { + storage.delete(key); + }, + clear() { + storage.clear(); + }, + }; +} + +if (typeof window.localStorage === "undefined") { + window.localStorage = globalThis.localStorage; +} + +const { + createFeedEngine, + createSubscriptionAuthorsSource, + createBlacklistFilterStage, + createDedupeByRootStage, + createChronologicalSorter, +} = await import("../js/feedEngine/index.js"); + +async function testBlockedCreatorsFiltered() { + const engine = createFeedEngine(); + const feedName = "subscriptions-blocked"; + + const videos = [ + { id: "keep", pubkey: "pub1", created_at: 100, videoRootId: "root-keep" }, + { id: "blocked-event", pubkey: "pub1", created_at: 200, videoRootId: "root-bad" }, + { id: "blocked-author-event", pubkey: "blocked", created_at: 150 }, + { id: "ignored-author", pubkey: "other", created_at: 250 }, + ]; + + const service = { + async getFilteredActiveVideos() { + return videos; + }, + }; + + engine.registerFeed(feedName, { + source: createSubscriptionAuthorsSource({ service }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo(video, { blacklistedEventIds, isAuthorBlocked }) { + if (blacklistedEventIds.has(video.id)) { + return false; + } + if (isAuthorBlocked(video.pubkey)) { + return false; + } + return true; + }, + }), + createDedupeByRootStage(), + ], + sorter: createChronologicalSorter(), + }); + + const result = await engine.runFeed(feedName, { + runtime: { + subscriptionAuthors: ["pub1", "blocked"], + authors: ["pub1", "blocked"], + blacklistedEventIds: new Set(["blocked-event"]), + isAuthorBlocked: (pubkey) => pubkey === "blocked", + }, + }); + + assert.deepEqual( + result.videos.map((video) => video.id), + ["keep"], + "subscriptions feed should drop blocked authors and events" + ); + + const reasons = result.metadata?.why || []; + const blacklistReasons = reasons.filter((entry) => entry.reason === "blacklist"); + assert.equal( + blacklistReasons.length, + 2, + "blocked entries should be logged in metadata" + ); +} + +async function testDuplicateRootsFiltered() { + const engine = createFeedEngine(); + const feedName = "subscriptions-dedupe"; + + const videos = [ + { id: "older", pubkey: "pub1", created_at: 100, videoRootId: "root-a" }, + { id: "newer", pubkey: "pub1", created_at: 200, videoRootId: "root-a" }, + { id: "other", pubkey: "pub1", created_at: 150, videoRootId: "root-b" }, + ]; + + const service = { + async getFilteredActiveVideos() { + return videos; + }, + }; + + engine.registerFeed(feedName, { + source: createSubscriptionAuthorsSource({ service }), + stages: [ + createBlacklistFilterStage({ shouldIncludeVideo: () => true }), + createDedupeByRootStage(), + ], + sorter: createChronologicalSorter(), + }); + + const result = await engine.runFeed(feedName, { + runtime: { + subscriptionAuthors: ["pub1"], + authors: ["pub1"], + blacklistedEventIds: new Set(), + isAuthorBlocked: () => false, + }, + }); + + assert.deepEqual( + result.videos.map((video) => video.id), + ["newer", "other"], + "dedupe stage should keep only the newest root version" + ); + + const dedupeReasons = result.metadata?.why?.filter( + (entry) => entry.reason === "older-root-version" + ) || []; + assert.equal(dedupeReasons.length, 1, "older version should be recorded"); + assert.equal(dedupeReasons[0].videoId, "older"); +} + +await testBlockedCreatorsFiltered(); +await testDuplicateRootsFiltered(); + +console.log("subscriptions-feed tests passed");