fix(stash): avoid nip44 getter entirely — check login type + window.nostr directly

The NUser extension signer's .nip44 getter calls .nostr which throws
"Browser extension not available" when window.nostr is absent. A try/catch
around ?.nip44 doesn't work because esbuild optimises it away.

Fix: check login type + window.nostr directly (no getter, no throw) to
determine nip44 availability, and wrap all actual signer accesses at
call-time in their own try/catch.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-30 19:21:28 +07:00
parent 4c4e7159ba
commit 73f51662af

View file

@ -1,6 +1,7 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
@ -47,14 +48,26 @@ const STASH_D_TAG = 'skatespots-stash';
export function useStash() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const queryClient = useQueryClient();
const { mutateAsync: createEvent } = useNostrPublish();
// Safely check if nip44 is available without throwing (some signers throw
// "Browser extension not available" when the getter is accessed with no login)
const hasNip44 = (() => {
try { return !!user?.signer?.nip44; } catch { return false; }
})();
// Determine if nip44 is safely available WITHOUT touching any throwing getter.
// Extension signers throw "Browser extension not available" from their .nostr
// getter when window.nostr isn't present yet (page load race, or no extension).
// We check the login type instead: nsec and bunker have nip44 built-in always;
// extension logins only have it when window.nostr is present; amber never has it.
const hasNip44 = useMemo(() => {
if (!user?.pubkey) return false;
const login = logins.find((l) => l.pubkey === user.pubkey);
if (!login) return false;
// nsec and bunker: nip44 is always available (pure JS, no external dep)
if (login.type === 'nsec' || login.type === 'bunker') return true;
// extension: safe to check window.nostr directly (no getter, no throw)
if (login.type === 'extension') return !!(globalThis as { nostr?: { nip44?: unknown } }).nostr?.nip44;
// amber / unknown: no nip44 support
return false;
}, [user?.pubkey, logins]);
// Fetch and decrypt stash
const { data: stash, isLoading, error } = useQuery({
@ -81,8 +94,12 @@ export function useStash() {
const event = events[0];
try {
// Decrypt the content (encrypted to self)
const decrypted = await user.signer!.nip44!.decrypt(user.pubkey, event.content);
// Decrypt the content (encrypted to self) — access nip44 inside try/catch
// to guard against extension signers throwing when window.nostr is gone
let nip44Dec: typeof user.signer.nip44;
try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; }
if (!nip44Dec) return { items: [], updatedAt: 0 };
const decrypted = await nip44Dec.decrypt(user.pubkey, event.content);
const stashData = JSON.parse(decrypted) as Stash;
return stashData;
} catch (err) {
@ -100,8 +117,13 @@ export function useStash() {
throw new Error('Encryption not available');
}
// Access nip44 inside a try/catch at call time (signer is guaranteed by hasNip44 above)
let nip44Enc: typeof user.signer.nip44;
try { nip44Enc = user.signer?.nip44; } catch { throw new Error('Encryption not available'); }
if (!nip44Enc) throw new Error('Encryption not available');
// Encrypt to self
const encrypted = await user.signer!.nip44!.encrypt(user.pubkey, JSON.stringify(newStash));
const encrypted = await nip44Enc.encrypt(user.pubkey, JSON.stringify(newStash));
// Publish as replaceable event
await createEvent({