/** * Chat list — DChain messenger. * Safe-area aware, Ionicons, polished empty states, responsive press feedback. */ import React, { useCallback, useMemo, useEffect, useState } from 'react'; import { View, Text, FlatList, Pressable, TouchableOpacity } from 'react-native'; import { router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { getWSClient } from '@/lib/ws'; import { Avatar } from '@/components/ui/Avatar'; import { formatTime, formatAmount } from '@/lib/utils'; import type { Contact } from '@/lib/types'; const MIN_FEE = 5000; // ─── 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 ────────────────────────────────────────────────────────────────── /** Truncate a message preview without breaking words too awkwardly. */ function previewText(s: string, max = 60): string { if (s.length <= max) return s; return s.slice(0, max).trimEnd() + '…'; } /** Short address helper matching the rest of the app. */ function shortAddr(a: string, n = 6): string { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } export default function ChatsScreen() { const contacts = useStore(s => s.contacts); const messages = useStore(s => s.messages); const requests = useStore(s => s.requests); const balance = useStore(s => s.balance); const keyFile = useStore(s => s.keyFile); const insets = useSafeAreaInsets(); // Real-time transport indicator (green dot when WS is live, yellow when // using HTTP polling fallback). const [wsLive, setWsLive] = useState(false); useEffect(() => { const ws = getWSClient(); setWsLive(ws.isConnected()); return ws.onConnectionChange(ok => setWsLive(ok)); }, []); const hasBalance = balance >= MIN_FEE; const displayName = (c: Contact) => c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address, 5); const lastMsg = (c: Contact) => { const msgs = messages[c.address]; return msgs?.length ? msgs[msgs.length - 1] : null; }; // Sort contacts: most recent activity first. const sortedContacts = useMemo(() => { const withTime = contacts.map(c => { const last = lastMsg(c); return { contact: c, sortKey: last ? last.timestamp : (c.addedAt / 1000), }; }); return withTime .sort((a, b) => b.sortKey - a.sortKey) .map(x => x.contact); }, [contacts, messages]); const renderItem = useCallback(({ item: c, index }: { item: Contact; index: number }) => { const last = lastMsg(c); const name = displayName(c); const hasKey = !!c.x25519Pub; return ( router.push(`/(app)/chats/${c.address}`)} android_ripple={{ color: C.surface2 }} style={({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, borderTopWidth: index === 0 ? 0 : 1, borderTopColor: C.line, backgroundColor: pressed ? C.surface2 : 'transparent', })} > {/* Avatar with E2E status pip */} {hasKey && ( )} {/* Text column */} {name} {last && ( {formatTime(last.timestamp)} )} {last?.mine && ( )} {last ? previewText(last.text) : (hasKey ? 'Напишите первое сообщение' : 'Ожидание публикации ключа…')} ); }, [messages, lastMsg]); return ( {/* ── Header ── */} Сообщения {contacts.length > 0 && ( {contacts.length} {contacts.length === 1 ? 'контакт' : contacts.length < 5 ? 'контакта' : 'контактов'} {' · '} E2E {' · '} {wsLive ? 'live' : 'polling'} )} {/* Incoming requests chip */} {requests.length > 0 && ( router.push('/(app)/requests')} activeOpacity={0.7} style={{ flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: 'rgba(125,181,255,0.14)', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 7, borderWidth: 1, borderColor: 'rgba(125,181,255,0.25)', }} > {requests.length} )} {/* Add contact button */} hasBalance ? router.push('/(app)/new-contact') : router.push('/(app)/wallet')} activeOpacity={0.7} style={{ width: 38, height: 38, borderRadius: 19, backgroundColor: hasBalance ? C.accent : C.surface2, alignItems: 'center', justifyContent: 'center', }} > {/* ── No balance gate (no contacts) ── */} {!hasBalance && contacts.length === 0 && ( Пополните баланс Отправка запроса контакта стоит{' '} {formatAmount(MIN_FEE)} {' '}— антиспам-сбор идёт напрямую получателю. router.push('/(app)/wallet')} activeOpacity={0.7} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 11, borderRadius: 10, backgroundColor: C.accent, }} > Перейти в кошелёк )} {/* ── Low balance warning (has contacts) ── */} {!hasBalance && contacts.length > 0 && ( router.push('/(app)/wallet')} activeOpacity={0.7} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginHorizontal: 16, marginTop: 12, backgroundColor: 'rgba(255,122,135,0.08)', borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)', }} > Недостаточно токенов для добавления контакта )} {/* ── Empty state (has balance, no contacts) ── */} {contacts.length === 0 && hasBalance && ( Нет диалогов Добавьте контакт по адресу или{' '} @username {' '}для начала зашифрованной переписки. router.push('/(app)/new-contact')} activeOpacity={0.7} style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 22, paddingVertical: 12, borderRadius: 12, backgroundColor: C.accent, }} > Добавить контакт )} {/* ── Chat list ── */} {contacts.length > 0 && ( c.address} renderItem={renderItem} contentContainerStyle={{ paddingBottom: 20 }} showsVerticalScrollIndicator={false} /> )} ); }