/** * signerCapabilities — safely probe what a Nostr signer can do. * ───────────────────────────────────────────────────────────────────────────── * The NUser extension signer exposes `.nip44` / `.nip04` as GETTERS that call * `.nostr` (window.nostr) and THROW "Browser extension not available" when the * extension isn't present. Accessing `user.signer?.nip44` during render will * therefore crash the whole React tree for extension logins whose extension * isn't injected yet (page-load race) or is missing entirely. * * These helpers determine capability WITHOUT touching the throwing getter: * we look at the login type + probe `window.nostr` directly (a plain property * read that never throws). Use `hasNip44(user, logins)` anywhere you'd * otherwise write `!!user?.signer?.nip44` in a render path. */ interface MaybeUser { pubkey?: string; } interface MaybeLogin { type: string; pubkey?: string; } function windowNostr(): { nip44?: unknown; nip04?: unknown } | undefined { return (globalThis as { nostr?: { nip44?: unknown; nip04?: unknown } }).nostr; } /** * Resolve the login that backs the current user. * * IMPORTANT: we cannot rely solely on `l.pubkey === user.pubkey`. Depending on * the login method and how Nostrify rehydrates from localStorage, the top-level * `pubkey` on a stored login object is not always populated (nsec logins in * particular expose the pubkey nested under the decrypted key material). When a * pubkey match fails we fall back to the ACTIVE login (`logins[0]`), which is * exactly the login `useCurrentUser` turns into `users[0]` / `user`. Without * this fallback, a perfectly capable nsec/amber user gets wrongly told * "Encryption Required" on the Stash page. */ function resolveLogin( user: MaybeUser | null | undefined, logins: readonly MaybeLogin[], ): MaybeLogin | undefined { if (!user?.pubkey || logins.length === 0) return undefined; return logins.find((l) => l.pubkey === user.pubkey) ?? logins[0]; } /** True if the user's signer can do NIP-44 encryption. Never throws. */ export function hasNip44( user: MaybeUser | null | undefined, logins: readonly MaybeLogin[], ): boolean { const login = resolveLogin(user, logins); if (!login) return false; // nsec + bunker: NIP-44 always available (pure JS / remote signer implements it) if (login.type === 'nsec' || login.type === 'bunker') return true; // amber (NIP-55): the Amber signer implements nip44_encrypt / nip44_decrypt // over native intents (CapacitorAmberSigner) and URL redirects (AmberSigner). if (login.type === 'x-amber') return true; // extension: probe window.nostr directly (no throwing getter) if (login.type === 'extension') return !!windowNostr()?.nip44; // unknown: no NIP-44 return false; } /** * True when the active signer prompts the user for EVERY cryptographic op — * i.e. Amber (NIP-55), where each sign/encrypt/decrypt launches Amber's * Approve/Reject screen as a foreground intent. * * This matters for passive decryption: with an extension/nsec/bunker signer, * decrypting many events in a background query is silent and instant, so it's * fine to do it automatically (on mount/focus). With Amber, the SAME code * launches Amber once per event — so Amber visibly flashes on navigation and * can storm the user with popups. Callers use this to gate/defer passive * decryption (only decrypt on explicit user intent, don't auto-refetch) when * the signer is interactive. */ export function isInteractiveSigner( user: MaybeUser | null | undefined, logins: readonly MaybeLogin[], ): boolean { const login = resolveLogin(user, logins); if (!login) return false; // Amber (NIP-55) is the only signer that requires a user interaction per op. return login.type === 'x-amber'; } /** True if the user's signer can do NIP-04 encryption. Never throws. */ export function hasNip04( user: MaybeUser | null | undefined, logins: readonly MaybeLogin[], ): boolean { const login = resolveLogin(user, logins); if (!login) return false; if (login.type === 'nsec' || login.type === 'bunker') return true; if (login.type === 'x-amber') return true; if (login.type === 'extension') return !!windowNostr()?.nip04; return false; }