From b910141699971367a382ce871f29b09d8f12b6f3 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 14:57:35 +0700 Subject: [PATCH] fix(amber): stop passive decryption from storming Amber on navigation (1.2.16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/hooks/useCordnChat.ts | 12 +++++++++--- src/hooks/useCrewDismissals.ts | 22 +++++++++++++++++++++- src/hooks/useDirectMessages.ts | 33 ++++++++++++++++++++++++--------- src/hooks/usePrivateContacts.ts | 8 +++++++- src/hooks/useStash.ts | 8 ++++++-- src/lib/signerCapabilities.ts | 23 +++++++++++++++++++++++ 7 files changed, 91 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 5bb7e65..d117091 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.15", + "version": "1.2.16", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/useCordnChat.ts b/src/hooks/useCordnChat.ts index 8358855..ea342ba 100644 --- a/src/hooks/useCordnChat.ts +++ b/src/hooks/useCordnChat.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; +import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { buildEnvelope, fetchGroupMessages, @@ -38,12 +38,18 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) const canEncrypt = checkNip44(user, logins); const active = !!endpoint && !!user && enabled && canEncrypt; + // Amber prompts on every message decrypt. A 5s live poll would fire an Amber + // popup every 5 seconds — unusable. For Amber we disable the poll (the user + // refreshes/sends to pull new messages) and stop focus refetches; silent + // signers keep the live 5s poll. + const interactive = isInteractiveSigner(user, logins); const query = useQuery({ queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey], enabled: active, - refetchInterval: active ? 5000 : false, - staleTime: 2000, + refetchInterval: active && !interactive ? 5000 : false, + refetchOnWindowFocus: !interactive, + staleTime: interactive ? 5 * 60 * 1000 : 2000, queryFn: async (): Promise => { if (!endpoint || !user) return []; const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; diff --git a/src/hooks/useCrewDismissals.ts b/src/hooks/useCrewDismissals.ts index d3d096f..049d1d6 100644 --- a/src/hooks/useCrewDismissals.ts +++ b/src/hooks/useCrewDismissals.ts @@ -1,6 +1,8 @@ import { useNostr } from '@nostrify/react'; +import { useNostrLogin } from '@nostrify/react/login'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { isInteractiveSigner } from '@/lib/signerCapabilities'; /** * Cross-device "dismissed crew notifications" set. @@ -65,15 +67,33 @@ export function dismissKey(pubkey: string | undefined) { export function useCrewDismissals() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + + // Amber (NIP-55) prompts on every decrypt. This query is mounted GLOBALLY via + // the header NotificationBell, so auto-decrypting here would launch Amber on + // every navigation/focus — the "Amber flashes on every tap" bug. For an + // interactive signer we therefore rely on the instant local mirror ONLY and + // never passively decrypt the synced remote list. Dismissals still sync OUT on + // the write path (useDismissCrewNotification), and a fresh device simply + // starts from an empty local mirror — a minor, acceptable degradation vs. + // storming the user with Amber popups just for walking around the app. + const interactive = isInteractiveSigner(user, logins); return useQuery({ queryKey: dismissKey(user?.pubkey), // Enabled even when logged out returns just the local mirror (harmless). - staleTime: 30_000, + staleTime: interactive ? Infinity : 30_000, + // Never re-run passive decryption on window focus (Amber returning focus + // would otherwise re-trigger this and pop Amber again in a loop). + refetchOnWindowFocus: false, + refetchOnReconnect: !interactive, queryFn: async (c): Promise> => { const local = new Set(readLocal()); if (!user?.pubkey) return local; + // Interactive signer (Amber): do NOT decrypt passively. Local mirror only. + if (interactive) return local; + const nip44 = getNip44(user); // No NIP-44 → can't read the synced list; local mirror is all we have. if (!nip44) return local; diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 461c339..f63f235 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -7,7 +7,26 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; -import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; +import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; + +/** + * React Query options that keep an interactive signer (Amber) from being stormed + * with decrypt popups. Each DM query decrypts up to hundreds of gift wraps, and + * every decrypt is an Amber intent — so with Amber we must NOT poll and must NOT + * re-run on window focus (Amber handing focus back would re-trigger the query + * and pop Amber again). With a silent signer (extension/nsec/bunker) we keep the + * live polling behaviour. Callers spread this over their base options. + */ +function amberSafeQueryOptions(interactive: boolean) { + return { + refetchOnWindowFocus: false as const, + // No background polling for Amber (each poll = a decrypt storm). Silent + // signers keep a 60s poll for near-live updates. + refetchInterval: interactive ? (false as const) : 60000, + // Long stale time for Amber so navigating back to a chat doesn't re-decrypt. + staleTime: interactive ? 5 * 60 * 1000 : 30000, + }; +} // NIP-17 Message Types export interface DirectMessage { @@ -333,8 +352,7 @@ export function useConversation(otherPubkey: string) { return messages.sort((a, b) => a.createdAt - b.createdAt); }, enabled: nip44Available && !!otherPubkey, - staleTime: 30000, - refetchInterval: 30000, // Poll for new messages + ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); } @@ -459,8 +477,7 @@ export function useConversations() { ); }, enabled: nip44Available, - staleTime: 30000, - refetchInterval: 60000, // Poll for new conversations + ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); } @@ -609,8 +626,7 @@ export function useGroupConversations() { ); }, enabled: nip44Available, - staleTime: 30000, - refetchInterval: 60000, + ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); } @@ -715,7 +731,6 @@ export function useGroupConversation(participants: string[]) { return messages.sort((a, b) => a.createdAt - b.createdAt); }, enabled: nip44Available && participants.length > 0, - staleTime: 30000, - refetchInterval: 30000, + ...amberSafeQueryOptions(isInteractiveSigner(user, logins)), }); } diff --git a/src/hooks/usePrivateContacts.ts b/src/hooks/usePrivateContacts.ts index 93ecc78..965fe58 100644 --- a/src/hooks/usePrivateContacts.ts +++ b/src/hooks/usePrivateContacts.ts @@ -1,6 +1,8 @@ import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { isInteractiveSigner } from '@/lib/signerCapabilities'; /** * Private, encrypted contacts list (Armada-style). @@ -24,11 +26,15 @@ interface EncryptedContacts { export function usePrivateContacts() { const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + // Amber prompts on the decrypt below — don't re-run it on every window focus. + const interactive = isInteractiveSigner(user, logins); return useQuery({ queryKey: ['private-contacts', user?.pubkey], enabled: !!user?.pubkey, - staleTime: 60 * 1000, + staleTime: interactive ? Infinity : 60 * 1000, + refetchOnWindowFocus: false, queryFn: async (c): Promise => { if (!user?.pubkey || !user.signer?.nip44) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); diff --git a/src/hooks/useStash.ts b/src/hooks/useStash.ts index 5bb0884..6b92a90 100644 --- a/src/hooks/useStash.ts +++ b/src/hooks/useStash.ts @@ -4,7 +4,7 @@ import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; -import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; +import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; // Stash item stored in encrypted NIP-51 list export interface StashItem { @@ -55,6 +55,9 @@ export function useStash() { // Determine if nip44 is safely available WITHOUT touching any throwing getter. const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]); + // Amber prompts on the stash decrypt — keep it from re-running on window focus + // (e.g. every time Amber hands focus back) so it decrypts once, not repeatedly. + const interactive = isInteractiveSigner(user, logins); // Fetch and decrypt stash const { data: stash, isLoading, error } = useQuery({ @@ -95,7 +98,8 @@ export function useStash() { } }, enabled: !!user?.pubkey && hasNip44, - staleTime: 60000, // 1 minute + staleTime: interactive ? Infinity : 60000, + refetchOnWindowFocus: false, }); // Save stash (encrypt and publish) diff --git a/src/lib/signerCapabilities.ts b/src/lib/signerCapabilities.ts index 8deaf49..a08d251 100644 --- a/src/lib/signerCapabilities.ts +++ b/src/lib/signerCapabilities.ts @@ -64,6 +64,29 @@ export function hasNip44( 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,