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
733 lines
29 KiB
TypeScript
733 lines
29 KiB
TypeScript
/**
|
||
* Settings screen — DChain explorer style, inline styles, Russian locale.
|
||
*/
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
View, Text, ScrollView, TextInput, Alert, TouchableOpacity,
|
||
} from 'react-native';
|
||
import * as Clipboard from 'expo-clipboard';
|
||
import * as FileSystem from 'expo-file-system';
|
||
import * as Sharing from 'expo-sharing';
|
||
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 { saveSettings, deleteKeyFile } from '@/lib/storage';
|
||
import {
|
||
setNodeUrl, getNetStats, resolveUsername, reverseResolve,
|
||
buildCallContractTx, submitTx,
|
||
USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
|
||
humanizeTxError,
|
||
} from '@/lib/api';
|
||
import { shortAddr } from '@/lib/crypto';
|
||
import { formatAmount } from '@/lib/utils';
|
||
import { Avatar } from '@/components/ui/Avatar';
|
||
|
||
// ─── 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;
|
||
|
||
// ─── Reusable sub-components ─────────────────────────────────────────────────
|
||
|
||
function SectionLabel({ children }: { children: string }) {
|
||
return (
|
||
<Text style={{
|
||
color: C.muted, fontSize: 11, letterSpacing: 1,
|
||
textTransform: 'uppercase', marginBottom: 8,
|
||
}}>
|
||
{children}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function Card({ children, style }: { children: React.ReactNode; style?: object }) {
|
||
return (
|
||
<View style={{
|
||
backgroundColor: C.surface, borderRadius: 12,
|
||
overflow: 'hidden', marginBottom: 20, ...style,
|
||
}}>
|
||
{children}
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function CardRow({
|
||
icon, label, value, first,
|
||
}: {
|
||
icon: keyof typeof Ionicons.glyphMap;
|
||
label: string;
|
||
value?: string;
|
||
first?: boolean;
|
||
}) {
|
||
return (
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||
paddingHorizontal: 14, paddingVertical: 12,
|
||
borderTopWidth: first ? 0 : 1, borderTopColor: C.line,
|
||
}}>
|
||
<View style={{
|
||
width: 34, height: 34, borderRadius: 17,
|
||
backgroundColor: C.surface2,
|
||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||
}}>
|
||
<Ionicons name={icon} size={16} color={C.muted} />
|
||
</View>
|
||
<View style={{ flex: 1, minWidth: 0 }}>
|
||
<Text style={{ color: C.muted, fontSize: 12 }}>{label}</Text>
|
||
{value !== undefined && (
|
||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '500', marginTop: 1 }} numberOfLines={1}>
|
||
{value}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function FieldInput({
|
||
label, value, onChangeText, placeholder, keyboardType, autoCapitalize, autoCorrect,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChangeText: (v: string) => void;
|
||
placeholder?: string;
|
||
keyboardType?: any;
|
||
autoCapitalize?: any;
|
||
autoCorrect?: boolean;
|
||
}) {
|
||
return (
|
||
<View style={{ paddingHorizontal: 14, paddingVertical: 10 }}>
|
||
<Text style={{ color: C.muted, fontSize: 11, marginBottom: 6 }}>{label}</Text>
|
||
<View style={{
|
||
backgroundColor: C.surface2, borderRadius: 8,
|
||
paddingHorizontal: 12, paddingVertical: 8,
|
||
borderWidth: 1, borderColor: C.line,
|
||
}}>
|
||
<TextInput
|
||
value={value}
|
||
onChangeText={onChangeText}
|
||
placeholder={placeholder}
|
||
placeholderTextColor={C.muted}
|
||
keyboardType={keyboardType}
|
||
autoCapitalize={autoCapitalize ?? 'none'}
|
||
autoCorrect={autoCorrect ?? false}
|
||
style={{ color: C.text, fontSize: 14, height: 36 }}
|
||
/>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||
|
||
export default function SettingsScreen() {
|
||
const keyFile = useStore(s => s.keyFile);
|
||
const settings = useStore(s => s.settings);
|
||
const setSettings = useStore(s => s.setSettings);
|
||
const username = useStore(s => s.username);
|
||
const setUsername = useStore(s => s.setUsername);
|
||
const setKeyFile = useStore(s => s.setKeyFile);
|
||
const balance = useStore(s => s.balance);
|
||
|
||
const [nodeUrl, setNodeUrlLocal] = useState(settings.nodeUrl);
|
||
const [contractId, setContractId] = useState(settings.contractId);
|
||
const [nodeStatus, setNodeStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
||
const [peerCount, setPeerCount] = useState<number | null>(null);
|
||
const [blockCount, setBlockCount] = useState<number | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||
const insets = useSafeAreaInsets();
|
||
|
||
// Username registration state
|
||
const [nameInput, setNameInput] = useState('');
|
||
const [registering, setRegistering] = useState(false);
|
||
const [nameError, setNameError] = useState<string | null>(null);
|
||
|
||
// Sanitize: lowercase, only a-z 0-9 _ -, max 32 (contract limit)
|
||
function onNameInputChange(v: string) {
|
||
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
|
||
setNameInput(cleaned);
|
||
setNameError(null);
|
||
}
|
||
|
||
async function registerUsername() {
|
||
if (!keyFile) return;
|
||
const name = nameInput.trim();
|
||
// Mirror blockchain/native_username.go validateName so the UI gives
|
||
// immediate feedback without a round trip to the chain.
|
||
if (name.length < MIN_USERNAME_LENGTH) {
|
||
setNameError(`Минимум ${MIN_USERNAME_LENGTH} символа`);
|
||
return;
|
||
}
|
||
if (!/^[a-z]/.test(name)) {
|
||
setNameError('Имя должно начинаться с буквы a-z');
|
||
return;
|
||
}
|
||
if (!settings.contractId) {
|
||
setNameError('Не задан ID контракта реестра в настройках ноды.');
|
||
return;
|
||
}
|
||
const fee = USERNAME_REGISTRATION_FEE;
|
||
// Reserve for: registry fee (burned) + MIN_CALL_FEE (to validator)
|
||
// + small gas headroom (native contract is cheap, but gas is pre-charged).
|
||
const GAS_HEADROOM = 2_000;
|
||
const total = fee + 1000 + GAS_HEADROOM;
|
||
if (balance < total) {
|
||
setNameError(`Нужно ${formatAmount(total)} (с запасом на газ), доступно ${formatAmount(balance)}.`);
|
||
return;
|
||
}
|
||
// Check if name is already taken
|
||
try {
|
||
const existing = await resolveUsername(settings.contractId, name);
|
||
if (existing) {
|
||
setNameError(`@${name} уже зарегистрировано.`);
|
||
return;
|
||
}
|
||
} catch {
|
||
// ignore — lookup failure is OK, contract will reject on duplicate
|
||
}
|
||
|
||
Alert.alert(
|
||
'Купить @' + name + '?',
|
||
`Стоимость: ${formatAmount(fee)} + комиссия ${formatAmount(1000)}.\nИмя привязывается к вашему адресу навсегда (до release).`,
|
||
[
|
||
{ text: 'Отмена', style: 'cancel' },
|
||
{
|
||
text: 'Купить', onPress: async () => {
|
||
setRegistering(true);
|
||
setNameError(null);
|
||
try {
|
||
const tx = buildCallContractTx({
|
||
from: keyFile.pub_key,
|
||
contractId: settings.contractId,
|
||
method: 'register',
|
||
args: [name],
|
||
// Attach the registration fee in tx.amount — the contract
|
||
// requires exactly this much and burns it. Visible in the
|
||
// explorer so the user sees the real cost.
|
||
amount: USERNAME_REGISTRATION_FEE,
|
||
privKey: keyFile.priv_key,
|
||
});
|
||
await submitTx(tx);
|
||
Alert.alert(
|
||
'Отправлено',
|
||
`Транзакция покупки @${name} принята. Имя появится в профиле через несколько секунд.`,
|
||
);
|
||
setNameInput('');
|
||
// Poll every 2s for up to 20s until the address ↔ name binding is visible.
|
||
let attempts = 0;
|
||
const iv = setInterval(async () => {
|
||
attempts++;
|
||
const got = keyFile
|
||
? await reverseResolve(settings.contractId, keyFile.pub_key)
|
||
: null;
|
||
if (got) {
|
||
setUsername(got);
|
||
clearInterval(iv);
|
||
} else if (attempts >= 10) {
|
||
clearInterval(iv);
|
||
}
|
||
}, 2000);
|
||
} catch (e: any) {
|
||
setNameError(humanizeTxError(e));
|
||
} finally {
|
||
setRegistering(false);
|
||
}
|
||
},
|
||
},
|
||
],
|
||
);
|
||
}
|
||
|
||
// Flat fee — same for every name that passes validation.
|
||
const nameFee = USERNAME_REGISTRATION_FEE;
|
||
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
|
||
|
||
useEffect(() => { checkNode(); }, []);
|
||
|
||
// Pick up auto-discovered contract IDs (useWellKnownContracts updates the
|
||
// store; reflect it into the local TextInput state so the UI stays consistent).
|
||
useEffect(() => {
|
||
setContractId(settings.contractId);
|
||
}, [settings.contractId]);
|
||
|
||
// When the registry contract becomes known (either via manual save or
|
||
// auto-discovery), look up the user's registered username reactively.
|
||
// Sets username unconditionally — a null result CLEARS the cached name,
|
||
// which matters when the user switches nodes / chains: a name on the
|
||
// previous chain should no longer show when connected to a chain where
|
||
// the same pubkey isn't registered.
|
||
useEffect(() => {
|
||
if (!settings.contractId || !keyFile) {
|
||
setUsername(null);
|
||
return;
|
||
}
|
||
(async () => {
|
||
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||
setUsername(name);
|
||
})();
|
||
}, [settings.contractId, keyFile, setUsername]);
|
||
|
||
async function checkNode() {
|
||
setNodeStatus('checking');
|
||
try {
|
||
const stats = await getNetStats();
|
||
setNodeStatus('ok');
|
||
setPeerCount(stats.peer_count);
|
||
setBlockCount(stats.total_blocks);
|
||
if (settings.contractId && keyFile) {
|
||
// Address → username: must use reverseResolve, not resolveUsername
|
||
// (resolveUsername goes username → address).
|
||
const name = await reverseResolve(settings.contractId, keyFile.pub_key);
|
||
if (name) setUsername(name);
|
||
}
|
||
} catch {
|
||
setNodeStatus('error');
|
||
}
|
||
}
|
||
|
||
async function saveNode() {
|
||
const url = nodeUrl.trim().replace(/\/$/, '');
|
||
setNodeUrl(url);
|
||
const next = { nodeUrl: url, contractId: contractId.trim() };
|
||
setSettings(next);
|
||
await saveSettings(next);
|
||
Alert.alert('Сохранено', 'Настройки ноды обновлены.');
|
||
checkNode();
|
||
}
|
||
|
||
async function exportKey() {
|
||
if (!keyFile) return;
|
||
try {
|
||
const json = JSON.stringify(keyFile, null, 2);
|
||
const path = FileSystem.cacheDirectory + 'dchain_key.json';
|
||
await FileSystem.writeAsStringAsync(path, json);
|
||
if (await Sharing.isAvailableAsync()) {
|
||
await Sharing.shareAsync(path, {
|
||
mimeType: 'application/json',
|
||
dialogTitle: 'Экспорт ключа DChain',
|
||
});
|
||
}
|
||
} catch (e: any) {
|
||
Alert.alert('Ошибка экспорта', e.message);
|
||
}
|
||
}
|
||
|
||
async function copyAddress() {
|
||
if (!keyFile) return;
|
||
await Clipboard.setStringAsync(keyFile.pub_key);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}
|
||
|
||
function logout() {
|
||
Alert.alert(
|
||
'Удалить аккаунт',
|
||
'Ключ будет удалён с устройства. Убедитесь, что у вас есть резервная копия!',
|
||
[
|
||
{ text: 'Отмена', style: 'cancel' },
|
||
{
|
||
text: 'Удалить',
|
||
style: 'destructive',
|
||
onPress: async () => {
|
||
await deleteKeyFile();
|
||
setKeyFile(null);
|
||
router.replace('/');
|
||
},
|
||
},
|
||
],
|
||
);
|
||
}
|
||
|
||
const statusColor = nodeStatus === 'ok' ? C.ok : nodeStatus === 'error' ? C.err : C.warn;
|
||
const statusLabel = nodeStatus === 'ok' ? 'Подключена' : nodeStatus === 'error' ? 'Недоступна' : 'Проверка…';
|
||
|
||
return (
|
||
<ScrollView
|
||
style={{ flex: 1, backgroundColor: C.bg }}
|
||
contentContainerStyle={{
|
||
paddingHorizontal: 16,
|
||
paddingTop: insets.top + 12,
|
||
paddingBottom: Math.max(insets.bottom, 24) + 20,
|
||
}}
|
||
>
|
||
<Text style={{ color: C.text, fontSize: 22, fontWeight: '700', marginBottom: 24 }}>
|
||
Настройки
|
||
</Text>
|
||
|
||
{/* ── Профиль ── */}
|
||
<SectionLabel>Профиль</SectionLabel>
|
||
<Card>
|
||
{/* Avatar row */}
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 14,
|
||
paddingHorizontal: 14, paddingVertical: 14,
|
||
}}>
|
||
<Avatar
|
||
name={username ? `@${username}` : (keyFile?.pub_key ?? '?')}
|
||
size="md"
|
||
/>
|
||
<View style={{ flex: 1, minWidth: 0 }}>
|
||
{username ? (
|
||
<Text style={{ color: C.text, fontWeight: '700', fontSize: 16 }}>@{username}</Text>
|
||
) : (
|
||
<Text style={{ color: C.muted, fontSize: 13 }}>Имя не зарегистрировано</Text>
|
||
)}
|
||
<Text style={{ color: C.muted, fontFamily: 'monospace', fontSize: 11 }} numberOfLines={1}>
|
||
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Copy address */}
|
||
<View style={{ paddingHorizontal: 14, paddingBottom: 12, borderTopWidth: 1, borderTopColor: C.line, paddingTop: 10 }}>
|
||
<TouchableOpacity
|
||
onPress={copyAddress}
|
||
style={{
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
paddingVertical: 10, borderRadius: 8,
|
||
backgroundColor: copied ? 'rgba(65,201,138,0.12)' : C.surface2,
|
||
}}
|
||
>
|
||
<Ionicons
|
||
name={copied ? 'checkmark-outline' : 'copy-outline'}
|
||
size={15}
|
||
color={copied ? C.ok : C.muted}
|
||
/>
|
||
<Text style={{ color: copied ? C.ok : C.muted, fontSize: 13, fontWeight: '600' }}>
|
||
{copied ? 'Скопировано' : 'Скопировать адрес'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Card>
|
||
|
||
{/* ── Имя пользователя ── */}
|
||
<SectionLabel>Имя пользователя</SectionLabel>
|
||
<Card>
|
||
{username ? (
|
||
// Already registered
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||
paddingHorizontal: 14, paddingVertical: 14,
|
||
}}>
|
||
<View style={{
|
||
width: 34, height: 34, borderRadius: 17,
|
||
backgroundColor: 'rgba(65,201,138,0.12)',
|
||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||
}}>
|
||
<Ionicons name="at-outline" size={16} color={C.ok} />
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: C.text, fontSize: 15, fontWeight: '700' }}>@{username}</Text>
|
||
<Text style={{ color: C.muted, fontSize: 12, marginTop: 2 }}>Привязано к вашему адресу</Text>
|
||
</View>
|
||
<View style={{
|
||
paddingHorizontal: 10, paddingVertical: 4,
|
||
borderRadius: 999, backgroundColor: 'rgba(65,201,138,0.12)',
|
||
}}>
|
||
<Text style={{ color: C.ok, fontSize: 11, fontWeight: '600' }}>Активно</Text>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
// Register new
|
||
<>
|
||
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 6 }}>
|
||
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600', marginBottom: 3 }}>
|
||
Купить никнейм
|
||
</Text>
|
||
<Text style={{ color: C.muted, fontSize: 12, lineHeight: 17 }}>
|
||
Короткие имена дороже. Оплата идёт в казну контракта реестра.
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Input */}
|
||
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center',
|
||
backgroundColor: C.surface2, borderRadius: 8,
|
||
paddingHorizontal: 12,
|
||
borderWidth: 1, borderColor: nameError ? C.err : C.line,
|
||
}}>
|
||
<Text style={{ color: C.muted, fontSize: 15, marginRight: 4 }}>@</Text>
|
||
<TextInput
|
||
value={nameInput}
|
||
onChangeText={onNameInputChange}
|
||
placeholder="alice"
|
||
placeholderTextColor={C.muted}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
maxLength={64}
|
||
style={{ color: C.text, fontSize: 15, height: 40, flex: 1 }}
|
||
/>
|
||
{nameInput.length > 0 && (
|
||
<Text style={{ color: C.muted, fontSize: 11 }}>{nameInput.length}</Text>
|
||
)}
|
||
</View>
|
||
{nameError && (
|
||
<Text style={{ color: C.err, fontSize: 12, marginTop: 6 }}>⚠ {nameError}</Text>
|
||
)}
|
||
</View>
|
||
|
||
{/* Fee breakdown + rules */}
|
||
<View style={{ paddingHorizontal: 14, paddingTop: 10 }}>
|
||
<View style={{
|
||
backgroundColor: C.surface2, borderRadius: 8,
|
||
paddingHorizontal: 12, paddingVertical: 10,
|
||
}}>
|
||
{/* Primary cost line */}
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||
<Ionicons name="flame-outline" size={14} color={C.warn} />
|
||
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Плата за ник (сгорает)</Text>
|
||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>
|
||
{formatAmount(nameFee)}
|
||
</Text>
|
||
</View>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||
<Ionicons name="cube-outline" size={14} color={C.muted} />
|
||
<Text style={{ color: C.muted, fontSize: 12, flex: 1 }}>Комиссия сети (валидатору)</Text>
|
||
<Text style={{ color: C.muted, fontSize: 13 }}>
|
||
{formatAmount(1000)}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Total */}
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 8,
|
||
paddingTop: 6,
|
||
borderTopWidth: 1, borderTopColor: C.line,
|
||
}}>
|
||
<Text style={{ color: C.text, fontSize: 12, fontWeight: '600', flex: 1 }}>Итого</Text>
|
||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '700' }}>
|
||
{formatAmount(nameFee + 1000)}
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Rules */}
|
||
<Text style={{ color: C.muted, fontSize: 11, lineHeight: 17, marginTop: 8 }}>
|
||
Минимум {MIN_USERNAME_LENGTH} символа, только{' '}
|
||
<Text style={{ color: C.text }}>a-z 0-9 _ -</Text>
|
||
, первый символ — буква.
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* Register button */}
|
||
<View style={{ paddingHorizontal: 14, paddingVertical: 12 }}>
|
||
<TouchableOpacity
|
||
onPress={registerUsername}
|
||
disabled={registering || !nameIsValid || !settings.contractId}
|
||
style={{
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
paddingVertical: 12, borderRadius: 8,
|
||
backgroundColor: (registering || !nameIsValid || !settings.contractId)
|
||
? C.surface2 : C.accent,
|
||
}}
|
||
>
|
||
<Ionicons
|
||
name={registering ? 'hourglass-outline' : 'at-outline'}
|
||
size={16}
|
||
color={(registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg}
|
||
/>
|
||
<Text style={{
|
||
color: (registering || !nameIsValid || !settings.contractId) ? C.muted : C.bg,
|
||
fontWeight: '700', fontSize: 14,
|
||
}}>
|
||
{registering ? 'Покупка…' : 'Купить никнейм'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
{!settings.contractId && (
|
||
<Text style={{ color: C.warn, fontSize: 11, marginTop: 6, textAlign: 'center' }}>
|
||
Укажите ID контракта реестра в настройках ноды ниже
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</>
|
||
)}
|
||
</Card>
|
||
|
||
{/* ── Нода ── */}
|
||
<SectionLabel>Нода</SectionLabel>
|
||
<Card>
|
||
{/* Connection status */}
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||
paddingHorizontal: 14, paddingVertical: 12,
|
||
}}>
|
||
<View style={{
|
||
width: 34, height: 34, borderRadius: 17,
|
||
backgroundColor: `${statusColor}15`,
|
||
alignItems: 'center', justifyContent: 'center',
|
||
}}>
|
||
<Ionicons
|
||
name={nodeStatus === 'ok' ? 'cloud-done-outline' : nodeStatus === 'error' ? 'cloud-offline-outline' : 'cloud-outline'}
|
||
size={16}
|
||
color={statusColor}
|
||
/>
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Подключение</Text>
|
||
<Text style={{ color: statusColor, fontSize: 12 }}>{statusLabel}</Text>
|
||
</View>
|
||
{nodeStatus === 'ok' && (
|
||
<View style={{ alignItems: 'flex-end', gap: 2 }}>
|
||
{peerCount !== null && (
|
||
<Text style={{ color: C.muted, fontSize: 11 }}>
|
||
<Text style={{ color: C.text, fontWeight: '600' }}>{peerCount}</Text> пиров
|
||
</Text>
|
||
)}
|
||
{blockCount !== null && (
|
||
<Text style={{ color: C.muted, fontSize: 11 }}>
|
||
<Text style={{ color: C.text, fontWeight: '600' }}>{blockCount.toLocaleString()}</Text> блоков
|
||
</Text>
|
||
)}
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Node URL input */}
|
||
<View style={{ borderTopWidth: 1, borderTopColor: C.line }}>
|
||
<FieldInput
|
||
label="URL ноды"
|
||
value={nodeUrl}
|
||
onChangeText={setNodeUrlLocal}
|
||
keyboardType="url"
|
||
placeholder="http://localhost:8080"
|
||
/>
|
||
</View>
|
||
|
||
{/* Registry contract — auto-detected from node; manual override under advanced */}
|
||
<View style={{ borderTopWidth: 1, borderTopColor: C.line, paddingHorizontal: 14, paddingVertical: 12 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||
<Ionicons
|
||
name={settings.contractId ? 'checkmark-circle' : 'help-circle-outline'}
|
||
size={14}
|
||
color={settings.contractId ? C.ok : C.warn}
|
||
/>
|
||
<Text style={{ color: C.muted, fontSize: 11, letterSpacing: 0.5 }}>
|
||
КОНТРАКТ РЕЕСТРА ИМЁН
|
||
</Text>
|
||
<Text style={{
|
||
marginLeft: 'auto' as any, color: settings.contractId ? C.ok : C.warn, fontSize: 11, fontWeight: '600',
|
||
}}>
|
||
{settings.contractId ? 'Авто-обнаружен' : 'Не найден'}
|
||
</Text>
|
||
</View>
|
||
<Text style={{ color: C.text, fontSize: 12, fontFamily: 'monospace' }} numberOfLines={1}>
|
||
{settings.contractId || '—'}
|
||
</Text>
|
||
<TouchableOpacity
|
||
onPress={() => setShowAdvanced(v => !v)}
|
||
style={{ marginTop: 8 }}
|
||
>
|
||
<Text style={{ color: C.accent, fontSize: 11 }}>
|
||
{showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
{showAdvanced && (
|
||
<View style={{
|
||
marginTop: 8,
|
||
backgroundColor: C.surface2, borderRadius: 8,
|
||
paddingHorizontal: 12, paddingVertical: 8,
|
||
borderWidth: 1, borderColor: C.line,
|
||
}}>
|
||
<TextInput
|
||
value={contractId}
|
||
onChangeText={setContractId}
|
||
placeholder="hex contract ID"
|
||
placeholderTextColor={C.muted}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
style={{ color: C.text, fontSize: 13, fontFamily: 'monospace', height: 36 }}
|
||
/>
|
||
<Text style={{ color: C.muted, fontSize: 10, marginTop: 4 }}>
|
||
Оставьте пустым — клиент запросит канонический контракт у ноды.
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Save button */}
|
||
<View style={{ paddingHorizontal: 14, paddingBottom: 12, paddingTop: 4, borderTopWidth: 1, borderTopColor: C.line }}>
|
||
<TouchableOpacity
|
||
onPress={saveNode}
|
||
style={{
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
paddingVertical: 11, borderRadius: 8, backgroundColor: C.accent,
|
||
}}
|
||
>
|
||
<Ionicons name="save-outline" size={16} color={C.bg} />
|
||
<Text style={{ color: C.bg, fontWeight: '700', fontSize: 14 }}>Сохранить и переподключиться</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Card>
|
||
|
||
{/* ── Безопасность ── */}
|
||
<SectionLabel>Безопасность</SectionLabel>
|
||
<Card>
|
||
<View style={{
|
||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||
paddingHorizontal: 14, paddingVertical: 14,
|
||
}}>
|
||
<View style={{
|
||
width: 34, height: 34, borderRadius: 17,
|
||
backgroundColor: 'rgba(125,181,255,0.10)',
|
||
alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||
}}>
|
||
<Ionicons name="shield-outline" size={16} color={C.accent} />
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: C.text, fontSize: 14, fontWeight: '600' }}>Экспорт ключа</Text>
|
||
<Text style={{ color: C.muted, fontSize: 12, marginTop: 1 }}>Сохранить приватный ключ как key.json</Text>
|
||
</View>
|
||
<TouchableOpacity
|
||
onPress={exportKey}
|
||
style={{
|
||
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8,
|
||
backgroundColor: C.surface2, borderWidth: 1, borderColor: C.line,
|
||
}}
|
||
>
|
||
<Text style={{ color: C.text, fontSize: 13, fontWeight: '600' }}>Экспорт</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Card>
|
||
|
||
{/* ── Опасная зона ── */}
|
||
<SectionLabel>Опасная зона</SectionLabel>
|
||
<Card style={{ backgroundColor: 'rgba(255,122,135,0.04)', borderWidth: 1, borderColor: 'rgba(255,122,135,0.20)' }}>
|
||
<View style={{ paddingHorizontal: 14, paddingVertical: 14 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||
<Ionicons name="warning-outline" size={16} color={C.err} />
|
||
<Text style={{ color: C.err, fontSize: 14, fontWeight: '700' }}>Удалить аккаунт</Text>
|
||
</View>
|
||
<Text style={{ color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 }}>
|
||
Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии.
|
||
</Text>
|
||
<TouchableOpacity
|
||
onPress={logout}
|
||
style={{
|
||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||
paddingVertical: 11, borderRadius: 8,
|
||
backgroundColor: 'rgba(255,122,135,0.12)',
|
||
}}
|
||
>
|
||
<Ionicons name="trash-outline" size={16} color={C.err} />
|
||
<Text style={{ color: C.err, fontWeight: '700', fontSize: 14 }}>Удалить с устройства</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</Card>
|
||
</ScrollView>
|
||
);
|
||
}
|