diff --git a/js/app.js b/js/app.js index c6fb9acb..75c0db38 100644 --- a/js/app.js +++ b/js/app.js @@ -5308,6 +5308,82 @@ class Application { } } + /** + * Register the "for-you" feed pipeline. + */ + registerForYouFeed() { + if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { + return null; + } + + const existingDefinition = + typeof this.feedEngine.getFeedDefinition === "function" + ? this.feedEngine.getFeedDefinition("for-you") + : null; + if (existingDefinition) { + return existingDefinition; + } + + try { + const app = this; + const resolveThresholdFromApp = (key) => ({ runtimeValue, defaultValue }) => { + if ( + Number.isFinite(runtimeValue) || + runtimeValue === Number.POSITIVE_INFINITY + ) { + return runtimeValue; + } + + if (app && typeof app.getActiveModerationThresholds === "function") { + const active = app.getActiveModerationThresholds(); + const candidate = active && typeof active === "object" ? active[key] : undefined; + if ( + Number.isFinite(candidate) || + candidate === Number.POSITIVE_INFINITY + ) { + return candidate; + } + } + + return defaultValue; + }; + return this.feedEngine.registerFeed("for-you", { + source: createActiveNostrSource({ service: this.nostrService }), + stages: [ + // TODO(tag-preferences): keep tag-preference filtering consolidated in + // createTagPreferenceFilterStage so each feed has a single source of + // truth when interest-based inclusion/ranking lands. + createTagPreferenceFilterStage(), + createBlacklistFilterStage({ + shouldIncludeVideo: (video, options) => + this.nostrService.shouldIncludeVideo(video, options), + }), + createDedupeByRootStage({ + dedupe: (videos) => this.dedupeVideosByRoot(videos), + }), + createModerationStage({ + getService: () => this.nostrService.getModerationService(), + autoplayThreshold: resolveThresholdFromApp("autoplayBlockThreshold"), + blurThreshold: resolveThresholdFromApp("blurThreshold"), + trustedMuteHideThreshold: resolveThresholdFromApp("trustedMuteHideThreshold"), + trustedReportHideThreshold: resolveThresholdFromApp("trustedSpamHideThreshold"), + }), + createResolvePostedAtStage(), + ], + sorter: createChronologicalSorter(), + hooks: { + timestamps: { + getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video), + }, + }, + }); + } catch (error) { + devLogger.warn("[Application] Failed to register for-you feed:", error); + return null; + } + } + registerSubscriptionsFeed() { if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { return null; @@ -5408,6 +5484,32 @@ class Application { } } + buildForYouFeedRuntime() { + const blacklist = + this.blacklistedEventIds instanceof Set + ? new Set(this.blacklistedEventIds) + : new Set(); + + const preferenceSource = + typeof this.getHashtagPreferences === "function" + ? this.getHashtagPreferences() + : {}; + const { interests = [], disinterests = [] } = preferenceSource || {}; + const moderationThresholds = this.getActiveModerationThresholds(); + + return { + blacklistedEventIds: blacklist, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + tagPreferences: { + interests: Array.isArray(interests) ? [...interests] : [], + disinterests: Array.isArray(disinterests) ? [...disinterests] : [], + }, + moderationThresholds: moderationThresholds + ? { ...moderationThresholds } + : undefined, + }; + } + buildRecentFeedRuntime() { const blacklist = this.blacklistedEventIds instanceof Set @@ -5434,6 +5536,63 @@ class Application { }; } + refreshForYouFeed({ reason, fallbackVideos } = {}) { + const runtime = this.buildForYouFeedRuntime(); + 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("for-you", { 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 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; + } + const payload = { videos: fallback, metadata }; + this.renderVideoList(payload); + return payload; + }); + } + refreshRecentFeed({ reason, fallbackVideos } = {}) { const runtime = this.buildRecentFeedRuntime(); const normalizedReason = typeof reason === "string" ? reason : undefined; @@ -5541,6 +5700,53 @@ class Application { } } + async loadForYouVideos(forceFetch = false) { + devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")"); + + const container = this.mountVideoListView(); + const hasCachedVideos = + this.nostrService && + Array.isArray(this.nostrService.getFilteredActiveVideos()) && + this.nostrService.getFilteredActiveVideos().length > 0; + + if (!hasCachedVideos) { + if (this.videoListView && container) { + this.videoListView.showLoading("Fetching for-you videos…"); + } else if (container) { + container.innerHTML = getSidebarLoadingMarkup("Fetching for-you videos…"); + } + } + + let initialRefreshPromise = null; + + const videos = await this.nostrService.loadVideos({ + forceFetch, + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + onVideos: (payload, detail = {}) => { + const promise = this.refreshForYouFeed({ + reason: detail?.reason, + fallbackVideos: payload, + }); + if (!initialRefreshPromise) { + initialRefreshPromise = promise; + } + }, + }); + + if (initialRefreshPromise) { + await initialRefreshPromise; + } else if (!Array.isArray(videos) || videos.length === 0) { + await this.refreshForYouFeed({ reason: "initial", fallbackVideos: [] }); + } + + this.videoSubscription = this.nostrService.getVideoSubscription() || null; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + } + async loadOlderVideos(lastTimestamp) { const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, { blacklistedEventIds: this.blacklistedEventIds, diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 724c78c9..2a90d72a 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -253,6 +253,7 @@ export default class ApplicationBootstrap { app.feedEngine.run = (...args) => app.feedEngine.runFeed(...args); } app.registerRecentFeed(); + app.registerForYouFeed(); app.registerSubscriptionsFeed(); app.registerWatchHistoryFeed(); diff --git a/js/viewManager.js b/js/viewManager.js index 88cabb56..4a783274 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -94,11 +94,11 @@ export const viewInitRegistry = { }, "for-you": () => { const app = getApplication(); - if (app && typeof app.loadVideos === "function") { + if (app && typeof app.loadForYouVideos === "function") { if (typeof app.mountVideoListView === "function") { app.mountVideoListView(); } - app.loadVideos(); + app.loadForYouVideos(); } // Force profile updates after the new view is in place. const refreshApp = getApplication();