Files
dchain/client-app/app/(app)/chats/index.tsx
vsecoder 7e7393e4f8 chore: initial commit for v0.0.1
DChain single-node blockchain + React Native messenger client.

Core:
- PBFT consensus with multi-sig validator admission + equivocation slashing
- BadgerDB + schema migration scaffold (CurrentSchemaVersion=0)
- libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1)
- Native Go contracts (username_registry) alongside WASM (wazero)
- WebSocket gateway with topic-based fanout + Ed25519-nonce auth
- Relay mailbox with NaCl envelope encryption (X25519 + Ed25519)
- Prometheus /metrics, per-IP rate limit, body-size cap

Deployment:
- Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus
- 3-node dev compose (docker-compose.yml) with mocked internet topology
- 3-validator prod compose (deploy/prod/) for federation
- Auto-update from Gitea via /api/update-check + systemd timer
- Build-time version injection (ldflags → node --version)
- UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER)

Client (client-app/):
- Expo / React Native / NativeWind
- E2E NaCl encryption, typing indicator, contact requests
- Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch

Documentation:
- README.md, CHANGELOG.md, CONTEXT.md
- deploy/single/README.md with 6 operator scenarios
- deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design
- docs/contracts/*.md per contract
2026-04-17 14:16:44 +03:00

338 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (
<Pressable
onPress={() => 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 */}
<View style={{ position: 'relative', marginRight: 12 }}>
<Avatar name={name} size="md" />
{hasKey && (
<View style={{
position: 'absolute', right: -2, bottom: -2,
width: 14, height: 14, borderRadius: 7,
backgroundColor: C.ok,
borderWidth: 2, borderColor: C.bg,
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="lock-closed" size={7} color="#0b1220" />
</View>
)}
</View>
{/* Text column */}
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={{ color: C.text, fontWeight: '600', fontSize: 15, flex: 1 }}
numberOfLines={1}
>
{name}
</Text>
{last && (
<Text style={{ color: C.muted, fontSize: 11, marginLeft: 8 }}>
{formatTime(last.timestamp)}
</Text>
)}
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 2 }}>
{last?.mine && (
<Ionicons
name="checkmark-done-outline"
size={13}
color={C.muted}
style={{ marginRight: 4 }}
/>
)}
<Text
style={{ color: C.muted, fontSize: 13, flex: 1 }}
numberOfLines={1}
>
{last ? previewText(last.text) : (hasKey ? 'Напишите первое сообщение' : 'Ожидание публикации ключа…')}
</Text>
</View>
</View>
</Pressable>
);
}, [messages, lastMsg]);
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
{/* ── Header ── */}
<View style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: insets.top + 10,
paddingBottom: 14,
borderBottomWidth: 1, borderBottomColor: C.line,
}}>
<View>
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', letterSpacing: -0.3 }}>
Сообщения
</Text>
{contacts.length > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 2 }}>
<Text style={{ color: C.muted, fontSize: 12 }}>
{contacts.length} {contacts.length === 1 ? 'контакт' : contacts.length < 5 ? 'контакта' : 'контактов'}
{' · '}
<Text style={{ color: C.ok }}>E2E</Text>
{' · '}
</Text>
<View style={{
width: 6, height: 6, borderRadius: 3,
backgroundColor: wsLive ? C.ok : C.warn,
}} />
<Text style={{ color: wsLive ? C.ok : C.warn, fontSize: 11 }}>
{wsLive ? 'live' : 'polling'}
</Text>
</View>
)}
</View>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
{/* Incoming requests chip */}
{requests.length > 0 && (
<TouchableOpacity
onPress={() => 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)',
}}
>
<Ionicons name="mail-unread-outline" size={14} color={C.accent} />
<Text style={{ color: C.accent, fontSize: 12, fontWeight: '600' }}>
{requests.length}
</Text>
</TouchableOpacity>
)}
{/* Add contact button */}
<TouchableOpacity
onPress={() => 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',
}}
>
<Ionicons
name={hasBalance ? 'add-outline' : 'lock-closed-outline'}
size={20}
color={hasBalance ? C.bg : C.muted}
/>
</TouchableOpacity>
</View>
</View>
{/* ── No balance gate (no contacts) ── */}
{!hasBalance && contacts.length === 0 && (
<View style={{
margin: 16, padding: 18,
backgroundColor: 'rgba(125,181,255,0.07)',
borderRadius: 14,
borderWidth: 1, borderColor: 'rgba(125,181,255,0.15)',
}}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<View style={{
width: 32, height: 32, borderRadius: 16,
backgroundColor: 'rgba(125,181,255,0.18)',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="diamond-outline" size={16} color={C.accent} />
</View>
<Text style={{ color: C.text, fontWeight: '700', fontSize: 15 }}>
Пополните баланс
</Text>
</View>
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 20, marginBottom: 14 }}>
Отправка запроса контакта стоит{' '}
<Text style={{ color: C.text, fontWeight: '600' }}>{formatAmount(MIN_FEE)}</Text>
{' '} антиспам-сбор идёт напрямую получателю.
</Text>
<TouchableOpacity
onPress={() => router.push('/(app)/wallet')}
activeOpacity={0.7}
style={{
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, borderRadius: 10,
backgroundColor: C.accent,
}}
>
<Ionicons name="wallet-outline" size={16} color={C.bg} />
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 13 }}>Перейти в кошелёк</Text>
</TouchableOpacity>
</View>
)}
{/* ── Low balance warning (has contacts) ── */}
{!hasBalance && contacts.length > 0 && (
<TouchableOpacity
onPress={() => 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)',
}}
>
<Ionicons name="warning-outline" size={16} color={C.err} />
<Text style={{ color: C.err, fontSize: 13, flex: 1 }}>
Недостаточно токенов для добавления контакта
</Text>
<Ionicons name="chevron-forward" size={14} color={C.err} />
</TouchableOpacity>
)}
{/* ── Empty state (has balance, no contacts) ── */}
{contacts.length === 0 && hasBalance && (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<View style={{
width: 88, height: 88, borderRadius: 44,
backgroundColor: C.surface,
alignItems: 'center', justifyContent: 'center',
marginBottom: 18,
}}>
<Ionicons name="chatbubbles-outline" size={40} color={C.accent} />
</View>
<Text style={{ color: C.text, fontSize: 19, fontWeight: '700', textAlign: 'center', marginBottom: 8 }}>
Нет диалогов
</Text>
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 22, marginBottom: 20 }}>
Добавьте контакт по адресу или{' '}
<Text style={{ color: C.accent, fontWeight: '600' }}>@username</Text>
{' '}для начала зашифрованной переписки.
</Text>
<TouchableOpacity
onPress={() => router.push('/(app)/new-contact')}
activeOpacity={0.7}
style={{
flexDirection: 'row', alignItems: 'center', gap: 8,
paddingHorizontal: 22, paddingVertical: 12, borderRadius: 12,
backgroundColor: C.accent,
}}
>
<Ionicons name="person-add-outline" size={16} color={C.bg} />
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Добавить контакт</Text>
</TouchableOpacity>
</View>
)}
{/* ── Chat list ── */}
{contacts.length > 0 && (
<FlatList
data={sortedContacts}
keyExtractor={c => c.address}
renderItem={renderItem}
contentContainerStyle={{ paddingBottom: 20 }}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}