/** * 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 ( {children} ); } function Card({ children, style }: { children: React.ReactNode; style?: object }) { return ( {children} ); } function CardRow({ icon, label, value, first, }: { icon: keyof typeof Ionicons.glyphMap; label: string; value?: string; first?: boolean; }) { return ( {label} {value !== undefined && ( {value} )} ); } 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 ( {label} ); } // ─── 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(null); const [blockCount, setBlockCount] = useState(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(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 ( Настройки {/* ── Профиль ── */} Профиль {/* Avatar row */} {username ? ( @{username} ) : ( Имя не зарегистрировано )} {keyFile ? shortAddr(keyFile.pub_key, 10) : '—'} {/* Copy address */} {copied ? 'Скопировано' : 'Скопировать адрес'} {/* ── Имя пользователя ── */} Имя пользователя {username ? ( // Already registered @{username} Привязано к вашему адресу Активно ) : ( // Register new <> Купить никнейм Короткие имена дороже. Оплата идёт в казну контракта реестра. {/* Input */} @ {nameInput.length > 0 && ( {nameInput.length} )} {nameError && ( ⚠ {nameError} )} {/* Fee breakdown + rules */} {/* Primary cost line */} Плата за ник (сгорает) {formatAmount(nameFee)} Комиссия сети (валидатору) {formatAmount(1000)} {/* Total */} Итого {formatAmount(nameFee + 1000)} {/* Rules */} Минимум {MIN_USERNAME_LENGTH} символа, только{' '} a-z 0-9 _ - , первый символ — буква. {/* Register button */} {registering ? 'Покупка…' : 'Купить никнейм'} {!settings.contractId && ( Укажите ID контракта реестра в настройках ноды ниже )} )} {/* ── Нода ── */} Нода {/* Connection status */} Подключение {statusLabel} {nodeStatus === 'ok' && ( {peerCount !== null && ( {peerCount} пиров )} {blockCount !== null && ( {blockCount.toLocaleString()} блоков )} )} {/* Node URL input */} {/* Registry contract — auto-detected from node; manual override under advanced */} КОНТРАКТ РЕЕСТРА ИМЁН {settings.contractId ? 'Авто-обнаружен' : 'Не найден'} {settings.contractId || '—'} setShowAdvanced(v => !v)} style={{ marginTop: 8 }} > {showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'} {showAdvanced && ( Оставьте пустым — клиент запросит канонический контракт у ноды. )} {/* Save button */} Сохранить и переподключиться {/* ── Безопасность ── */} Безопасность Экспорт ключа Сохранить приватный ключ как key.json Экспорт {/* ── Опасная зона ── */} Опасная зона Удалить аккаунт Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии. Удалить с устройства ); }