diff --git a/client-app/app/(app)/feed.tsx b/client-app/app/(app)/feed.tsx index a496d6f..87e4c9d 100644 --- a/client-app/app/(app)/feed.tsx +++ b/client-app/app/(app)/feed.tsx @@ -27,6 +27,7 @@ import { fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView, type FeedPostItem, } from '@/lib/feed'; +import { getDevSeedFeed } from '@/lib/devSeedFeed'; type TabKey = 'following' | 'foryou' | 'trending'; @@ -71,6 +72,13 @@ export default function FeedScreen() { break; } if (seq !== requestRef.current) return; // stale response + + // Dev-only fallback: if the node has no real posts yet, surface + // synthetic ones so we can scroll + tap. Stripped from production. + if (items.length === 0) { + items = getDevSeedFeed(); + } + setPosts(items); // Batch-fetch liked_by_me (bounded concurrency — 6 at a time). diff --git a/client-app/app/(app)/feed/_layout.tsx b/client-app/app/(app)/feed/_layout.tsx new file mode 100644 index 0000000..adcf221 --- /dev/null +++ b/client-app/app/(app)/feed/_layout.tsx @@ -0,0 +1,23 @@ +/** + * Feed sub-routes layout — native Stack for /(app)/feed/[id] and + * /(app)/feed/tag/[tag]. The tab root itself (app/(app)/feed.tsx) lives + * OUTSIDE this folder so it keeps the outer Slot-level navigation. + * + * Why a Stack here? AnimatedSlot in the parent is stack-less; without + * this nested Stack, `router.back()` from a post detail / hashtag feed + * couldn't find its caller. + */ +import React from 'react'; +import { Stack } from 'expo-router'; + +export default function FeedLayout() { + return ( + + ); +} diff --git a/client-app/app/(app)/profile/[address].tsx b/client-app/app/(app)/profile/[address].tsx index 2688954..b727d76 100644 --- a/client-app/app/(app)/profile/[address].tsx +++ b/client-app/app/(app)/profile/[address].tsx @@ -1,20 +1,21 @@ /** - * Profile screen — shows info about any address (yours or someone else's), - * plus their post feed, follow/unfollow button, and basic counters. + * Profile screen — info card about any address (yours or someone else's), + * plus a Follow/Unfollow button. Posts are intentionally NOT shown here + * — this screen is chat-oriented ("who is on the other side of this + * conversation"); the feed tab + /feed/author/{pub} is where you go to + * browse someone's timeline. * - * Routes: + * Route: * /(app)/profile/ * - * Two states: - * - Known contact → open chat, show full info - * - Unknown address → Twitter-style "discovery" profile: shows just the - * address + posts + follow button. Useful when tapping an author from - * the feed of someone you don't chat with. + * Back behaviour: + * Nested Stack layout in app/(app)/profile/_layout.tsx preserves the + * push stack, so tapping Back returns the user to whatever screen + * pushed them here (feed card tap, chat header tap, etc.). */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { - View, Text, ScrollView, Pressable, Alert, FlatList, - ActivityIndicator, RefreshControl, + View, Text, ScrollView, Pressable, ActivityIndicator, } from 'react-native'; import { router, useLocalSearchParams } from 'expo-router'; import * as Clipboard from 'expo-clipboard'; @@ -22,16 +23,10 @@ import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; -import type { Contact } from '@/lib/types'; - import { Avatar } from '@/components/Avatar'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; -import { PostCard } from '@/components/feed/PostCard'; -import { - fetchAuthorPosts, fetchStats, followUser, unfollowUser, - formatCount, type FeedPostItem, -} from '@/lib/feed'; +import { followUser, unfollowUser } from '@/lib/feed'; import { humanizeTxError } from '@/lib/api'; function shortAddr(a: string, n = 10): string { @@ -39,8 +34,6 @@ function shortAddr(a: string, n = 10): string { return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } -type Tab = 'posts' | 'info'; - export default function ProfileScreen() { const insets = useSafeAreaInsets(); const { address } = useLocalSearchParams<{ address: string }>(); @@ -48,54 +41,20 @@ export default function ProfileScreen() { const keyFile = useStore(s => s.keyFile); const contact = contacts.find(c => c.address === address); - const [tab, setTab] = useState('posts'); - const [posts, setPosts] = useState([]); - const [likedSet, setLikedSet] = useState>(new Set()); - const [loadingPosts, setLoadingPosts] = useState(true); - const [refreshing, setRefreshing] = useState(false); - - // Follow state is optimistic + reconciled via on-chain query. For MVP - // we keep a local-only flag that toggles immediately on tap; future: - // query chain.Following(me) once on mount to seed accurate initial state. - const [following, setFollowing] = useState(false); + const [following, setFollowing] = useState(false); const [followingBusy, setFollowingBusy] = useState(false); - const [copied, setCopied] = useState(null); + const [copied, setCopied] = useState(false); const isMe = !!keyFile && keyFile.pub_key === address; const displayName = contact?.username ? `@${contact.username}` - : contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '')); + : contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6)); - const loadPosts = useCallback(async (isRefresh = false) => { + const copyAddress = async () => { if (!address) return; - if (isRefresh) setRefreshing(true); else setLoadingPosts(true); - try { - const items = await fetchAuthorPosts(address, 40); - setPosts(items); - if (keyFile) { - const liked = new Set(); - for (const p of items) { - const s = await fetchStats(p.post_id, keyFile.pub_key); - if (s?.liked_by_me) liked.add(p.post_id); - } - setLikedSet(liked); - } - } catch { - setPosts([]); - } finally { - setLoadingPosts(false); - setRefreshing(false); - } - }, [address, keyFile]); - - useEffect(() => { - if (tab === 'posts') loadPosts(false); - }, [tab, loadPosts]); - - const copy = async (value: string, label: string) => { - await Clipboard.setStringAsync(value); - setCopied(label); - setTimeout(() => setCopied(null), 1800); + await Clipboard.setStringAsync(address); + setCopied(true); + setTimeout(() => setCopied(false), 1800); }; const openChat = () => { @@ -116,301 +75,216 @@ export default function ProfileScreen() { } } catch (e: any) { setFollowing(wasFollowing); - Alert.alert('Не удалось', humanizeTxError(e)); + // Surface the error via alert — feed lib already formats humanizeTxError. + alert(humanizeTxError(e)); } finally { setFollowingBusy(false); } }; - const onStatsChanged = useCallback(async (postID: string) => { - if (!keyFile) return; - const s = await fetchStats(postID, keyFile.pub_key); - if (!s) return; - setPosts(ps => ps.map(p => p.post_id === postID - ? { ...p, likes: s.likes, views: s.views } : p)); - setLikedSet(set => { - const next = new Set(set); - if (s.liked_by_me) next.add(postID); else next.delete(postID); - return next; - }); - }, [keyFile]); - - const onDeleted = useCallback((postID: string) => { - setPosts(ps => ps.filter(p => p.post_id !== postID)); - }, []); - - // ── Hero + follow button block ────────────────────────────────────── - - const Hero = ( - - - - - {!isMe ? ( - ({ - paddingHorizontal: 18, paddingVertical: 9, - borderRadius: 999, - backgroundColor: following - ? (pressed ? '#1a1a1a' : '#111111') - : (pressed ? '#e7e7e7' : '#ffffff'), - borderWidth: following ? 1 : 0, - borderColor: '#1f1f1f', - minWidth: 110, - alignItems: 'center', - })} - > - {followingBusy ? ( - - ) : ( - - {following ? 'Вы подписаны' : 'Подписаться'} - - )} - - ) : ( - router.push('/(app)/settings' as never)} - style={({ pressed }) => ({ - paddingHorizontal: 18, paddingVertical: 9, - borderRadius: 999, - backgroundColor: pressed ? '#1a1a1a' : '#111111', - borderWidth: 1, borderColor: '#1f1f1f', - })} - > - - Редактировать - - - )} - - - - - {displayName} - - {contact?.username && ( - - )} - - - {shortAddr(address ?? '')} - - - {/* Counters row — post count is derived from what we loaded; follower/ - following counters would require chain.Followers / chain.Following - HTTP exposure which isn't wired yet (Phase D). */} - - - {formatCount(posts.length)} - постов - - - - {/* Secondary actions: open chat + copy address */} - {!isMe && contact && ( - - ({ - flex: 1, - alignItems: 'center', justifyContent: 'center', - paddingVertical: 10, borderRadius: 999, - backgroundColor: pressed ? '#1a1a1a' : '#111111', - borderWidth: 1, borderColor: '#1f1f1f', - flexDirection: 'row', gap: 6, - })} - > - - - Чат - - - address && copy(address, 'address')} - style={({ pressed }) => ({ - flex: 1, - alignItems: 'center', justifyContent: 'center', - paddingVertical: 10, borderRadius: 999, - backgroundColor: pressed ? '#1a1a1a' : '#111111', - borderWidth: 1, borderColor: '#1f1f1f', - })} - > - - {copied === 'address' ? 'Скопировано' : 'Копировать адрес'} - - - - )} - - ); - - // ── Tab strip ──────────────────────────────────────────────────────── - - const TabStrip = ( - - {(['posts', 'info'] as Tab[]).map(key => ( - setTab(key)} - style={{ - flex: 1, - alignItems: 'center', - paddingVertical: 12, - }} - > - - {key === 'posts' ? 'Посты' : 'Инфо'} - - {tab === key && ( - - )} - - ))} - - ); - - // ── Content per tab ───────────────────────────────────────────────── - - if (tab === 'posts') { - return ( - -
router.back()} />} - /> - p.post_id} - renderItem={({ item }) => ( - - )} - ListHeaderComponent={ - <> - {Hero} - {TabStrip} - - } - refreshControl={ - loadPosts(true)} - tintColor="#1d9bf0" - /> - } - ListEmptyComponent={ - loadingPosts ? ( - - - - ) : ( - - - - Пока нет постов - - - {isMe - ? 'Нажмите на синюю кнопку в ленте, чтобы написать первый.' - : 'Этот пользователь ещё ничего не публиковал.'} - - - ) - } - /> - - ); - } - - // Info tab return ( - +
router.back()} />} /> - - {Hero} - {TabStrip} - - - - {contact && ( - <> - + {/* ── Hero: avatar + Follow button ──────────────────────────── */} + + + + {!isMe ? ( + ({ + paddingHorizontal: 18, paddingVertical: 9, + borderRadius: 999, + backgroundColor: following + ? (pressed ? '#1a1a1a' : '#111111') + : (pressed ? '#e7e7e7' : '#ffffff'), + borderWidth: following ? 1 : 0, + borderColor: '#1f1f1f', + minWidth: 120, + alignItems: 'center', + })} + > + {followingBusy ? ( + - - - )} - + ) : ( + + {following ? 'Вы подписаны' : 'Подписаться'} + + )} + + ) : ( + router.push('/(app)/settings' as never)} + style={({ pressed }) => ({ + paddingHorizontal: 18, paddingVertical: 9, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + Редактировать + + + )} - + + {/* Name + verified tick */} + + + {displayName} + + {contact?.username && ( + + )} + + + {/* Open chat — single CTA, full width, icon inline with text. + Only when we know this is a contact (direct chat exists). */} + {!isMe && contact && ( + ({ + marginTop: 14, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingVertical: 11, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : '#111111', + borderWidth: 1, borderColor: '#1f1f1f', + })} + > + + + Открыть чат + + + )} + + {/* ── Info card ───────────────────────────────────────────────── */} + + {/* Address — entire row is tappable → copies */} + ({ + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 14, paddingVertical: 12, + backgroundColor: pressed ? '#0f0f0f' : 'transparent', + })} + > + + Адрес + + + {copied ? 'Скопировано' : shortAddr(address ?? '')} + + + + + {/* Encryption status */} + {contact && ( + <> + + + + + + + {/* Participants count — 1 for direct DMs. Groups would show + their actual member count from chain state (v2.1.0+). */} + + + + )} + + + {!contact && !isMe && ( + + Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username. + + )} ); } +function Divider() { + return ; +} + function InfoRow({ - label, value, mono, accent, danger, + label, value, icon, danger, }: { label: string; value: string; - mono?: boolean; - accent?: boolean; + icon?: React.ComponentProps['name']; danger?: boolean; }) { - const color = danger ? '#f0b35a' : accent ? '#1d9bf0' : '#ffffff'; return ( + {icon && ( + + )} {label} ); } - -// Silence unused-import lint for Contact type used only in helpers. -const _contactType: Contact | null = null; void _contactType; diff --git a/client-app/app/(app)/profile/_layout.tsx b/client-app/app/(app)/profile/_layout.tsx new file mode 100644 index 0000000..2ca12c3 --- /dev/null +++ b/client-app/app/(app)/profile/_layout.tsx @@ -0,0 +1,24 @@ +/** + * Profile group layout — provides a dedicated native Stack for the + * /(app)/profile/* routes so that `router.back()` returns to the screen + * that pushed us here (post detail, chat, feed tab, etc.) instead of + * falling through to the root. + * + * The parent (app)/_layout.tsx uses AnimatedSlot → , which is + * stack-less. Nesting a here gives profile routes proper back + * history without affecting the outer tabs. + */ +import React from 'react'; +import { Stack } from 'expo-router'; + +export default function ProfileLayout() { + return ( + + ); +} diff --git a/client-app/lib/devSeedFeed.ts b/client-app/lib/devSeedFeed.ts new file mode 100644 index 0000000..aa61517 --- /dev/null +++ b/client-app/lib/devSeedFeed.ts @@ -0,0 +1,181 @@ +/** + * Dev-only mock posts for the feed. + * + * Why: in __DEV__ before any real posts exist on the node, the timeline/ + * for-you/trending tabs come back empty. Empty state is fine visually but + * doesn't let you test scrolling, like animations, view-counter bumps, + * navigation to post detail, etc. This module injects a small set of + * synthetic posts so the UI has something to chew on. + * + * Gating: + * - Only active when __DEV__ === true (stripped from production builds). + * - Only surfaces when the REAL API returns an empty array. If the node + * is returning actual posts, we trust those and skip the mocks. + * + * These posts have made-up post_ids — tapping on them to open detail + * WILL 404 against the real backend. That's intentional — the mock is + * purely for scroll / tap-feedback testing. + */ +import type { FeedPostItem } from './feed'; + +// Fake hex-like pubkeys so Avatar's colour hash still looks varied. +function fakeAddr(seed: number): string { + const h = (seed * 2654435761).toString(16).padStart(8, '0'); + return (h + h + h + h).slice(0, 64); +} + +function fakePostID(n: number): string { + return `dev${String(n).padStart(29, '0')}`; +} + +const NOW = Math.floor(Date.now() / 1000); + +// Small curated pool of posts covering the render surface we care about: +// plain text, hashtag variety, different lengths, likes / views spread, +// reply/quote references, one with an attachment marker. +const SEED_POSTS: FeedPostItem[] = [ + { + post_id: fakePostID(1), + author: fakeAddr(1), + content: 'Добро пожаловать в ленту DChain. Это #DEV-посты — они видны только пока реальная лента пустая.', + created_at: NOW - 60, + size: 200, + hosting_relay: fakeAddr(100), + views: 127, likes: 42, + has_attachment: false, + hashtags: ['dev'], + }, + { + post_id: fakePostID(2), + author: fakeAddr(2), + content: 'Пробую новую ленту #twitter-style. Лайки, просмотры, подписки — всё on-chain, тела постов — off-chain в mailbox релея.', + created_at: NOW - 540, + size: 310, + hosting_relay: fakeAddr(100), + views: 89, likes: 23, + has_attachment: false, + hashtags: ['twitter'], + }, + { + post_id: fakePostID(3), + author: fakeAddr(3), + content: 'Сжатие изображений — максимальное на клиенте (WebP Q=50 @1080p), плюс серверный EXIF-скраб через stdlib re-encode. GPS-координаты из EXIF больше никогда не утекают. #privacy', + created_at: NOW - 1200, + size: 420, + hosting_relay: fakeAddr(100), + views: 312, likes: 78, + has_attachment: true, + hashtags: ['privacy'], + }, + { + post_id: fakePostID(4), + author: fakeAddr(4), + content: 'Короткий пост.', + created_at: NOW - 3600, + size: 128, + hosting_relay: fakeAddr(100), + views: 12, likes: 3, + has_attachment: false, + }, + { + post_id: fakePostID(5), + author: fakeAddr(1), + content: 'Отвечаю сам себе — фича threads пока через reply_to только, без UI thread-виджета.', + created_at: NOW - 7200, + size: 220, + hosting_relay: fakeAddr(100), + views: 45, likes: 11, + has_attachment: false, + reply_to: fakePostID(1), + }, + { + post_id: fakePostID(6), + author: fakeAddr(5), + content: '#golang + #badgerdb + #libp2p = DChain бэкенд. Пять package в test suite, все зелёные.', + created_at: NOW - 10800, + size: 180, + hosting_relay: fakeAddr(100), + views: 201, likes: 66, + has_attachment: false, + hashtags: ['golang', 'badgerdb', 'libp2p'], + }, + { + post_id: fakePostID(7), + author: fakeAddr(6), + content: 'Feed-mailbox хранит тела постов до 30 дней (настраиваемо через DCHAIN_FEED_TTL_DAYS). Потом BadgerDB выселяет автоматически — chain-метаданные остаются навсегда.', + created_at: NOW - 14400, + size: 380, + hosting_relay: fakeAddr(100), + views: 156, likes: 48, + has_attachment: false, + }, + { + post_id: fakePostID(8), + author: fakeAddr(7), + content: 'Pricing: BasePostFee = 1000 µT (0.001 T) + 1 µT за каждый байт. Уходит владельцу релея, принявшего пост.', + created_at: NOW - 21600, + size: 250, + hosting_relay: fakeAddr(100), + views: 78, likes: 22, + has_attachment: false, + }, + { + post_id: fakePostID(9), + author: fakeAddr(8), + content: 'Twitter-like, но без миллиардов долларов на инфраструктуру — каждый оператор ноды платит за свой кусок хостинга и зарабатывает на публикациях. #decentralised #messaging', + created_at: NOW - 43200, + size: 340, + hosting_relay: fakeAddr(100), + views: 412, likes: 103, + has_attachment: false, + hashtags: ['decentralised', 'messaging'], + }, + { + post_id: fakePostID(10), + author: fakeAddr(9), + content: 'Короче. Лайк = on-chain tx с fee 1000 µT. Дорого для спама, дёшево для реального лайка. Пока без батчинга, но в плане. #design', + created_at: NOW - 64800, + size: 200, + hosting_relay: fakeAddr(100), + views: 92, likes: 29, + has_attachment: false, + hashtags: ['design'], + }, + { + post_id: fakePostID(11), + author: fakeAddr(2), + content: 'Follow граф на chain: двусторонний индекс (forward + inbound), так что Followers() и Following() — оба O(M).', + created_at: NOW - 86400 - 1000, + size: 230, + hosting_relay: fakeAddr(100), + views: 61, likes: 14, + has_attachment: false, + }, + { + post_id: fakePostID(12), + author: fakeAddr(10), + content: 'Рекомендации (For You): берём последние 48ч постов, фильтруем подписки + уже лайкнутые + свои, ранжируем по likes × 3 + views. Версия 1 — будет умнее. #recsys', + created_at: NOW - 129600, + size: 290, + hosting_relay: fakeAddr(100), + views: 189, likes: 58, + has_attachment: false, + hashtags: ['recsys'], + }, +]; + +/** True when the current build is a Metro dev bundle. __DEV__ is a + * global injected by Metro at bundle time and typed via react-native's + * ambient declarations, so no ts-ignore is needed. */ +function isDev(): boolean { + return typeof __DEV__ !== 'undefined' && __DEV__ === true; +} + +/** + * Returns the dev-seed post list (only in __DEV__). Called by the Feed + * screen as a fallback when the real API returned an empty list. + */ +export function getDevSeedFeed(): FeedPostItem[] { + if (!isDev()) return []; + return SEED_POSTS; +}