Files
dchain/client-app/app/(app)/new-contact.tsx
vsecoder 516940fa8e fix(client): contact-request endpoint path + search screen polish
1. Contact requests silently 404'd
   fetchContactRequests hit /api/relay/contacts, but the server mounts
   the whole /relay/* group at root (no /api prefix). Result: every
   poll returned 404, the catch swallowed it, and the notifications
   tab stayed empty even after the user sent themselves a CONTACT_
   REQUEST on-chain. Fixed the client path to /relay/contacts — same
   pattern as sendEnvelope / fetchInbox in the v1.0.x relay cleanup.

2. Search screen was half-finished
   SearchBar used a dual-state hack (idle-centered Text overlaid with
   an invisible TextInput) that broke focus + alignment on Android and
   sometimes ate taps. Rewrote as a plain single-row pill: icon +
   TextInput + optional clear button. Fewer moving parts, predictable
   focus, proper placeholder styling.

   new-contact.tsx cleaned up:
   - Title "New chat" → "Поиск" (matches the NavBar tab label and the
     rest of the Russian UI).
   - All labels localised: "Accept/Decline", "Intro", "Anti-spam fee",
     fee-tier names, error messages, final CTA.
   - Proper empty-state hint when query is empty (icon + headline +
     explanation of @username / hex / DC prefix) instead of just a
     floating helper text.
   - Search button hidden until user types something — the empty-
     state stands alone, no dead grey button under it.
   - onClear handler on SearchBar resets the resolved profile too.

   requests.tsx localised: title, empty-state, Accept/Decline button
   copy, confirmation Alert text.

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

312 lines
12 KiB
TypeScript
Raw 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.

/**
* 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<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} не зарегистрирован в этой сети`); 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 (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Поиск"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<SearchBar
value={query}
onChangeText={setQuery}
placeholder="@alice, hex pubkey или DC-адрес"
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 }}>Найти</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 }}>
Найдите собеседника
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Введите <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>,
если человек зарегистрировал ник, либо полный hex pubkey или <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> адрес.
</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 готов' : 'Ключ ещё не опубликован'}
</Text>
</View>
</View>
</View>
</View>
{/* Intro */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
Сообщение (опционально, видно в открытом виде на chain)
</Text>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Привет! Это Влад со встречи в среду"
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)
</Text>
<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,
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#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>
</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 }}>
Отправить запрос · {formatAmount(fee + 1000)}
</Text>
)}
</Pressable>
</>
)}
</ScrollView>
</View>
);
}