Files
dchain/client-app/app/(app)/settings.tsx
vsecoder e62b72b5be fix(client): safeBack helper + prevent self-contact-request
1. GO_BACK warning & stuck screens

   When a deep link or direct push put the user on /feed/[id],
   /profile/[address], /compose, or /settings without any prior stack
   entry, tapping the header chevron emitted:
     "ERROR The action 'GO_BACK' was not handled by any navigator"
   and did nothing — user was stuck.

   New helper lib/utils.safeBack(fallback = '/(app)/chats') wraps
   router.canGoBack() — when there's history it pops; otherwise it
   replace-navigates to a sensible fallback (chats list by default,
   '/' for auth screens so we land back at the onboarding).

   Applied to every header chevron and back-from-detail flow:
   app/(app)/chats/[id], app/(app)/compose, app/(app)/feed/[id]
   (header + onDeleted), app/(app)/feed/tag/[tag],
   app/(app)/profile/[address], app/(app)/new-contact (header + OK
   button on request-sent alert), app/(app)/settings,
   app/(auth)/create, app/(auth)/import.

2. Prevent self-contact-request

   new-contact.tsx now compares the resolved address against
   keyFile.pub_key at two points:
     - right after resolveUsername + getIdentity in search() — before
       the profile card even renders, so the user doesn't see the
       "Send request" CTA for themselves.
     - inside sendRequest() as a belt-and-braces guard in case the
       check was somehow bypassed.
   The search path shows an inline error ("That's you. You can't
   send a contact request to yourself."); sendRequest falls back to
   an Alert with the same meaning. Both compare case-insensitively
   against the pubkey hex so mixed-case pastes work.

   Technically the server would still accept a self-request (the
   chain stores it under contact_in:<self>:<self>), but it's a dead-
   end UX-wise — the user can't chat with themselves — so the client
   blocks it preemptively instead of letting users pay the fee for
   nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:19 +03:00

596 lines
19 KiB
TypeScript
Raw Permalink 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>
{/* ── 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>
);
}