With Amber as signer, every nip44 decrypt is a foreground intent (visible popup). Several hooks decrypt inside auto-running queries, so Amber flashed on navigation/focus and (worst case) storms — one popup per encrypted event, and polling queries re-fired every few seconds. New helper isInteractiveSigner() (signerCapabilities.ts) detects the Amber (x-amber) signer, where each crypto op prompts the user. Hooks now gate passive decryption for interactive signers only; extension/nsec/bunker keep their silent, live-polling behaviour. Fixes: - useCrewDismissals (GLOBAL via header NotificationBell — the main offender): Amber users now use the instant localStorage mirror only, never passively decrypt the synced list; disabled refetchOnWindowFocus + Infinity staleTime. Dismissals still sync OUT on the write path. - useDirectMessages (all 4 DM query hooks): added amberSafeQueryOptions() — no background polling and no focus refetch for Amber (these decrypt up to ~200 gift wraps × 2 each); silent signers keep the 60s poll. - useStash / usePrivateContacts: disabled focus refetch + Infinity staleTime for Amber so the single decrypt runs once, not on every focus. - useCordnChat: dropped the 5s live poll for Amber (was a popup every 5s); silent signers keep it. Scanned every signEvent call site: all are inside mutations/user callbacks (zaps, invites, DMs, chat, follows, purchases, NIP-98 auth) — no passive auto-signing anywhere. Deliberate user actions still prompt Amber as intended. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
101 lines
4.3 KiB
TypeScript
101 lines
4.3 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|