diff --git a/js/app.js b/js/app.js index 1a89cd70..4d6bf5c4 100644 --- a/js/app.js +++ b/js/app.js @@ -22,6 +22,7 @@ import { DEFAULT_BLUR_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, + FEED_TYPES, } from "./constants.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; @@ -3946,22 +3947,22 @@ class Application { async refreshForYouFeed(...args) { this._initCoordinators(); - return this._feed.refreshForYouFeed(...args); + return this._feed.refreshFeed(FEED_TYPES.FOR_YOU, ...args); } refreshKidsFeed(...args) { this._initCoordinators(); - return this._feed.refreshKidsFeed(...args); + return this._feed.refreshFeed(FEED_TYPES.KIDS, ...args); } refreshExploreFeed(...args) { this._initCoordinators(); - return this._feed.refreshExploreFeed(...args); + return this._feed.refreshFeed(FEED_TYPES.EXPLORE, ...args); } refreshRecentFeed(...args) { this._initCoordinators(); - return this._feed.refreshRecentFeed(...args); + return this._feed.refreshFeed("recent", ...args); } checkRelayHealthWarning(...args) { diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 8fa082e2..11863bb1 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -785,48 +785,92 @@ export function createFeedCoordinator(deps) { }; }, - async refreshForYouFeed({ reason, fallbackVideos } = {}) { - let watchHistoryItems = []; + async refreshFeed(feedType, { reason, fallbackVideos } = {}) { + const normalizedReason = typeof reason === "string" ? reason : undefined; + const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; + let runtime = {}; + let metadataModifier = (m) => m; + + // 1. Prepare Runtime try { - watchHistoryItems = await watchHistoryService.loadLatest(undefined, { - allowStale: true, - }); - } catch (error) { - devLogger.warn( - "[Application] Failed to preload watch history for For You feed:", - error, + switch (feedType) { + case FEED_TYPES.FOR_YOU: { + let watchHistoryItems = []; + try { + watchHistoryItems = await watchHistoryService.loadLatest( + undefined, + { allowStale: true }, + ); + } catch (err) { + devLogger.warn( + "[Application] Failed to preload watch history for For You feed:", + err, + ); + } + runtime = this.buildForYouFeedRuntime({ watchHistoryItems }); + break; + } + case FEED_TYPES.KIDS: + runtime = this.buildKidsFeedRuntime(); + metadataModifier = (m) => { + if (runtime?.ageGroup && !m.ageGroup) + m.ageGroup = runtime.ageGroup; + return m; + }; + break; + case FEED_TYPES.EXPLORE: + runtime = this.buildExploreFeedRuntime(); + metadataModifier = (m) => { + const next = { ...m }; + if (!next.sortOrder) next.sortOrder = FEED_TYPES.EXPLORE; + next.preserveOrder = true; + return next; + }; + break; + case "recent": + runtime = this.buildRecentFeedRuntime(); + break; + default: + devLogger.warn(`[Application] Unknown feed type: ${feedType}`); + return Promise.resolve({ + videos: fallback, + metadata: { reason: normalizedReason, engine: "unknown" }, + }); + } + } catch (err) { + devLogger.error( + `[Application] Failed to build runtime for ${feedType}:`, + err, ); } - const runtime = this.buildForYouFeedRuntime({ watchHistoryItems }); - const normalizedReason = typeof reason === "string" ? reason : undefined; - const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; - + // 2. Check Engine if (!this.feedEngine || typeof this.feedEngine.run !== "function") { - const metadata = { + const metadata = metadataModifier({ reason: normalizedReason, engine: "unavailable", - }; + }); this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { this.videoListView.state.videosMap = this.videosMap; } - this.updateForYouTelemetryMetadata([], metadata); + // Telemetry update for unavailable engine? + if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) { + this.updateFeedTelemetryMetadata(feedType, [], metadata); + } this.renderVideoList({ videos: fallback, metadata }); return Promise.resolve({ videos: fallback, metadata }); } + // 3. Run Engine return this.feedEngine - .run(FEED_TYPES.FOR_YOU, { runtime }) + .run(feedType, { runtime }) .then((result) => { const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = { - ...(result?.metadata || {}), - }; - if (normalizedReason) { - metadata.reason = normalizedReason; - } + let metadata = { ...(result?.metadata || {}) }; + if (normalizedReason) metadata.reason = normalizedReason; + metadata = metadataModifier(metadata); this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); @@ -835,141 +879,8 @@ export function createFeedCoordinator(deps) { } const items = Array.isArray(result?.items) ? result.items : []; - this.updateForYouTelemetryMetadata(items, metadata); - - const payload = { videos, metadata }; - this.renderVideoList(payload); - return payload; - }) - .catch((error) => { - devLogger.error("[Application] Failed to run for-you feed:", error); - const metadata = { - reason: normalizedReason || "error:for-you-feed", - error: true, - }; - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.updateForYouTelemetryMetadata([], metadata); - const payload = { videos: fallback, metadata }; - this.renderVideoList(payload); - return payload; - }); - }, - - refreshKidsFeed({ reason, fallbackVideos } = {}) { - const runtime = this.buildKidsFeedRuntime(); - 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", - }; - if (runtime?.ageGroup && !metadata.ageGroup) { - metadata.ageGroup = runtime.ageGroup; - } - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, [], metadata); - this.renderVideoList({ videos: fallback, metadata }); - return Promise.resolve({ videos: fallback, metadata }); - } - - return this.feedEngine - .run(FEED_TYPES.KIDS, { runtime }) - .then((result) => { - const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = { - ...(result?.metadata || {}), - }; - if (normalizedReason) { - metadata.reason = normalizedReason; - } - if (runtime?.ageGroup && !metadata.ageGroup) { - metadata.ageGroup = runtime.ageGroup; - } - - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - - const items = Array.isArray(result?.items) ? result.items : []; - this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, items, metadata); - - const payload = { videos, metadata }; - this.renderVideoList(payload); - return payload; - }) - .catch((error) => { - devLogger.error("[Application] Failed to run kids feed:", error); - const metadata = { - reason: normalizedReason || "error:kids-feed", - error: true, - }; - if (runtime?.ageGroup && !metadata.ageGroup) { - metadata.ageGroup = runtime.ageGroup; - } - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, [], metadata); - const payload = { videos: fallback, metadata }; - this.renderVideoList(payload); - return payload; - }); - }, - - refreshExploreFeed({ reason, fallbackVideos } = {}) { - const runtime = this.buildExploreFeedRuntime(); - const normalizedReason = typeof reason === "string" ? reason : undefined; - const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; - const applyExploreOrderingMetadata = (source) => { - const next = source && typeof source === "object" ? { ...source } : {}; - if (!next.sortOrder) { - next.sortOrder = FEED_TYPES.EXPLORE; - } - next.preserveOrder = true; - return next; - }; - - if (!this.feedEngine || typeof this.feedEngine.run !== "function") { - const metadata = applyExploreOrderingMetadata({ - 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(FEED_TYPES.EXPLORE, { runtime }) - .then((result) => { - const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = applyExploreOrderingMetadata({ - ...(result?.metadata || {}), - ...(normalizedReason ? { reason: normalizedReason } : {}), - }); - - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; + if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) { + this.updateFeedTelemetryMetadata(feedType, items, metadata); } const payload = { videos, metadata }; @@ -977,73 +888,26 @@ export function createFeedCoordinator(deps) { return payload; }) .catch((error) => { - devLogger.error("[Application] Failed to run explore feed:", error); - const metadata = applyExploreOrderingMetadata({ - reason: normalizedReason || "error:explore-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; - }); - }, - - 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) => { - devLogger.error("[Application] Failed to run recent feed:", error); - const metadata = { - reason: normalizedReason || "error:recent-feed", + devLogger.error( + `[Application] Failed to run ${feedType} feed:`, + error, + ); + let metadata = { + reason: normalizedReason || `error:${feedType}-feed`, error: true, }; + metadata = metadataModifier(metadata); + this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { this.videoListView.state.videosMap = this.videosMap; } + + if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) { + this.updateFeedTelemetryMetadata(feedType, [], metadata); + } + const payload = { videos: fallback, metadata }; this.renderVideoList(payload); return payload; @@ -1101,29 +965,31 @@ export function createFeedCoordinator(deps) { case "recent": includeTags = true; loadingMessage = "Fetching recent videos\u2026"; - refreshMethod = (opts) => this.refreshRecentFeed(opts); + refreshMethod = (opts) => this.refreshFeed("recent", opts); shouldCheckRelayHealth = true; break; case FEED_TYPES.FOR_YOU: includeTags = false; loadingMessage = "Fetching for-you videos\u2026"; - refreshMethod = (opts) => this.refreshForYouFeed(opts); + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.FOR_YOU, opts); shouldCheckRelayHealth = true; break; case FEED_TYPES.KIDS: includeTags = true; loadingMessage = "Fetching kids videos\u2026"; - refreshMethod = (opts) => this.refreshKidsFeed(opts); + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.KIDS, opts); shouldCheckRelayHealth = false; break; case FEED_TYPES.EXPLORE: includeTags = false; loadingMessage = "Fetching explore videos\u2026"; - refreshMethod = (opts) => this.refreshExploreFeed(opts); + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.EXPLORE, opts); shouldCheckRelayHealth = false; break; default: - devLogger.warn(`[Application] Unknown feed type for loadFeedVideos: ${feedType}`); + devLogger.warn( + `[Application] Unknown feed type for loadFeedVideos: ${feedType}`, + ); return; } @@ -1225,7 +1091,7 @@ export function createFeedCoordinator(deps) { return; } - await this.refreshRecentFeed({ reason: "older-fetch" }); + await this.refreshFeed("recent", { reason: "older-fetch" }); }, /** diff --git a/tests/app/feedCoordinator.test.mjs b/tests/app/feedCoordinator.test.mjs index 80f93cfd..be34d9fd 100644 --- a/tests/app/feedCoordinator.test.mjs +++ b/tests/app/feedCoordinator.test.mjs @@ -52,10 +52,7 @@ test('createFeedCoordinator - loadForYouVideos', async (t) => { checkRelayHealthWarning: mock.fn(), setFeedTelemetryContext: mock.fn(), isAuthorBlocked: mock.fn(() => false), - refreshForYouFeed: mock.fn(async () => {}), - refreshRecentFeed: mock.fn(async () => {}), - refreshKidsFeed: mock.fn(async () => {}), - refreshExploreFeed: mock.fn(async () => {}), + refreshFeed: mock.fn(async () => {}), blacklistedEventIds: new Set(), }; @@ -80,8 +77,9 @@ test('createFeedCoordinator - loadForYouVideos', async (t) => { // Verify nostrService.loadVideos called assert.strictEqual(app.nostrService.loadVideos.mock.callCount(), 1, 'Should call nostrService.loadVideos'); - // Verify refreshForYouFeed called via callback - assert.strictEqual(app.refreshForYouFeed.mock.callCount(), 1, 'Should call refreshForYouFeed via callback'); + // Verify refreshFeed called via callback + assert.strictEqual(app.refreshFeed.mock.callCount(), 1, 'Should call refreshFeed via callback'); + assert.strictEqual(app.refreshFeed.mock.calls[0].arguments[0], FEED_TYPES.FOR_YOU, 'Should call refreshFeed with FOR_YOU'); // Verify subscription updated assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription');