fix(amber): stop decrypt loops after navigation
This commit is contained in:
parent
20ff45089f
commit
1c361b8df5
4 changed files with 54 additions and 8 deletions
|
|
@ -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<CordnMessage[]> => {
|
||||
queryFn: async (c): Promise<CordnMessage[]> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string[]>(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('{')
|
||||
|
|
|
|||
|
|
@ -245,6 +245,21 @@ type DecryptedDm = CachedDm;
|
|||
const DM_DECRYPT_CACHE_MAX = 4000;
|
||||
const dmDecryptCache = new Map<string, DecryptedDm>();
|
||||
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
|
||||
/**
|
||||
* 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<string, string>();
|
||||
|
||||
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<DecryptedDm | null> {
|
||||
// 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<DecryptedDm | null> => {
|
||||
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<string, 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;
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue