diff --git a/AGENTS.md b/AGENTS.md index 3a7b205..884a92d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -496,6 +496,24 @@ session with **no other memory of this project**. So: (previously the NIP-65 fallback was effectively dead code because our own DM relays are always non-empty). `NRelay1.event()` DOES throw on `OK:false`, so per-relay failures are real; the composer only clears on success. + - **Amber "flashing" on navigation (fixed 2026-10-12).** For an interactive + signer every `nip44.decrypt` opens Amber, and the read hooks decrypt **2 per + gift wrap** (seal + rumor) — up to ~400 calls per query, and + `DirectMessagesPage` mounts **two** such hooks at once + (`useConversations` + `useGroupConversations`). `amberSafeQueryOptions` + (`useDirectMessages.ts`) only disabled polling/focus-refetch and set a **5-min** + staleTime, so navigating back re-decrypted. It now also sets, for interactive + signers: `staleTime: Infinity`, `gcTime: Infinity`, `refetchOnMount: false`, + `refetchOnReconnect: false` — decrypt **once per session**, mirroring + `useStash`/`useSyncedAppData`. Consequence: Amber users don't get live DM + updates, so **`DirectMessagesPage` gained a manual Refresh button** + (`socialUi.dms.refresh`; en/es). Same guard added to `usePrivateContacts` and + `useCordnChat`. Also `useSyncPendingPosts` (`useOfflineCache.ts`) signed queued + **legacy/unsigned** posts in a 30 s loop with **no interactive gate** — it now + skips unsigned posts for interactive signers (pre-signed ones still publish). + ⚠️ If you add a new read hook that decrypts, gate it the same way or Amber + will storm on every navigation. (Not yet done: a persisted decrypt cache, so + the first load per app start still decrypts everything.) - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and does **not** use the ContextVM server. AI goes through our own **server-side diff --git a/src/hooks/useCordnChat.ts b/src/hooks/useCordnChat.ts index ea342ba..89a3950 100644 --- a/src/hooks/useCordnChat.ts +++ b/src/hooks/useCordnChat.ts @@ -49,7 +49,9 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) enabled: active, refetchInterval: active && !interactive ? 5000 : false, refetchOnWindowFocus: !interactive, - staleTime: interactive ? 5 * 60 * 1000 : 2000, + 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 => { if (!endpoint || !user) return []; const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts index 19a83d5..b2f9c65 100644 --- a/src/hooks/useDirectMessages.ts +++ b/src/hooks/useDirectMessages.ts @@ -35,11 +35,20 @@ function dmReadRelays(metadata: RelayMetadata): string[] { function amberSafeQueryOptions(interactive: boolean) { return { refetchOnWindowFocus: false as const, + // A relay reconnect would otherwise re-run the decrypt storm. + refetchOnReconnect: !interactive, // 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, + // For Amber the whole point is: decrypt ONCE, then never again while the + // session lives. A finite staleTime (it was 5 min) still re-decrypted on a + // later navigation; `gcTime: Infinity` + `refetchOnMount: false` keep the + // result cached for the session (mirrors useStash/useSyncedAppData). A + // manual refresh still works — `refetch()` ignores staleTime. + staleTime: interactive ? Infinity : 30000, + ...(interactive + ? { gcTime: Infinity, refetchOnMount: false as const } + : {}), }; } diff --git a/src/hooks/useOfflineCache.ts b/src/hooks/useOfflineCache.ts index 83520d1..e8d9e6d 100644 --- a/src/hooks/useOfflineCache.ts +++ b/src/hooks/useOfflineCache.ts @@ -21,6 +21,8 @@ import { updatePendingPostStatus, } from '@/lib/offlineCache'; import { useCurrentUser } from './useCurrentUser'; +import { useNostrLogin } from '@nostrify/react/login'; +import { isInteractiveSigner } from '@/lib/signerCapabilities'; import { useToast } from './useToast'; import { useTranslation } from '@/contexts/language'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -191,6 +193,11 @@ export function useSyncPendingPosts() { const queryClient = useQueryClient(); const { nostr } = useNostr(); const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + // Amber must not be popped from a background interval. New queued posts carry + // an already-signed event; only legacy/unsigned entries need a `signEvent`, and + // for an interactive signer we leave those queued rather than prompt silently. + const interactive = isInteractiveSigner(user, logins); const { toast } = useToast(); const [isSyncing, setIsSyncing] = useState(false); const syncingRef = useRef(false); @@ -217,6 +224,9 @@ export function useSyncPendingPosts() { for (const post of pending) { if (cancelled) break; + // Interactive signer (Amber): never sign from a background loop. + // Pre-signed posts still publish; legacy unsigned ones stay queued. + if (!post.signedEvent && interactive) continue; try { await updatePendingPostStatus(post.id, 'publishing'); const event: NostrEvent = @@ -257,7 +267,7 @@ export function useSyncPendingPosts() { clearTimeout(timeout); clearInterval(interval); }; - }, [isOnline, user, nostr, queryClient, toast, t]); + }, [isOnline, user, interactive, nostr, queryClient, toast, t]); return { isSyncing }; } diff --git a/src/hooks/usePrivateContacts.ts b/src/hooks/usePrivateContacts.ts index 28006fa..27457f9 100644 --- a/src/hooks/usePrivateContacts.ts +++ b/src/hooks/usePrivateContacts.ts @@ -35,6 +35,9 @@ export function usePrivateContacts() { enabled: !!user?.pubkey, staleTime: interactive ? Infinity : 60 * 1000, refetchOnWindowFocus: false, + // Amber: keep the decrypted result for the whole session so navigating back + // to Contacts doesn't re-prompt (was still re-decrypting after GC). + ...(interactive ? { gcTime: Infinity, refetchOnMount: false as const } : {}), queryFn: async (c): Promise => { if (!user?.pubkey || !user.signer?.nip44) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 860c04f..3e5ee8a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -4075,6 +4075,7 @@ socialUi: { subtitleShort: 'Encrypted', newMessage: 'New Message', groups: 'Groups', + refresh: 'Refresh', loadFailed: 'Failed to load messages', emptyTitle: 'No messages yet', emptyDesc: 'Start a conversation with someone in the community', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index a286476..ff35870 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -4075,6 +4075,7 @@ socialUi: { subtitleShort: 'Cifrado', newMessage: 'Nuevo mensaje', groups: 'Grupos', + refresh: 'Actualizar', loadFailed: 'No se pudieron cargar los mensajes', emptyTitle: 'Aún no hay mensajes', emptyDesc: 'Empieza una conversación con alguien de la comunidad', diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index b6be14a..6b083b0 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -27,6 +27,7 @@ import { ShieldCheck, X, Users, + RefreshCw, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -212,10 +213,10 @@ export default function DirectMessagesPage() { const { t } = useTranslation(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); - const { data: inbox, isLoading, error, refetch } = useConversations(); + const { data: inbox, isLoading, error, refetch, isRefetching } = useConversations(); const conversations = inbox?.conversations; const undecryptable = inbox?.undecryptable ?? 0; - const { data: groups } = useGroupConversations(); + const { data: groups, refetch: refetchGroups } = useGroupConversations(); const { getLastRead } = useDMRead(); const [showNewMessage, setShowNewMessage] = useState(false); @@ -326,13 +327,30 @@ export default function DirectMessagesPage() { } actions={ - +
+ {/* Manual refresh — Amber doesn't poll (each decrypt is a prompt), + so this is how new messages are pulled in. */} + + +
} />