diff --git a/src/hooks/useCordnChat.ts b/src/hooks/useCordnChat.ts index 237ad90..91ac2dc 100644 --- a/src/hooks/useCordnChat.ts +++ b/src/hooks/useCordnChat.ts @@ -58,11 +58,13 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) staleTime: interactive ? Infinity : 2000, // Amber: decrypt the backlog once per session, not on every crew-page visit. ...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}), - queryFn: async (): Promise => { + queryFn: async (c): Promise => { if (!endpoint || !user) return []; const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; const signer = user.signer as unknown as Nip44Signer; - const signal = AbortSignal.timeout(10000); + // Honour query cancellation too — the fetch loop decrypts per message and + // must not keep opening Amber after the crew page is closed. + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); return fetchGroupMessages({ relay, endpoint, diff --git a/src/hooks/useCordnJoinNotifications.ts b/src/hooks/useCordnJoinNotifications.ts index 8c58125..d27cca4 100644 --- a/src/hooks/useCordnJoinNotifications.ts +++ b/src/hooks/useCordnJoinNotifications.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -25,6 +25,11 @@ export function useCordnJoinNotifications(opened = false) { const interactive = isInteractiveSigner(user, logins); const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const [dismissed, setDismissed] = useLocalStorage(DISMISSED_KEY, []); + // The bell can close while a decrypt loop is running; React Query stops + // scheduling new fetches when `enabled` flips, but does not necessarily abort + // the current queryFn. Read the live open state inside the loop. + const openedRef = useRef(opened); + openedRef.current = opened; // Amber prompts once per decrypt. Load these only when the bell is opened; // passive signers can fetch them quietly in the background. const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened); @@ -51,6 +56,9 @@ export function useCordnJoinNotifications(opened = false) { ); const notices: CordnJoinNotice[] = []; for (const event of events) { + // Closing the bell/unmounting the query must stop further Amber + // decrypt prompts; the old loop kept going after navigation. + if (!openedRef.current || c.signal.aborted) break; let plain: string; try { plain = event.content.trim().startsWith('{') diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index dc900c1..e57a5ba 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -245,6 +245,21 @@ type DecryptedDm = CachedDm; const DM_DECRYPT_CACHE_MAX = 4000; const dmDecryptCache = new Map(); const dmDecryptInFlight = new Map>(); +/** + * Seal plaintext cache. A gift wrap needs TWO decrypts (seal, then rumor). If + * the user navigates while Amber is open after the first one, the query aborts + * before we can build the full message. Keeping the seal means the next visit + * only asks Amber for the rumor instead of prompting twice for the same wrap. + */ +const dmSealCache = new Map(); + +function rememberSeal(key: string, sealJson: string): void { + if (dmSealCache.size >= DM_DECRYPT_CACHE_MAX) { + const oldest = dmSealCache.keys().next().value; + if (oldest !== undefined) dmSealCache.delete(oldest); + } + dmSealCache.set(key, sealJson); +} // Which accounts' persisted caches have been loaded into memory this session, // and any hydration in flight (so concurrent readers await the same load). @@ -286,11 +301,17 @@ async function decryptGiftWrap( viewerPk: string, signer: DmSigner, wrap: NostrEvent, + signal?: AbortSignal, ): Promise { + // A cancelled query must stop launching NEW Amber intents. Without this, a + // user who starts decrypting the inbox and then navigates away would keep + // getting Amber popups for every remaining gift wrap in the background. + if (signal?.aborted) return null; const key = `${viewerPk}:${wrap.id}`; // Hydrate the persisted cache once per account before the first lookup, so a // relaunch reuses decryptions that already happened on this device. await ensureDmCacheHydrated(viewerPk); + if (signal?.aborted) return null; const cached = dmDecryptCache.get(key); if (cached) return cached; const flying = dmDecryptInFlight.get(key); @@ -298,7 +319,15 @@ async function decryptGiftWrap( const task = (async (): Promise => { try { - const sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content); + let sealJson = dmSealCache.get(key); + if (!sealJson) { + sealJson = await signer.nip44!.decrypt(wrap.pubkey, wrap.content); + rememberSeal(key, sealJson); + } + // Navigation may have cancelled the query while Amber was open. Do not + // ask for the second decrypt (which would pop Amber again) — the next + // explicit visit reuses the cached seal and only asks for the rumor. + if (signal?.aborted) return null; const seal = JSON.parse(sealJson) as NostrEvent; if (seal.kind !== SEAL_KIND) return null; // SECURITY: verify the seal's signature (sender impersonation). @@ -326,6 +355,7 @@ async function decryptGiftWrap( if (oldest !== undefined) dmDecryptCache.delete(oldest); } dmDecryptCache.set(key, dm); + dmSealCache.delete(key); // Persist so this wrap is never decrypted again (fire-and-forget). void saveDmDecryptKey(key, viewerPk, dm).catch(() => {}); return dm; @@ -661,10 +691,11 @@ export function useConversation(otherPubkey: string) { const messages: DirectMessage[] = []; for (const giftWrap of uniqueEvents) { + if (c.signal.aborted) break; try { // Decrypt via the shared session cache (coalesced; one decrypt per wrap // per session even across different views). - const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap); + const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // 1:1 only. A message with more than one recipient is a GROUP message @@ -750,9 +781,10 @@ export function useConversations() { let undecryptable = 0; for (const giftWrap of uniqueEvents) { + if (c.signal.aborted) break; try { // Decrypt via the shared session cache. - const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap); + const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // 1:1 list only. A message with more than one recipient is a GROUP @@ -864,8 +896,9 @@ export function useGroupConversations() { const byGroup = new Map(); for (const giftWrap of uniqueEvents) { + if (c.signal.aborted) break; try { - const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap); + const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // 1:1 messages are surfaced by useConversations, not here. if (dm.recipients.length <= 1) continue; @@ -975,8 +1008,9 @@ export function useGroupConversation(participants: string[]) { const messages: DirectMessage[] = []; for (const giftWrap of uniqueEvents) { + if (c.signal.aborted) break; try { - const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap); + const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal); if (!dm) continue; // Only include if this message is part of the target group. diff --git a/src/lib/cordn/client.ts b/src/lib/cordn/client.ts index ca97ba0..d43b4ef 100644 --- a/src/lib/cordn/client.ts +++ b/src/lib/cordn/client.ts @@ -91,6 +91,8 @@ export async function fetchGroupMessages(params: { const out: CordnMessage[] = []; for (const ev of events) { + // Never keep opening Amber after the chat query has been cancelled. + if (signal.aborted) break; const sealed = parseSealed(ev.content); if (!sealed) continue; const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId);