Files
dchain/client-app/app/(app)/settings.tsx
vsecoder 8940b97cc6 feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
Part of PR #3. Pairing flow still to come.

Devices screen — app/(app)/devices.tsx:
  * Lists every active device from /api/devices/{self}.
  * "THIS DEVICE" badge on our own row, Unlink button on every other.
  * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal.
  * Pull-to-refresh; empty state when balance is too low for auto-link.
  * Placeholder row for "Link new device" — wired in next commit.

Settings → Devices entry row: added under a new "Devices" section.

Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx:
  * New AsyncStorage marker `dchain_device_registered` tracks whether
    this install ever made it into the on-chain registry.
  * wipeAllLocalState() zeroes secure-store key + contacts + settings +
    chats cache + marker. Safe-idempotent.
  * Bootstrap effect in app layout splits three branches by
    (our_pub in chain's active list × marker_set):
      - in list      → mark registered, done.
      - not in list + was registered → REVOKED → wipe + redirect to auth.
      - not in list + never registered → first boot, LINK_DEVICE.
  * Network errors never trigger wipe — only an explicit "pub missing
    from chain response" decides it. Belt-and-suspenders against a
    misbehaving node spuriously dropping records.

Next: pairing flow so a second device (desktop, tablet, new phone)
can come online, show a 6-digit code, receive master priv via a
one-shot relay envelope encrypted to its fresh device X25519 pub,
then self-link.
2026-04-22 16:28:16 +03:00

608 lines
19 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.

/**
* Settings screen — sub-route, открывается по tap'у на profile-avatar в
* TabHeader. Использует обычный `<Header>` с back-кнопкой.
*
* Секции:
* 1. Профиль — avatar, @username, short-address, Copy row.
* 2. Username — регистрация в native:username_registry (если не куплено).
* 3. Node — URL + contract ID + Save + Status.
* 4. Account — Export key, Delete account.
*
* Весь Pressable'овый layout живёт на ВНЕШНЕМ View с static style —
* Pressable handle-ит только background change (через вложенный View
* в ({pressed}) callback'е), никаких layout props в callback-style.
* Это лечит web-баг, где Pressable style-функция не применяет
* percentage/padding layout надёжно.
*/
import React, { useState, useEffect } from 'react';
import {
View, Text, ScrollView, TextInput, Alert, Pressable, ActivityIndicator, Share,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
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, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
type NodeStatus = 'idle' | 'checking' | 'ok' | 'error';
type IoniconName = React.ComponentProps<typeof Ionicons>['name'];
// ─── Shared layout primitives ─────────────────────────────────────
function SectionLabel({ children }: { children: string }) {
return (
<Text
style={{
color: '#5a5a5a',
fontSize: 11,
letterSpacing: 1.2,
textTransform: 'uppercase',
marginTop: 18,
marginBottom: 8,
paddingHorizontal: 14,
fontWeight: '700',
}}
>
{children}
</Text>
);
}
function Card({ children }: { children: React.ReactNode }) {
return (
<View
style={{
backgroundColor: '#0a0a0a',
borderRadius: 14,
marginHorizontal: 14,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#1f1f1f',
}}
>
{children}
</View>
);
}
/**
* Row — clickable / non-clickable list item внутри Card'а.
*
* Layout живёт на ВНЕШНЕМ контейнере (View если read-only, Pressable
* если tappable). Для pressed-стейта используется вложенный `<View>`
* с background-color, чтобы не полагаться на style-функцию Pressable'а
* (web-баг).
*/
function Row({
icon, label, value, onPress, right, danger, first,
}: {
icon: IoniconName;
label: string;
value?: string;
onPress?: () => void;
right?: React.ReactNode;
danger?: boolean;
first?: boolean;
}) {
const body = (pressed: boolean) => (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 13,
backgroundColor: pressed ? '#151515' : 'transparent',
borderTopWidth: first ? 0 : 1,
borderTopColor: '#1f1f1f',
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: danger ? 'rgba(244,33,46,0.12)' : '#1a1a1a',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={icon} size={16} color={danger ? '#f4212e' : '#ffffff'} />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text
style={{
color: danger ? '#f4212e' : '#ffffff',
fontSize: 14,
fontWeight: '600',
}}
>
{label}
</Text>
{value !== undefined && (
<Text numberOfLines={1} style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
{value}
</Text>
)}
</View>
{right}
{onPress && !right && (
<Ionicons name="chevron-forward" size={16} color="#5a5a5a" />
)}
</View>
);
if (!onPress) return <View>{body(false)}</View>;
return (
<Pressable onPress={onPress}>
{({ pressed }) => body(pressed)}
</Pressable>
);
}
// ─── Screen ───────────────────────────────────────────────────────
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const setKeyFile = useStore(s => s.setKeyFile);
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 balance = useStore(s => s.balance);
const [nodeUrl, setNodeUrlInput] = useState(settings.nodeUrl);
const [contractId, setContractId] = useState(settings.contractId);
const [nodeStatus, setNodeStatus] = useState<NodeStatus>('idle');
const [peerCount, setPeerCount] = useState<number | null>(null);
const [blockCount, setBlockCount] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [savingNode, setSavingNode] = useState(false);
// Username registration state
const [nameInput, setNameInput] = useState('');
const [nameError, setNameError] = useState<string | null>(null);
const [registering, setRegistering] = useState(false);
useEffect(() => { checkNode(); }, []);
useEffect(() => { setContractId(settings.contractId); }, [settings.contractId]);
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);
} catch {
setNodeStatus('error');
}
}
async function saveNode() {
setSavingNode(true);
const url = nodeUrl.trim().replace(/\/$/, '');
setNodeUrl(url);
const next = { nodeUrl: url, contractId: contractId.trim() };
setSettings(next);
await saveSettings(next);
await checkNode();
setSavingNode(false);
Alert.alert('Saved', 'Node settings updated.');
}
async function copyAddress() {
if (!keyFile) return;
await Clipboard.setStringAsync(keyFile.pub_key);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
async function exportKey() {
if (!keyFile) return;
try {
await Share.share({
message: JSON.stringify(keyFile, null, 2),
title: 'DChain key file',
});
} catch (e: any) {
Alert.alert('Export failed', e?.message ?? 'Unknown error');
}
}
function logout() {
Alert.alert(
'Delete account',
'Your key will be removed from this device. Make sure you have a backup!',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
await deleteKeyFile();
setKeyFile(null);
router.replace('/');
},
},
],
);
}
const onNameChange = (v: string) => {
const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH);
setNameInput(cleaned);
setNameError(null);
};
const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput);
async function registerUsername() {
if (!keyFile) return;
const name = nameInput.trim();
if (!nameIsValid) {
setNameError(`Min ${MIN_USERNAME_LENGTH} chars, starts with a-z`);
return;
}
if (!settings.contractId) {
setNameError('No registry contract in node settings');
return;
}
const total = USERNAME_REGISTRATION_FEE + 1000 + 2000;
if (balance < total) {
setNameError(`Need ${formatAmount(total)}, have ${formatAmount(balance)}`);
return;
}
try {
const existing = await resolveUsername(settings.contractId, name);
if (existing) { setNameError(`@${name} already taken`); return; }
} catch { /* ignore */ }
Alert.alert(
`Buy @${name}?`,
`Cost: ${formatAmount(USERNAME_REGISTRATION_FEE)} + fee ${formatAmount(1000)}.\nBinds to your address until released.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Buy',
onPress: async () => {
setRegistering(true);
setNameError(null);
try {
const tx = buildCallContractTx({
from: keyFile.pub_key,
contractId: settings.contractId,
method: 'register',
args: [name],
amount: USERNAME_REGISTRATION_FEE,
privKey: keyFile.priv_key,
});
await submitTx(tx);
setNameInput('');
Alert.alert('Submitted', 'Registration tx accepted. Name appears in a few seconds.');
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);
}
},
},
],
);
}
const statusColor =
nodeStatus === 'ok' ? '#3ba55d' :
nodeStatus === 'error' ? '#f4212e' :
'#f0b35a';
const statusLabel =
nodeStatus === 'ok' ? 'Connected' :
nodeStatus === 'error' ? 'Unreachable' :
'Checking…';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Settings"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
{/* ── Profile ── */}
<SectionLabel>Profile</SectionLabel>
<Card>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
padding: 14,
gap: 14,
}}
>
<Avatar
name={username ?? keyFile?.pub_key ?? '?'}
address={keyFile?.pub_key}
size={56}
/>
<View style={{ flex: 1, minWidth: 0 }}>
{username ? (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 17 }}>
@{username}
</Text>
<Ionicons name="checkmark-circle" size={15} color="#1d9bf0" style={{ marginLeft: 4 }} />
</View>
) : (
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>No username yet</Text>
)}
<Text
style={{
color: '#8b8b8b',
fontSize: 11,
marginTop: 2,
fontFamily: 'monospace',
}}
numberOfLines={1}
>
{keyFile ? shortAddr(keyFile.pub_key, 10) : '—'}
</Text>
</View>
</View>
<Row
icon={copied ? 'checkmark-outline' : 'copy-outline'}
label={copied ? 'Copied!' : 'Copy address'}
onPress={copyAddress}
right={<View style={{ width: 16 }} />}
/>
</Card>
{/* ── Username (только если ещё нет) ── */}
{!username && (
<>
<SectionLabel>Username</SectionLabel>
<Card>
<View style={{ padding: 14 }}>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14, marginBottom: 4 }}>
Buy a username
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 10 }}>
Flat {formatAmount(USERNAME_REGISTRATION_FEE)} fee + {formatAmount(1000)} network.
Only a-z, 0-9, _, -. Starts with a letter.
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 4,
borderWidth: 1,
borderColor: nameError ? '#f4212e' : '#1f1f1f',
}}
>
<Text style={{ color: '#5a5a5a', fontSize: 15, marginRight: 2 }}>@</Text>
<TextInput
value={nameInput}
onChangeText={onNameChange}
placeholder="alice"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
maxLength={MAX_USERNAME_LENGTH}
style={{
flex: 1,
color: '#ffffff',
fontSize: 15,
paddingVertical: 8,
}}
/>
</View>
{nameError && (
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
{nameError}
</Text>
)}
<PrimaryButton
onPress={registerUsername}
disabled={registering || !nameIsValid || !settings.contractId}
loading={registering}
label={`Buy @${nameInput || 'username'}`}
style={{ marginTop: 12 }}
/>
</View>
</Card>
</>
)}
{/* ── Node ── */}
<SectionLabel>Node</SectionLabel>
<Card>
<View style={{ padding: 14, gap: 10 }}>
<LabeledInput
label="Node URL"
value={nodeUrl}
onChangeText={setNodeUrlInput}
placeholder="http://localhost:8080"
/>
<LabeledInput
label="Username contract"
value={contractId}
onChangeText={setContractId}
placeholder="auto-discovered via /api/well-known-contracts"
monospace
/>
<PrimaryButton
onPress={saveNode}
disabled={savingNode}
loading={savingNode}
label="Save"
style={{ marginTop: 4 }}
/>
</View>
<Row
icon="pulse-outline"
label="Status"
value={
nodeStatus === 'ok'
? `${statusLabel} · ${blockCount ?? 0} blocks · ${peerCount ?? 0} peers`
: statusLabel
}
right={
<View
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: statusColor }}
/>
}
/>
</Card>
{/* ── Devices — multi-device registry (v2.2.0) ── */}
<SectionLabel>Devices</SectionLabel>
<Card>
<Row
icon="phone-portrait-outline"
label="Linked devices"
value="Manage the devices that receive your messages"
onPress={() => router.push('/(app)/devices' as never)}
first
/>
</Card>
{/* ── Account ── */}
<SectionLabel>Account</SectionLabel>
<Card>
<Row
icon="download-outline"
label="Export key"
value="Save your private key as JSON"
onPress={exportKey}
first
/>
<Row
icon="trash-outline"
label="Delete account"
value="Remove key from this device"
onPress={logout}
danger
/>
</Card>
</ScrollView>
</View>
);
}
// ─── Form primitives ──────────────────────────────────────────────
function LabeledInput({
label, value, onChangeText, placeholder, monospace,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
monospace?: boolean;
}) {
return (
<View>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginBottom: 6 }}>{label}</Text>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
style={{
color: '#ffffff',
fontSize: monospace ? 13 : 14,
fontFamily: monospace ? 'monospace' : undefined,
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: '#1f1f1f',
}}
/>
</View>
);
}
function PrimaryButton({
label, onPress, disabled, loading, style,
}: {
label: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
style?: object;
}) {
return (
<Pressable onPress={onPress} disabled={disabled} style={style}>
{({ pressed }) => (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 11,
borderRadius: 999,
backgroundColor: disabled
? '#1a1a1a'
: pressed ? '#1a8cd8' : '#1d9bf0',
}}
>
{loading ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text
style={{
color: disabled ? '#5a5a5a' : '#ffffff',
fontWeight: '700',
fontSize: 14,
}}
>
{label}
</Text>
)}
</View>
)}
</Pressable>
);
}