From 34ef65db7b98971299f0fe2d429a1aa4cd7f3c82 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:37:10 -0400 Subject: [PATCH] Gate subscriptions feed on nostr warmup --- js/services/nostrService.js | 109 +++++++++++++++------ js/subscriptions.js | 90 ++++++++++++++++++ tests/subscriptions-manager.test.mjs | 137 +++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 30 deletions(-) diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 1a45ec0b..ce053751 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -117,6 +117,9 @@ export class NostrService { this.videosByAuthorIndex = null; this.authorIndexDirty = false; this.moderationService = moderationService || null; + this.initialLoadPromise = null; + this.initialLoadResolved = false; + this.initialLoadResolve = null; try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); @@ -142,6 +145,43 @@ export class NostrService { this.emitter.emit(eventName, detail); } + ensureInitialLoadDeferred() { + if (!this.initialLoadPromise) { + this.initialLoadResolved = false; + this.initialLoadPromise = new Promise((resolve) => { + this.initialLoadResolve = (value) => { + if (this.initialLoadResolved) { + return; + } + this.initialLoadResolved = true; + this.initialLoadResolve = null; + resolve(value); + }; + }); + } + + return this.initialLoadPromise; + } + + resolveInitialLoad(value) { + if (this.initialLoadResolved) { + return; + } + + if (typeof this.initialLoadResolve === "function") { + try { + this.initialLoadResolve(value); + } finally { + this.initialLoadResolve = null; + this.initialLoadResolved = true; + } + } + } + + awaitInitialLoad() { + return this.ensureInitialLoadDeferred(); + } + getModerationService() { if (!this.moderationService) { return null; @@ -396,42 +436,51 @@ export class NostrService { isAuthorBlocked, onVideos, } = {}) { - await this.ensureAccessControlReady(); + this.ensureInitialLoadDeferred(); - if (forceFetch) { - this.clearVideoSubscription(); - } + try { + await this.ensureAccessControlReady(); - const applyAndNotify = (videos, reason) => { - const filtered = this.filterVideos(videos, { - blacklistedEventIds, - isAuthorBlocked, - }); - this.cacheVideos(filtered); - if (typeof onVideos === "function") { - try { - onVideos(filtered, { reason }); - } catch (error) { - userLogger.warn("[nostrService] onVideos handler threw", error); - } + if (forceFetch) { + this.clearVideoSubscription(); } - this.emit("videos:updated", { videos: filtered, reason }); - return filtered; - }; - const cached = this.nostrClient.getActiveVideos(); - const initial = applyAndNotify(cached, "cache"); + const applyAndNotify = (videos, reason) => { + const filtered = this.filterVideos(videos, { + blacklistedEventIds, + isAuthorBlocked, + }); + this.cacheVideos(filtered); + if (typeof onVideos === "function") { + try { + onVideos(filtered, { reason }); + } catch (error) { + userLogger.warn("[nostrService] onVideos handler threw", error); + } + } + this.emit("videos:updated", { videos: filtered, reason }); + return filtered; + }; - if (!getStoredVideoSubscription()) { - const subscription = this.nostrClient.subscribeVideos(() => { - const updated = this.nostrClient.getActiveVideos(); - applyAndNotify(updated, "subscription"); - }); - this.setVideoSubscription(subscription); - this.emit("subscription:started", { subscription }); + const cached = this.nostrClient.getActiveVideos(); + const initial = applyAndNotify(cached, "cache"); + + this.resolveInitialLoad({ videos: initial, reason: "cache" }); + + if (!getStoredVideoSubscription()) { + const subscription = this.nostrClient.subscribeVideos(() => { + const updated = this.nostrClient.getActiveVideos(); + applyAndNotify(updated, "subscription"); + }); + this.setVideoSubscription(subscription); + this.emit("subscription:started", { subscription }); + } + + return initial; + } catch (error) { + this.resolveInitialLoad({ videos: [], reason: "error", error }); + throw error; } - - return initial; } async fetchVideos(options = {}) { diff --git a/js/subscriptions.js b/js/subscriptions.js index 0ebff445..5aa77358 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -18,6 +18,7 @@ import { VideoListView } from "./ui/views/VideoListView.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { devLogger, userLogger } from "./utils/logger.js"; import moderationService from "./services/moderationService.js"; +import nostrService from "./services/nostrService.js"; const getApp = () => getApplication(); @@ -36,6 +37,12 @@ class SubscriptionsManager { this.lastRunOptions = null; this.lastResult = null; this.lastContainerId = null; + this.unsubscribeFromNostrUpdates = null; + this.pendingRefreshPromise = null; + this.scheduledRefreshDetail = null; + this.isRunningFeed = false; + this.hasRenderedOnce = false; + this.ensureNostrServiceListener(); } /** @@ -302,6 +309,7 @@ class SubscriptionsManager { } this.lastRunOptions = null; this.lastResult = null; + this.hasRenderedOnce = Boolean(container); return null; } @@ -316,6 +324,7 @@ class SubscriptionsManager { limit, containerId }; + this.hasRenderedOnce = false; return null; } @@ -328,6 +337,7 @@ class SubscriptionsManager { containerId }; this.lastResult = { items: [], metadata: { reason: "no-subscriptions" } }; + this.hasRenderedOnce = true; return this.lastResult; } @@ -341,10 +351,24 @@ class SubscriptionsManager { }; this.ensureFeedRegistered(); + this.ensureNostrServiceListener(); + + if (typeof nostrService?.awaitInitialLoad === "function") { + try { + await nostrService.awaitInitialLoad(); + } catch (error) { + devLogger.warn( + "[SubscriptionsManager] Failed to await nostrService initial load:", + error + ); + } + } + const engine = this.getFeedEngine(); if (!engine || typeof engine.run !== "function") { container.innerHTML = "
Subscriptions are unavailable right now.
"; + this.hasRenderedOnce = true; return null; } @@ -362,6 +386,7 @@ class SubscriptionsManager { }; try { + this.isRunningFeed = true; const result = await engine.run("subscriptions", runOptions); const videos = Array.isArray(result?.items) @@ -378,6 +403,7 @@ class SubscriptionsManager { this.lastResult = result; this.renderSameGridStyle(result, containerId, { limit, reason }); + this.hasRenderedOnce = true; return result; } catch (error) { userLogger.error( @@ -387,8 +413,72 @@ class SubscriptionsManager { container.innerHTML = "Unable to load subscriptions right now.
"; this.lastResult = null; + this.hasRenderedOnce = Boolean(container); + return null; + } finally { + this.isRunningFeed = false; + this.processScheduledRefresh(); + } + } + + ensureNostrServiceListener() { + if (this.unsubscribeFromNostrUpdates || typeof nostrService?.on !== "function") { + return; + } + + this.unsubscribeFromNostrUpdates = nostrService.on( + "videos:updated", + (detail) => { + this.handleNostrVideosUpdated(detail); + } + ); + } + + handleNostrVideosUpdated(detail) { + if (!detail || !Array.isArray(detail.videos) || !detail.videos.length) { + return; + } + + this.scheduledRefreshDetail = detail; + this.processScheduledRefresh(); + } + + processScheduledRefresh() { + if (!this.lastRunOptions || !this.hasRenderedOnce) { return null; } + + if (!this.scheduledRefreshDetail) { + return null; + } + + if (this.isRunningFeed || this.pendingRefreshPromise) { + return null; + } + + const detail = this.scheduledRefreshDetail; + this.scheduledRefreshDetail = null; + + const refreshReason = + typeof detail?.reason === "string" && detail.reason + ? `nostr:${detail.reason}` + : "nostr:update"; + + this.pendingRefreshPromise = this.refreshActiveFeed({ reason: refreshReason }) + .catch((error) => { + devLogger.warn( + "[SubscriptionsManager] Failed to refresh after nostrService update:", + error + ); + }) + .finally(() => { + this.pendingRefreshPromise = null; + if (this.scheduledRefreshDetail) { + this.processScheduledRefresh(); + } + }); + + return this.pendingRefreshPromise; } ensureFeedRegistered() { diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index d201b726..63b6b9c1 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -1,8 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + import { nostrClient } from "../js/nostr.js"; import { subscriptions } from "../js/subscriptions.js"; +import { setApplication } from "../js/applicationContext.js"; +import nostrService from "../js/services/nostrService.js"; +import moderationService from "../js/services/moderationService.js"; test("loadSubscriptions aggregates relay results when one rejects", async () => { const SubscriptionsManager = subscriptions.constructor; @@ -120,3 +125,135 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => } }); +test( + "showSubscriptionVideos waits for nostrService warm-up and refreshes after updates", + async () => { + const SubscriptionsManager = subscriptions.constructor; + + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalNavigator = globalThis.navigator; + + const dom = new JSDOM( + "", + { url: "https://example.test/" } + ); + + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.navigator = dom.window.navigator; + + const manager = new SubscriptionsManager(); + + const originalAwaitInitialLoad = nostrService.awaitInitialLoad; + const originalAwaitUserBlockRefresh = moderationService.awaitUserBlockRefresh; + + let resolveInitialLoad; + const initialLoadPromise = new Promise((resolve) => { + resolveInitialLoad = resolve; + }); + nostrService.awaitInitialLoad = () => initialLoadPromise; + + moderationService.awaitUserBlockRefresh = async () => {}; + + const renderCalls = []; + manager.renderSameGridStyle = (result, containerId, options) => { + renderCalls.push({ result, containerId, options }); + }; + + manager.loadSubscriptions = async () => { + manager.loaded = true; + manager.subscribedPubkeys = new Set(["pub-1"]); + }; + + const feedResults = [ + { items: [] }, + { items: [{ video: { id: "video-1", pubkey: "pub-1" } }] }, + ]; + const runCalls = []; + const feedEngine = { + async run(name, options) { + runCalls.push({ name, options }); + return feedResults.shift() ?? { items: [] }; + }, + }; + + setApplication({ + feedEngine, + videosMap: new Map(), + registerSubscriptionsFeed: () => {}, + isAuthorBlocked: () => false, + blacklistedEventIds: new Set(), + }); + + const showPromise = manager.showSubscriptionVideos( + "viewer-pub", + "subscriptionsVideoList" + ); + + await Promise.resolve(); + assert.equal( + runCalls.length, + 0, + "engine.run should not execute before nostrService initial load resolves", + ); + + resolveInitialLoad(); + await showPromise; + + assert.equal(runCalls.length, 1, "engine.run should execute after warm-up"); + assert.equal(renderCalls.length, 1, "initial render should occur once"); + + nostrService.emit("videos:updated", { + reason: "subscription", + videos: [{ id: "video-1", pubkey: "pub-1" }], + }); + + if (manager.pendingRefreshPromise) { + await manager.pendingRefreshPromise; + } else { + await Promise.resolve(); + } + + assert.equal( + runCalls.length, + 2, + "engine.run should execute again after nostrService update", + ); + assert.equal(renderCalls.length, 2, "render should run again after update"); + assert.equal( + renderCalls[1].result.items[0].video.id, + "video-1", + "updated render should receive fresh videos", + ); + + if (typeof manager.unsubscribeFromNostrUpdates === "function") { + manager.unsubscribeFromNostrUpdates(); + } + + nostrService.awaitInitialLoad = originalAwaitInitialLoad; + moderationService.awaitUserBlockRefresh = originalAwaitUserBlockRefresh; + setApplication(null); + + dom.window.close(); + + if (typeof originalWindow === "undefined") { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + + if (typeof originalDocument === "undefined") { + delete globalThis.document; + } else { + globalThis.document = originalDocument; + } + + if (typeof originalNavigator === "undefined") { + delete globalThis.navigator; + } else { + globalThis.navigator = originalNavigator; + } + }, +); +