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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 'ов';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user