/**
* 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}
/>
)}
);
}