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>
This commit is contained in:
vsecoder
2026-04-18 23:39:38 +03:00
parent 060ac6c2c9
commit f7a849ddcb
14 changed files with 134 additions and 139 deletions

View File

@@ -98,11 +98,11 @@ export default function ComposeScreen() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) { if (!perm.granted) {
Alert.alert( Alert.alert(
'Нужен доступ к фото', 'Photo access required',
'Откройте настройки и разрешите доступ к галерее.', 'Please enable photo library access in Settings.',
[ [
{ text: 'Отмена' }, { text: 'Cancel' },
{ text: 'Настройки', onPress: () => Linking.openSettings() }, { text: 'Settings', onPress: () => Linking.openSettings() },
], ],
); );
return; return;
@@ -132,8 +132,8 @@ export default function ComposeScreen() {
if (bytes.length > MAX_POST_BYTES - 512) { if (bytes.length > MAX_POST_BYTES - 512) {
Alert.alert( Alert.alert(
'Слишком большое', 'Image too large',
`Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`, `Image is ${Math.round(bytes.length / 1024)} KB but the limit is ${MAX_POST_BYTES / 1024} KB. Try picking a smaller one.`,
); );
return; return;
} }
@@ -147,7 +147,7 @@ export default function ComposeScreen() {
height: manipulated.height, height: manipulated.height,
}); });
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e)); Alert.alert('Failed', String(e?.message ?? e));
} finally { } finally {
setPicking(false); setPicking(false);
} }
@@ -159,19 +159,19 @@ export default function ComposeScreen() {
// Balance guard. // Balance guard.
if (balance !== null && balance < estimatedFee) { if (balance !== null && balance < estimatedFee) {
Alert.alert( Alert.alert(
'Недостаточно средств', 'Insufficient balance',
`Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`, `Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`,
); );
return; return;
} }
Alert.alert( Alert.alert(
'Опубликовать пост?', 'Publish post?',
`Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`, `Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
[ [
{ text: 'Отмена', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Опубликовать', text: 'Publish',
onPress: async () => { onPress: async () => {
setBusy(true); setBusy(true);
try { try {
@@ -185,7 +185,7 @@ export default function ComposeScreen() {
// Close composer and open the new post. // Close composer and open the new post.
router.replace(`/(app)/feed/${postID}` as never); router.replace(`/(app)/feed/${postID}` as never);
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось опубликовать', humanizeTxError(e)); Alert.alert('Failed to publish', humanizeTxError(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -235,7 +235,7 @@ export default function ComposeScreen() {
fontSize: 14, fontSize: 14,
}} }}
> >
Опубликовать Publish
</Text> </Text>
)} )}
</Pressable> </Pressable>
@@ -251,7 +251,7 @@ export default function ComposeScreen() {
<TextInput <TextInput
value={content} value={content}
onChangeText={setContent} onChangeText={setContent}
placeholder="Что происходит?" placeholder="What's happening?"
placeholderTextColor="#5a5a5a" placeholderTextColor="#5a5a5a"
multiline multiline
maxLength={MAX_CONTENT_LENGTH} maxLength={MAX_CONTENT_LENGTH}
@@ -328,7 +328,7 @@ export default function ComposeScreen() {
</Pressable> </Pressable>
</View> </View>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}> <Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
{Math.round(attach.size / 1024)} KB · метаданные удалят на сервере {Math.round(attach.size / 1024)} KB · metadata stripped on server
</Text> </Text>
</View> </View>
)} )}

View File

@@ -79,7 +79,7 @@ export default function PostDetailScreen() {
<Header <Header
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
title="Пост" title="Post"
/> />
{loading ? ( {loading ? (
@@ -94,7 +94,7 @@ export default function PostDetailScreen() {
<View style={{ padding: 24, alignItems: 'center' }}> <View style={{ padding: 24, alignItems: 'center' }}>
<Ionicons name="trash-outline" size={32} color="#6a6a6a" /> <Ionicons name="trash-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#8b8b8b', marginTop: 10 }}> <Text style={{ color: '#8b8b8b', marginTop: 10 }}>
Пост удалён или больше недоступен Post deleted or no longer available
</Text> </Text>
</View> </View>
) : ( ) : (
@@ -131,18 +131,18 @@ export default function PostDetailScreen() {
textTransform: 'uppercase', textTransform: 'uppercase',
marginBottom: 10, marginBottom: 10,
}}> }}>
Информация о посте Post details
</Text> </Text>
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} /> <DetailRow label="Views" value={formatCount(stats?.views ?? post.views)} />
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} /> <DetailRow label="Likes" value={formatCount(stats?.likes ?? post.likes)} />
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} /> <DetailRow label="Size" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
<DetailRow <DetailRow
label="Стоимость публикации" label="Paid to publish"
value={formatFee(1000 + post.size)} value={formatFee(1000 + post.size)}
/> />
<DetailRow <DetailRow
label="Хостинг" label="Hosted on"
value={shortAddr(post.hosting_relay)} value={shortAddr(post.hosting_relay)}
mono mono
/> />
@@ -151,7 +151,7 @@ export default function PostDetailScreen() {
<> <>
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} /> <View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}> <Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
Хештеги Hashtags
</Text> </Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{post.hashtags.map(tag => ( {post.hashtags.map(tag => (

View File

@@ -31,9 +31,9 @@ import {
type TabKey = 'following' | 'foryou' | 'trending'; type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = { const TAB_LABELS: Record<TabKey, string> = {
following: 'Подписки', following: 'Following',
foryou: 'Для вас', foryou: 'For you',
trending: 'В тренде', trending: 'Trending',
}; };
export default function FeedScreen() { export default function FeedScreen() {
@@ -201,15 +201,15 @@ export default function FeedScreen() {
const emptyHint = useMemo(() => { const emptyHint = useMemo(() => {
switch (tab) { switch (tab) {
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.'; case 'following': return 'Follow someone to see their posts here.';
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.'; case 'foryou': return 'No recommendations yet — come back later.';
case 'trending': return 'В этой ленте пока тихо.'; case 'trending': return 'Nothing trending yet.';
} }
}, [tab]); }, [tab]);
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" /> <TabHeader title="Feed" />
{/* Tab strip — три таба, равномерно распределены по ширине {/* Tab strip — три таба, равномерно распределены по ширине
(justifyContent: space-between). Каждый Pressable hug'ает (justifyContent: space-between). Каждый Pressable hug'ает
@@ -305,14 +305,14 @@ export default function FeedScreen() {
) : error ? ( ) : error ? (
<EmptyState <EmptyState
icon="alert-circle-outline" icon="alert-circle-outline"
title="Не удалось загрузить ленту" title="Couldn't load feed"
subtitle={error} subtitle={error}
onRetry={() => loadPosts(false)} onRetry={() => loadPosts(false)}
/> />
) : ( ) : (
<EmptyState <EmptyState
icon="newspaper-outline" icon="newspaper-outline"
title="Здесь пока пусто" title="Nothing to show yet"
subtitle={emptyHint} subtitle={emptyHint}
/> />
) )
@@ -409,7 +409,7 @@ function EmptyState({
})} })}
> >
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Попробовать снова Try again
</Text> </Text>
</Pressable> </Pressable>
)} )}

View File

@@ -119,10 +119,10 @@ export default function HashtagScreen() {
}}> }}>
<Ionicons name="pricetag-outline" size={32} color="#6a6a6a" /> <Ionicons name="pricetag-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}> <Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
Пока нет постов с этим тегом No posts with this tag yet
</Text> </Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}> <Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
Будьте первым напишите пост с #{tag} Be the first write a post with #{tag}
</Text> </Text>
</View> </View>
) )

View File

@@ -27,9 +27,9 @@ import { SearchBar } from '@/components/SearchBar';
const MIN_CONTACT_FEE = 5000; const MIN_CONTACT_FEE = 5000;
const FEE_TIERS = [ const FEE_TIERS = [
{ value: 5_000, label: 'Базовая' }, { value: 5_000, label: 'Min' },
{ value: 10_000, label: 'Стандарт' }, { value: 10_000, label: 'Standard' },
{ value: 50_000, label: 'Приоритет' }, { value: 50_000, label: 'Priority' },
]; ];
interface Resolved { interface Resolved {
@@ -61,7 +61,7 @@ export default function NewContactScreen() {
if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) { if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) {
const name = q.replace('@', ''); const name = q.replace('@', '');
const addr = await resolveUsername(settings.contractId, name); const addr = await resolveUsername(settings.contractId, name);
if (!addr) { setError(`@${name} не зарегистрирован в этой сети`); return; } if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr; address = addr;
} }
const identity = await getIdentity(address); const identity = await getIdentity(address);
@@ -71,7 +71,7 @@ export default function NewContactScreen() {
x25519: identity?.x25519_pub || undefined, x25519: identity?.x25519_pub || undefined,
}); });
} catch (e: any) { } catch (e: any) {
setError(e?.message ?? 'Не удалось найти пользователя'); setError(e?.message ?? 'Lookup failed');
} finally { } finally {
setSearching(false); setSearching(false);
} }
@@ -80,7 +80,7 @@ export default function NewContactScreen() {
async function sendRequest() { async function sendRequest() {
if (!resolved || !keyFile) return; if (!resolved || !keyFile) return;
if (balance < fee + 1000) { if (balance < fee + 1000) {
Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (плата + сетевая комиссия).`); Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
return; return;
} }
setSending(true); setError(null); setSending(true); setError(null);
@@ -94,8 +94,8 @@ export default function NewContactScreen() {
}); });
await submitTx(tx); await submitTx(tx);
Alert.alert( Alert.alert(
'Запрос отправлен', 'Request sent',
`Запрос на общение отправлен ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`, `A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => router.back() }], [{ text: 'OK', onPress: () => router.back() }],
); );
} catch (e: any) { } catch (e: any) {
@@ -112,7 +112,7 @@ export default function NewContactScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
title="Поиск" title="Search"
divider={false} divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/> />
@@ -124,7 +124,7 @@ export default function NewContactScreen() {
<SearchBar <SearchBar
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="@alice, hex pubkey или DC-адрес" placeholder="@alice, hex pubkey or DC address"
onSubmitEditing={search} onSubmitEditing={search}
autoFocus autoFocus
onClear={() => { setResolved(null); setError(null); }} onClear={() => { setResolved(null); setError(null); }}
@@ -143,7 +143,7 @@ export default function NewContactScreen() {
{searching ? ( {searching ? (
<ActivityIndicator color="#ffffff" size="small" /> <ActivityIndicator color="#ffffff" size="small" />
) : ( ) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Найти</Text> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
)} )}
</Pressable> </Pressable>
)} )}
@@ -163,11 +163,11 @@ export default function NewContactScreen() {
<Ionicons name="person-add-outline" size={24} color="#6a6a6a" /> <Ionicons name="person-add-outline" size={24} color="#6a6a6a" />
</View> </View>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 6 }}> <Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 6 }}>
Найдите собеседника Find someone to message
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}> <Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Введите <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, Enter an <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text> if
если человек зарегистрировал ник, либо полный hex pubkey или <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> адрес. the person registered one, or paste a full hex pubkey or <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text> </Text>
</View> </View>
)} )}
@@ -218,7 +218,7 @@ export default function NewContactScreen() {
color: resolved.x25519 ? '#3ba55d' : '#f0b35a', color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
fontSize: 11, fontWeight: '500', fontSize: 11, fontWeight: '500',
}}> }}>
{resolved.x25519 ? 'E2E готов' : 'Ключ ещё не опубликован'} {resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
</Text> </Text>
</View> </View>
</View> </View>
@@ -227,12 +227,12 @@ export default function NewContactScreen() {
{/* Intro */} {/* Intro */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}> <Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 18, marginBottom: 6 }}>
Сообщение (опционально, видно в открытом виде на chain) Intro (optional, plaintext on-chain)
</Text> </Text>
<TextInput <TextInput
value={intro} value={intro}
onChangeText={setIntro} onChangeText={setIntro}
placeholder="Привет! Это Влад со встречи в среду" placeholder="Hey, it's Jordan from the conference"
placeholderTextColor="#5a5a5a" placeholderTextColor="#5a5a5a"
multiline multiline
maxLength={140} maxLength={140}
@@ -250,7 +250,7 @@ export default function NewContactScreen() {
{/* Fee tier */} {/* Fee tier */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}> <Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
Плата за запрос (уходит получателю, anti-spam) Anti-spam fee (goes to the recipient)
</Text> </Text>
{/* Fee-tier pills. {/* Fee-tier pills.
Layout (background, border, padding) lives on a static Layout (background, border, padding) lives on a static
@@ -314,7 +314,7 @@ export default function NewContactScreen() {
<ActivityIndicator color="#ffffff" size="small" /> <ActivityIndicator color="#ffffff" size="small" />
) : ( ) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>
Отправить запрос · {formatAmount(fee + 1000)} Send request · {formatAmount(fee + 1000)}
</Text> </Text>
)} )}
</Pressable> </Pressable>

View File

@@ -48,7 +48,7 @@ export default function ProfileScreen() {
const isMe = !!keyFile && keyFile.pub_key === address; const isMe = !!keyFile && keyFile.pub_key === address;
const displayName = contact?.username const displayName = contact?.username
? `@${contact.username}` ? `@${contact.username}`
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6)); : contact?.alias ?? (isMe ? 'You' : shortAddr(address ?? '', 6));
const copyAddress = async () => { const copyAddress = async () => {
if (!address) return; if (!address) return;
@@ -85,7 +85,7 @@ export default function ProfileScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header <Header
title="Профиль" title="Profile"
divider divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />} left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
/> />
@@ -124,7 +124,7 @@ export default function ProfileScreen() {
fontSize: 13, fontSize: 13,
}} }}
> >
{following ? 'Вы подписаны' : 'Подписаться'} {following ? 'Following' : 'Follow'}
</Text> </Text>
)} )}
</Pressable> </Pressable>
@@ -139,7 +139,7 @@ export default function ProfileScreen() {
})} })}
> >
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Редактировать Edit
</Text> </Text>
</Pressable> </Pressable>
)} )}
@@ -177,7 +177,7 @@ export default function ProfileScreen() {
> >
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" /> <Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}> <Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Открыть чат Open chat
</Text> </Text>
</Pressable> </Pressable>
)} )}
@@ -203,7 +203,7 @@ export default function ProfileScreen() {
})} })}
> >
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}> <Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
Адрес Address
</Text> </Text>
<Text <Text
style={{ style={{
@@ -214,7 +214,7 @@ export default function ProfileScreen() {
}} }}
numberOfLines={1} numberOfLines={1}
> >
{copied ? 'Скопировано' : shortAddr(address ?? '')} {copied ? 'Copied' : shortAddr(address ?? '')}
</Text> </Text>
<Ionicons <Ionicons
name={copied ? 'checkmark' : 'copy-outline'} name={copied ? 'checkmark' : 'copy-outline'}
@@ -229,17 +229,17 @@ export default function ProfileScreen() {
<> <>
<Divider /> <Divider />
<InfoRow <InfoRow
label="Шифрование" label="Encryption"
value={contact.x25519Pub value={contact.x25519Pub
? 'end-to-end (NaCl)' ? 'end-to-end (NaCl)'
: 'ключ ещё не опубликован'} : 'key not published yet'}
danger={!contact.x25519Pub} danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'} icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/> />
<Divider /> <Divider />
<InfoRow <InfoRow
label="Добавлен" label="Added"
value={new Date(contact.addedAt).toLocaleDateString()} value={new Date(contact.addedAt).toLocaleDateString()}
icon="calendar-outline" icon="calendar-outline"
/> />
@@ -251,7 +251,7 @@ export default function ProfileScreen() {
<> <>
<Divider /> <Divider />
<InfoRow <InfoRow
label="Участников" label="Members"
value="—" value="—"
icon="people-outline" icon="people-outline"
/> />
@@ -270,7 +270,7 @@ export default function ProfileScreen() {
paddingHorizontal: 24, paddingHorizontal: 24,
lineHeight: 17, lineHeight: 17,
}}> }}>
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username. This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @username.
</Text> </Text>
)} )}
</ScrollView> </ScrollView>

View File

@@ -51,7 +51,7 @@ export default function RequestsScreen() {
setRequests(requests.filter(r => r.txHash !== req.txHash)); setRequests(requests.filter(r => r.txHash !== req.txHash));
router.replace(`/(app)/chats/${req.from}` as never); router.replace(`/(app)/chats/${req.from}` as never);
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось принять', humanizeTxError(e)); Alert.alert('Accept failed', humanizeTxError(e));
} finally { } finally {
setAccepting(null); setAccepting(null);
} }
@@ -59,12 +59,12 @@ export default function RequestsScreen() {
function decline(req: ContactRequest) { function decline(req: ContactRequest) {
Alert.alert( Alert.alert(
'Отклонить запрос', 'Decline request',
`Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`, `Decline request from ${req.username ? '@' + req.username : shortAddr(req.from)}?`,
[ [
{ text: 'Отмена', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Отклонить', text: 'Decline',
style: 'destructive', style: 'destructive',
onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)), onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)),
}, },
@@ -91,7 +91,7 @@ export default function RequestsScreen() {
{name} {name}
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}> <Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
хочет добавить вас в контакты · {relativeTime(req.timestamp)} wants to add you as a contact · {relativeTime(req.timestamp)}
</Text> </Text>
{req.intro ? ( {req.intro ? (
<Text <Text
@@ -123,7 +123,7 @@ export default function RequestsScreen() {
{isAccepting ? ( {isAccepting ? (
<ActivityIndicator size="small" color="#ffffff" /> <ActivityIndicator size="small" color="#ffffff" />
) : ( ) : (
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Принять</Text> <Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>Accept</Text>
)} )}
</Pressable> </Pressable>
<Pressable <Pressable
@@ -137,7 +137,7 @@ export default function RequestsScreen() {
borderWidth: 1, borderColor: '#1f1f1f', borderWidth: 1, borderColor: '#1f1f1f',
})} })}
> >
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Отклонить</Text> <Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 13 }}>Decline</Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -147,16 +147,16 @@ export default function RequestsScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}> <View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Уведомления" /> <TabHeader title="Notifications" />
{requests.length === 0 ? ( {requests.length === 0 ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}> <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<Ionicons name="notifications-outline" size={42} color="#3a3a3a" /> <Ionicons name="notifications-outline" size={42} color="#3a3a3a" />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}> <Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
Всё прочитано All caught up
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}> <Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6, lineHeight: 19 }}>
Запросы на общение и события сети появятся здесь. Contact requests and network events will appear here.
</Text> </Text>
</View> </View>
) : ( ) : (

View File

@@ -187,17 +187,17 @@ export default function WelcomeScreen() {
<FeatureRow <FeatureRow
icon="lock-closed" icon="lock-closed"
title="End-to-end encryption" title="End-to-end encryption"
text="X25519 + NaCl на каждом сообщении. Даже релей-нода не может прочитать переписку." text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
/> />
<FeatureRow <FeatureRow
icon="key" icon="key"
title="Твои ключи — твой аккаунт" title="Your keys, your account"
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство." text="No phone, email, or server passwords. Keys never leave your device."
/> />
<FeatureRow <FeatureRow
icon="git-network" icon="git-network"
title="Decentralised" title="Decentralised"
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры." text="Anyone can run a node. No single point of failure or censorship."
/> />
</ScrollView> </ScrollView>
@@ -206,7 +206,7 @@ export default function WelcomeScreen() {
flexDirection: 'row', justifyContent: 'flex-end', flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8,
}}> }}>
<CTAPrimary label="Продолжить" onPress={() => goToPage(1)} /> <CTAPrimary label="Continue" onPress={() => goToPage(1)} />
</View> </View>
</View> </View>
@@ -223,22 +223,22 @@ export default function WelcomeScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}> <Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
Как это работает How it works
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}> <Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Сообщения проходят через релей-ноду в зашифрованном виде. Messages travel through a relay node in encrypted form.
Выбери публичную или подключи свою. Pick a public one or run your own.
</Text> </Text>
<OptionCard <OptionCard
icon="globe" icon="globe"
title="Публичная нода" title="Public node"
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение." text="Quick and easy — community-hosted relay, small fee per delivered message."
/> />
<OptionCard <OptionCard
icon="hardware-chip" icon="hardware-chip"
title="Своя нода" title="Self-hosted"
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут." text="Maximum control. Source is open — spin up your own in five minutes."
/> />
<Text style={{ <Text style={{
@@ -302,11 +302,11 @@ export default function WelcomeScreen() {
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8,
}}> }}>
<CTASecondary <CTASecondary
label="Исходники" label="Source"
icon="logo-github" icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})} onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/> />
<CTAPrimary label="Продолжить" onPress={() => goToPage(2)} /> <CTAPrimary label="Continue" onPress={() => goToPage(2)} />
</View> </View>
</View> </View>
@@ -334,11 +334,11 @@ export default function WelcomeScreen() {
<Ionicons name="key" size={44} color="#1d9bf0" /> <Ionicons name="key" size={44} color="#1d9bf0" />
</View> </View>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}> <Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
Твой аккаунт Your account
</Text> </Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}> <Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Создай новую пару ключей или импортируй существующую. Generate a fresh keypair or import an existing one.
Ключи хранятся только на этом устройстве. Keys stay on this device only.
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
@@ -349,11 +349,11 @@ export default function WelcomeScreen() {
paddingHorizontal: 24, paddingBottom: 8, paddingHorizontal: 24, paddingBottom: 8,
}}> }}>
<CTASecondary <CTASecondary
label="Импорт" label="Import"
onPress={() => router.push('/(auth)/import' as never)} onPress={() => router.push('/(auth)/import' as never)}
/> />
<CTAPrimary <CTAPrimary
label="Создать аккаунт" label="Create account"
onPress={() => router.push('/(auth)/create' as never)} onPress={() => router.push('/(auth)/create' as never)}
/> />
</View> </View>

View File

@@ -18,7 +18,7 @@ export interface SearchBarProps {
} }
export function SearchBar({ export function SearchBar({
value, onChangeText, placeholder = 'Поиск', autoFocus, onSubmitEditing, onClear, value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, onClear,
}: SearchBarProps) { }: SearchBarProps) {
return ( return (
<View <View

View File

@@ -90,7 +90,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
letterSpacing: 1.2, letterSpacing: 1.2,
}} }}
> >
ПОСТ POST
</Text> </Text>
</View> </View>
@@ -132,7 +132,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
> >
<Ionicons name="image-outline" size={11} color={subColor} /> <Ionicons name="image-outline" size={11} color={subColor} />
<Text style={{ color: subColor, fontSize: 11 }}> <Text style={{ color: subColor, fontSize: 11 }}>
с фото photo
</Text> </Text>
</View> </View>
)} )}

View File

@@ -109,7 +109,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
// Roll back optimistic update. // Roll back optimistic update.
setLocalLiked(wasLiked); setLocalLiked(wasLiked);
setLocalLikeCount(c => c + (wasLiked ? 1 : -1)); setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
Alert.alert('Не удалось', String(e?.message ?? e)); Alert.alert('Failed', String(e?.message ?? e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -128,13 +128,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = []; const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
if (mine) { if (mine) {
options.push({ options.push({
label: 'Удалить пост', label: 'Delete post',
destructive: true, destructive: true,
onPress: () => { onPress: () => {
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [ Alert.alert('Delete post?', 'This action cannot be undone.', [
{ text: 'Отмена', style: 'cancel' }, { text: 'Cancel', style: 'cancel' },
{ {
text: 'Удалить', text: 'Delete',
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
try { try {
@@ -145,7 +145,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
}); });
onDeleted?.(post.post_id); onDeleted?.(post.post_id);
} catch (e: any) { } catch (e: any) {
Alert.alert('Ошибка', String(e?.message ?? e)); Alert.alert('Error', String(e?.message ?? e));
} }
}, },
}, },
@@ -160,9 +160,9 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive', style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
onPress: o.onPress, onPress: o.onPress,
})), })),
{ text: 'Отмена', style: 'cancel' as const }, { text: 'Cancel', style: 'cancel' as const },
]; ];
Alert.alert('Действия', '', buttons); Alert.alert('Actions', '', buttons);
}, [keyFile, mine, post.post_id, onDeleted]); }, [keyFile, mine, post.post_id, onDeleted]);
// Attachment preview URL — native Image can stream straight from the // Attachment preview URL — native Image can stream straight from the

View File

@@ -74,14 +74,14 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
post, contacts: targets, keyFile, post, contacts: targets, keyFile,
}); });
if (failed > 0) { if (failed > 0) {
Alert.alert('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`); Alert.alert('Done', `Sent to ${ok} of ${ok + failed} ${plural(ok + failed)}.`);
} }
// Close + reset regardless — done is done. // Close + reset regardless — done is done.
setPicked(new Set()); setPicked(new Set());
setQuery(''); setQuery('');
onClose(); onClose();
} catch (e: any) { } catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e)); Alert.alert('Failed', String(e?.message ?? e));
} finally { } finally {
setSending(false); setSending(false);
} }
@@ -136,7 +136,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
paddingHorizontal: 16, marginBottom: 10, paddingHorizontal: 16, marginBottom: 10,
}}> }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}> <Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
Поделиться постом Share post
</Text> </Text>
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />
<Pressable onPress={closeAndReset} hitSlop={8}> <Pressable onPress={closeAndReset} hitSlop={8}>
@@ -158,7 +158,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
<TextInput <TextInput
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Поиск по контактам" placeholder="Search contacts"
placeholderTextColor="#5a5a5a" placeholderTextColor="#5a5a5a"
style={{ style={{
flex: 1, flex: 1,
@@ -196,8 +196,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
<Ionicons name="people-outline" size={28} color="#5a5a5a" /> <Ionicons name="people-outline" size={28} color="#5a5a5a" />
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}> <Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
{query.length > 0 {query.length > 0
? 'Нет контактов по такому запросу' ? 'No contacts match this search'
: 'Контакты с ключами шифрования отсутствуют'} : 'No contacts with encryption keys yet'}
</Text> </Text>
</View> </View>
} }
@@ -227,8 +227,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
fontSize: 14, fontSize: 14,
}}> }}>
{picked.size === 0 {picked.size === 0
? 'Выберите контакты' ? 'Select contacts'
: `Отправить (${picked.size})`} : `Send (${picked.size})`}
</Text> </Text>
)} )}
</Pressable> </Pressable>
@@ -298,10 +298,5 @@ function shortAddr(a: string, n = 6): string {
} }
function plural(n: number): string { function plural(n: number): string {
const mod100 = n % 100; return n === 1 ? 'chat' : 'chats';
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 19) return 'ов';
if (mod10 === 1) return '';
if (mod10 >= 2 && mod10 <= 4) return 'а';
return 'ов';
} }

