diff --git a/js/app.js b/js/app.js index 5e6e1577..73f53f57 100644 --- a/js/app.js +++ b/js/app.js @@ -18,6 +18,13 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; +import { + createFeedEngine, + createActiveNostrSource, + createBlacklistFilterStage, + createDedupeByRootStage, + createChronologicalSorter, +} from "./feedEngine/index.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; import PlaybackService from "./services/playbackService.js"; @@ -163,9 +170,19 @@ class Application { this.videoListViewBlacklistHandler = null; this.boundVideoListShareListener = null; this.boundVideoListContextListener = null; + this.latestFeedMetadata = null; this.nostrService = services.nostrService || nostrService; this.r2Service = services.r2Service || r2Service; + this.feedEngine = services.feedEngine || createFeedEngine(); + if ( + this.feedEngine && + typeof this.feedEngine.run !== "function" && + typeof this.feedEngine.runFeed === "function" + ) { + this.feedEngine.run = (...args) => this.feedEngine.runFeed(...args); + } + this.registerRecentFeed(); this.playbackService = services.playbackService || @@ -4903,6 +4920,111 @@ class Application { } } + /** + * Register the default "recent" feed pipeline. + */ + registerRecentFeed() { + if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { + return null; + } + + const existingDefinition = + typeof this.feedEngine.getFeedDefinition === "function" + ? this.feedEngine.getFeedDefinition("recent") + : null; + if (existingDefinition) { + return existingDefinition; + } + + try { + return this.feedEngine.registerFeed("recent", { + source: createActiveNostrSource({ service: this.nostrService }), + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + this.nostrService.shouldIncludeVideo(video, options), + }), + createDedupeByRootStage({ + dedupe: (videos) => this.dedupeVideosByRoot(videos), + }), + ], + sorter: createChronologicalSorter(), + }); + } catch (error) { + console.warn("[Application] Failed to register recent feed:", error); + return null; + } + } + + buildRecentFeedRuntime() { + const blacklist = + this.blacklistedEventIds instanceof Set + ? new Set(this.blacklistedEventIds) + : new Set(); + + return { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + }; + } + + refreshRecentFeed({ reason, fallbackVideos } = {}) { + const runtime = this.buildRecentFeedRuntime(); + const normalizedReason = typeof reason === "string" ? reason : undefined; + const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; + + if (!this.feedEngine || typeof this.feedEngine.run !== "function") { + const metadata = { + reason: normalizedReason, + engine: "unavailable", + }; + this.latestFeedMetadata = metadata; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + this.renderVideoList({ videos: fallback, metadata }); + return Promise.resolve({ videos: fallback, metadata }); + } + + return this.feedEngine + .run("recent", { runtime }) + .then((result) => { + const videos = Array.isArray(result?.videos) ? result.videos : []; + const metadata = { + ...(result?.metadata || {}), + }; + if (normalizedReason) { + metadata.reason = normalizedReason; + } + + this.latestFeedMetadata = metadata; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + + const payload = { videos, metadata }; + this.renderVideoList(payload); + return payload; + }) + .catch((error) => { + console.error("[Application] Failed to run recent feed:", error); + const metadata = { + reason: normalizedReason || "error:recent-feed", + error: true, + }; + this.latestFeedMetadata = metadata; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + const payload = { videos: fallback, metadata }; + this.renderVideoList(payload); + return payload; + }); + } + /** * Subscribe to videos (older + new) and render them as they come in. */ @@ -4916,15 +5038,27 @@ class Application { container.innerHTML = getSidebarLoadingMarkup("Fetching recent videos…"); } + let initialRefreshPromise = null; + const videos = await this.nostrService.loadVideos({ forceFetch, blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload) => this.renderVideoList(payload), + onVideos: (payload, detail = {}) => { + const promise = this.refreshRecentFeed({ + reason: detail?.reason, + fallbackVideos: payload, + }); + if (!initialRefreshPromise) { + initialRefreshPromise = promise; + } + }, }); - if (!Array.isArray(videos) || videos.length === 0) { - this.renderVideoList([]); + if (initialRefreshPromise) { + await initialRefreshPromise; + } else if (!Array.isArray(videos) || videos.length === 0) { + await this.refreshRecentFeed({ reason: "initial", fallbackVideos: [] }); } this.videoSubscription = this.nostrService.getVideoSubscription() || null; @@ -4945,11 +5079,7 @@ class Application { return; } - const all = this.nostrService.getFilteredActiveVideos({ - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - }); - this.renderVideoList(all); + await this.refreshRecentFeed({ reason: "older-fetch" }); } /** @@ -5239,7 +5369,7 @@ class Application { }); } - async renderVideoList(videos) { + async renderVideoList(payload) { if (!this.videoListView) { return; } @@ -5249,7 +5379,26 @@ class Application { return; } - this.videoListView.render(videos); + let videos = []; + let metadata = null; + + if (Array.isArray(payload)) { + videos = payload; + } else if (payload && typeof payload === "object") { + if (Array.isArray(payload.videos)) { + videos = payload.videos; + } + if (payload.metadata && typeof payload.metadata === "object") { + metadata = { ...payload.metadata }; + } + } + + this.latestFeedMetadata = metadata; + if (this.videoListView) { + this.videoListView.state.feedMetadata = metadata; + } + + this.videoListView.render(videos, metadata); } refreshVideoDiscussionCounts(videos = []) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index a400ad4c..53ddcd49 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -54,6 +54,10 @@ export class VideoListView { loadedThumbnails: state.loadedThumbnails instanceof Map ? state.loadedThumbnails : new Map(), videosMap: state.videosMap instanceof Map ? state.videosMap : new Map(), + feedMetadata: + state.feedMetadata && typeof state.feedMetadata === "object" + ? { ...state.feedMetadata } + : null, }; this.renderers = { @@ -234,7 +238,7 @@ export class VideoListView { this.handlers.blacklist = typeof handler === "function" ? handler : null; } - render(videos) { + render(videos, metadata = null) { if (!this.container) { return []; } @@ -244,6 +248,10 @@ export class VideoListView { this._lastRenderedVideoListElement = this.container; } + const normalizedMetadata = + metadata && typeof metadata === "object" ? { ...metadata } : null; + this.state.feedMetadata = normalizedMetadata; + const source = Array.isArray(videos) ? videos : []; const dedupedVideos = this.utils.dedupeVideos(source); this.currentVideos = dedupedVideos.slice(); @@ -471,7 +479,7 @@ export class VideoListView { this.currentVideos[index] = next; this.state.videosMap.set(videoId, next); - this.render(this.currentVideos.slice()); + this.render(this.currentVideos.slice(), this.state.feedMetadata); return next; } diff --git a/tests/feed-engine.test.mjs b/tests/feed-engine.test.mjs index 0fe10d13..a1791719 100644 --- a/tests/feed-engine.test.mjs +++ b/tests/feed-engine.test.mjs @@ -171,8 +171,52 @@ async function testWatchHistoryHookIsolation() { assert.equal(whyA.videoId, "x1"); } +async function testBlacklistOrderingWithRuntimeChanges() { + const engine = createFeedEngine(); + const feedName = "runtime-order"; + + engine.registerFeed(feedName, { + source: async () => [ + { video: { id: "v1", created_at: 100 } }, + { video: { id: "v2", created_at: 200 } }, + { video: { id: "v3", created_at: 300 } }, + ], + stages: [ + createBlacklistFilterStage({ + shouldIncludeVideo(video, { blacklistedEventIds }) { + return !blacklistedEventIds.has(video.id); + }, + }), + ], + sorter: createChronologicalSorter(), + }); + + const baseline = await engine.runFeed(feedName, { + runtime: { blacklistedEventIds: new Set() }, + }); + assert.deepEqual(baseline.videos.map((video) => video.id), [ + "v3", + "v2", + "v1", + ]); + + const withoutTop = await engine.runFeed(feedName, { + runtime: { blacklistedEventIds: new Set(["v3"]) }, + }); + assert.deepEqual(withoutTop.videos.map((video) => video.id), ["v2", "v1"]); + + const withoutMiddle = await engine.runFeed(feedName, { + runtime: { blacklistedEventIds: new Set(["v2"]) }, + }); + assert.deepEqual(withoutMiddle.videos.map((video) => video.id), [ + "v3", + "v1", + ]); +} + await testDedupeOrdering(); await testBlacklistFiltering(); await testWatchHistoryHookIsolation(); +await testBlacklistOrderingWithRuntimeChanges(); console.log("All feed engine tests passed");