Files
dchain/client-app/app/(app)/requests.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

237 lines
8.2 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.

/**
* 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>
);
}