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:
332
client-app/app/(app)/new-contact.tsx
Normal file
332
client-app/app/(app)/new-contact.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Add New Contact — DChain explorer design style.
|
||||
* Sends CONTACT_REQUEST on-chain with correct amount/fee fields.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Alert, TouchableOpacity, TextInput,
|
||||
} 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 { getIdentity, buildContactRequestTx, submitTx } from '@/lib/api';
|
||||
import { shortAddr } from '@/lib/crypto';
|
||||
import { formatAmount } from '@/lib/utils';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
const C = {
|
||||
bg: '#0b1220',
|
||||
surface: '#111a2b',
|
||||
surface2:'#162035',
|
||||
line: '#1c2840',
|
||||
text: '#e6edf9',
|
||||
muted: '#98a7c2',
|
||||
accent: '#7db5ff',
|
||||
ok: '#41c98a',
|
||||
warn: '#f0b35a',
|
||||
err: '#ff7a87',
|
||||
} as const;
|
||||
|
||||
const MIN_CONTACT_FEE = 5000;
|
||||
|
||||
const FEE_OPTIONS = [
|
||||
{ label: '5 000 µT', value: 5_000, note: 'минимум' },
|
||||
{ label: '10 000 µT', value: 10_000, note: 'стандарт' },
|
||||
{ label: '50 000 µT', value: 50_000, note: 'приоритет' },
|
||||
];
|
||||
|
||||
interface Resolved {
|
||||
address: string;
|
||||
nickname?: string;
|
||||
x25519?: string;
|
||||
}
|
||||
|
||||
export default function NewContactScreen() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const settings = useStore(s => s.settings);
|
||||
const balance = useStore(s => s.balance);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [intro, setIntro] = useState('');
|
||||
const [fee, setFee] = useState(MIN_CONTACT_FEE);
|
||||
const [resolved, setResolved] = useState<Resolved | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function search() {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setSearching(true);
|
||||
setResolved(null);
|
||||
setError(null);
|
||||
try {
|
||||
let address = q;
|
||||
|
||||
// @username lookup via registry contract
|
||||
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
|
||||
const name = q.replace('@', '');
|
||||
const { resolveUsername } = await import('@/lib/api');
|
||||
const addr = await resolveUsername(settings.contractId, name);
|
||||
if (!addr) {
|
||||
setError(`@${name} не зарегистрирован в сети.`);
|
||||
return;
|
||||
}
|
||||
address = addr;
|
||||
}
|
||||
|
||||
// Fetch identity to get nickname and x25519 key
|
||||
const identity = await getIdentity(address);
|
||||
setResolved({
|
||||
address: identity?.pub_key ?? address,
|
||||
nickname: identity?.nickname || undefined,
|
||||
x25519: identity?.x25519_pub || undefined,
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest() {
|
||||
if (!resolved || !keyFile) return;
|
||||
if (balance < fee + 1000) {
|
||||
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (fee + комиссия сети).`);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tx = buildContactRequestTx({
|
||||
from: keyFile.pub_key,
|
||||
to: resolved.address,
|
||||
contactFee: fee,
|
||||
intro: intro.trim() || undefined,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
Alert.alert(
|
||||
'Запрос отправлен',
|
||||
`Контакту ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)} отправлен запрос.`,
|
||||
[{ text: 'OK', onPress: () => router.back() }],
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = resolved
|
||||
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={{ flex: 1, backgroundColor: C.bg }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: insets.top + 8,
|
||||
paddingBottom: Math.max(insets.bottom, 32),
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 24, marginLeft: -8 }}>
|
||||
<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: 20, fontWeight: '700' }}>Добавить контакт</Text>
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Адрес или @username
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 16 }}>
|
||||
<View style={{
|
||||
flex: 1, backgroundColor: C.surface, borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 2,
|
||||
}}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={t => { setQuery(t); setError(null); }}
|
||||
onSubmitEditing={search}
|
||||
placeholder="@username или 64-символьный hex"
|
||||
placeholderTextColor={C.muted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={{ color: C.text, fontSize: 14, height: 44 }}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={search}
|
||||
disabled={searching || !query.trim()}
|
||||
style={{
|
||||
paddingHorizontal: 16, borderRadius: 10,
|
||||
backgroundColor: searching || !query.trim() ? C.surface2 : C.accent,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: searching || !query.trim() ? C.muted : '#0b1220', fontWeight: '700', fontSize: 14 }}>
|
||||
{searching ? '…' : 'Найти'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(255,122,135,0.08)', borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 10, marginBottom: 16,
|
||||
}}>
|
||||
<Text style={{ color: C.err, fontSize: 13 }}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Resolved contact card */}
|
||||
{resolved && (
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 12,
|
||||
padding: 14, marginBottom: 20,
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<Avatar name={displayName ?? resolved.address} size="md" />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
{resolved.nickname && (
|
||||
<Text style={{ color: C.text, fontWeight: '600', fontSize: 15 }}>
|
||||
@{resolved.nickname}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||||
{resolved.address}
|
||||
</Text>
|
||||
{resolved.x25519 && (
|
||||
<Text style={{ color: C.ok, fontSize: 11, marginTop: 2 }}>
|
||||
✓ Зашифрованные сообщения поддерживаются
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={{
|
||||
paddingHorizontal: 8, paddingVertical: 3,
|
||||
backgroundColor: 'rgba(65,201,138,0.12)', borderRadius: 999,
|
||||
}}>
|
||||
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Найден</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Intro + fee (shown after search succeeds) */}
|
||||
{resolved && (
|
||||
<>
|
||||
{/* Intro */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Сообщение (необязательно)
|
||||
</Text>
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 8, marginBottom: 20,
|
||||
}}>
|
||||
<TextInput
|
||||
value={intro}
|
||||
onChangeText={setIntro}
|
||||
placeholder="Привет, хочу добавить тебя в контакты…"
|
||||
placeholderTextColor={C.muted}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
maxLength={280}
|
||||
style={{
|
||||
color: C.text, fontSize: 14, lineHeight: 20,
|
||||
minHeight: 72, textAlignVertical: 'top',
|
||||
}}
|
||||
/>
|
||||
<Text style={{ color: C.muted, fontSize: 11, textAlign: 'right', marginTop: 4 }}>
|
||||
{intro.length}/280
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Fee selector */}
|
||||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Антиспам-сбор (отправляется контакту)
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 8 }}>
|
||||
{FEE_OPTIONS.map(opt => (
|
||||
<TouchableOpacity
|
||||
key={opt.value}
|
||||
onPress={() => setFee(opt.value)}
|
||||
style={{
|
||||
flex: 1, borderRadius: 10, paddingVertical: 10, alignItems: 'center',
|
||||
backgroundColor: fee === opt.value
|
||||
? 'rgba(125,181,255,0.15)' : C.surface,
|
||||
borderWidth: fee === opt.value ? 1 : 0,
|
||||
borderColor: fee === opt.value ? C.accent : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: fee === opt.value ? C.accent : C.text,
|
||||
fontWeight: '600', fontSize: 13,
|
||||
}}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 11, marginTop: 2 }}>{opt.note}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Balance info */}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||||
backgroundColor: C.surface, borderRadius: 8,
|
||||
paddingHorizontal: 12, paddingVertical: 9, marginBottom: 20,
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 13 }}>Ваш баланс:</Text>
|
||||
<Text style={{ color: balance >= fee + 1000 ? C.text : C.err, fontSize: 13, fontWeight: '600' }}>
|
||||
{formatAmount(balance)}
|
||||
</Text>
|
||||
<Text style={{ color: C.muted, fontSize: 13, marginLeft: 'auto' as any }}>
|
||||
Итого: {formatAmount(fee + 1000)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Send button */}
|
||||
<TouchableOpacity
|
||||
onPress={sendRequest}
|
||||
disabled={sending || balance < fee + 1000}
|
||||
style={{
|
||||
paddingVertical: 15, borderRadius: 10, alignItems: 'center',
|
||||
backgroundColor: (sending || balance < fee + 1000) ? C.surface2 : C.accent,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: (sending || balance < fee + 1000) ? C.muted : '#0b1220',
|
||||
fontWeight: '700', fontSize: 15,
|
||||
}}>
|
||||
{sending ? 'Отправка…' : 'Отправить запрос'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hint when no search yet */}
|
||||
{!resolved && !error && (
|
||||
<View style={{
|
||||
backgroundColor: C.surface, borderRadius: 12,
|
||||
padding: 16, marginTop: 8, alignItems: 'center',
|
||||
}}>
|
||||
<Text style={{ color: C.muted, fontSize: 14, textAlign: 'center', lineHeight: 21 }}>
|
||||
Введите @username или вставьте 64-символьный hex-адрес пользователя DChain.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user