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
This commit is contained in:
Claude committed 2026-02-08 02:02:51 +00:00
1 parent ce16140343
commit 8bf8da03b9
10 files changed
+101 -29

No files matched your search

+19 -2
View File
@@ -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") {
+10 -7
View File
@@ -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 (
+1
View File
@@ -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;
}
+20 -6
View File
@@ -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,
+17 -5
View File
@@ -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
}
+5 -1
View File
@@ -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<string, { scheme: string, timestamp: number }>} */
const cache = new Map();
+5 -1
View File
@@ -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;
+5 -1
View File
@@ -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
+14 -4
View File
@@ -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.");
+5 -2
View File
@@ -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;