/** * 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, loadMessages } 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); // Подгружаем кэш сообщений из AsyncStorage при открытии чата. // Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша // история старше недели пропадает при каждом рестарте приложения. // appendMsg в store идемпотентен по id, поэтому безопасно гонять его // для каждого кэшированного сообщения. useEffect(() => { if (!contactX25519) return; let cancelled = false; loadMessages(contactX25519).then(cached => { if (cancelled) return; for (const m of cached) appendMsg(contactX25519, m); }).catch(() => { /* cache miss / JSON error — not fatal */ }); return () => { cancelled = true; }; }, [contactX25519, appendMsg]); 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; // Dedup id — используем стабильный серверный env.id (hex // sha256(nonce||ct)[:16]). Раньше собирался из env.timestamp, // но клиентский тип не имел sent_at, поэтому timestamp был // undefined и все id коллапсировали на "undefined". const msg = { id: env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`, from: env.sender_pub, text, timestamp: env.timestamp, mine: false, }; appendMsg(contactX25519, msg); await appendMessage(contactX25519, msg); } } catch (e: any) { // Шумные ошибки (404 = нет mailbox'а, Network request failed = // нода недоступна) — ожидаемы в dev-среде и при offline-режиме, // не спамим console. Остальное — логируем. const msg = String(e?.message ?? e ?? ''); if (/→\s*404\b/.test(msg)) return; if (/ 404\b/.test(msg)) return; if (/Network request failed/i.test(msg)) return; if (/Failed to fetch/i.test(msg)) return; 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]); }