/** * 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 } 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: 'Базовая' }, { value: 10_000, label: 'Стандарт' }, { value: 50_000, label: 'Приоритет' }, ]; 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} не зарегистрирован в этой сети`); return; } address = addr; } const identity = await getIdentity(address); setResolved({ address: identity?.pub_key ?? address, nickname: identity?.nickname || undefined, x25519: identity?.x25519_pub || undefined, }); } catch (e: any) { setError(e?.message ?? 'Не удалось найти пользователя'); } finally { setSearching(false); } } async function sendRequest() { if (!resolved || !keyFile) return; if (balance < fee + 1000) { Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (плата + сетевая комиссия).`); 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( 'Запрос отправлен', `Запрос на общение отправлен ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`, [{ text: 'OK', onPress: () => router.back() }], ); } catch (e: any) { setError(humanizeTxError(e)); } finally { setSending(false); } } const displayName = resolved ? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address)) : ''; return (
router.back()} />} /> { 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 ? ( ) : ( Найти )} )} {/* Empty-state hint — показываем когда ничего не введено и нет результата */} {query.trim().length === 0 && !resolved && ( Найдите собеседника Введите @username, если человек зарегистрировал ник, либо полный hex pubkey или DC… адрес. )} {error && ( {error} )} {/* Resolved profile card */} {resolved && ( <> {displayName} {shortAddr(resolved.address, 10)} {resolved.x25519 ? 'E2E готов' : 'Ключ ещё не опубликован'} {/* Intro */} Сообщение (опционально, видно в открытом виде на chain) {intro.length}/140 {/* Fee tier */} Плата за запрос (уходит получателю, anti-spam) {FEE_TIERS.map(t => { const active = fee === t.value; return ( setFee(t.value)} style={({ pressed }) => ({ flex: 1, alignItems: 'center', paddingVertical: 10, borderRadius: 10, backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111', borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f', })} > {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 ? ( ) : ( Отправить запрос · {formatAmount(fee + 1000)} )} )} ); }