diff --git a/js/services/nip71IngestService.js b/js/services/nip71IngestService.js index 51181f60..f28e3cf7 100644 --- a/js/services/nip71IngestService.js +++ b/js/services/nip71IngestService.js @@ -36,6 +36,11 @@ const SUBSCRIPTION_KEY = "nip71-ingest"; // subscribing. const OPEN_RETRY_DELAY_MS = 2000; const MAX_OPEN_ATTEMPTS = 30; // ~60s of retries +// Defer the first subscription until the native feed has rendered (signalled by +// the first videos:updated). Injecting foreign authors hydrates their WoT graph +// (kind 30000), which during cold-start competes with the initial feed load; a +// fallback covers the empty-feed case where no videos:updated ever fires. +const FEED_READY_FALLBACK_MS = 12000; export function createNip71IngestService({ nostrClient, @@ -48,6 +53,7 @@ export function createNip71IngestService({ openRetryDelayMs = OPEN_RETRY_DELAY_MS, maxOpenAttempts = MAX_OPEN_ATTEMPTS, refreshThrottleMs = REFRESH_THROTTLE_MS, + feedReadyFallbackMs = FEED_READY_FALLBACK_MS, } = {}) { let subscription = null; let buffer = []; @@ -58,6 +64,9 @@ export function createNip71IngestService({ let started = false; let lastRefreshAt = 0; let pendingRefreshTimer = null; + let deferArmed = false; + let deferTimer = null; + let offFeedReady = null; function isAvailable() { return ( @@ -199,6 +208,13 @@ export function createNip71IngestService({ } catch (error) { logger?.warn?.("[nip71Ingest] getFilteredActiveVideos failed", error); } + // Confirm ingested videos survive the feed filter (whitelist/NSFW/etc.). + const foreignCount = Array.isArray(videos) + ? videos.filter((v) => v?.source === "nip71-ingest").length + : 0; + userLogger.info( + `[nip71Ingest] refresh: ${foreignCount} ingested of ${videos.length} in filtered feed`, + ); if (typeof nostrService?.emit === "function") { nostrService.emit("videos:updated", { videos, @@ -208,6 +224,35 @@ export function createNip71IngestService({ } } + // Defer the first start until the native feed has rendered (first + // videos:updated), with a fallback timer for the empty-feed case. Keeps ingest + // off the cold-start critical path. + function startWhenFeedReady() { + if (started || deferArmed || !isAvailable()) { + return false; + } + deferArmed = true; + + const begin = () => { + if (typeof offFeedReady === "function") { + offFeedReady(); + offFeedReady = null; + } + if (deferTimer) { + clearTimeout(deferTimer); + deferTimer = null; + } + deferArmed = false; + start(); + }; + + if (typeof nostrService?.on === "function") { + offFeedReady = nostrService.on("videos:updated", begin); + } + deferTimer = setTimeout(begin, feedReadyFallbackMs); + return true; + } + function start() { if (started || !isAvailable()) { return false; @@ -313,6 +358,19 @@ export function createNip71IngestService({ function stop() { closeSubscription(); + if (typeof offFeedReady === "function") { + try { + offFeedReady(); + } catch (error) { + // best-effort + } + offFeedReady = null; + } + if (deferTimer) { + clearTimeout(deferTimer); + deferTimer = null; + } + deferArmed = false; if (typeof offWhitelistChange === "function") { try { offWhitelistChange(); @@ -326,6 +384,7 @@ export function createNip71IngestService({ return { isAvailable, + startWhenFeedReady, start, stop, restart, diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 5749f7d3..2f04c9f1 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -858,6 +858,12 @@ export default class ApplicationBootstrap { // Inbound NIP-71 ingest: pull video events published by other Nostr apps // (scoped to whitelisted authors while whitelist mode is on) and surface // them in the feed. Gated by FEATURE_NIP71_INGEST. Best-effort. + // + // Deferred until the native feed has rendered once: injecting foreign + // authors triggers the moderation/WoT stage to hydrate their social graph + // (kind 30000), which during cold-start competes with the initial feed load + // and can stall it. Starting after the first videos:updated keeps ingest off + // the critical path; a fallback timer covers the empty-feed case. const nip71IngestService = createNip71IngestService({ nostrClient, nostrService: app.nostrService, @@ -865,11 +871,7 @@ export default class ApplicationBootstrap { }); app.nip71IngestService = nip71IngestService; if (nip71IngestService.isAvailable()) { - try { - nip71IngestService.start(); - } catch (error) { - devLogger.warn("[applicationBootstrap] NIP-71 ingest start failed", error); - } + nip71IngestService.startWhenFeedReady(); nostrUnsubscribes.push(() => nip71IngestService.stop()); } nostrUnsubscribes.push( diff --git a/tests/nip71-ingest-service.test.mjs b/tests/nip71-ingest-service.test.mjs index f7664fea..41b7918b 100644 --- a/tests/nip71-ingest-service.test.mjs +++ b/tests/nip71-ingest-service.test.mjs @@ -43,10 +43,17 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) { getActiveKey: activeKey, getSubscriptionManager: () => manager, }; + const feedListeners = new Set(); const nostrService = { emit(name, detail) { emitted.push({ name, detail }); }, + on(name, fn) { + if (name === "videos:updated") { + feedListeners.add(fn); + } + return () => feedListeners.delete(fn); + }, getFilteredActiveVideos: () => Array.from(activeMap.values()), }; const accessControl = { @@ -72,6 +79,7 @@ function makeHarness({ whitelistOn = false, whitelist = [] } = {}) { }, fireEvent: (e) => onEvent?.(e), triggerWhitelistChange: () => whitelistListener?.(), + triggerFeedReady: () => feedListeners.forEach((fn) => fn({})), nostrClient, nostrService, accessControl, @@ -218,6 +226,30 @@ test("re-subscribes when the whitelist changes", () => { assert.deepEqual(h.capturedFilters[0].authors, ["aa", "cc"], "subscription re-scoped after change"); }); +test("startWhenFeedReady defers subscription until the native feed renders", () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] }); + const svc = makeService(h, { feedReadyFallbackMs: 100000 }); + + assert.equal(svc.startWhenFeedReady(), true); + assert.equal(h.capturedFilters, null, "does not subscribe during cold-start"); + + // Native feed renders -> first videos:updated -> ingest starts now. + h.triggerFeedReady(); + assert.ok(h.capturedFilters, "subscribes once the feed is ready"); + assert.deepEqual(h.capturedFilters[0].authors, ["aa"]); + svc.stop(); +}); + +test("startWhenFeedReady falls back to starting if the feed never emits", async () => { + const h = makeHarness({ whitelistOn: true, whitelist: ["aa"] }); + const svc = makeService(h, { feedReadyFallbackMs: 5 }); + svc.startWhenFeedReady(); + assert.equal(h.capturedFilters, null); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.ok(h.capturedFilters, "fallback timer started ingest"); + svc.stop(); +}); + test("coalesces refresh signals so an event burst can't storm the feed", () => { const h = makeHarness(); const svc = makeService(h, { refreshThrottleMs: 10000 });