/** * Chat view — DChain messenger. * Safe-area aware header/input, smooth scroll, proper E2E indicators, * responsive send button with press feedback. */ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, FlatList, TextInput, TouchableOpacity, Pressable, KeyboardAvoidingView, Platform, ActivityIndicator, Alert, } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { formatTime, randomId } from '@/lib/utils'; import { Avatar } from '@/components/ui/Avatar'; import type { Message } from '@/lib/types'; // ─── Design tokens ──────────────────────────────────────────────────────────── const C = { bg: '#0b1220', surface: '#111a2b', surface2:'#162035', surface3:'#1a2640', line: '#1c2840', text: '#e6edf9', muted: '#98a7c2', accent: '#7db5ff', ok: '#41c98a', warn: '#f0b35a', err: '#ff7a87', } as const; // ─── Helpers ────────────────────────────────────────────────────────────────── function shortAddr(a: string, n = 5): string { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } /** Group messages by calendar day for day-separator labels. */ function dateBucket(ts: number): string { const d = new Date(ts * 1000); const now = new Date(); const yday = new Date(); yday.setDate(now.getDate() - 1); const same = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); if (same(d, now)) return 'Сегодня'; if (same(d, yday)) return 'Вчера'; return d.toLocaleDateString('ru', { day: 'numeric', month: 'long' }); } // A row in the FlatList: either a message or a date separator we inject. type Row = | { kind: 'msg'; msg: Message } | { kind: 'sep'; id: string; label: string }; function buildRows(msgs: Message[]): Row[] { const rows: Row[] = []; let lastBucket = ''; for (const m of msgs) { const b = dateBucket(m.timestamp); if (b !== lastBucket) { rows.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b }); lastBucket = b; } rows.push({ kind: 'msg', msg: m }); } return rows; } // ─── Screen ─────────────────────────────────────────────────────────────────── export default function ChatScreen() { const { id: contactAddress } = useLocalSearchParams<{ id: string }>(); 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 insets = useSafeAreaInsets(); 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); // Poll relay inbox for messages from this contact useMessages(contact?.x25519Pub ?? ''); // Subscribe to typing indicators sent to us. Clears after 3 seconds of // silence so the "typing…" bubble disappears naturally when the peer // stops. sendTyping on our side happens per-keystroke (throttled below). useEffect(() => { if (!keyFile?.x25519_pub) return; const ws = getWSClient(); let clearTimer: 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; // Only show typing for the contact currently open in this view. if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return; setPeerTyping(true); if (clearTimer) clearTimeout(clearTimer); clearTimer = setTimeout(() => setPeerTyping(false), 3_000); }); return () => { off(); if (clearTimer) clearTimeout(clearTimer); }; }, [keyFile?.x25519_pub, contact?.x25519Pub]); // Throttled sendTyping: fire on every keystroke but no more than every 2s. const lastSentTyping = useRef(0); const handleTextChange = useCallback((t: string) => { setText(t); if (!contact?.x25519Pub || !t.trim()) return; const now = Date.now(); if (now - lastSentTyping.current < 2_000) return; lastSentTyping.current = now; getWSClient().sendTyping(contact.x25519Pub); }, [contact?.x25519Pub]); // Load cached messages on mount useEffect(() => { if (contactAddress) { loadMessages(contactAddress).then(cached => { setMsgs(contactAddress, cached as Message[]); }); } }, [contactAddress]); const displayName = contact?.username ? `@${contact.username}` : contact?.alias ?? shortAddr(contactAddress ?? '', 6); const canSend = !!text.trim() && !sending && !!contact?.x25519Pub; const send = useCallback(async () => { if (!text.trim() || !keyFile || !contact) return; if (!contact.x25519Pub) { Alert.alert( 'Ключ ещё не опубликован', 'Контакт пока не опубликовал ключ шифрования. Попробуйте позже.', ); return; } setSending(true); try { const { nonce, ciphertext } = encryptMessage( text.trim(), keyFile.x25519_priv, contact.x25519Pub, ); await sendEnvelope({ sender_pub: keyFile.x25519_pub, recipient_pub: contact.x25519Pub, nonce, ciphertext, }); const msg: Message = { id: randomId(), from: keyFile.x25519_pub, text: text.trim(), timestamp: Math.floor(Date.now() / 1000), mine: true, }; appendMsg(contact.address, msg); await appendMessage(contact.address, msg); setText(''); setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 50); } catch (e: any) { Alert.alert('Ошибка отправки', e.message); } finally { setSending(false); } }, [text, keyFile, contact]); const rows = buildRows(chatMsgs); const renderRow = ({ item }: { item: Row }) => { if (item.kind === 'sep') { return ( {item.label} ); } const m = item.msg; return ( {!m.mine && ( )} {m.text} {formatTime(m.timestamp)} {m.mine && ( )} ); }; return ( {/* ── Header ── */} router.back()} activeOpacity={0.6} style={{ padding: 8, marginRight: 4 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > {contact?.x25519Pub && ( )} {displayName} {peerTyping ? ( <> печатает… ) : contact?.x25519Pub ? ( <> E2E шифрование ) : ( <> Ожидание ключа шифрования )} {/* ── Messages ── */} r.kind === 'sep' ? r.id : r.msg.id} renderItem={renderRow} contentContainerStyle={{ paddingTop: 14, paddingBottom: 10, flexGrow: 1 }} onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} ListEmptyComponent={() => ( Начните разговор Сообщения зашифрованы end-to-end.{'\n'}Только вы и {displayName} их прочитаете. )} showsVerticalScrollIndicator={false} /> {/* ── Input bar ── */} ({ width: 42, height: 42, borderRadius: 21, backgroundColor: canSend ? C.accent : C.surface2, alignItems: 'center', justifyContent: 'center', flexShrink: 0, opacity: pressed && canSend ? 0.7 : 1, transform: [{ scale: pressed && canSend ? 0.95 : 1 }], })} > {sending ? : } ); }