View File

@@ -79,23 +79,23 @@ async function post<T>(path: string, body: unknown): Promise<T> {
export function humanizeTxError(e: unknown): string { export function humanizeTxError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e); const raw = e instanceof Error ? e.message : String(e);
if (raw.startsWith('429')) { if (raw.startsWith('429')) {
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.'; return 'Too many requests to the node. Wait a couple of seconds and try again.';
} }
if (raw.startsWith('400') && raw.includes('timestamp')) { if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).'; return 'Device clock is out of sync with the node. Check the time on your phone (±1 hour).';
} }
if (raw.startsWith('400') && raw.includes('signature')) { if (raw.startsWith('400') && raw.includes('signature')) {
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.'; return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
} }
if (raw.startsWith('400')) { if (raw.startsWith('400')) {
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`; return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
} }
if (raw.startsWith('5')) { if (raw.startsWith('5')) {
return `Ошибка ноды (${raw}). Попробуйте позже.`; return `Node error (${raw}). Please try again later.`;
} }
// Network-level // Network-level
if (raw.toLowerCase().includes('network request failed')) { if (raw.toLowerCase().includes('network request failed')) {
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.'; return 'Cannot reach the node. Check the URL in settings and that the server is online.';
} }
return raw; return raw;
} }

View File

@@ -5,10 +5,10 @@
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри. * который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
*/ */
// ─── Русские месяцы (genitive для "17 июня 2025") ──────────────────────────── // English short month names ("Jun 17, 2025").
const RU_MONTHS_GEN = [ const MONTHS_SHORT = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
]; ];
function sameDay(a: Date, b: Date): boolean { function sameDay(a: Date, b: Date): boolean {
@@ -20,8 +20,8 @@ function sameDay(a: Date, b: Date): boolean {
} }
/** /**
* Day-bucket label для сепараторов внутри чата. * Day-bucket label for chat separators.
* "Сегодня" / "Вчера" / "17 июня 2025" * "Today" / "Yesterday" / "Jun 17, 2025"
* *
* @param ts unix-seconds * @param ts unix-seconds
*/ */
@@ -29,9 +29,9 @@ export function dateBucket(ts: number): string {
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
const now = new Date(); const now = new Date();
const yday = new Date(); yday.setDate(now.getDate() - 1); const yday = new Date(); yday.setDate(now.getDate() - 1);
if (sameDay(d, now)) return 'Сегодня'; if (sameDay(d, now)) return 'Today';
if (sameDay(d, yday)) return 'Вчера'; if (sameDay(d, yday)) return 'Yesterday';
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${d.getFullYear()}`; return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
} }
/** /**