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:
413
client-app/app/(app)/chats/[id].tsx
Normal file
413
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 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<FlatList>(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<typeof setTimeout> | 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 (
|
||||
<View style={{ alignItems: 'center', marginVertical: 12 }}>
|
||||
<View style={{
|
||||
backgroundColor: C.surface2, borderRadius: 999,
|
||||
paddingHorizontal: 12, paddingVertical: 4,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 11, fontWeight: '500' }}>{item.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const m = item.msg;
|
||||
return (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: m.mine ? 'flex-end' : 'flex-start',
|
||||
paddingHorizontal: 12,
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
{!m.mine && (
|
||||
<View style={{ marginRight: 8, marginBottom: 4, flexShrink: 0 }}>
|
||||
<Avatar name={displayName} size="sm" />
|
||||
</View>
|
||||
)}
|
||||
<View style={{
|
||||
maxWidth: '78%',
|
||||
backgroundColor: m.mine ? C.accent : C.surface,
|
||||
borderRadius: 18,
|
||||
borderTopRightRadius: m.mine ? 6 : 18,
|
||||
borderTopLeftRadius: m.mine ? 18 : 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 9, paddingBottom: 7,
|
||||
borderWidth: m.mine ? 0 : 1,
|
||||
borderColor: C.line,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: m.mine ? C.bg : C.text,
|
||||
fontSize: 15, lineHeight: 21,
|
||||
}}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end',
|
||||
marginTop: 3, gap: 4,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: m.mine ? 'rgba(11,18,32,0.65)' : C.muted,
|
||||
fontSize: 10,
|
||||
}}>
|
||||
{formatTime(m.timestamp)}
|
||||
</Text>
|
||||
{m.mine && (
|
||||
<Ionicons
|
||||
name="checkmark-done"
|
||||
size={12}
|
||||
color="rgba(11,18,32,0.65)"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor: C.bg }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1, borderBottomColor: C.line,
|
||||
backgroundColor: C.surface,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
activeOpacity={0.6}
|
||||
style={{ padding: 8, marginRight: 4 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ marginRight: 10, position: 'relative' }}>
|
||||
<Avatar name={displayName} size="sm" />
|
||||
{contact?.x25519Pub && (
|
||||
<View style={{
|
||||
position: 'absolute', right: -2, bottom: -2,
|
||||
width: 12, height: 12, borderRadius: 6,
|
||||
backgroundColor: C.ok,
|
||||
borderWidth: 2, borderColor: C.surface,
|
||||
}} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||
{peerTyping ? (
|
||||
<>
|
||||
<Ionicons name="ellipsis-horizontal" size={12} color={C.accent} />
|
||||
<Text style={{ color: C.accent, fontSize: 11, fontWeight: '500' }}>печатает…</Text>
|
||||
</>
|
||||
) : contact?.x25519Pub ? (
|
||||
<>
|
||||
<Ionicons name="lock-closed" size={10} color={C.ok} />
|
||||
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '500' }}>E2E шифрование</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="time-outline" size={10} color={C.warn} />
|
||||
<Text style={{ color: C.warn, fontSize: 11 }}>Ожидание ключа шифрования</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.6}
|
||||
style={{ padding: 8 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="ellipsis-vertical" size={18} color={C.muted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* ── Messages ── */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
|
||||
renderItem={renderRow}
|
||||
contentContainerStyle={{ paddingTop: 14, paddingBottom: 10, flexGrow: 1 }}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||
ListEmptyComponent={() => (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, gap: 14 }}>
|
||||
<View style={{
|
||||
width: 76, height: 76, borderRadius: 38,
|
||||
backgroundColor: 'rgba(125,181,255,0.08)',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name="lock-closed-outline" size={34} color={C.accent} />
|
||||
</View>
|
||||
<Text style={{ color: C.text, fontSize: 15, fontWeight: '600', textAlign: 'center' }}>
|
||||
Начните разговор
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||||
Сообщения зашифрованы end-to-end.{'\n'}Только вы и {displayName} их прочитаете.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
{/* ── Input bar ── */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'flex-end', gap: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Math.max(insets.bottom, 8),
|
||||
borderTopWidth: 1, borderTopColor: C.line,
|
||||
backgroundColor: C.surface,
|
||||
}}>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
backgroundColor: C.surface2,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1, borderColor: C.line,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
minHeight: 42, maxHeight: 140,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={handleTextChange}
|
||||
placeholder="Сообщение…"
|
||||
placeholderTextColor={C.muted}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
style={{
|
||||
color: C.text, fontSize: 15, lineHeight: 21,
|
||||
// iOS needs explicit padding:0 to avoid extra vertical space
|
||||
paddingTop: 0, paddingBottom: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={send}
|
||||
disabled={!canSend}
|
||||
style={({ pressed }) => ({
|
||||
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
|
||||
? <ActivityIndicator color={C.bg} size="small" />
|
||||
: <Ionicons
|
||||
name="send"
|
||||
size={18}
|
||||
color={canSend ? C.bg : C.muted}
|
||||
style={{ marginLeft: 2 }} // visual centre of the paper-plane glyph
|
||||
/>
|
||||
}
|
||||
</Pressable>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
7
client-app/app/(app)/chats/_layout.tsx
Normal file
7
client-app/app/(app)/chats/_layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ChatsLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
);
|
||||
}
|
||||
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