Files
dchain/client-app/app/(app)/feed/index.tsx
vsecoder 98a0a4b8ba fix(nav): move feed.tsx → feed/index.tsx to unblock duplicate-route error
expo-router error: "A navigator cannot contain multiple 'Screen'
components with the same name (found duplicate screen named 'feed')".

Cause: having both app/(app)/feed.tsx (a file-route) and
app/(app)/feed/ (a folder with _layout.tsx) at the same level makes
expo-router try to register two routes called "feed".

Fix: the folder's _layout.tsx wraps the tab + its sub-routes in a
single Stack, so the tab screen becomes feed/index.tsx — the initial
screen of that Stack. Back navigation from /feed/[id] and
/feed/tag/[tag] now correctly pops to the Feed tab root.

No other route references changed (paths like /(app)/feed,
/(app)/feed/<id>, /(app)/feed/tag/<tag> still resolve identically).

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

338 lines
11 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.

/**
* Feed tab — Twitter-style timeline with three sources:
*
* Подписки → /feed/timeline?follower=me (posts from people I follow)
* Для вас → /feed/foryou?pub=me (recommendations)
* В тренде → /feed/trending?window=24 (most-engaged in last 24h)
*
* Floating compose button (bottom-right) → /(app)/compose modal.
*
* Uses a single FlatList per tab with pull-to-refresh + optimistic
* local updates. Stats (likes, likedByMe) are fetched once per refresh
* and piggy-backed onto each PostCard via props; the card does the
* optimistic toggle locally until the next refresh reconciles.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
View, Text, FlatList, Pressable, RefreshControl, ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { TabHeader } from '@/components/TabHeader';
import { PostCard } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import {
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
type FeedPostItem,
} from '@/lib/feed';
import { getDevSeedFeed } from '@/lib/devSeedFeed';
type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = {
following: 'Подписки',
foryou: 'Для вас',
trending: 'В тренде',
};
export default function FeedScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [tab, setTab] = useState<TabKey>('foryou'); // default: discovery
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Guard against rapid tab switches overwriting each other's results.
const requestRef = useRef(0);
const loadPosts = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
setError(null);
const seq = ++requestRef.current;
try {
let items: FeedPostItem[] = [];
switch (tab) {
case 'following':
items = await fetchTimeline(keyFile.pub_key, 40);
break;
case 'foryou':
items = await fetchForYou(keyFile.pub_key, 40);
break;
case 'trending':
items = await fetchTrending(24, 40);
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).
const liked = new Set<string>();
const chunks = chunk(items, 6);
for (const group of chunks) {
const results = await Promise.all(
group.map(p => fetchStats(p.post_id, keyFile.pub_key)),
);
results.forEach((s, i) => {
if (s?.liked_by_me) liked.add(group[i].post_id);
});
}
if (seq !== requestRef.current) return;
setLikedSet(liked);
} catch (e: any) {
if (seq !== requestRef.current) return;
const msg = String(e?.message ?? e);
// Silence benign network/404 — just show empty state.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts([]);
} else {
setError(msg);
}
} finally {
if (seq !== requestRef.current) return;
setLoading(false);
setRefreshing(false);
}
}, [keyFile, tab]);
useEffect(() => { loadPosts(false); }, [loadPosts]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const stats = await fetchStats(postID, keyFile.pub_key);
if (!stats) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: stats.likes, views: stats.views }
: p));
setLikedSet(s => {
const next = new Set(s);
if (stats.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));
}, []);
// View counter: fire bumpView once when a card scrolls into view.
const viewedRef = useRef<Set<string>>(new Set());
const onViewableItemsChanged = useRef(({ viewableItems }: { viewableItems: Array<{ item: FeedPostItem; isViewable: boolean }> }) => {
for (const { item, isViewable } of viewableItems) {
if (isViewable && !viewedRef.current.has(item.post_id)) {
viewedRef.current.add(item.post_id);
bumpView(item.post_id);
}
}
}).current;
const viewabilityConfig = useRef({ itemVisiblePercentThreshold: 60, minimumViewTime: 1000 }).current;
const emptyHint = useMemo(() => {
switch (tab) {
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.';
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.';
case 'trending': return 'В этой ленте пока тихо.';
}
}, [tab]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" />
{/* Tab strip — больше воздуха между табами. Горизонтальный padding
на контейнере + gap между Pressable'ами делает табы "breathable",
индикатор активной вкладки — тонкая полоска только под текстом,
шириной примерно с лейбл. */}
<View
style={{
flexDirection: 'row',
paddingHorizontal: 12,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#141414',
}}
>
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
<Pressable
key={key}
onPress={() => setTab(key)}
style={({ pressed }) => ({
flex: 1,
alignItems: 'center',
paddingVertical: 16,
backgroundColor: pressed ? '#0a0a0a' : 'transparent',
})}
>
<Text
style={{
color: tab === key ? '#ffffff' : '#6a6a6a',
fontWeight: tab === key ? '700' : '500',
fontSize: 14,
letterSpacing: -0.1,
}}
>
{TAB_LABELS[key]}
</Text>
{tab === key && (
<View
style={{
marginTop: 10,
width: 32,
height: 3,
borderRadius: 1.5,
backgroundColor: '#1d9bf0',
}}
/>
)}
</Pressable>
))}
</View>
{/* Feed list */}
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
onDeleted={onDeleted}
/>
)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => loadPosts(true)}
tintColor="#1d9bf0"
/>
}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : error ? (
<EmptyState
icon="alert-circle-outline"
title="Не удалось загрузить ленту"
subtitle={error}
onRetry={() => loadPosts(false)}
/>
) : (
<EmptyState
icon="newspaper-outline"
title="Здесь пока пусто"
subtitle={emptyHint}
/>
)
}
contentContainerStyle={posts.length === 0 ? { flexGrow: 1 } : undefined}
/>
{/* Floating compose button — pinned to the bottom-right corner
with 14px side inset. Vertical offset clears the 5-icon NavBar
(which lives below this view in the same layer) by sitting
~14px above its top edge. */}
<Pressable
onPress={() => router.push('/(app)/compose' as never)}
style={({ pressed }) => ({
position: 'absolute',
right: 14,
bottom: Math.max(insets.bottom, 0) + 62,
width: 56, height: 56,
borderRadius: 28,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
alignItems: 'center', justifyContent: 'center',
shadowColor: '#1d9bf0',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 6,
})}
>
<Ionicons name="create-outline" size={24} color="#ffffff" />
</Pressable>
</View>
);
}
// ── Empty state ─────────────────────────────────────────────────────────
function EmptyState({
icon, title, subtitle, onRetry,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
subtitle?: string;
onRetry?: () => void;
}) {
return (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<View
style={{
width: 64, height: 64, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 14,
}}
>
<Ionicons name={icon} size={28} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginBottom: 6 }}>
{title}
</Text>
{subtitle && (
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
{subtitle}
</Text>
)}
{onRetry && (
<Pressable
onPress={onRetry}
style={({ pressed }) => ({
marginTop: 16,
paddingHorizontal: 20, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Попробовать снова
</Text>
</Pressable>
)}
</View>
);
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}