diff --git a/js/app.js b/js/app.js index bd9637f2..7857b05e 100644 --- a/js/app.js +++ b/js/app.js @@ -4135,6 +4135,14 @@ class Application { : Promise.resolve(detail?.postLogin ?? null); const postLoginResult = detail?.postLogin ?? null; + // relaysReadyPromise resolves as soon as the user's relay list is loaded, + // before profile fetching completes. Lists can start loading immediately + // after relays are available — they don't depend on profile data. + const relaysReadyPromise = + detail && typeof detail.relaysReadyPromise?.then === "function" + ? detail.relaysReadyPromise + : postLoginPromise; + if (detail && typeof detail === "object") { try { detail.__handled = true; @@ -4270,8 +4278,10 @@ class Application { return null; }); - // Chain list loading to profile state to ensure relays are loaded first. - const listStatePromise = profileStatePromise.then(() => { + // Start list loading as soon as relays are ready — lists don't depend on + // profile data, only on relay URLs. This runs in parallel with profile + // fetching instead of sequentially after it. + const listStatePromise = relaysReadyPromise.then(() => { const tasks = []; if (activePubkey && typeof this.authService.loadBlocksForPubkey === "function") { tasks.push( @@ -4328,6 +4338,7 @@ class Application { }); }); + // DMs can wait for profile since they need encryption context. const dmStatePromise = profileStatePromise.then(() => { if ( activePubkey && @@ -4373,7 +4384,8 @@ class Application { return Promise.resolve(); }); - const hashtagPreferencesPromise = profileStatePromise.then(() => { + // Hashtag preferences drive feed filtering — start as soon as relays are ready. + const hashtagPreferencesPromise = relaysReadyPromise.then(() => { if ( activePubkey && this.hashtagPreferences && @@ -4387,6 +4399,9 @@ class Application { this.capturePermissionPromptFromError( this.hashtagPreferences?.lastLoadError, ); + // Ensure the cached snapshot is updated before the feed renders + // so the feed engine has access to interests/disinterests. + this.updateCachedHashtagPreferences(); }) .catch((error) => { devLogger.warn( @@ -4399,13 +4414,9 @@ class Application { return Promise.resolve(); }); - void Promise.allSettled([ - profileStatePromise, - listStatePromise, - dmStatePromise, - ]); + // Ensure DMs are tracked for cleanup. + void dmStatePromise; void nwcPromise; - void hashtagPreferencesPromise; if (activePubkey) { const seedBlacklist = () => { @@ -4438,20 +4449,37 @@ class Application { ); } }); - - // Subscriptions are loaded in list tasks to align with per-category loading states. } + // Show loading state immediately while lists are syncing. try { this.reinitializeVideoListView({ reason: "login", postLoginResult }); } catch (error) { devLogger.warn("Failed to reinitialize video list view after login:", error); } - this.lastIdentityRefreshPromise = this.refreshAllVideoGrids({ - reason: "auth-login", - forceMainReload: true, - }); + // Wait for the critical feed-filtering lists (blocks, subscriptions, + // hashtag preferences) to settle before rendering the video grid. This + // prevents the feed from briefly showing unfiltered content. We use a + // time-boxed wait so the UI isn't blocked indefinitely if decryption stalls. + const FEED_SYNC_TIMEOUT_MS = 8000; + const feedSyncPromise = Promise.race([ + Promise.allSettled([ + listStatePromise, + hashtagPreferencesPromise, + profileStatePromise, + ]), + new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)), + ]).catch(() => null); + + // Chain the grid refresh after the feed data is available. + this.lastIdentityRefreshPromise = feedSyncPromise + .then(() => + this.refreshAllVideoGrids({ + reason: "auth-login", + forceMainReload: true, + }), + ); this.lastIdentityRefreshPromise .catch((error) => { devLogger.error("Failed to refresh video grids after login:", error); diff --git a/js/services/authService.js b/js/services/authService.js index 0e0cf847..602a2ed4 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -623,10 +623,11 @@ export default class AuthService { postLoginError: null, }; - const { detail: postLoginDetail, completionPromise } = this.applyPostLoginState({ + const { detail: postLoginDetail, completionPromise, relaysReadyPromise } = this.applyPostLoginState({ deferBlocks: true, }); detail.postLogin = postLoginDetail; + detail.relaysReadyPromise = relaysReadyPromise; const postLoginPromise = Promise.resolve(completionPromise) .then((postLogin) => { @@ -694,7 +695,8 @@ export default class AuthService { }; if (!activePubkey) { - return { detail, completionPromise: Promise.resolve(detail) }; + const resolved = Promise.resolve(detail); + return { detail, completionPromise: resolved, relaysReadyPromise: resolved }; } const runOperation = async (operation) => { @@ -718,6 +720,13 @@ export default class AuthService { const schedule = (callback) => Promise.resolve().then(() => callback()); + // Expose relay readiness separately so list loading can start immediately + // after relays are available, without waiting for profile to complete. + let resolveRelaysReady; + const relaysReadyPromise = new Promise((resolve) => { + resolveRelaysReady = resolve; + }); + const completionPromise = (async () => { if (this.relayManager && typeof this.relayManager.loadRelayList === "function") { const relayOp = { @@ -738,6 +747,7 @@ export default class AuthService { }; await runOperation(relayOp); } + resolveRelaysReady(detail); const concurrentOps = []; @@ -775,7 +785,7 @@ export default class AuthService { return detail; })(); - return { detail, completionPromise }; + return { detail, completionPromise, relaysReadyPromise }; } createBlocksLoadOperation( diff --git a/js/subscriptions.js b/js/subscriptions.js index a20788e3..5a6e7c34 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -502,14 +502,19 @@ class SubscriptionsManager { ); const hadCachedSnapshot = cachedSnapshot.hasSnapshot; const shouldForceFullFetch = !cachedSnapshot.hasSnapshot; - let events = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedUserPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls, - since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0), - timeoutMs: 12000, - }); + + // Fetch user and session actor subscription lists in parallel to reduce + // total relay wait time during login. + const fetchPromises = [ + nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedUserPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls, + since: shouldForceFullFetch ? 0 : (cachedSnapshot.createdAt || 0), + timeoutMs: 12000, + }), + ]; const normalizedSessionActorPubkey = normalizeNostrPubkey( nostrClient?.sessionActor?.pubkey, @@ -525,17 +530,22 @@ class SubscriptionsManager { ), ); const shouldForceSessionFetch = !sessionCachedSnapshot.hasSnapshot; - const sessionEvents = await nostrClient.fetchListIncrementally({ - kind: SUBSCRIPTION_SET_KIND, - pubkey: normalizedSessionActorPubkey, - dTag: SUBSCRIPTION_LIST_IDENTIFIER, - relayUrls, - since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0), - timeoutMs: 12000, - }); - if (sessionEvents.length) { - events = events.concat(sessionEvents); - } + fetchPromises.push( + nostrClient.fetchListIncrementally({ + kind: SUBSCRIPTION_SET_KIND, + pubkey: normalizedSessionActorPubkey, + dTag: SUBSCRIPTION_LIST_IDENTIFIER, + relayUrls, + since: shouldForceSessionFetch ? 0 : (sessionCachedSnapshot.createdAt || 0), + timeoutMs: 12000, + }), + ); + } + + const fetchResults = await Promise.all(fetchPromises); + let events = fetchResults[0] || []; + if (shouldFetchSessionActor && fetchResults[1]?.length) { + events = events.concat(fetchResults[1]); } const mergedEvents = []; @@ -809,7 +819,9 @@ class SubscriptionsManager { try { // Race against a timeout so the UI doesn't hang indefinitely if relays stall. - const timeoutMs = 6000; + // The timeout must accommodate relay fetch (~12s) + decryption (~3-12s per + // scheme attempt), so we use 30s to give the full pipeline enough room. + const timeoutMs = 30000; await Promise.race([ loader, new Promise((_, reject) =>