/** * Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes * for the active chat. Falls back to 30-second polling whenever the WS is * not connected — preserves correctness on older nodes or flaky networks. * * Flow: * 1. On mount: one HTTP fetch so we have whatever is already in the inbox * 2. Subscribe to topic `inbox:` — the node pushes a summary * for each fresh envelope as soon as mailbox.Store() succeeds * 3. On each push, pull the full envelope list (cheap — bounded by * MailboxPerRecipientCap) and decrypt anything we haven't seen yet * 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it * reconnects */ import { useEffect, useCallback, useRef } from 'react'; import { fetchInbox } from '@/lib/api'; import { getWSClient } from '@/lib/ws'; import { decryptMessage } from '@/lib/crypto'; import { appendMessage } from '@/lib/storage'; import { useStore } from '@/lib/store'; const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect export function useMessages(contactX25519: string) { const keyFile = useStore(s => s.keyFile); const appendMsg = useStore(s => s.appendMessage); const pullAndDecrypt = useCallback(async () => { if (!keyFile || !contactX25519) return; try { const envelopes = await fetchInbox(keyFile.x25519_pub); for (const env of envelopes) { // Only process messages from this contact if (env.sender_pub !== contactX25519) continue; const text = decryptMessage( env.ciphertext, env.nonce, env.sender_pub, keyFile.x25519_priv, ); if (!text) continue; const msg = { id: `${env.sender_pub}_${env.timestamp}_${env.nonce.slice(0, 8)}`, from: env.sender_pub, text, timestamp: env.timestamp, mine: false, }; appendMsg(contactX25519, msg); await appendMessage(contactX25519, msg); } } catch (e) { // Don't surface inbox errors aggressively — next event or poll retries console.warn('[useMessages] pull error:', e); } }, [keyFile, contactX25519, appendMsg]); // ── Fallback polling state ──────────────────────────────────────────── const pollTimerRef = useRef | null>(null); const disconnectTORef = useRef | null>(null); const startPolling = useCallback(() => { if (pollTimerRef.current) return; console.log('[useMessages] WS down — starting HTTP poll fallback'); pullAndDecrypt(); pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL); }, [pullAndDecrypt]); const stopPolling = useCallback(() => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } if (disconnectTORef.current) { clearTimeout(disconnectTORef.current); disconnectTORef.current = null; } }, []); useEffect(() => { if (!keyFile || !contactX25519) return; const ws = getWSClient(); // Initial fetch — populate whatever landed before we mounted. pullAndDecrypt(); // Subscribe to our x25519 inbox — the node emits on mailbox.Store. // Topic filter: only envelopes for ME; we then filter by sender inside // the handler so we only render messages in THIS chat. const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => { if (frame.event !== 'inbox') return; const d = frame.data as { sender_pub?: string } | undefined; // Optimisation: if the envelope is from a different peer, skip the // whole refetch — we'd just drop it in the sender filter below anyway. if (d?.sender_pub && d.sender_pub !== contactX25519) return; pullAndDecrypt(); }); // Manage fallback polling based on WS connection state. const offConn = ws.onConnectionChange((ok) => { if (ok) { stopPolling(); // Catch up anything we missed while disconnected. pullAndDecrypt(); } else if (disconnectTORef.current === null) { disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING); } }); ws.connect(); return () => { offInbox(); offConn(); stopPolling(); }; }, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]); }