Files
dchain/client-app/app/(app)/new-contact.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

342 lines
13 KiB
TypeScript

/**
* Add new contact — dark minimalist, inspired by the reference.
*
* Flow:
* 1. Пользователь вводит @username или hex pubkey / DC-address.
* 2. Жмёт Search → resolveUsername → getIdentity.
* 3. Показываем preview (avatar + имя + address + наличие x25519).
* 4. Выбирает fee (chip-selector) + вводит intro.
* 5. Submit → CONTACT_REQUEST tx.
*/
import React, { useState } from 'react';
import {
View, Text, ScrollView, Alert, Pressable, TextInput, ActivityIndicator,
} 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, resolveUsername, 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';
import { SearchBar } from '@/components/SearchBar';
const MIN_CONTACT_FEE = 5000;
const FEE_TIERS = [
{ value: 5_000, label: 'Min' },
{ value: 10_000, label: 'Standard' },
{ value: 50_000, label: 'Priority' },
];
interface Resolved {
address: string;
nickname?: string;
x25519?: string;
}
export default function NewContactScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const settings = useStore(s => s.settings);
const balance = useStore(s => s.balance);
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;
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
const name = q.replace('@', '');
const addr = await resolveUsername(settings.contractId, name);
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr;
}
// Block self-lookup — can't message yourself, and the on-chain
// CONTACT_REQUEST tx would go through but serve no purpose.
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setError("That's you. You can't send a contact request to yourself.");
return;
}
const identity = await getIdentity(address);
const resolvedAddr = identity?.pub_key ?? address;
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setError("That's you. You can't send a contact request to yourself.");
return;
}
setResolved({
address: resolvedAddr,
nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined,
});
} catch (e: any) {
setError(e?.message ?? 'Lookup failed');
} finally {
setSearching(false);
}
}
async function sendRequest() {
if (!resolved || !keyFile) return;
if (resolved.address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
Alert.alert('Can\'t message yourself', "This is your own address.");
return;
}
if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
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(
'Request sent',
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => safeBack() }],
);
} catch (e: any) {
setError(humanizeTxError(e));
} finally {
setSending(false);
}
}
const displayName = resolved
? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address))
: '';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Search"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<SearchBar
value={query}
onChangeText={setQuery}
placeholder="@alice, hex pubkey or DC address"
onSubmitEditing={search}
autoFocus
onClear={() => { setResolved(null); setError(null); }}
/>
{query.trim().length > 0 && (
<Pressable
onPress={search}
disabled={searching}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 11, borderRadius: 999, marginTop: 12,
backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{searching ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
)}
</Pressable>
)}
{/* Empty-state hint — показываем когда ничего не введено и нет результата */}
{query.trim().length === 0 && !resolved && (
<View style={{ marginTop: 28, alignItems: 'center', paddingHorizontal: 16 }}>
<View
style={{
width: 56, height: 56, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}
>
<Ionicons name="person-add-outline" size={24} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 6 }}>
Find someone to message
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Enter an <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text> if
the person registered one, or paste a full hex pubkey or <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
</View>
)}
{error && (
<View style={{
marginTop: 14,
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(244,33,46,0.08)',
borderWidth: 1, borderColor: 'rgba(244,33,46,0.25)',
}}>
<Text style={{ color: '#f4212e', fontSize: 13 }}>{error}</Text>
</View>
)}
{/* Resolved profile card */}
{resolved && (
<>
<View style={{
marginTop: 18,
padding: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
}}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<Avatar
name={displayName}
address={resolved.address}
size={52}
dotColor={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 16 }}>
{displayName}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace', marginTop: 2 }} numberOfLines={1}>
{shortAddr(resolved.address, 10)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 5, gap: 4 }}>
<Ionicons
name={resolved.x25519 ? 'lock-closed' : 'time-outline'}
size={11}
color={resolved.x25519 ? '#3ba55d' : '#f0b35a'}
/>
<Text style={{
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
fontSize: 11, fontWeight: '500',
}}>
{resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
</Text>
</View>
</View>
</View>
</View>
{/* Intro */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
Intro (optional, plaintext on-chain)
</Text>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Hey, it's Jordan from the conference"
placeholderTextColor="#5a5a5a"
multiline
maxLength={140}
style={{
color: '#ffffff', fontSize: 14,
backgroundColor: '#0a0a0a', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
borderWidth: 1, borderColor: '#1f1f1f',
minHeight: 80, textAlignVertical: 'top',
}}
/>
<Text style={{ color: '#5a5a5a', fontSize: 11, textAlign: 'right', marginTop: 4 }}>
{intro.length}/140
</Text>
{/* Fee tier */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
Anti-spam fee (goes to the recipient)
</Text>
{/* Fee-tier pills.
Layout (background, border, padding) lives on a static
inner View — Pressable's dynamic style-function has been
observed to drop backgroundColor between renders on
some RN/Android versions, which is what made these
chips look like three bare text labels on black
instead of distinct pills. Press feedback via opacity
on the Pressable itself, which is stable. */}
<View style={{ flexDirection: 'row', gap: 8 }}>
{FEE_TIERS.map(t => {
const active = fee === t.value;
return (
<Pressable
key={t.value}
onPress={() => setFee(t.value)}
style={({ pressed }) => ({
flex: 1,
opacity: pressed ? 0.7 : 1,
})}
>
<View
style={{
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : '#111111',
borderWidth: 1,
borderColor: active ? '#ffffff' : '#1f1f1f',
}}
>
<Text style={{
color: active ? '#000' : '#ffffff',
fontWeight: '700', fontSize: 13,
}}>
{t.label}
</Text>
<Text style={{
color: active ? '#333' : '#8b8b8b',
fontSize: 11, marginTop: 2,
}}>
{formatAmount(t.value)}
</Text>
</View>
</Pressable>
);
})}
</View>
{/* Submit */}
<Pressable
onPress={sendRequest}
disabled={sending}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 13, borderRadius: 999, marginTop: 20,
backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{sending ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
Send request · {formatAmount(fee + 1000)}
</Text>
)}
</Pressable>
</>
)}
</ScrollView>
</View>
);
}