From 516940fa8e4a1bef136b5fb8562788b99ff4ccf8 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Sat, 18 Apr 2026 23:19:03 +0300 Subject: [PATCH] fix(client): contact-request endpoint path + search screen polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- client-app/app/(app)/new-contact.tsx | 93 ++++++++++++++--------- client-app/app/(app)/requests.tsx | 22 +++--- client-app/components/SearchBar.tsx | 107 +++++++++++---------------- client-app/lib/api.ts | 4 +- 4 files changed, 115 insertions(+), 111 deletions(-) diff --git a/client-app/app/(app)/new-contact.tsx b/client-app/app/(app)/new-contact.tsx index e2636eb..05a8b40 100644 --- a/client-app/app/(app)/new-contact.tsx +++ b/client-app/app/(app)/new-contact.tsx @@ -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 (
router.back()} />} /> @@ -121,33 +121,56 @@ export default function NewContactScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - - Enter a @username, a - hex pubkey or a DC… address. - - { setResolved(null); setError(null); }} /> - ({ - flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 11, borderRadius: 999, marginTop: 12, - backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0', - })} - > - {searching ? ( - - ) : ( - Search - )} - + {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 && ( - {resolved.x25519 ? 'E2E-ready' : 'Key not published yet'} + {resolved.x25519 ? 'E2E готов' : 'Ключ ещё не опубликован'} @@ -204,12 +227,12 @@ export default function NewContactScreen() { {/* Intro */} - Intro (optional, plaintext on-chain) + Сообщение (опционально, видно в открытом виде на chain) - Anti-spam fee (goes to recipient) + Плата за запрос (уходит получателю, anti-spam) {FEE_TIERS.map(t => { @@ -276,7 +299,7 @@ export default function NewContactScreen() { ) : ( - Send request · {formatAmount(fee + 1000)} + Отправить запрос · {formatAmount(fee + 1000)} )} diff --git a/client-app/app/(app)/requests.tsx b/client-app/app/(app)/requests.tsx index b3f9f59..dad308f 100644 --- a/client-app/app/(app)/requests.tsx +++ b/client-app/app/(app)/requests.tsx @@ -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} - wants to message you · {relativeTime(req.timestamp)} + хочет добавить вас в контакты · {relativeTime(req.timestamp)} {req.intro ? ( ) : ( - Accept + Принять )} - Decline + Отклонить @@ -147,16 +147,16 @@ export default function RequestsScreen() { return ( - + {requests.length === 0 ? ( - All caught up + Всё прочитано - Contact requests and network events will appear here. + Запросы на общение и события сети появятся здесь. ) : ( diff --git a/client-app/components/SearchBar.tsx b/client-app/components/SearchBar.tsx index 0c63e53..fe972c9 100644 --- a/client-app/components/SearchBar.tsx +++ b/client-app/components/SearchBar.tsx @@ -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 ( - {centered ? ( - // ── Idle state — только текст+icon, отцентрированы. - // Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную. - - - {placeholder} - setFocused(true)} - onSubmitEditing={onSubmitEditing} - returnKeyType="search" - style={{ - position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, - color: 'transparent', - // Скрываем cursor в idle-режиме; при focus компонент перерисуется. - }} - /> - - ) : ( - - - setFocused(true)} - onBlur={() => setFocused(false)} - onSubmitEditing={onSubmitEditing} - returnKeyType="search" - style={{ - flex: 1, - color: '#ffffff', - fontSize: 14, - padding: 0, - includeFontPadding: false, - }} - /> - + + + {value.length > 0 && ( + { + onChangeText(''); + onClear?.(); + }} + hitSlop={8} + > + + )} ); diff --git a/client-app/lib/api.ts b/client-app/lib/api.ts index d72b0aa..859af26 100644 --- a/client-app/lib/api.ts +++ b/client-app/lib/api.ts @@ -302,7 +302,7 @@ export async function fetchInbox(x25519PubHex: string): Promise { // ─── 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 { - const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`); + const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`); return data.contacts ?? []; }