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>
This commit is contained in:
vsecoder
2026-04-18 23:19:03 +03:00
parent 3e9ddc1a43
commit 516940fa8e
4 changed files with 115 additions and 111 deletions

View File

@@ -27,9 +27,9 @@ 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' },
{ value: 5_000, label: 'Базовая' },
{ value: 10_000, label: 'Стандарт' },
{ value: 50_000, label: 'Приоритет' },
];
interface Resolved {
@@ -61,7 +61,7 @@ export default function NewContactScreen() {
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; }
if (!addr) { setError(`@${name} не зарегистрирован в этой сети`); return; }
address = addr;
}
const identity = await getIdentity(address);
@@ -71,7 +71,7 @@ export default function NewContactScreen() {
x25519: identity?.x25519_pub || undefined,
});
} catch (e: any) {
setError(e?.message ?? 'Lookup failed');
setError(e?.message ?? 'Не удалось найти пользователя');
} finally {
setSearching(false);
}
@@ -80,7 +80,7 @@ export default function NewContactScreen() {
async function sendRequest() {
if (!resolved || !keyFile) return;
if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (fee + network).`);
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (плата + сетевая комиссия).`);
return;
}
setSending(true); setError(null);
@@ -94,8 +94,8 @@ export default function NewContactScreen() {
});
await submitTx(tx);
Alert.alert(
'Request sent',
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
'Запрос отправлен',
`Запрос на общение отправлен ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => router.back() }],
);
} catch (e: any) {
@@ -112,7 +112,7 @@ export default function NewContactScreen() {
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="New chat"
title="Поиск"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/>
@@ -121,33 +121,56 @@ export default function NewContactScreen() {
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
Enter a <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, a
hex pubkey or a <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
<SearchBar
value={query}
onChangeText={setQuery}
placeholder="@alice or hex / DC address"
placeholder="@alice, hex pubkey или DC-адрес"
onSubmitEditing={search}
autoFocus
onClear={() => { setResolved(null); setError(null); }}
/>
{query.trim().length > 0 && (
<Pressable
onPress={search}
disabled={searching || !query.trim()}
disabled={searching}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 11, borderRadius: 999, marginTop: 12,
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{searching ? (
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
<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={{
@@ -195,7 +218,7 @@ export default function NewContactScreen() {
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
fontSize: 11, fontWeight: '500',
}}>
{resolved.x25519 ? 'E2E-ready' : 'Key not published yet'}
{resolved.x25519 ? 'E2E готов' : 'Ключ ещё не опубликован'}
</Text>
</View>
</View>
@@ -204,12 +227,12 @@ export default function NewContactScreen() {
{/* Intro */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
Intro (optional, plaintext on-chain)
Сообщение (опционально, видно в открытом виде на chain)
</Text>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Hey, it's Jordan from the conference"
placeholder="Привет! Это Влад со встречи в среду"
placeholderTextColor="#5a5a5a"
multiline
maxLength={140}
@@ -227,7 +250,7 @@ export default function NewContactScreen() {
{/* Fee tier */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
Anti-spam fee (goes to recipient)
Плата за запрос (уходит получателю, anti-spam)
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{FEE_TIERS.map(t => {
@@ -276,7 +299,7 @@ export default function NewContactScreen() {
<ActivityIndicator color="#ffffff" size="small" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
Send request · {formatAmount(fee + 1000)}
Отправить запрос · {formatAmount(fee + 1000)}
</Text>
)}
</Pressable>

View File

@@ -51,7 +51,7 @@ export default function RequestsScreen() {
setRequests(requests.filter(r => r.txHash !== req.txHash));
router.replace(`/(app)/chats/${req.from}` as never);
} catch (e: any) {
Alert.alert('Accept failed', humanizeTxError(e));
Alert.alert('Не удалось принять', humanizeTxError(e));
} finally {
setAccepting(null);
}
@@ -59,12 +59,12 @@ export default function RequestsScreen() {
function decline(req: ContactRequest) {
Alert.alert(
'Decline request',
`Decline request from ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
'Отклонить запрос',
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Отмена', style: 'cancel' },
{
text: 'Decline',
text: 'Отклонить',
style: 'destructive',
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
},
@@ -91,7 +91,7 @@ export default function RequestsScreen() {
{name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
wants to message you · {relativeTime(req.timestamp)}
хочет добавить вас в контакты · {relativeTime(req.timestamp)}
</Text>
{req.intro ? (
<Text
@@ -123,7 +123,7 @@ export default function RequestsScreen() {
{isAccepting ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Accept</Text>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Принять</Text>
)}
</Pressable>
<Pressable
@@ -137,7 +137,7 @@ export default function RequestsScreen() {
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Decline</Text>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Отклонить</Text>
</Pressable>
</View>
</View>
@@ -147,16 +147,16 @@ export default function RequestsScreen() {
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Notifications" />
<TabHeader title="Уведомления" />
{requests.length === 0 ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<Ionicons name="notifications-outline" size={42} color="#3a3a3a" />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
All caught up
Всё прочитано
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}>
Contact requests and network events will appear here.
Запросы на общение и события сети появятся здесь.
</Text>
</View>
) : (

View File

@@ -1,12 +1,11 @@
/**
* SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы.
*
* Когда пользователь тапает/фокусирует — поле становится input-friendly, но
* визуально рестайл не нужен: при наличии текста placeholder скрыт и
* пользовательский ввод выравнивается влево автоматически (multiline off).
* SearchBar — single-TextInput pill. Icon + input в одном ряду, без
* idle/focused двойного состояния (раньше был хак с невидимым
* TextInput поверх отцентрированного Text — ломал focus и выравнивание
* на Android).
*/
import React, { useState } from 'react';
import { View, TextInput, Text } from 'react-native';
import React from 'react';
import { View, TextInput, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface SearchBarProps {
@@ -15,73 +14,55 @@ export interface SearchBarProps {
placeholder?: string;
autoFocus?: boolean;
onSubmitEditing?: () => void;
onClear?: () => void;
}
export function SearchBar({
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing,
value, onChangeText, placeholder = 'Поиск', autoFocus, onSubmitEditing, onClear,
}: SearchBarProps) {
const [focused, setFocused] = useState(false);
// Placeholder центрируется пока нет фокуса И нет значения.
// Как только юзер фокусируется или начинает печатать — иконка+текст
// прыгают к левому краю, чтобы не мешать вводу.
const centered = !focused && !value;
return (
<View
style={{
backgroundColor: '#1a1a1a',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderWidth: 1,
borderColor: '#1f1f1f',
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 9,
minHeight: 36,
justifyContent: 'center',
gap: 8,
}}
>
{centered ? (
// ── Idle state — только текст+icon, отцентрированы.
// Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную.
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 6 }} />
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>{placeholder}</Text>
<TextInput
value={value}
onChangeText={onChangeText}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
position: 'absolute', left: 0, right: 0, top: 0, bottom: 0,
color: 'transparent',
// Скрываем cursor в idle-режиме; при focus компонент перерисуется.
}}
/>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 8 }} />
<Ionicons name="search" size={16} color="#6a6a6a" />
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#8b8b8b"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
autoFocus
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
autoFocus={autoFocus}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
flex: 1,
color: '#ffffff',
fontSize: 14,
paddingVertical: 10,
padding: 0,
includeFontPadding: false,
}}
/>
</View>
{value.length > 0 && (
<Pressable
onPress={() => {
onChangeText('');
onClear?.();
}}
hitSlop={8}
>
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
);

View File

@@ -302,7 +302,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
/**
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
* Maps blockchain.ContactInfo returned by GET /relay/contacts?pub=...
* The response shape is { pub, count, contacts: ContactInfo[] }.
*/
export interface ContactRequestRaw {
@@ -316,7 +316,7 @@ export interface ContactRequestRaw {
}
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`);
return data.contacts ?? [];
}