Files
dchain/client-app/app/(app)/chats/[id].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

414 lines
15 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 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>
);
}