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,
|
staleTime: interactive ? Infinity : 2000,
|
||||||
// Amber: decrypt the backlog once per session, not on every crew-page visit.
|
// Amber: decrypt the backlog once per session, not on every crew-page visit.
|
||||||
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
|
...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}),
|
||||||
queryFn: async (): Promise<CordnMessage[]> => {
|
queryFn: async (c): Promise<CordnMessage[]> => {
|
||||||
if (!endpoint || !user) return [];
|
if (!endpoint || !user) return [];
|
||||||
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
|
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
|
||||||
const signer = user.signer as unknown as Nip44Signer;
|
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({
|
return fetchGroupMessages({
|
||||||
relay,
|
relay,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -25,6 +25,11 @@ export function useCordnJoinNotifications(opened = false) {
|
||||||
const interactive = isInteractiveSigner(user, logins);
|
const interactive = isInteractiveSigner(user, logins);
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const [dismissed, setDismissed] = useLocalStorage<string[]>(DISMISSED_KEY, []);
|
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;
|
// Amber prompts once per decrypt. Load these only when the bell is opened;
|
||||||
// passive signers can fetch them quietly in the background.
|
// passive signers can fetch them quietly in the background.
|
||||||
const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened);
|
const canRead = !!user?.pubkey && hasNip44(user, logins) && (!interactive || opened);
|
||||||
|
|
@ -51,6 +56,9 @@ export function useCordnJoinNotifications(opened = false) {
|
||||||
);
|
);
|
||||||
const notices: CordnJoinNotice[] = [];
|
const notices: CordnJoinNotice[] = [];
|
||||||
for (const event of events) {
|
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;
|
let plain: string;
|
||||||
try {
|
try {
|
||||||
plain = event.content.trim().startsWith('{')
|
plain = event.content.trim().startsWith('{')
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,21 @@ type DecryptedDm = CachedDm;
|
||||||
const DM_DECRYPT_CACHE_MAX = 4000;
|
const DM_DECRYPT_CACHE_MAX = 4000;
|
||||||
const dmDecryptCache = new Map<string, DecryptedDm>();
|
const dmDecryptCache = new Map<string, DecryptedDm>();
|
||||||
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
|
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,
|
// Which accounts' persisted caches have been loaded into memory this session,
|
||||||
// and any hydration in flight (so concurrent readers await the same load).
|
// and any hydration in flight (so concurrent readers await the same load).
|
||||||
|
|
@ -286,11 +301,17 @@ async function decryptGiftWrap(
|
||||||
viewerPk: string,
|
viewerPk: string,
|
||||||
signer: DmSigner,
|
signer: DmSigner,
|
||||||
wrap: NostrEvent,
|
wrap: NostrEvent,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<DecryptedDm | null> {
|
): 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}`;
|
const key = `${viewerPk}:${wrap.id}`;
|
||||||
// Hydrate the persisted cache once per account before the first lookup, so a
|
// Hydrate the persisted cache once per account before the first lookup, so a
|
||||||
// relaunch reuses decryptions that already happened on this device.
|
// relaunch reuses decryptions that already happened on this device.
|
||||||
await ensureDmCacheHydrated(viewerPk);
|
await ensureDmCacheHydrated(viewerPk);
|
||||||
|
if (signal?.aborted) return null;
|
||||||
const cached = dmDecryptCache.get(key);
|
const cached = dmDecryptCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const flying = dmDecryptInFlight.get(key);
|
const flying = dmDecryptInFlight.get(key);
|
||||||
|
|
@ -298,7 +319,15 @@ async function decryptGiftWrap(
|
||||||
|
|
||||||
const task = (async (): Promise<DecryptedDm | null> => {
|
const task = (async (): Promise<DecryptedDm | null> => {
|
||||||
try {
|
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;
|
const seal = JSON.parse(sealJson) as NostrEvent;
|
||||||
if (seal.kind !== SEAL_KIND) return null;
|
if (seal.kind !== SEAL_KIND) return null;
|
||||||
// SECURITY: verify the seal's signature (sender impersonation).
|
// SECURITY: verify the seal's signature (sender impersonation).
|
||||||
|
|
@ -326,6 +355,7 @@ async function decryptGiftWrap(
|
||||||
if (oldest !== undefined) dmDecryptCache.delete(oldest);
|
if (oldest !== undefined) dmDecryptCache.delete(oldest);
|
||||||
}
|
}
|
||||||
dmDecryptCache.set(key, dm);
|
dmDecryptCache.set(key, dm);
|
||||||
|
dmSealCache.delete(key);
|
||||||
// Persist so this wrap is never decrypted again (fire-and-forget).
|
// Persist so this wrap is never decrypted again (fire-and-forget).
|
||||||
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
|
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
|
||||||
return dm;
|
return dm;
|
||||||
|
|
@ -661,10 +691,11 @@ export function useConversation(otherPubkey: string) {
|
||||||
const messages: DirectMessage[] = [];
|
const messages: DirectMessage[] = [];
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
// Decrypt via the shared session cache (coalesced; one decrypt per wrap
|
// Decrypt via the shared session cache (coalesced; one decrypt per wrap
|
||||||
// per session even across different views).
|
// 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;
|
if (!dm) continue;
|
||||||
|
|
||||||
// 1:1 only. A message with more than one recipient is a GROUP message
|
// 1:1 only. A message with more than one recipient is a GROUP message
|
||||||
|
|
@ -750,9 +781,10 @@ export function useConversations() {
|
||||||
let undecryptable = 0;
|
let undecryptable = 0;
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
// Decrypt via the shared session cache.
|
// 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;
|
if (!dm) continue;
|
||||||
|
|
||||||
// 1:1 list only. A message with more than one recipient is a GROUP
|
// 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[]>();
|
const byGroup = new Map<string, DirectMessage[]>();
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
|
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
|
||||||
if (!dm) continue;
|
if (!dm) continue;
|
||||||
// 1:1 messages are surfaced by useConversations, not here.
|
// 1:1 messages are surfaced by useConversations, not here.
|
||||||
if (dm.recipients.length <= 1) continue;
|
if (dm.recipients.length <= 1) continue;
|
||||||
|
|
@ -975,8 +1008,9 @@ export function useGroupConversation(participants: string[]) {
|
||||||
const messages: DirectMessage[] = [];
|
const messages: DirectMessage[] = [];
|
||||||
|
|
||||||
for (const giftWrap of uniqueEvents) {
|
for (const giftWrap of uniqueEvents) {
|
||||||
|
if (c.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap);
|
const dm = await decryptGiftWrap(user.pubkey, user.signer, giftWrap, c.signal);
|
||||||
if (!dm) continue;
|
if (!dm) continue;
|
||||||
|
|
||||||
// Only include if this message is part of the target group.
|
// Only include if this message is part of the target group.
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export async function fetchGroupMessages(params: {
|
||||||
|
|
||||||
const out: CordnMessage[] = [];
|
const out: CordnMessage[] = [];
|
||||||
for (const ev of events) {
|
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);
|
const sealed = parseSealed(ev.content);
|
||||||
if (!sealed) continue;
|
if (!sealed) continue;
|
||||||
const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId);
|
const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue