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
This commit is contained in:
337
client-app/app/(app)/chats/index.tsx
Normal file
337
client-app/app/(app)/chats/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user