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:
236
client-app/app/(app)/requests.tsx
Normal file
236
client-app/app/(app)/requests.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user