Files
dchain/client-app/app/(app)/new-contact.tsx
vsecoder 060ac6c2c9 fix(contact): fee-tier pills lose background via Pressable style-fn
Same Pressable dynamic-style bug that keeps reappearing: on some RN
builds the style function is re-evaluated during render in a way that
drops properties (previously hit on the FAB, then on PostCard, now on
the anti-spam fee selector). User saw three bare text labels on black
stuck together instead of distinct white/grey pills.

Fix: move visual properties (backgroundColor, borderWidth, padding,
border) to a static inner <View>. Pressable keeps only the opacity-
based press feedback which is stable because no other properties need
to flip on press. Functionally identical UX, layout guaranteed.

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

327 lines
13 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>
{/* Fee-tier pills.
Layout (background, border, padding) lives on a static
inner View — Pressable's dynamic style-function has been
observed to drop backgroundColor between renders on
some RN/Android versions, which is what made these
chips look like three bare text labels on black
instead of distinct pills. Press feedback via opacity
on the Pressable itself, which is stable. */}
<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,
opacity: pressed ? 0.7 : 1,
})}
>
<View
style={{
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : '#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>
</View>
</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>
);
}