Files
dchain/client-app/app/index.tsx
vsecoder 5b64ef2560 feat(client): Twitter-style social feed UI (Phase C of v2.0.0)
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).

Feed screens

  app/(app)/feed.tsx — Feed tab
    - Three-way tab strip: Подписки / Для вас / В тренде backed by
      /feed/timeline, /feed/foryou, /feed/trending respectively
    - Default landing tab is "Для вас" — surfaces discovery without
      requiring the user to follow anyone first
    - FlatList with pull-to-refresh + viewability-driven view counter
      bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
    - Floating blue compose button → /compose
    - Per-post liked_by_me fetched in batches of 6 after list load

  app/(app)/compose.tsx — post composer modal
    - Fullscreen, Twitter-like header (✕ left, Опубликовать right)
    - Auto-focused multiline TextInput, 4000 char cap
    - Hashtag preview chips that auto-update as you type
    - expo-image-picker + expo-image-manipulator pipeline: resize to
      1080px max-dim, JPEG Q=50 (client-side first-pass compression
      before the mandatory server-side scrub)
    - Live fee estimate + balance guard with a confirmation modal
      ("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
    - Exif: false passed to ImagePicker as an extra privacy layer

  app/(app)/feed/[id].tsx — post detail
    - Full PostCard rendering + detailed info panel (views, likes,
      size, fee, hosting relay, hashtags as tappable chips)
    - Triggers bumpView on mount
    - 410 (on-chain soft-delete) routes back to the feed

  app/(app)/feed/tag/[tag].tsx — hashtag feed

  app/(app)/profile/[address].tsx — rebuilt
    - Twitter-ish profile: avatar, name, address short-form, post count
    - Posts | Инфо tab strip
    - Follow / Unfollow button for non-self profiles (optimistic UI)
    - Edit button on self profile → settings
    - Secondary actions (chat, copy address) when viewing a known contact

Supporting library

  lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
    - publishPost (POST /feed/publish, signed)
    - publishAndCommit (publish → on-chain CREATE_POST)
    - fetchPost / fetchStats / bumpView
    - fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
      fetchHashtag
    - buildCreatePostTx / buildDeletePostTx
    - buildFollowTx / buildUnfollowTx
    - buildLikePostTx / buildUnlikePostTx
    - likePost / unlikePost / followUser / unfollowUser / deletePost
      (high-level helpers that bundle build + submitTx)
    - formatFee, formatRelativeTime, formatCount — Twitter-like display
      helpers

  components/feed/PostCard.tsx — core card component
    - Memoised for performance (N-row re-render on every like elsewhere
      would cost a lot otherwise)
    - Optimistic like toggle with heart-bounce spring animation
    - Hashtag highlighting in body text (tappable → hashtag feed)
    - Long-press context menu (Delete, owner-only)
    - Views / likes / share-link / reply icons in footer row

Navigation cleanup

  - NavBar: removed the SOON pill on the Feed tab (it's shipped now)
  - (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
  - AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
    sub-routes so back-swipe-right closes them

Channel removal (client side)

  - lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
    'channel' flag removed. `kind` field kept for backward compat with
    existing AsyncStorage records.
  - lib/devSeed.ts: dropped the 5 channel seed contacts.
  - components/ChatTile.tsx: removed channel kindIcon branch.

Dependencies

  - expo-image-manipulator added for client-side image compression.
  - expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
    that API to the legacy sub-path; the new streaming API isn't yet
    stable).

Type check

  - npx tsc --noEmit — clean, 0 errors.

Next (not in this commit)

  - Direct attachment-bytes endpoint on the server so post-detail can
    actually render the image (currently shows placeholder with URL)
  - Cross-relay body fetch via /api/relays + hosting_relay pubkey
  - Mentions (@username) with notifications
  - Full-text search

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

520 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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 на каждом сообщении. Даже релей-нода не может прочитать переписку."
/>
<FeatureRow
icon="key"
title="Твои ключи — твой аккаунт"
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство."
/>
<FeatureRow
icon="git-network"
title="Decentralised"
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры."
/>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTAPrimary label="Продолжить" 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 }}>
Как это работает
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Сообщения проходят через релей-ноду в зашифрованном виде.
Выбери публичную или подключи свою.
</Text>
<OptionCard
icon="globe"
title="Публичная нода"
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение."
/>
<OptionCard
icon="hardware-chip"
title="Своя нода"
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут."
/>
<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="Исходники"
icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/>
<CTAPrimary label="Продолжить" 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' }}>
Твой аккаунт
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Создай новую пару ключей или импортируй существующую.
Ключи хранятся только на этом устройстве.
</Text>
</View>
</ScrollView>
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Импорт"
onPress={() => router.push('/(auth)/import' as never)}
/>
<CTAPrimary
label="Создать аккаунт"
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>
);
}