Files
dchain/client-app/app/(app)/new-contact.tsx
vsecoder f7a849ddcb chore(client): translate all user-visible strings to English
Mixed-language UI was confusing — onboarding said "Why DChain / How it
works / Your keys" in English headings but feature descriptions and
CTAs were in Russian; compose's confirm dialog was Russian; feed tabs
were Russian; error messages in humanizeTxError were Russian.
Everything user-facing is now English.

Files touched (only string literals, not comments):
  app/index.tsx              onboarding slides + CTA buttons
  app/(app)/compose.tsx      composer alerts, header button, placeholder,
                             attachment-size hint
  app/(app)/feed/index.tsx   tab labels (Following/For you/Trending),
                             empty-state hints, retry button
  app/(app)/feed/[id].tsx    post detail header + stats rows (Views,
                             Likes, Size, Paid to publish, Hosted on,
                             Hashtags)
  app/(app)/feed/tag/[tag].tsx  empty-state copy
  app/(app)/profile/[address].tsx  Profile header, Follow/Following,
                             Edit, Open chat, Address, Copied, Encryption,
                             Added, Members, unknown-contact hint
  app/(app)/new-contact.tsx  Search title, placeholder, Search button,
                             empty-state hint, E2E-ready indicator,
                             Intro label + placeholder, fee-tier labels
                             (Min / Standard / Priority), Send request,
                             Insufficient-balance alert, Request-sent
                             alert
  app/(app)/requests.tsx     Notifications title, empty-state, Accept /
                             Decline buttons, decline-confirm alert,
                             "wants to add you" line
  components/SearchBar.tsx   default placeholder
  components/feed/PostCard.tsx  long-press menu (Delete post, confirm,
                             Actions / Cancel)
  components/feed/ShareSheet.tsx  sheet title, contact-search placeholder,
                             empty state, Select contacts / Send button,
                             plural helper rewritten for English
  components/chat/PostRefCard.tsx  "POST" ribbon, "photo" indicator
  lib/api.ts                 humanizeTxError (rate-limit, clock skew,
                             bad signature, 400/5xx/network-error
                             messages)
  lib/dates.ts               dateBucket now returns Today/Yesterday/
                             "Jun 17, 2025"; month array switched to
                             English short forms

Code comments left in Russian intentionally — they're developer
context, not user-facing. This commit is purely display-string.

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

327 lines
12 KiB
TypeScript

/**
* 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: 'Min' },
{ value: 10_000, label: 'Standard' },
{ value: 50_000, label: 'Priority' },
];
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} is not registered on this chain`); 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 ?? 'Lookup failed');
} finally {
setSearching(false);
}
}
async function sendRequest() {
if (!resolved || !keyFile) return;
if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
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(
'Request sent',
`A contact request has been sent to ${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="Search"
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 or DC address"
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 }}>Search</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 }}>
Find someone to message
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Enter an <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text> if
the person registered one, or paste a full hex pubkey or <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</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 ready' : 'Key not published yet'}
</Text>
</View>
</View>
</View>
</View>
{/* Intro */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
Intro (optional, plaintext on-chain)
</Text>
<TextInput
value={intro}
onChangeText={setIntro}
placeholder="Hey, it's Jordan from the conference"
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 fee (goes to the recipient)
</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 }}>
Send request · {formatAmount(fee + 1000)}
</Text>
)}
</Pressable>
</>
)}
</ScrollView>
</View>
);
}