Files
dchain/client-app/app/(app)/requests.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

174 lines
6.3 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.

/**
* Contact requests / notifications — dark minimalist.
*
* В референсе нижний таб «notifications» ведёт сюда. Пока это только
* incoming CONTACT_REQUEST'ы; позже сюда же придут другие системные
* уведомления (slash, ADD_VALIDATOR со-sig-ing, и т.д.).
*/
import React, { useState } from 'react';
import { View, Text, FlatList, Alert, Pressable, 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 {
buildAcceptContactTx, submitTx, getIdentity, humanizeTxError,
} from '@/lib/api';
import { saveContact } from '@/lib/storage';
import { shortAddr } from '@/lib/crypto';
import { relativeTime } from '@/lib/utils';
import type { ContactRequest } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
import { TabHeader } from '@/components/TabHeader';
import { IconButton } from '@/components/IconButton';
export default function RequestsScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const requests = useStore(s => s.requests);
const setRequests = useStore(s => s.setRequests);
const upsertContact = useStore(s => s.upsertContact);
const [accepting, setAccepting] = useState<string | null>(null);
async function accept(req: ContactRequest) {
if (!keyFile) return;
setAccepting(req.txHash);
try {
const identity = await getIdentity(req.from);
const x25519Pub = identity?.x25519_pub ?? '';
const tx = buildAcceptContactTx({
from: keyFile.pub_key, to: req.from, privKey: keyFile.priv_key,
});
await submitTx(tx);
const contact = { address: req.from, x25519Pub, username: req.username, addedAt: Date.now() };
upsertContact(contact);
await saveContact(contact);
setRequests(requests.filter(r => r.txHash !== req.txHash));
router.replace(`/(app)/chats/${req.from}` as never);
} catch (e: any) {
Alert.alert('Не удалось принять', humanizeTxError(e));
} finally {
setAccepting(null);
}
}
function decline(req: ContactRequest) {
Alert.alert(
'Отклонить запрос',
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Отклонить',
style: 'destructive',
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
},
],
);
}
const renderItem = ({ item: req }: { item: ContactRequest }) => {
const name = req.username ? `@${req.username}` : shortAddr(req.from);
const isAccepting = accepting === req.txHash;
return (
<View
style={{
flexDirection: 'row',
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#0f0f0f',
}}
>
<Avatar name={name} address={req.from} size={44} />
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}>
{name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
хочет добавить вас в контакты · {relativeTime(req.timestamp)}
</Text>
{req.intro ? (
<Text
style={{
color: '#d0d0d0', fontSize: 13, lineHeight: 18,
marginTop: 6,
padding: 8,
borderRadius: 10,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
}}
numberOfLines={3}
>
{req.intro}
</Text>
) : null}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 10 }}>
<Pressable
onPress={() => accept(req)}
disabled={isAccepting}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: 9, borderRadius: 999,
backgroundColor: isAccepting ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{isAccepting ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Принять</Text>
)}
</Pressable>
<Pressable
onPress={() => decline(req)}
disabled={isAccepting}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingVertical: 9, borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Отклонить</Text>
</Pressable>
</View>
</View>
</View>
);
};
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<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 }}>
Всё прочитано
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}>
Запросы на общение и события сети появятся здесь.
</Text>
</View>
) : (
<FlatList
data={requests}
keyExtractor={r => r.txHash}
renderItem={renderItem}
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
}