Files
dchain/client-app/app/index.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

520 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Onboarding — 3-слайдовый pager перед auth-экранами.
*
* Slide 1 — "Why DChain": value-proposition, 3 пункта с иконками.
* Slide 2 — "How it works": выбор релей-ноды (public paid vs свой node),
* ссылка на Gitea, + node URL input с live ping.
* Slide 3 — "Your keys": кнопки Create / Import.
*
* Если `keyFile` в store уже есть (bootstrap из RootLayout загрузил) —
* делаем <Redirect /> в (app), чтобы пользователь не видел вообще никакого
* мелькания onboarding'а. До загрузки `booted === false` root показывает
* чёрный экран.
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
View, Text, TextInput, Pressable, ScrollView,
Alert, ActivityIndicator, Linking, Dimensions,
useWindowDimensions,
} from 'react-native';
import { router, Redirect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { saveSettings } from '@/lib/storage';
import { setNodeUrl, getNetStats } from '@/lib/api';
const { width: SCREEN_W } = Dimensions.get('window');
const GITEA_URL = 'https://git.vsecoder.vodka/vsecoder/dchain';
export default function WelcomeScreen() {
const insets = useSafeAreaInsets();
const { height: SCREEN_H } = useWindowDimensions();
const keyFile = useStore(s => s.keyFile);
const booted = useStore(s => s.booted);
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const scrollRef = useRef<ScrollView>(null);
const [page, setPage] = useState(0);
const [nodeInput, setNodeInput] = useState('');
const [scanning, setScanning] = useState(false);
const [checking, setChecking] = useState(false);
const [nodeOk, setNodeOk] = useState<boolean | null>(null);
const [permission, requestPermission] = useCameraPermissions();
useEffect(() => { setNodeInput(settings.nodeUrl); }, [settings.nodeUrl]);
// ВСЕ hooks должны быть объявлены ДО любого early-return, иначе
// React на следующем render'е посчитает разное число hooks и выкинет
// "Rendered fewer hooks than expected". useCallback ниже — тоже hook.
const applyNode = useCallback(async (url: string) => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setChecking(true);
setNodeOk(null);
setNodeUrl(clean);
try {
await getNetStats();
setNodeOk(true);
const next = { ...settings, nodeUrl: clean };
setSettings(next);
await saveSettings(next);
} catch {
setNodeOk(false);
} finally {
setChecking(false);
}
}, [settings, setSettings]);
const onQrScanned = useCallback(({ data }: { data: string }) => {
setScanning(false);
let url = data.trim();
try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {}
setNodeInput(url);
applyNode(url);
}, [applyNode]);
// Bootstrap ещё не закончился — ничего не рендерим, RootLayout покажет
// чёрный экран (single source of truth для splash-state'а).
if (!booted) return null;
// Ключи уже загружены — сразу в main app, без мелькания onboarding'а.
if (keyFile) return <Redirect href={'/(app)/chats' as never} />;
const openScanner = async () => {
if (!permission?.granted) {
const { granted } = await requestPermission();
if (!granted) {
Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.');
return;
}
}
setScanning(true);
};
const goToPage = (p: number) => {
scrollRef.current?.scrollTo({ x: p * SCREEN_W, animated: true });
setPage(p);
};
if (scanning) {
return (
<View style={{ flex: 1, backgroundColor: '#000' }}>
<CameraView
style={{ flex: 1 }}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={onQrScanned}
/>
<View style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
alignItems: 'center', justifyContent: 'center',
}}>
<View style={{ width: 240, height: 240, borderWidth: 2, borderColor: '#fff', borderRadius: 16 }} />
<Text style={{ color: '#fff', marginTop: 20, opacity: 0.8 }}>
Point at a DChain node QR code
</Text>
</View>
<Pressable
onPress={() => setScanning(false)}
style={{
position: 'absolute', top: 56, left: 16,
backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 8,
}}
>
<Text style={{ color: '#fff', fontSize: 16 }}> Cancel</Text>
</Pressable>
</View>
);
}
const statusColor = nodeOk === true ? '#3ba55d' : nodeOk === false ? '#f4212e' : '#8b8b8b';
// Высота footer'а (dots + inset) — резервируем под неё снизу каждого
// слайда, чтобы CTA-кнопки оказывались прямо над индикатором страниц,
// а не залезали под него.
const FOOTER_H = Math.max(insets.bottom, 20) + 8 + 12 + 7; // = padBottom + padTop + dot
const PAGE_H = SCREEN_H - FOOTER_H;
return (
<View style={{ flex: 1, backgroundColor: '#000000' }}>
<ScrollView
ref={scrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={e => {
const p = Math.round(e.nativeEvent.contentOffset.x / SCREEN_W);
setPage(p);
}}
style={{ flex: 1 }}
keyboardShouldPersistTaps="handled"
>
{/* ───────── Slide 1: Why DChain ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 60,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', marginBottom: 36 }}>
<View
style={{
width: 88, height: 88, borderRadius: 24,
backgroundColor: '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
marginBottom: 14,
}}
>
<Ionicons name="chatbubbles" size={44} color="#ffffff" />
</View>
<Text style={{ color: '#ffffff', fontSize: 30, fontWeight: '800', letterSpacing: -0.8 }}>
DChain
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 14, lineHeight: 20, marginTop: 6 }}>
A messenger that belongs to you.
</Text>
</View>
<FeatureRow
icon="lock-closed"
title="End-to-end encryption"
text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
/>
<FeatureRow
icon="key"
title="Your keys, your account"
text="No phone, email, or server passwords. Keys never leave your device."
/>
<FeatureRow
icon="git-network"
title="Decentralised"
text="Anyone can run a node. No single point of failure or censorship."
/>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTAPrimary label="Continue" onPress={() => goToPage(1)} />
</View>
</View>
{/* ───────── Slide 2: How it works ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 40,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
How it works
</Text>
<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>
<OptionCard
icon="globe"
title="Public node"
text="Quick and easy — community-hosted relay, small fee per delivered message."
/>
<OptionCard
icon="hardware-chip"
title="Self-hosted"
text="Maximum control. Source is open — spin up your own in five minutes."
/>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2, marginTop: 20, marginBottom: 8,
}}>
Node URL
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View
style={{
flex: 1, flexDirection: 'row', alignItems: 'center',
backgroundColor: '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12, paddingHorizontal: 12, gap: 8,
}}
>
<View style={{ width: 7, height: 7, borderRadius: 3.5, backgroundColor: statusColor }} />
<TextInput
value={nodeInput}
onChangeText={t => { setNodeInput(t); setNodeOk(null); }}
onEndEditing={() => applyNode(nodeInput)}
onSubmitEditing={() => applyNode(nodeInput)}
placeholder="http://192.168.1.10:8080"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="done"
style={{ flex: 1, color: '#ffffff', fontSize: 14, paddingVertical: 12 }}
/>
{checking
? <ActivityIndicator size="small" color="#8b8b8b" />
: nodeOk === true
? <Ionicons name="checkmark" size={16} color="#3ba55d" />
: nodeOk === false
? <Ionicons name="close" size={16} color="#f4212e" />
: null}
</View>
<Pressable
onPress={openScanner}
style={({ pressed }) => ({
width: 48, alignItems: 'center', justifyContent: 'center',
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 12,
})}
>
<Ionicons name="qr-code-outline" size={22} color="#ffffff" />
</Pressable>
</View>
{nodeOk === false && (
<Text style={{ color: '#f4212e', fontSize: 12, marginTop: 6 }}>
Cannot reach node check URL and that the node is running
</Text>
)}
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Source"
icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/>
<CTAPrimary label="Continue" onPress={() => goToPage(2)} />
</View>
</View>
{/* ───────── Slide 3: Your keys ───────── */}
<View style={{ width: SCREEN_W, height: PAGE_H }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingHorizontal: 28,
paddingTop: insets.top + 60,
paddingBottom: 16,
}}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', marginBottom: 36 }}>
<View
style={{
width: 88, height: 88, borderRadius: 24,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}
>
<Ionicons name="key" size={44} color="#1d9bf0" />
</View>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
Your account
</Text>
<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>
</View>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Import"
onPress={() => router.push('/(auth)/import' as never)}
/>
<CTAPrimary
label="Create account"
onPress={() => router.push('/(auth)/create' as never)}
/>
</View>
</View>
</ScrollView>
{/* Footer: dots-only pager indicator. CTA-кнопки теперь inline
на каждом слайде, чтобы выглядели как полноценные кнопки, а не
мелкий "Далее" в углу. */}
<View style={{
paddingHorizontal: 28,
paddingBottom: Math.max(insets.bottom, 20) + 8,
paddingTop: 12,
flexDirection: 'row',
alignItems: 'center', justifyContent: 'center',
gap: 6,
}}>
{[0, 1, 2].map(i => (
<Pressable
key={i}
onPress={() => goToPage(i)}
hitSlop={8}
style={{
width: page === i ? 22 : 7,
height: 7,
borderRadius: 3.5,
backgroundColor: page === i ? '#1d9bf0' : '#2a2a2a',
}}
/>
))}
</View>
</View>
);
}
// ───────── helper components ─────────
/**
* Primary CTA button — синий pill. Натуральная ширина (hugs content),
* `numberOfLines={1}` на лейбле чтобы текст не переносился. Фон
* применяется через inner View, а не напрямую на Pressable — это
* обходит редкие RN-баги, когда backgroundColor на Pressable не
* рендерится пока кнопка не нажата.
*/
function CTAPrimary({ label, onPress }: { label: string; onPress: () => void }) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}>
<View
style={{
height: 46,
paddingHorizontal: 22,
borderRadius: 999,
backgroundColor: '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
}}
>
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 15 }}
>
{label}
</Text>
</View>
</Pressable>
);
}
/** Secondary CTA — тёмный pill с border'ом, optional icon слева. */
function CTASecondary({
label, icon, onPress,
}: {
label: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
onPress: () => void;
}) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}>
<View
style={{
height: 46,
paddingHorizontal: 18,
borderRadius: 999,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
gap: 6,
}}
>
{icon && <Ionicons name={icon} size={15} color="#ffffff" />}
<Text
numberOfLines={1}
style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}
>
{label}
</Text>
</View>
</Pressable>
);
}
function FeatureRow({
icon, title, text,
}: { icon: React.ComponentProps<typeof Ionicons>['name']; title: string; text: string }) {
return (
<View style={{ flexDirection: 'row', marginBottom: 20, gap: 14 }}>
<View
style={{
width: 40, height: 40, borderRadius: 12,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color="#1d9bf0" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 3 }}>
{title}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
{text}
</Text>
</View>
</View>
);
}
function OptionCard({
icon, title, text, actionLabel, onAction,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
text: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<View
style={{
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 14, padding: 14, marginBottom: 10,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<Ionicons name={icon} size={18} color="#1d9bf0" />
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}>
{title}
</Text>
</View>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19 }}>
{text}
</Text>
{actionLabel && onAction && (
<Pressable onPress={onAction} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginTop: 8 })}>
<Text style={{ color: '#1d9bf0', fontSize: 13, fontWeight: '600' }}>
{actionLabel}
</Text>
</Pressable>
)}
</View>
);
}