/** * useGlobalInbox — app-wide inbox listener. * * Подписан на WS-топик `inbox:` при любом экране внутри * (app)-группы. Когда приходит push с envelope, мы: * 1. Декриптуем — если это наш контакт, добавляем в store. * 2. Инкрементим unreadByContact[address]. * 3. Показываем local notification (от кого + счётчик). * * НЕ дублирует chat-detail'овский `useMessages` — тот делает initial * HTTP-pull при открытии чата и слушает тот же топик (двойная подписка * с фильтром по sender_pub). Оба держат в store консистентное состояние * через `appendMessage` (который идемпотентен по id). * * Фильтрация "app backgrounded" не нужна: Expo notifications'handler * показывает banner и в foreground, но при активном чате с этим * контактом нотификация dismiss'ится автоматически через * clearContactNotifications (вызывается при mount'е chats/[id]). */ import { useEffect, useRef } from 'react'; import { AppState } from 'react-native'; import { usePathname } from 'expo-router'; import { useStore } from '@/lib/store'; import { getWSClient } from '@/lib/ws'; import { decryptMessage } from '@/lib/crypto'; import { tryParsePostRef } from '@/lib/forwardPost'; import { fetchInbox } from '@/lib/api'; import { appendMessage } from '@/lib/storage'; import { randomId } from '@/lib/utils'; import { notifyIncoming } from './useNotifications'; export function useGlobalInbox() { const keyFile = useStore(s => s.keyFile); const contacts = useStore(s => s.contacts); const appendMsg = useStore(s => s.appendMessage); const incrementUnread = useStore(s => s.incrementUnread); const pathname = usePathname(); const contactsRef = useRef(contacts); const pathnameRef = useRef(pathname); useEffect(() => { contactsRef.current = contacts; }, [contacts]); useEffect(() => { pathnameRef.current = pathname; }, [pathname]); useEffect(() => { if (!keyFile?.x25519_pub) return; const ws = getWSClient(); const handleEnvelopePull = async () => { try { const envelopes = await fetchInbox(keyFile.x25519_pub); for (const env of envelopes) { // Найти контакт по sender_pub — если не знакомый, игнорим // (для MVP; в future можно показывать "unknown sender"). const c = contactsRef.current.find( x => x.x25519Pub === env.sender_pub, ); if (!c) continue; let text = ''; try { text = decryptMessage( env.ciphertext, env.nonce, env.sender_pub, keyFile.x25519_priv, ) ?? ''; } catch { continue; } if (!text) continue; // Стабильный id от сервера (sha256(nonce||ct)[:16]); fallback // на nonce-префикс если вдруг env.id пустой. const msgId = env.id || `${env.sender_pub}_${env.nonce.slice(0, 16)}`; const postRef = tryParsePostRef(text); const msg = { id: msgId, from: env.sender_pub, text: postRef ? '' : text, timestamp: env.timestamp, mine: false, ...(postRef && { postRef: { postID: postRef.post_id, author: postRef.author, excerpt: postRef.excerpt, hasImage: postRef.has_image, }, }), }; appendMsg(c.address, msg); await appendMessage(c.address, msg); // Если пользователь прямо сейчас в этом чате — unread не инкрементим, // notification не показываем. const inThisChat = pathnameRef.current === `/chats/${c.address}` || pathnameRef.current.startsWith(`/chats/${c.address}/`); if (inThisChat && AppState.currentState === 'active') continue; incrementUnread(c.address); const unread = useStore.getState().unreadByContact[c.address] ?? 1; notifyIncoming({ contactAddress: c.address, senderName: c.username ? `@${c.username}` : (c.alias ?? 'New message'), unreadCount: unread, }); } } catch { /* silent — ошибки pull'а обрабатывает useMessages */ } }; const off = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => { if (frame.event !== 'inbox') return; handleEnvelopePull(); }); return off; }, [keyFile, appendMsg, incrementUnread]); }