diff --git a/js/app/authSessionCoordinator.js b/js/app/authSessionCoordinator.js index d41e0ddd..b02afd82 100644 --- a/js/app/authSessionCoordinator.js +++ b/js/app/authSessionCoordinator.js @@ -9,6 +9,8 @@ * Methods use `this` which is bound to the Application instance. */ +import { clearDecryptionSchemeCache } from "../nostr/decryptionSchemeCache.js"; + /** * @param {object} deps - Injected dependencies. * @returns {object} Methods to be bound to the Application instance. @@ -218,10 +220,14 @@ export function createAuthSessionCoordinator(deps) { // parallel. They have no data dependencies on each other — only the feed // render needs all three to be settled. Loading them concurrently shaves // 20-30 seconds off the critical login path. - const listStatePromise = Promise.all([ - relaysReadyPromise, - permissionPromise, - ]).then(async () => { + // + // 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 () => { const parallelListTasks = []; if ( @@ -535,6 +541,7 @@ export function createAuthSessionCoordinator(deps) { this.resetHashtagPreferencesState(); this.resetPermissionPromptState(); + clearDecryptionSchemeCache(); this.updateAuthLoadingState({ profile: "idle", lists: "idle", dms: "idle" }); await this.nwcSettingsService.onLogout({ diff --git a/js/nostr/decryptionSchemeCache.js b/js/nostr/decryptionSchemeCache.js new file mode 100644 index 00000000..c53b76e0 --- /dev/null +++ b/js/nostr/decryptionSchemeCache.js @@ -0,0 +1,66 @@ +// js/nostr/decryptionSchemeCache.js + +/** + * Shared decryption scheme cache across all list services (blocks, + * subscriptions, hashtag preferences). When one service discovers the + * correct decryption scheme for a pubkey, the others can reuse it + * immediately instead of independently probing the NIP-07 extension. + * + * This eliminates redundant extension decrypt calls during the critical + * login path where all three services decrypt simultaneously. + */ + +const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** @type {Map} */ +const cache = new Map(); + +/** + * Retrieve the last successfully-used decryption scheme for a pubkey. + * Returns null if the entry is missing or expired. + * + * @param {string} pubkey - Hex pubkey to look up. + * @returns {string|null} The scheme identifier (e.g. "nip44_v2") or null. + */ +export function getLastSuccessfulScheme(pubkey) { + if (typeof pubkey !== "string" || !pubkey) { + return null; + } + + const entry = cache.get(pubkey); + if (!entry) { + return null; + } + + if (Date.now() - entry.timestamp > DECRYPTION_CACHE_TTL_MS) { + cache.delete(pubkey); + return null; + } + + return entry.scheme; +} + +/** + * Record a successful decryption scheme for a pubkey so other services + * can skip scheme discovery. + * + * @param {string} pubkey - Hex pubkey. + * @param {string} scheme - The scheme that succeeded (e.g. "nip44_v2"). + */ +export function setLastSuccessfulScheme(pubkey, scheme) { + if (typeof pubkey !== "string" || !pubkey) { + return; + } + if (typeof scheme !== "string" || !scheme) { + return; + } + + cache.set(pubkey, { scheme, timestamp: Date.now() }); +} + +/** + * Clear all cached entries. Useful on logout. + */ +export function clearDecryptionSchemeCache() { + cache.clear(); +} diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 5fdfb85a..ab2a1574 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -26,16 +26,18 @@ import { NIP07_PRIORITY, } from "../nostr/nip07Permissions.js"; import { relaySubscriptionService } from "./relaySubscriptionService.js"; +import { + getLastSuccessfulScheme, + setLastSuccessfulScheme, +} from "../nostr/decryptionSchemeCache.js"; const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; const DEFAULT_VERSION = 1; const DECRYPT_TIMEOUT_MS = 20000; -const DECRYPT_RETRY_DELAY_MS = 10000; -const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; - -const decryptionSuccessCache = new Map(); +// PERF: Reduced from 10s to 3s for faster recovery during login. +const DECRYPT_RETRY_DELAY_MS = 3000; class TinyEventEmitter { constructor() { @@ -1076,17 +1078,32 @@ class HashtagPreferencesService { return { ok: false, error }; } - const cachedEntry = decryptionSuccessCache.get(userPubkey); - const cachedScheme = - cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS - ? cachedEntry.scheme - : null; + // Use the shared cross-service cache so all list services benefit from + // the first successful decrypt during login. + const cachedScheme = getLastSuccessfulScheme(userPubkey); const order = determineDecryptionOrder( event, Array.from(decryptors.keys()), cachedScheme, ); + + // PERF: Fast path — if the shared cache already knows the correct scheme + // (e.g. blocks or subscriptions decrypted first), try ONLY that scheme + // before falling back to the full parallel probe. This avoids queuing + // redundant NIP-07 extension calls. + if (cachedScheme && decryptors.has(cachedScheme)) { + try { + const plaintext = await decryptors.get(cachedScheme)(ciphertext); + if (typeof plaintext === "string") { + setLastSuccessfulScheme(userPubkey, cachedScheme); + return { ok: true, plaintext, scheme: cachedScheme }; + } + } catch (_fastPathError) { + // Cached scheme failed — fall through to parallel probe. + } + } + // PERF: Try all decryption schemes in parallel via Promise.any(). // The first scheme to succeed wins, avoiding sequential 3-15s timeouts // per scheme that previously made worst-case decryption take 45s+. @@ -1116,10 +1133,7 @@ class HashtagPreferencesService { if (attempts.length) { try { const result = await Promise.any(attempts); - decryptionSuccessCache.set(userPubkey, { - scheme: result.scheme, - timestamp: Date.now(), - }); + setLastSuccessfulScheme(userPubkey, result.scheme); return { ok: true, plaintext: result.plaintext, scheme: result.scheme }; } catch (aggregateError) { const attemptErrors = (aggregateError.errors || []).map((err, i) => ({ diff --git a/js/subscriptions.js b/js/subscriptions.js index 9284b5e6..f84be201 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -37,14 +37,19 @@ import { NIP07_PRIORITY, } from "./nostr/nip07Permissions.js"; import { relaySubscriptionService } from "./services/relaySubscriptionService.js"; +import { + getLastSuccessfulScheme, + setLastSuccessfulScheme, +} from "./nostr/decryptionSchemeCache.js"; const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; const DECRYPT_TIMEOUT_MS = 20000; -const DECRYPT_RETRY_DELAY_MS = 10000; -const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; - -const decryptionSuccessCache = new Map(); +// 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 +// the extension still initializing. +const DECRYPT_RETRY_DELAY_MS = 3000; function normalizeHexPubkey(value) { if (typeof value !== "string") { @@ -1097,17 +1102,31 @@ class SubscriptionsManager { } const availableSchemes = Array.from(decryptors.keys()); - const cachedEntry = decryptionSuccessCache.get(userPubkey); - const cachedScheme = - cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS - ? cachedEntry.scheme - : null; + // Use the shared cross-service cache so all list services benefit from + // the first successful decrypt during login. + const cachedScheme = getLastSuccessfulScheme(userPubkey); const order = determineDecryptionOrder( event, availableSchemes, cachedScheme, ); + + // PERF: Fast path — if the shared cache already knows the correct scheme + // (e.g. blocks decrypted first), try ONLY that scheme before falling back + // to the full parallel probe. This avoids queuing redundant extension calls. + if (cachedScheme && decryptors.has(cachedScheme)) { + try { + const plaintext = await decryptors.get(cachedScheme)(ciphertext); + if (typeof plaintext === "string") { + setLastSuccessfulScheme(userPubkey, cachedScheme); + return { ok: true, plaintext, scheme: cachedScheme }; + } + } catch (_fastPathError) { + // Cached scheme failed — fall through to parallel probe. + } + } + // PERF: Try all decryption schemes in parallel via Promise.any(). // The first scheme to succeed wins, avoiding sequential 3-15s timeouts // per scheme that previously made worst-case decryption take 45s+. @@ -1137,10 +1156,7 @@ class SubscriptionsManager { if (attempts.length) { try { const result = await Promise.any(attempts); - decryptionSuccessCache.set(userPubkey, { - scheme: result.scheme, - timestamp: Date.now(), - }); + setLastSuccessfulScheme(userPubkey, result.scheme); return { ok: true, plaintext: result.plaintext, scheme: result.scheme }; } catch (aggregateError) { const attemptErrors = (aggregateError.errors || []).map((err, i) => ({ diff --git a/js/userBlocks.js b/js/userBlocks.js index 4509013d..10439d9b 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -26,6 +26,10 @@ import { NIP07_PRIORITY, } from "./nostr/nip07Permissions.js"; import { relaySubscriptionService } from "./services/relaySubscriptionService.js"; +import { + getLastSuccessfulScheme, + setLastSuccessfulScheme, +} from "./nostr/decryptionSchemeCache.js"; class TinyEventEmitter { constructor() { @@ -81,11 +85,9 @@ const FAST_BLOCKLIST_TIMEOUT_MS = 2500; const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; const DECRYPT_TIMEOUT_MS = 20000; const BACKGROUND_DECRYPT_TIMEOUT_MS = 15000; -const DECRYPT_RETRY_DELAY_MS = 10000; +// PERF: Reduced from 10s to 3s for faster recovery during login. +const DECRYPT_RETRY_DELAY_MS = 3000; const MAX_BLOCKLIST_ENTRIES = 5000; -const DECRYPTION_CACHE_TTL_MS = 10 * 60 * 1000; - -const decryptionSuccessCache = new Map(); function sanitizeRelayList(candidate) { return Array.isArray(candidate) @@ -1159,11 +1161,9 @@ class UserBlockListManager { permissionError.signerStatus = signerStatus; } - const cachedEntry = decryptionSuccessCache.get(normalized); - const cachedScheme = - cachedEntry && Date.now() - cachedEntry.timestamp < DECRYPTION_CACHE_TTL_MS - ? cachedEntry.scheme - : null; + // Use the shared cross-service cache so all list services benefit from + // the first successful decrypt during login. + const cachedScheme = getLastSuccessfulScheme(normalized); return { decryptors, @@ -1306,41 +1306,57 @@ class UserBlockListManager { throw error; } + // PERF: Fast path — if the shared cache already knows the correct + // scheme (e.g. another list decrypted first), try ONLY that scheme + // before falling back to the full parallel probe. This avoids + // queuing redundant NIP-07 extension calls. + let decryptedText = ""; + const sharedCachedScheme = getLastSuccessfulScheme(normalized); + if (sharedCachedScheme && decryptors.has(sharedCachedScheme)) { + try { + const fastResult = await decryptors.get(sharedCachedScheme)(ev.content); + if (typeof fastResult === "string" && fastResult) { + decryptedText = fastResult; + setLastSuccessfulScheme(normalized, sharedCachedScheme); + } + } catch { + // Cached scheme failed — fall through to parallel probe. + } + } + // PERF: Try all decryption schemes in parallel via Promise.any(). // The first scheme to succeed wins, avoiding sequential 3-15s timeouts // per scheme that previously made worst-case decryption take 45s+. // Deduplicate schemes in the same family (nip44/nip44_v2 both call // the same underlying signer.nip44Decrypt) to avoid redundant calls. - let decryptedText = ""; - try { - const getSchemeFamily = (s) => - s === "nip44" || s === "nip44_v2" ? "nip44" : s; - const seenFamilies = new Set(); - const attempts = order - .map((scheme) => { - const family = getSchemeFamily(scheme); - if (seenFamilies.has(family)) return null; - seenFamilies.add(family); - const handler = decryptors.get(scheme); - if (typeof handler !== "function") return null; - return handler(ev.content).then((plaintext) => { - if (typeof plaintext !== "string") { - throw new Error("non-string-result"); - } - return { plaintext, scheme }; - }); - }) - .filter(Boolean); - if (attempts.length) { - const result = await Promise.any(attempts); - decryptedText = result.plaintext; - decryptionSuccessCache.set(normalized, { - scheme: result.scheme, - timestamp: Date.now(), - }); + if (!decryptedText) { + try { + const getSchemeFamily = (s) => + s === "nip44" || s === "nip44_v2" ? "nip44" : s; + const seenFamilies = new Set(); + const attempts = order + .map((scheme) => { + const family = getSchemeFamily(scheme); + if (seenFamilies.has(family)) return null; + seenFamilies.add(family); + const handler = decryptors.get(scheme); + if (typeof handler !== "function") return null; + return handler(ev.content).then((plaintext) => { + if (typeof plaintext !== "string") { + throw new Error("non-string-result"); + } + return { plaintext, scheme }; + }); + }) + .filter(Boolean); + if (attempts.length) { + const result = await Promise.any(attempts); + decryptedText = result.plaintext; + setLastSuccessfulScheme(normalized, result.scheme); + } + } catch { + // All schemes failed — decryptedText stays empty } - } catch { - // All schemes failed — decryptedText stays empty } if (!decryptedText) {