From 8bf8da03b9e3e7dfdc08d0e9bda25ed479c827c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 02:02:51 +0000 Subject: [PATCH] Speed up Nostr login and fix list sync, signer persistence, and feed registration The NIP-07 login flow was extremely slow because list decryption (blocks, subscriptions, hashtag preferences) started before the signer was ready. On page refresh, all three services failed with "permission-required" and scheduled 3-second retry delays, adding 6-10 seconds to every login. Key fixes: - List loading now waits for the permission pre-grant to complete before attempting decryption, ensuring the signer is available on first try - Extension wait timeout increased from 3s to 5s for slower extensions - Decrypt timeouts reduced from 20s to 10s (signer is guaranteed ready) - Auto-login now forwards authType/providerId from saved profiles - Storage tab unlock falls back to ensureActiveSignerForPubkey when the global signer is not yet registered - Watch history feed registration adds defensive re-registration and try/catch fallback to prevent "Feed not registered" errors - Decryption scheme cache TTL extended from 10 minutes to 2 hours - ensureExtensionPermissions waits for extension injection when there are outstanding permissions to request (was returning immediately) https://claude.ai/code/session_01T4wwPu7m5qp1Nxx3H2v57j --- js/app.js | 21 +++++++++++++++++-- js/app/authSessionCoordinator.js | 17 +++++++++------- js/app/feedCoordinator.js | 1 + js/historyView.js | 26 ++++++++++++++++++------ js/nostr/client.js | 22 +++++++++++++++----- js/nostr/decryptionSchemeCache.js | 6 +++++- js/services/hashtagPreferencesService.js | 6 +++++- js/subscriptions.js | 6 +++++- js/ui/profileModalController.js | 18 ++++++++++++---- js/userBlocks.js | 7 +++++-- 10 files changed, 101 insertions(+), 29 deletions(-) diff --git a/js/app.js b/js/app.js index 7d47dbfd..132182e6 100644 --- a/js/app.js +++ b/js/app.js @@ -1159,9 +1159,26 @@ class Application { async _initAutoLogin() { const savedPubKey = this.activeProfilePubkey; if (savedPubKey) { - // Auto-login if a pubkey was saved + // Look up the saved profile entry to forward authType and providerId. + // Without this, the login flow defaults to "nip07" and the signer + // restoration path cannot distinguish NIP-07 from NIP-46 or nsec, + // which leads to incorrect signer setup on page refresh. + const savedProfiles = this.authService.cloneSavedProfiles(); + const normalizedSaved = this.normalizeHexPubkey(savedPubKey) || savedPubKey; + const savedEntry = savedProfiles.find((entry) => { + const entryPubkey = this.normalizeHexPubkey(entry?.pubkey); + return entryPubkey && entryPubkey === normalizedSaved; + }); + const loginOptions = { persistActive: false }; + if (savedEntry?.authType) { + loginOptions.authType = savedEntry.authType; + } + if (savedEntry?.providerId) { + loginOptions.providerId = savedEntry.providerId; + } + try { - await this.authService.login(savedPubKey, { persistActive: false }); + await this.authService.login(savedPubKey, loginOptions); } catch (error) { devLogger.error("Auto-login failed:", error); if (error && error.code === "site-lockdown") { diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index 17523195..31431b9c 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -222,13 +222,16 @@ export function createAuthSessionCoordinator(deps) { // render needs all three to be settled. Loading them concurrently shaves // 20-30 seconds off the critical login path. // - // PERF: Lists no longer wait for the permission pre-grant to complete. - // The NIP-07 extension already granted decrypt permissions during the - // login flow (ensureExtensionPermissions is called in loginWithExtension), - // so the signer adapter's decrypt functions work immediately. Waiting for - // the redundant pre-grant was adding 2-5 seconds of unnecessary latency - // before list loading could even begin. - const listStatePromise = relaysReadyPromise.then(async () => { + // Lists wait for the permission pre-grant to settle before attempting + // decryption. During auto-login (page refresh), the NIP-07 extension + // may not be ready yet — without waiting, all three services would fail + // with "permission-required" and schedule 3-second retry delays, adding + // 6-10 seconds to the critical login path. By waiting for the pre-grant + // (which itself waits for the extension and requests permissions), the + // signer is guaranteed to be available when decryption starts. This does + // NOT slow down fresh logins because the pre-grant resolves immediately + // when permissions were already granted during loginWithExtension(). + const listStatePromise = Promise.all([relaysReadyPromise, permissionPromise]).then(async () => { const parallelListTasks = []; if ( diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 11863bb1..2db852fb 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -484,6 +484,7 @@ export function createFeedCoordinator(deps) { registerWatchHistoryFeed() { if (!this.feedEngine || typeof this.feedEngine.registerFeed !== "function") { + devLogger.warn("[Application] Cannot register watch-history feed: feedEngine not available."); return null; } diff --git a/js/historyView.js b/js/historyView.js index 9aa17fde..290fecdd 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -1400,12 +1400,26 @@ export function createWatchHistoryRenderer(config = {}) { const app = getAppInstance(); const engine = app?.feedEngine; if (engine && typeof engine.run === "function") { - const runtime = buildWatchHistoryFeedRuntime({ - actor: actorInput, - cursor, - forceRefresh, - }); - return engine.run("watch-history", { runtime }); + // Ensure the watch-history feed is registered. It may not be if the + // feed engine was ready but registration was silently skipped during + // bootstrap (e.g. timing edge cases). + if ( + typeof engine.getFeedDefinition === "function" && + !engine.getFeedDefinition("watch-history") && + typeof app.registerWatchHistoryFeed === "function" + ) { + app.registerWatchHistoryFeed(); + } + try { + const runtime = buildWatchHistoryFeedRuntime({ + actor: actorInput, + cursor, + forceRefresh, + }); + return engine.run("watch-history", { runtime }); + } catch (feedError) { + // Fall through to service fallback if feed is still not registered + } } const items = await watchHistoryService.loadLatest(actorInput, { allowStale: !forceRefresh, diff --git a/js/nostr/client.js b/js/nostr/client.js index 22dcb76d..373c058a 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -3122,9 +3122,10 @@ export class NostrClient { // Even if permissions are cached, we must ensure the extension object is actually // injected and available on the window before returning success. // This prevents race conditions where the app loads faster than the extension. + // 5 seconds gives slower extensions (e.g. Alby) enough time to inject. if (typeof window !== "undefined" && !window.nostr) { try { - await waitForNip07Extension(3000); + await waitForNip07Extension(5000); } catch (error) { return { ok: false, error: new Error("extension-unavailable") }; } @@ -3137,7 +3138,17 @@ export class NostrClient { return { ok: true }; } - const extension = typeof window !== "undefined" ? window.nostr : null; + let extension = typeof window !== "undefined" ? window.nostr : null; + // Wait for the extension to inject if it isn't available yet. The cached + // permission state implies a previous session with an extension, so it's + // likely to appear once the content script finishes loading. + if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) { + try { + extension = await waitForNip07Extension(5000); + } catch (error) { + // Fall through + } + } if (!extension) { this.extensionReady = false; return { ok: false, error: new Error("extension-unavailable") }; @@ -3237,11 +3248,12 @@ export class NostrClient { typeof window !== "undefined" && window && window.nostr ? window.nostr : null; // If the extension is missing but we have cached permissions, it implies the user - // previously logged in with an extension. We should wait briefly for injection - // to resolve the common race condition where the app loads faster than the extension. + // previously logged in with an extension. We should wait for injection to resolve + // the common race condition where the app loads faster than the extension. 5 seconds + // gives slower extensions (e.g. Alby) enough time to inject window.nostr. if (!extension && this.extensionPermissionCache && this.extensionPermissionCache.size > 0) { try { - extension = await waitForNip07Extension(3000); + extension = await waitForNip07Extension(5000); } catch (error) { // Fall through to existing signer check } diff --git a/js/nostr/decryptionSchemeCache.js b/js/nostr/decryptionSchemeCache.js index c53b76e0..ef413153 100644 --- a/js/nostr/decryptionSchemeCache.js +++ b/js/nostr/decryptionSchemeCache.js @@ -10,7 +10,11 @@ * login path where all three services decrypt simultaneously. */ -const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes +// Extended from 10 minutes to 2 hours. The cache is cleared on logout, so +// there's no privacy concern with a longer TTL. A short TTL caused redundant +// scheme probing during normal browsing sessions, triggering unnecessary +// NIP-07 extension calls that slow down background list re-syncs. +const DECRYPTION_CACHE_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours /** @type {Map} */ const cache = new Map(); diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 50fca17e..72861d8c 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -36,7 +36,11 @@ const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; const DEFAULT_VERSION = 1; -const DECRYPT_TIMEOUT_MS = 20000; +// PERF: Reduced from 20s to 10s — the signer is now guaranteed to be ready +// before list loading starts (authSessionCoordinator waits for the permission +// pre-grant), so decryption should succeed quickly. 10s is a generous fallback +// for slow relay responses while still failing fast enough to retry promptly. +const DECRYPT_TIMEOUT_MS = 10000; // PERF: Reduced from 10s to 3s for faster recovery during login. const DECRYPT_RETRY_DELAY_MS = 3000; diff --git a/js/subscriptions.js b/js/subscriptions.js index f84be201..53ad56d2 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -44,7 +44,11 @@ import { const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; -const DECRYPT_TIMEOUT_MS = 20000; +// PERF: Reduced from 20s to 10s — the signer is now guaranteed to be ready +// before list loading starts (authSessionCoordinator waits for the permission +// pre-grant), so decryption should succeed quickly. 10s is a generous fallback +// for slow relay responses while still failing fast enough to retry promptly. +const DECRYPT_TIMEOUT_MS = 10000; // PERF: Reduced from 10s to 3s — extensions that already granted permission // should recover quickly. The longer delay was causing unnecessary wait time // during the critical login path when the first decrypt attempt fails due to diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 2ff1bdd4..ae1a25db 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -9956,10 +9956,20 @@ export class ProfileModalController { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!pubkey) return; - // We need the active signer. - const signer = getActiveSigner(); - // Or try resolving via client if global one is missing? - // ProfileModalController imports getActiveSigner. + // We need the active signer. Try the global first, then resolve via the + // nostr client which can detect/restore the NIP-07 extension signer. + let signer = getActiveSigner(); + if ( + !signer && + this.services.nostrClient && + typeof this.services.nostrClient.ensureActiveSignerForPubkey === "function" + ) { + try { + signer = await this.services.nostrClient.ensureActiveSignerForPubkey(pubkey); + } catch (error) { + devLogger.warn("[ProfileModal] Failed to resolve signer for storage unlock:", error); + } + } if (!signer) { this.showError("No active signer found. Please login."); diff --git a/js/userBlocks.js b/js/userBlocks.js index 10439d9b..83723837 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -83,8 +83,11 @@ export const USER_BLOCK_EVENTS = Object.freeze({ const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; -const DECRYPT_TIMEOUT_MS = 20000; -const BACKGROUND_DECRYPT_TIMEOUT_MS = 15000; +// PERF: Reduced from 20s/15s to 10s/8s — the signer is now guaranteed to be +// ready before list loading starts (authSessionCoordinator waits for the +// permission pre-grant), so decryption should succeed quickly. +const DECRYPT_TIMEOUT_MS = 10000; +const BACKGROUND_DECRYPT_TIMEOUT_MS = 8000; // PERF: Reduced from 10s to 3s for faster recovery during login. const DECRYPT_RETRY_DELAY_MS = 3000; const MAX_BLOCKLIST_ENTRIES = 5000;