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:
@@ -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); }}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={search}
|
||||
disabled={searching || !query.trim()}
|
||||
style={({ pressed }) => ({
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
paddingVertical: 11, borderRadius: 999, marginTop: 12,
|
||||
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
|
||||
})}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color="#ffffff" size="small" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{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={{
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 }} />
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8b8b8b"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
padding: 0,
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Ionicons name="search" size={16} color="#6a6a6a" />
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#5a5a5a"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus={autoFocus}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
returnKeyType="search"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
fontSize: 14,
|
||||
paddingVertical: 10,
|
||||
padding: 0,
|
||||
includeFontPadding: false,
|
||||
}}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onChangeText('');
|
||||
onClear?.();
|
||||
}}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user