mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Speed up Nostr login and fix list sync, storage unlock, and feed registration
- Remove redundant double signer readiness gate evaluation that added 5-10s to the login path (authSessionCoordinator) - Reduce decryption timeouts (10s→6s per scheme, 3s→1.5s retry delay, 8s→5s NIP-07 no-prompt, 45s→25s overall ensureLoaded) for faster scheme fallback during list sync - Fix storage tab unlock: remove overly strict capabilities check that blocked lazy-injecting NIP-07 extensions, and add auto-unlock attempt when profile modal opens with an active signer - Fix subscription/hashtag decryption: always resolve signer for background refreshes (was previously gated behind allowPermissionPrompt), and always check window.nostr as fallback decryptor regardless of prompt mode - Fix capabilities check in subscription decryption: use method existence instead of dynamic capabilities getter that returns false during lazy extension injection - Wrap Promise.any decryptor calls in Promise.resolve().then() to catch synchronous throws from NIP-07 adapter methods when extension modules haven't loaded yet - Make watch-history feed registration race-safe with pre-check and try/catch for concurrent registration attempts - Improve NIP-07 extension detection with DOMContentLoaded listener for faster detection of extensions that inject during page load https://claude.ai/code/session_0174XTB5ADJ9cMBSucqL9Wer
This commit is contained in:
6 files changed
+173
-93
No files matched your search
@@ -288,22 +288,12 @@ export function createAuthSessionCoordinator(deps) {
|
||||
? Promise.resolve()
|
||||
.then(async () => {
|
||||
const signerGateStart = now();
|
||||
let outcome = await evaluateSignerReadinessGate(activePubkey);
|
||||
if (!outcome.ready) {
|
||||
outcome = {
|
||||
...outcome,
|
||||
recoveryAttempted: true,
|
||||
initialStatus: outcome.status,
|
||||
};
|
||||
const recoveryOutcome = await evaluateSignerReadinessGate(
|
||||
activePubkey,
|
||||
);
|
||||
outcome = {
|
||||
...recoveryOutcome,
|
||||
recoveryAttempted: true,
|
||||
initialStatus: outcome.initialStatus,
|
||||
};
|
||||
}
|
||||
// PERF: Evaluate signer readiness once. The previous double-gate
|
||||
// evaluation added 5-10s to the login path by repeating the same
|
||||
// extension wait + permission request. A single evaluation is
|
||||
// sufficient because ensureActiveSignerForPubkey already waits
|
||||
// for extension injection internally.
|
||||
const outcome = await evaluateSignerReadinessGate(activePubkey);
|
||||
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
@@ -311,7 +301,7 @@ export function createAuthSessionCoordinator(deps) {
|
||||
);
|
||||
const finalOutcome = {
|
||||
...outcome,
|
||||
recoveryAttempted: Boolean(outcome.recoveryAttempted),
|
||||
recoveryAttempted: false,
|
||||
durationMs,
|
||||
};
|
||||
userLogger.info("[signer-ready]", {
|
||||
@@ -323,7 +313,7 @@ export function createAuthSessionCoordinator(deps) {
|
||||
|
||||
if (!finalOutcome.ready && finalOutcome.error) {
|
||||
devLogger.warn(
|
||||
"[Application] Signer readiness gate not ready after bounded recovery:",
|
||||
"[Application] Signer readiness gate not ready:",
|
||||
finalOutcome.error,
|
||||
);
|
||||
}
|
||||
@@ -336,7 +326,7 @@ export function createAuthSessionCoordinator(deps) {
|
||||
ready: false,
|
||||
status: classifySignerGateError(error),
|
||||
error,
|
||||
recoveryAttempted: true,
|
||||
recoveryAttempted: false,
|
||||
durationMs: 0,
|
||||
};
|
||||
})
|
||||
@@ -712,7 +702,10 @@ export function createAuthSessionCoordinator(deps) {
|
||||
// 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 = 12000;
|
||||
// PERF: Reduced from 12s to 8s — list decryption timeouts have been
|
||||
// lowered so settled results arrive faster. 8s prevents the feed from
|
||||
// being blocked while still giving most lists time to decrypt.
|
||||
const FEED_SYNC_TIMEOUT_MS = 8000;
|
||||
const feedSyncPromise = Promise.race([
|
||||
Promise.allSettled([
|
||||
listStatePromise,
|
||||
|
||||
@@ -665,6 +665,27 @@ export function registerWatchHistoryFeed(engine, options = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already registered to avoid "already registered" throw from
|
||||
// the engine when bootstrap and lazy registration race.
|
||||
if (typeof engine.getFeedDefinition === "function") {
|
||||
const existing = engine.getFeedDefinition("watch-history");
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const definition = createWatchHistoryFeedDefinition(options);
|
||||
return engine.registerFeed("watch-history", definition);
|
||||
try {
|
||||
return engine.registerFeed("watch-history", definition);
|
||||
} catch (error) {
|
||||
// Handle race condition where another call registered between our
|
||||
// check and the registration attempt.
|
||||
if (typeof engine.getFeedDefinition === "function") {
|
||||
const raceWinner = engine.getFeedDefinition("watch-history");
|
||||
if (raceWinner) {
|
||||
return raceWinner;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -402,14 +402,42 @@ export function waitForNip07Extension(timeoutMs = 5000) {
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let resolved = false;
|
||||
|
||||
const finish = (ext) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearInterval(interval);
|
||||
resolve(ext);
|
||||
};
|
||||
|
||||
const fail = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearInterval(interval);
|
||||
reject(new Error("Nostr extension not found within timeout."));
|
||||
};
|
||||
|
||||
// Poll every 50ms for window.nostr injection.
|
||||
const interval = setInterval(() => {
|
||||
if (window.nostr) {
|
||||
clearInterval(interval);
|
||||
resolve(window.nostr);
|
||||
finish(window.nostr);
|
||||
} else if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(interval);
|
||||
reject(new Error("Nostr extension not found within timeout."));
|
||||
fail();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Additionally listen for the DOMContentLoaded event — many extensions
|
||||
// inject window.nostr during this phase, and detecting it via the event
|
||||
// can be faster than waiting for the next poll tick.
|
||||
if (typeof document !== "undefined" && document.readyState === "loading") {
|
||||
const onReady = () => {
|
||||
document.removeEventListener("DOMContentLoaded", onReady);
|
||||
if (window.nostr) {
|
||||
finish(window.nostr);
|
||||
}
|
||||
};
|
||||
document.addEventListener("DOMContentLoaded", onReady);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -36,13 +36,14 @@ const LOG_PREFIX = "[HashtagPreferences]";
|
||||
const HASHTAG_IDENTIFIER = "bitvid:tag-preferences";
|
||||
|
||||
const DEFAULT_VERSION = 1;
|
||||
// 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;
|
||||
// PERF: Reduced from 10s to 6s — the signer is guaranteed ready before list
|
||||
// loading starts (authSessionCoordinator waits for the permission pre-grant),
|
||||
// so decryption should succeed within 2-3s. 6s is generous enough for slow
|
||||
// relays while still failing fast for scheme fallback.
|
||||
const DECRYPT_TIMEOUT_MS = 6000;
|
||||
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
|
||||
// should recover near-instantly. Shorter delay speeds up the login path.
|
||||
const DECRYPT_RETRY_DELAY_MS = 1500;
|
||||
|
||||
class TinyEventEmitter {
|
||||
constructor() {
|
||||
@@ -984,10 +985,13 @@ class HashtagPreferencesService {
|
||||
(signerReadinessGate.status === "permission-denied" ||
|
||||
signerReadinessGate.status === "extension-unavailable");
|
||||
let signer = getActiveSigner();
|
||||
// FIX: Always attempt to resolve the signer, not only when
|
||||
// allowPermissionPrompt is true. ensureActiveSignerForPubkey does not
|
||||
// prompt the user — it only waits for an already-injected extension.
|
||||
// Without this, background refreshes can never decrypt.
|
||||
if (
|
||||
!signer &&
|
||||
typeof nostrClient?.ensureActiveSignerForPubkey === "function" &&
|
||||
allowPermissionPrompt
|
||||
typeof nostrClient?.ensureActiveSignerForPubkey === "function"
|
||||
) {
|
||||
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
|
||||
}
|
||||
@@ -1044,11 +1048,11 @@ class HashtagPreferencesService {
|
||||
sources.set(scheme, source);
|
||||
};
|
||||
|
||||
// PERF: Previous 5s no-prompt timeout was too aggressive — extensions that
|
||||
// have already granted permission still need time to decrypt. 8s gives a
|
||||
// realistic window while still failing faster than the full 15s interactive
|
||||
// timeout.
|
||||
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
|
||||
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
|
||||
// gate guarantees the extension is ready before decryption starts, so
|
||||
// decrypt calls should complete within 1-2s. 5s accommodates slow
|
||||
// extensions while still failing fast for scheme fallback.
|
||||
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 5000;
|
||||
const signerDecryptOptions = {
|
||||
priority: NIP07_PRIORITY.NORMAL,
|
||||
timeoutMs: nip07DecryptTimeoutMs,
|
||||
@@ -1207,12 +1211,17 @@ class HashtagPreferencesService {
|
||||
const decryptFn = decryptors.get(scheme);
|
||||
if (!decryptFn) return null;
|
||||
attemptSchemes.push(scheme);
|
||||
return decryptFn(ciphertext).then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
throw new Error("Decryption returned non-string payload.");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
// FIX: Wrap in Promise.resolve().then() to catch synchronous throws
|
||||
// from decryptor functions (e.g. NIP-07 adapter nip44Decrypt throws
|
||||
// immediately if the extension hasn't injected its nip44 module yet).
|
||||
return Promise.resolve()
|
||||
.then(() => decryptFn(ciphertext))
|
||||
.then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
throw new Error("Decryption returned non-string payload.");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
|
||||
+53
-40
@@ -44,16 +44,16 @@ import {
|
||||
|
||||
const SUBSCRIPTION_SET_KIND =
|
||||
getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000;
|
||||
// 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
|
||||
// the extension still initializing.
|
||||
const DECRYPT_RETRY_DELAY_MS = 3000;
|
||||
// PERF: Reduced from 10s to 6s — the signer is guaranteed ready before list
|
||||
// loading starts (authSessionCoordinator waits for the permission pre-grant),
|
||||
// so decryption should succeed within 2-3s. 6s is generous enough for slow
|
||||
// relays while still failing fast enough for the scheme fallback to try the
|
||||
// next decryption method without stalling the login path.
|
||||
const DECRYPT_TIMEOUT_MS = 6000;
|
||||
// PERF: Reduced from 3s to 1.5s — extensions that already granted permission
|
||||
// should recover near-instantly. The shorter delay prevents unnecessary wait
|
||||
// time during the critical login path when the first decrypt attempt fails.
|
||||
const DECRYPT_RETRY_DELAY_MS = 1500;
|
||||
|
||||
function normalizeHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
@@ -909,9 +909,10 @@ class SubscriptionsManager {
|
||||
|
||||
try {
|
||||
// Race against a timeout so the UI doesn't hang indefinitely if relays stall.
|
||||
// The timeout must accommodate relay fetch (~12s) + decryption (~20s timeout
|
||||
// with ~5s per scheme attempt × up to 3 schemes), so we use 45s.
|
||||
const timeoutMs = 45000;
|
||||
// The timeout accommodates relay fetch (~12s) + decryption (~6s timeout per
|
||||
// scheme × up to 3 schemes = ~18s) = ~30s. Reduced from 45s to prevent
|
||||
// blocking the login path for nearly a minute.
|
||||
const timeoutMs = 25000;
|
||||
await Promise.race([
|
||||
loader,
|
||||
new Promise((_, reject) =>
|
||||
@@ -1005,28 +1006,33 @@ class SubscriptionsManager {
|
||||
};
|
||||
|
||||
let signer = getActiveSigner();
|
||||
// FIX: Always attempt to resolve the signer if one isn't available,
|
||||
// regardless of allowPermissionPrompt. The previous guard meant that
|
||||
// background refreshes (allowPermissionPrompt=false) could never decrypt
|
||||
// because they skipped signer resolution entirely. ensureActiveSignerForPubkey
|
||||
// does not itself prompt the user — it only waits for an already-injected
|
||||
// extension or returns the existing signer.
|
||||
if (
|
||||
allowPermissionPrompt &&
|
||||
(!signer ||
|
||||
(!signerHasRequiredDecryptors(signer) &&
|
||||
typeof nostrClient?.ensureActiveSignerForPubkey === "function"))
|
||||
(!signer || !signerHasRequiredDecryptors(signer)) &&
|
||||
typeof nostrClient?.ensureActiveSignerForPubkey === "function"
|
||||
) {
|
||||
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
|
||||
}
|
||||
|
||||
const signerCapabilities = signer?.capabilities;
|
||||
const signerHasNip04 =
|
||||
typeof signer?.nip04Decrypt === "function" &&
|
||||
(!signerCapabilities || signerCapabilities.nip04 !== false);
|
||||
const signerHasNip44 =
|
||||
typeof signer?.nip44Decrypt === "function" &&
|
||||
(!signerCapabilities || signerCapabilities.nip44 !== false);
|
||||
// FIX: Check method existence only, not capabilities. NIP-07 adapters
|
||||
// always expose decrypt methods that check extension state at call time.
|
||||
// The capabilities getter dynamically queries window.nostr which may not
|
||||
// have injected nip04/nip44 modules yet (lazy injection), causing false
|
||||
// negatives that block decryption even when methods will work at call time.
|
||||
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
|
||||
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
|
||||
|
||||
const nostrApi = allowPermissionPrompt
|
||||
? typeof window !== "undefined"
|
||||
? window?.nostr
|
||||
: null
|
||||
: null;
|
||||
// FIX: Always check window.nostr for decrypt methods as a fallback,
|
||||
// not only when allowPermissionPrompt is true. The extension may have
|
||||
// already granted permissions from a previous session, and checking
|
||||
// its methods doesn't trigger any user-visible prompts.
|
||||
const nostrApi =
|
||||
typeof window !== "undefined" ? window?.nostr : null;
|
||||
const windowHasNip04 = typeof nostrApi?.nip04?.decrypt === "function";
|
||||
const windowHasNip44 =
|
||||
(nostrApi?.nip44 && typeof nostrApi.nip44.decrypt === "function") ||
|
||||
@@ -1079,11 +1085,11 @@ class SubscriptionsManager {
|
||||
decryptors.set(scheme, handler);
|
||||
};
|
||||
|
||||
// PERF: Previous 5s no-prompt timeout was too aggressive — extensions that
|
||||
// have already granted permission still need time to decrypt. 8s gives a
|
||||
// realistic window while still failing faster than the full 15s interactive
|
||||
// timeout.
|
||||
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
|
||||
// PERF: Reduced no-prompt timeout from 8s to 5s — the signer readiness
|
||||
// gate now guarantees the extension is ready before decryption starts, so
|
||||
// decrypt calls should complete within 1-2s. 5s accommodates slow
|
||||
// extensions while still failing fast enough for scheme fallback.
|
||||
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 5000;
|
||||
const signerDecryptOptions = {
|
||||
priority: NIP07_PRIORITY.NORMAL,
|
||||
timeoutMs: nip07DecryptTimeoutMs,
|
||||
@@ -1219,12 +1225,19 @@ class SubscriptionsManager {
|
||||
const decryptFn = decryptors.get(scheme);
|
||||
if (!decryptFn) return null;
|
||||
attemptSchemes.push(scheme);
|
||||
return decryptFn(ciphertext).then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
throw new Error("Decryption returned a non-string payload.");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
// FIX: Wrap in Promise.resolve().then() to catch synchronous throws
|
||||
// from decryptor functions (e.g. NIP-07 adapter nip44Decrypt throws
|
||||
// immediately if the extension hasn't injected its nip44 module yet).
|
||||
// Without this, a sync throw breaks the .map() chain and prevents
|
||||
// Promise.any() from trying the remaining schemes.
|
||||
return Promise.resolve()
|
||||
.then(() => decryptFn(ciphertext))
|
||||
.then((plaintext) => {
|
||||
if (typeof plaintext !== "string") {
|
||||
throw new Error("Decryption returned a non-string payload.");
|
||||
}
|
||||
return { plaintext, scheme };
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
|
||||
@@ -9982,7 +9982,23 @@ export class ProfileModalController {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUnlocked = storageService && storageService.masterKeys.has(pubkey);
|
||||
let isUnlocked = storageService && storageService.masterKeys.has(pubkey);
|
||||
|
||||
// Auto-unlock: If not already unlocked and a signer is available, attempt
|
||||
// a silent unlock in the background. This eliminates the manual "Unlock"
|
||||
// click for users whose extension has already granted permissions.
|
||||
if (!isUnlocked && storageService && !this.storageUnlockFailure) {
|
||||
const signer = getActiveSigner();
|
||||
if (signer && (typeof signer.nip44Decrypt === "function" || typeof signer.nip04Decrypt === "function")) {
|
||||
try {
|
||||
await storageService.unlock(pubkey, { signer });
|
||||
isUnlocked = storageService.masterKeys.has(pubkey);
|
||||
} catch (autoUnlockError) {
|
||||
// Silent failure — user can still manually unlock.
|
||||
devLogger.log("[ProfileModal] Auto-unlock storage skipped:", autoUnlockError?.message || autoUnlockError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.storageStatusText) {
|
||||
if (isUnlocked) {
|
||||
@@ -10238,14 +10254,14 @@ export class ProfileModalController {
|
||||
}
|
||||
}
|
||||
|
||||
const caps = signer?.capabilities && typeof signer.capabilities === "object"
|
||||
? signer.capabilities
|
||||
: null;
|
||||
const hasNip44Decrypt =
|
||||
caps?.nip44 !== false && typeof signer?.nip44Decrypt === "function";
|
||||
// FIX: Check for method existence only, not capabilities. NIP-07 adapters
|
||||
// always expose nip04Decrypt/nip44Decrypt methods that perform their own
|
||||
// existence checks at call time. The capabilities getter dynamically queries
|
||||
// the extension which may not have injected its NIP-44/04 modules yet
|
||||
// (lazy injection), causing a false-negative that blocks the unlock flow.
|
||||
const hasNip44Decrypt = typeof signer?.nip44Decrypt === "function";
|
||||
const hasNip04Decrypt =
|
||||
caps?.nip04 !== false &&
|
||||
(typeof signer?.nip04Decrypt === "function" || typeof signer?.decrypt === "function");
|
||||
typeof signer?.nip04Decrypt === "function" || typeof signer?.decrypt === "function";
|
||||
|
||||
if (!hasNip44Decrypt && !hasNip04Decrypt) {
|
||||
const missingDecryptError = new Error(
|
||||
|
||||
Reference in new issue
Block a user