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
237 lines
8.2 KiB
TypeScript
237 lines
8.2 KiB
TypeScript
/**
|
||
* Contact requests screen — DChain explorer design style.
|
||
*/
|
||
|
||
import React, { useState } from 'react';
|
||
import { View, Text, FlatList, Alert, 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 { buildAcceptContactTx, submitTx, getIdentity } from '@/lib/api';
|
||
import { saveContact } from '@/lib/storage';
|
||
import { shortAddr } from '@/lib/crypto';
|
||
import { relativeTime } from '@/lib/utils';
|
||
import { Avatar } from '@/components/ui/Avatar';
|
||
import type { ContactRequest } from '@/lib/types';
|
||
|
||
const C = {
|
||
bg: '#0b1220',
|
||
surface: '#111a2b',
|
||
surface2:'#162035',
|
||
line: '#1c2840',
|
||
text: '#e6edf9',
|
||
muted: '#98a7c2',
|
||
accent: '#7db5ff',
|
||
ok: '#41c98a',
|
||
warn: '#f0b35a',
|
||
err: '#ff7a87',
|
||
} as const;
|
||
|
||
export default function RequestsScreen() {
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const requests = useStore(s => s.requests);
|
||
const setRequests = useStore(s => s.setRequests);
|
||
const upsertContact = useStore(s => s.upsertContact);
|
||
const insets = useSafeAreaInsets();
|
||
|
||
const [accepting, setAccepting] = useState<string | null>(null);
|
||
|
||
async function accept(req: ContactRequest) {
|
||
if (!keyFile) return;
|
||
setAccepting(req.txHash);
|
||
try {
|
||
// Fetch requester's identity to get their x25519 key for messaging
|
||
const identity = await getIdentity(req.from);
|
||
const x25519Pub = identity?.x25519_pub ?? '';
|
||
|
||
const tx = buildAcceptContactTx({
|
||
from: keyFile.pub_key,
|
||
to: req.from,
|
||
privKey: keyFile.priv_key,
|
||
});
|
||
await submitTx(tx);
|
||
|
||
// Save contact with x25519 key (empty if they haven't registered one)
|
||
const contact = {
|
||
address: req.from,
|
||
x25519Pub,
|
||
username: req.username,
|
||
addedAt: Date.now(),
|
||
};
|
||
upsertContact(contact);
|
||
await saveContact(contact);
|
||
|
||
setRequests(requests.filter(r => r.txHash !== req.txHash));
|
||
Alert.alert('Принято', `${req.username ? '@' + req.username : shortAddr(req.from)} добавлен в контакты.`);
|
||
} catch (e: any) {
|
||
Alert.alert('Ошибка', e.message);
|
||
} finally {
|
||
setAccepting(null);
|
||
}
|
||
}
|
||
|
||
function decline(req: ContactRequest) {
|
||
Alert.alert(
|
||
'Отклонить запрос',
|
||
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
|
||
[
|
||
{ text: 'Отмена', style: 'cancel' },
|
||
{
|
||
text: 'Отклонить',
|
||
style: 'destructive',
|
||
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
|
||
},
|
||
],
|
||
);
|
||
}
|
||
|
||
return (
|
||
<View style={{ flex: 1, backgroundColor: C.bg }}>
|
||
{/* Header */}
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||
paddingHorizontal: 12,
|
||
paddingTop: insets.top + 8, paddingBottom: 12,
|
||
borderBottomWidth: 1, borderBottomColor: C.line,
|
||
}}>
|
||
<TouchableOpacity
|
||
onPress={() => router.back()}
|
||
activeOpacity={0.6}
|
||
style={{ padding: 8 }}
|
||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||
>
|
||
<Ionicons name="chevron-back" size={24} color={C.accent} />
|
||
</TouchableOpacity>
|
||
<Text style={{ color: C.text, fontSize: 18, fontWeight: '700', flex: 1 }}>
|
||
Запросы контактов
|
||
</Text>
|
||
{requests.length > 0 && (
|
||
<View style={{
|
||
backgroundColor: C.accent, borderRadius: 999,
|
||
paddingHorizontal: 10, paddingVertical: 3,
|
||
minWidth: 24, alignItems: 'center',
|
||
}}>
|
||
<Text style={{ color: C.bg, fontSize: 12, fontWeight: '700' }}>
|
||
{requests.length}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{requests.length === 0 ? (
|
||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
|
||
<View style={{
|
||
width: 80, height: 80, borderRadius: 40,
|
||
backgroundColor: C.surface,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
marginBottom: 16,
|
||
}}>
|
||
<Ionicons name="mail-outline" size={36} color={C.muted} />
|
||
</View>
|
||
<Text style={{ color: C.text, fontSize: 17, fontWeight: '600', marginBottom: 6 }}>
|
||
Нет входящих запросов
|
||
</Text>
|
||
<Text style={{ color: C.muted, fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||
Когда кто-то пришлёт вам запрос в контакты, он появится здесь.
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
<FlatList
|
||
data={requests}
|
||
keyExtractor={r => r.txHash}
|
||
contentContainerStyle={{ padding: 14, paddingBottom: 24, gap: 12 }}
|
||
renderItem={({ item: req }) => (
|
||
<RequestCard
|
||
req={req}
|
||
isAccepting={accepting === req.txHash}
|
||
onAccept={() => accept(req)}
|
||
onDecline={() => decline(req)}
|
||
/>
|
||
)}
|
||
/>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function RequestCard({
|
||
req, isAccepting, onAccept, onDecline,
|
||
}: {
|
||
req: ContactRequest;
|
||
isAccepting: boolean;
|
||
onAccept: () => void;
|
||
onDecline: () => void;
|
||
}) {
|
||
const displayName = req.username ? `@${req.username}` : shortAddr(req.from);
|
||
|
||
return (
|
||
<View style={{ backgroundColor: C.surface, borderRadius: 12, padding: 14 }}>
|
||
{/* Sender info */}
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||
<Avatar name={displayName} size="md" />
|
||
<View style={{ flex: 1, minWidth: 0 }}>
|
||
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }} numberOfLines={1}>
|
||
{displayName}
|
||
</Text>
|
||
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||
{req.from}
|
||
</Text>
|
||
</View>
|
||
<Text style={{ color: C.muted, fontSize: 12 }}>{relativeTime(req.timestamp)}</Text>
|
||
</View>
|
||
|
||
{/* Intro message */}
|
||
{!!req.intro && (
|
||
<View style={{
|
||
backgroundColor: C.surface2, borderRadius: 8,
|
||
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 12,
|
||
}}>
|
||
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 4 }}>Приветствие</Text>
|
||
<Text style={{ color: C.text, fontSize: 13, lineHeight: 19 }}>{req.intro}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{/* Divider */}
|
||
<View style={{ height: 1, backgroundColor: C.line, marginBottom: 12 }} />
|
||
|
||
{/* Actions */}
|
||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||
<TouchableOpacity
|
||
onPress={onAccept}
|
||
disabled={isAccepting}
|
||
activeOpacity={0.7}
|
||
style={{
|
||
flex: 1, paddingVertical: 11, borderRadius: 9,
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||
backgroundColor: isAccepting ? C.surface2 : C.ok,
|
||
}}
|
||
>
|
||
<Ionicons
|
||
name={isAccepting ? 'hourglass-outline' : 'checkmark-outline'}
|
||
size={16}
|
||
color={isAccepting ? C.muted : C.bg}
|
||
/>
|
||
<Text style={{ color: isAccepting ? C.muted : C.bg, fontWeight: '700', fontSize: 14 }}>
|
||
{isAccepting ? 'Принятие…' : 'Принять'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
onPress={onDecline}
|
||
disabled={isAccepting}
|
||
activeOpacity={0.7}
|
||
style={{
|
||
flex: 1, paddingVertical: 11, borderRadius: 9,
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||
backgroundColor: 'rgba(255,122,135,0.1)',
|
||
borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)',
|
||
}}
|
||
>
|
||
<Ionicons name="close-outline" size={16} color={C.err} />
|
||
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Отклонить</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|