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
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|