/** * Chat detail screen — верстка по референсу (X-style Messages). * * Структура: * [Header: back + avatar + name + typing-status | ⋯] * [FlatList: MessageBubble + DaySeparator, group-aware] * [Composer: floating, supports edit/reply banner] * * Весь presentational код вынесен в components/chat/*: * - MessageBubble (own/peer rendering) * - DaySeparator (day label между группами) * - buildRows (чистая функция группировки) * Date-форматирование — lib/dates.ts. */ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { View, Text, FlatList, KeyboardAvoidingView, Platform, Alert, Pressable, } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Clipboard from 'expo-clipboard'; import { useStore } from '@/lib/store'; import { useMessages } from '@/hooks/useMessages'; import { encryptMessage } from '@/lib/crypto'; import { sendEnvelope } from '@/lib/api'; import { getWSClient } from '@/lib/ws'; import { appendMessage, loadMessages } from '@/lib/storage'; import { randomId } from '@/lib/utils'; import type { Message } from '@/lib/types'; import { Avatar } from '@/components/Avatar'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; import { Composer, ComposerMode } from '@/components/Composer'; import { AttachmentMenu } from '@/components/chat/AttachmentMenu'; import { VideoCircleRecorder } from '@/components/chat/VideoCircleRecorder'; import { clearContactNotifications } from '@/hooks/useNotifications'; import { MessageBubble } from '@/components/chat/MessageBubble'; import { DaySeparator } from '@/components/chat/DaySeparator'; import { buildRows, Row } from '@/components/chat/rows'; import type { Attachment } from '@/lib/types'; function shortAddr(a: string, n = 6) { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } export default function ChatScreen() { const { id: contactAddress } = useLocalSearchParams<{ id: string }>(); const insets = useSafeAreaInsets(); const keyFile = useStore(s => s.keyFile); const contacts = useStore(s => s.contacts); const messages = useStore(s => s.messages); const setMsgs = useStore(s => s.setMessages); const appendMsg = useStore(s => s.appendMessage); const clearUnread = useStore(s => s.clearUnread); // При открытии чата: сбрасываем unread-счётчик и dismiss'им банер. useEffect(() => { if (!contactAddress) return; clearUnread(contactAddress); clearContactNotifications(contactAddress); }, [contactAddress, clearUnread]); const contact = contacts.find(c => c.address === contactAddress); const chatMsgs = messages[contactAddress ?? ''] ?? []; const listRef = useRef(null); const [text, setText] = useState(''); const [sending, setSending] = useState(false); const [peerTyping, setPeerTyping] = useState(false); const [composeMode, setComposeMode] = useState({ kind: 'new' }); const [pendingAttach, setPendingAttach] = useState(null); const [attachMenuOpen, setAttachMenuOpen] = useState(false); const [videoCircleOpen, setVideoCircleOpen] = useState(false); /** * ID сообщения, которое сейчас подсвечено (после jump-to-reply). На * ~2 секунды backgroundColor bubble'а мерцает accent-цветом. * `null` — ничего не подсвечено. */ const [highlightedId, setHighlightedId] = useState(null); const highlightClearTimer = useRef | null>(null); // ── Selection mode ─────────────────────────────────────────────────── // Активируется первым long-press'ом на bubble'е. Header меняется на // toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode // toggle'ит принадлежность к выборке. Cancel сбрасывает всё. const [selectedIds, setSelectedIds] = useState>(new Set()); const selectionMode = selectedIds.size > 0; useMessages(contact?.x25519Pub ?? ''); // ── Typing indicator от peer'а ───────────────────────────────────────── useEffect(() => { if (!keyFile?.x25519_pub) return; const ws = getWSClient(); let timer: ReturnType | null = null; const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => { if (frame.event !== 'typing') return; const d = frame.data as { from?: string } | undefined; if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return; setPeerTyping(true); if (timer) clearTimeout(timer); timer = setTimeout(() => setPeerTyping(false), 3_000); }); return () => { off(); if (timer) clearTimeout(timer); }; }, [keyFile?.x25519_pub, contact?.x25519Pub]); // Throttled типinginisi-ping собеседнику. const lastTypingSent = useRef(0); const onChange = useCallback((t: string) => { setText(t); if (!contact?.x25519Pub || !t.trim()) return; const now = Date.now(); if (now - lastTypingSent.current < 2_000) return; lastTypingSent.current = now; getWSClient().sendTyping(contact.x25519Pub); }, [contact?.x25519Pub]); // Восстановить сообщения из persistent-storage при первом заходе в чат. // // Важно: НЕ перезаписываем store пустым массивом — это стёрло бы // содержимое, которое уже лежит в zustand (например, из devSeed или // только что полученные по WS сообщения пока монтировались). Если // в кэше что-то есть — мержим: берём max(cached, in-store) по id. useEffect(() => { if (!contactAddress) return; loadMessages(contactAddress).then(cached => { if (!cached || cached.length === 0) return; // кэш пуст → оставляем store const existing = useStore.getState().messages[contactAddress] ?? []; const byId = new Map(); for (const m of cached as Message[]) byId.set(m.id, m); for (const m of existing) byId.set(m.id, m); // store-версия свежее const merged = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp); setMsgs(contactAddress, merged); }); }, [contactAddress, setMsgs]); const name = contact?.username ? `@${contact.username}` : contact?.alias ?? shortAddr(contactAddress ?? ''); // ── Compose actions ──────────────────────────────────────────────────── const cancelCompose = useCallback(() => { setComposeMode({ kind: 'new' }); setText(''); setPendingAttach(null); }, []); // buildRows выдаёт chronological [old → new]. FlatList работает // inverted, поэтому reverse'им: newest = data[0] = снизу экрана. // Определено тут (не позже) чтобы handlers типа onJumpToReply могли // искать индексы по id без forward-declaration. const rows = useMemo(() => { const chrono = buildRows(chatMsgs); return [...chrono].reverse(); }, [chatMsgs]); /** * Core send logic. Принимает явные text + attachment чтобы избегать * race'а со state updates при моментальной отправке голоса/видео. * Если передано null/undefined — берём из текущего state. */ const sendCore = useCallback(async ( textArg: string | null = null, attachArg: Attachment | null | undefined = undefined, ) => { if (!keyFile || !contact) return; const actualText = textArg !== null ? textArg : text; const actualAttach = attachArg !== undefined ? attachArg : pendingAttach; const hasText = !!actualText.trim(); const hasAttach = !!actualAttach; if (!hasText && !hasAttach) return; if (!contact.x25519Pub) { Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.'); return; } if (composeMode.kind === 'edit') { const target = chatMsgs.find(m => m.text === composeMode.text && m.mine); if (!target) { cancelCompose(); return; } const updated: Message = { ...target, text: actualText.trim(), edited: true }; setMsgs(contact.address, chatMsgs.map(m => m.id === target.id ? updated : m)); cancelCompose(); return; } setSending(true); try { if (hasText) { const { nonce, ciphertext } = encryptMessage( actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, ); await sendEnvelope({ senderPub: keyFile.x25519_pub, recipientPub: contact.x25519Pub, senderEd25519Pub: keyFile.pub_key, nonce, ciphertext, }); } const msg: Message = { id: randomId(), from: keyFile.x25519_pub, text: actualText.trim(), timestamp: Math.floor(Date.now() / 1000), mine: true, read: false, edited: false, attachment: actualAttach ?? undefined, replyTo: composeMode.kind === 'reply' ? { id: composeMode.msgId, text: composeMode.preview, author: composeMode.author } : undefined, }; appendMsg(contact.address, msg); await appendMessage(contact.address, msg); setText(''); setPendingAttach(null); setComposeMode({ kind: 'new' }); } catch (e: any) { Alert.alert('Send failed', e?.message ?? 'Unknown error'); } finally { setSending(false); } }, [ text, keyFile, contact, composeMode, chatMsgs, setMsgs, cancelCompose, appendMsg, pendingAttach, ]); // UI send button const send = useCallback(() => sendCore(), [sendCore]); // ── Selection handlers ─────────────────────────────────────────────── // Long-press — входим в selection mode и сразу отмечаем это сообщение. const onMessageLongPress = useCallback((m: Message) => { setSelectedIds(prev => { const next = new Set(prev); next.add(m.id); return next; }); }, []); // Tap в selection mode — toggle принадлежности. const onMessageTap = useCallback((m: Message) => { if (!selectionMode) return; setSelectedIds(prev => { const next = new Set(prev); if (next.has(m.id)) next.delete(m.id); else next.add(m.id); return next; }); }, [selectionMode]); const cancelSelection = useCallback(() => setSelectedIds(new Set()), []); // ── Swipe-to-reply ────────────────────────────────────────────────── const onMessageReply = useCallback((m: Message) => { if (selectionMode) return; setComposeMode({ kind: 'reply', msgId: m.id, author: m.mine ? 'You' : name, preview: m.text || (m.attachment ? `(${m.attachment.kind})` : ''), }); }, [name, selectionMode]); // ── Profile navigation (tap на аватарке / имени peer'а) ────────────── const onOpenPeerProfile = useCallback(() => { if (!contactAddress) return; router.push(`/(app)/profile/${contactAddress}` as never); }, [contactAddress]); // ── Jump to reply: tap по quoted-блоку в bubble'е ──────────────────── // Скроллим FlatList к оригинальному сообщению и зажигаем highlight // на ~2 секунды (highlightedId state + useEffect-driven анимация в // MessageBubble.highlightAnim). const onJumpToReply = useCallback((originalId: string) => { const idx = rows.findIndex(r => r.kind === 'msg' && r.msg.id === originalId); if (idx < 0) { // Сообщение не найдено (возможно удалено или ушло за пагинацию). // Silently no-op. return; } try { listRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.3, // оригинал — чуть выше середины экрана, не прямо в центре }); } catch { // scrollToIndex может throw'нуть если индекс за пределами рендера; // fallback: scrollToOffset на приблизительную позицию. } setHighlightedId(originalId); if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current); highlightClearTimer.current = setTimeout(() => { setHighlightedId(null); highlightClearTimer.current = null; }, 2000); }, [rows]); useEffect(() => { return () => { if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current); }; }, []); // ── Selection actions ──────────────────────────────────────────────── const deleteSelected = useCallback(() => { if (selectedIds.size === 0 || !contact) return; Alert.alert( `Delete ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}?`, 'This removes them from your device. Other participants keep their copies.', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: () => { setMsgs(contact.address, chatMsgs.filter(m => !selectedIds.has(m.id))); setSelectedIds(new Set()); }, }, ], ); }, [selectedIds, contact, chatMsgs, setMsgs]); const forwardSelected = useCallback(() => { // Forward UI ещё не реализован — показываем stub. Пример потока: // 1. открыть "Forward to…" screen со списком контактов // 2. для каждого выбранного контакта — sendEnvelope с оригинальным // текстом, timestamp=now Alert.alert( `Forward ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}`, 'Contact-picker screen is coming in the next iteration. For now, copy the text and paste.', [{ text: 'OK' }], ); }, [selectedIds]); // Copy доступен только когда выделено ровно одно сообщение. const copySelected = useCallback(async () => { if (selectedIds.size !== 1) return; const id = [...selectedIds][0]; const msg = chatMsgs.find(m => m.id === id); if (!msg) return; await Clipboard.setStringAsync(msg.text); setSelectedIds(new Set()); }, [selectedIds, chatMsgs]); // В group-чатах над peer-сообщениями рисуется имя отправителя и его // аватар (group = несколько участников). В DM (direct) и каналах // отправитель ровно один, поэтому имя/аватар не нужны — убираем. const withSenderMeta = contact?.kind === 'group'; const renderRow = ({ item }: { item: Row }) => { if (item.kind === 'sep') return ; return ( ); }; return ( {/* Header — использует общий компонент
, чтобы соблюдать правила шапки приложения (left slot / centered title / right slot). */} {selectionMode ? (
} title={`${selectedIds.size} selected`} right={ <> {selectedIds.size === 1 && ( )} } /> ) : (
router.back()} />} title={ {name} {peerTyping && ( typing… )} {!peerTyping && !contact?.x25519Pub && ( waiting for key )} } right={} /> )} {/* Messages — inverted: data[0] рендерится снизу, последующее — выше. Это стандартный chat-паттерн: FlatList сразу монтируется с "scroll position at bottom" без ручного scrollToEnd, и новые сообщения (добавляемые в начало reversed-массива) появляются внизу естественно. Никаких jerk'ов при открытии. */} r.kind === 'sep' ? r.id : r.msg.id} renderItem={renderRow} contentContainerStyle={{ paddingVertical: 10 }} showsVerticalScrollIndicator={false} ListEmptyComponent={() => ( Say hi to {name} Your messages are end-to-end encrypted. )} /> {/* Composer — floating, прибит к низу. */} setAttachMenuOpen(true)} attachment={pendingAttach} onClearAttach={() => setPendingAttach(null)} onFinishVoice={(att) => { // Voice отправляется сразу — sendCore получает attachment // явным аргументом, минуя state-задержку. sendCore('', att); }} onStartVideoCircle={() => setVideoCircleOpen(true)} /> setAttachMenuOpen(false)} onPick={(att) => setPendingAttach(att)} /> setVideoCircleOpen(false)} onFinish={(att) => { // Video-circle тоже отправляется сразу. sendCore('', att); }} /> ); }