diff --git a/AGENTS.md b/AGENTS.md index adeecc9..9610dff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,13 @@ session with **no other memory of this project**. So: MOTIS 2, open CORS) — note origin/destination go to that service, unlike road modes. **No external map-app handoff anywhere** (product decision) and no scooter/moped profile. +- **⚠️ `Layout` is NOT a router layout** — ~62 pages wrap themselves in + ``, so its chrome (`NotificationBell`, `SasquatchGuide`, nav) **remounts + on every navigation**. Consequence: any *decrypt-on-mount* hook in that chrome + re-opens Amber (NIP-55) on every page change. Encrypted hooks must be gated for + interactive signers: `staleTime/gcTime: Infinity`, `refetchOnMount: false`, + `refetchOnReconnect: false` (see `useSyncedAppData` / `useStash`). Hoisting + `Layout` into the router would fix the whole class — a known follow-up. - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/src/hooks/useStash.ts b/src/hooks/useStash.ts index b29de37..38c80b1 100644 --- a/src/hooks/useStash.ts +++ b/src/hooks/useStash.ts @@ -110,7 +110,12 @@ export function useStash(options?: { enabled?: boolean }) { }, enabled: callerEnabled && !!user?.pubkey && hasNip44, staleTime: interactive ? Infinity : 60000, + // Keep the decrypted stash in memory for the session so navigating away and + // back doesn't re-open Amber (gcTime would otherwise evict it after 5 min). + gcTime: interactive ? Infinity : undefined, + refetchOnMount: !interactive, refetchOnWindowFocus: false, + refetchOnReconnect: !interactive, }); /** diff --git a/src/hooks/useSyncedAppData.ts b/src/hooks/useSyncedAppData.ts index 5d948d0..99fac6f 100644 --- a/src/hooks/useSyncedAppData.ts +++ b/src/hooks/useSyncedAppData.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'; /** * useSyncedAppData — a small engine for storing a blob of app state as ONE @@ -68,6 +68,14 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) { const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]); const canSync = !!user?.pubkey && hasNip44; + // Amber (NIP-55) opens the signer app on EVERY nip44 decrypt. This engine is + // used by chrome that is mounted per page (e.g. the notification bell's read + // state), so re-decrypting on every navigation spams Amber popups. For an + // interactive signer, decrypt once per session and keep it cached — remounts + // read the cache instead of prompting. (Non-interactive signers are cheap, so + // they keep normal refetching.) + const interactive = isInteractiveSigner(user, logins); + const queryKey = useMemo( () => ['synced-app-data', dTag, user?.pubkey] as const, [dTag, user?.pubkey], @@ -77,8 +85,13 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) { const { data, isLoading } = useQuery({ queryKey, enabled: enabled && canSync, - staleTime: 60_000, + // For interactive signers: never consider it stale and never evict it from + // memory during the session → one decrypt, then cache hits. + staleTime: interactive ? Infinity : 60_000, + gcTime: interactive ? Infinity : undefined, + refetchOnMount: !interactive, refetchOnWindowFocus: false, + refetchOnReconnect: !interactive, queryFn: async ({ signal }): Promise => { if (!user?.pubkey || !hasNip44) return empty;