/** * 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-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 ? ( ) : ( Отправить запрос · {formatAmount(fee + 1000)} )} )} ); }