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;
+}