/** * 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(null); const [searching, setSearching] = useState(false); const [sending, setSending] = useState(false); const [error, setError] = useState(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 (
safeBack()} />} /> { setResolved(null); setError(null); }} /> {query.trim().length > 0 && ( ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 11, borderRadius: 999, marginTop: 12, backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', })} > {searching ? ( ) : ( Search )} )} {/* Empty-state hint — показываем когда ничего не введено и нет результата */} {query.trim().length === 0 && !resolved && ( Find someone to message Enter an @username if the person registered one, or paste a full hex pubkey or DC… address. )} {error && ( {error} )} {/* Resolved profile card */} {resolved && ( <> {displayName} {shortAddr(resolved.address, 10)} {resolved.x25519 ? 'E2E ready' : 'Key not published yet'} {/* Intro */} Intro (optional, plaintext on-chain) {intro.length}/140 {/* Fee tier */} Anti-spam fee (goes to the recipient) {/* 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. */} {FEE_TIERS.map(t => { const active = fee === t.value; return ( setFee(t.value)} style={({ pressed }) => ({ flex: 1, opacity: pressed ? 0.7 : 1, })} > {t.label} {formatAmount(t.value)} ); })} {/* Submit */} ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 13, borderRadius: 999, marginTop: 20, backgroundColor: sending ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', })} > {sending ? ( ) : ( Send request · {formatAmount(fee + 1000)} )} )} ); }