SkateSpots/src/lib/signerCapabilities.ts
shakespeare.diy b910141699 fix(amber): stop passive decryption from storming Amber on navigation (1.2.16)
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>
2026-09-07 14:57:35 +07:00

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;
}