Two dev-only seed modules removed now that the app talks to a real
backend:
- lib/devSeed.ts — fake 15+ contacts with mock chat histories,
mounted via useDevSeed() in (app)/_layout.tsx on empty store.
Was useful during client-first development; now it fights real
contact sync and confuses operators bringing up fresh nodes
("why do I see NBA scores and a dchain_updates channel in my
chat list?").
- lib/devSeedFeed.ts — 12 synthetic feed posts surfaced when the
real API returned empty. Same reasoning: operator imports genesis
key on a fresh node, opens Feed, sees 12 mock posts that aren't on
their chain. "Test data" that looks real is worse than an honest
empty state.
Feed screen now shows its proper empty state ("Пока нет
рекомендаций", etc.) when the API returns zero items OR on network
error. Chat screen starts empty until real contacts + messages
arrive via WS / storage cache.
Also cleaned a stale comment in chats/[id].tsx that referenced
devSeed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
/**
|
||
* 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, PostSeparator } from '@/components/feed/PostCard';
|
||
import { useStore } from '@/lib/store';
|
||
import {
|
||
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
|
||
type FeedPostItem,
|
||
} from '@/lib/feed';
|
||
|
||
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 [loadingMore, setLoadingMore] = useState(false);
|
||
const [exhausted, setExhausted] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
// 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);
|
||
setExhausted(false);
|
||
|
||
const seq = ++requestRef.current;
|
||
try {
|
||
let items: FeedPostItem[] = [];
|
||
switch (tab) {
|
||
case 'following':
|
||
items = await fetchTimeline(keyFile.pub_key, { limit: PAGE_SIZE });
|
||
break;
|
||
case 'foryou':
|
||
items = await fetchForYou(keyFile.pub_key, PAGE_SIZE);
|
||
break;
|
||
case 'trending':
|
||
items = await fetchTrending(24, PAGE_SIZE);
|
||
break;
|
||
}
|
||
if (seq !== requestRef.current) return; // stale response
|
||
|
||
setPosts(items);
|
||
// If the server returned fewer than PAGE_SIZE, we already have
|
||
// everything — disable further paginated fetches.
|
||
if (items.length < PAGE_SIZE) setExhausted(true);
|
||
|
||
// 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);
|
||
// Network / 404 is benign — node just unreachable or empty. Show
|
||
// the empty-state; the catch block above already cleared error
|
||
// on benign messages. Production treats this identically.
|
||
if (/Network request failed|→\s*404/.test(msg)) {
|
||
setPosts([]);
|
||
setExhausted(true);
|
||
} else {
|
||
setError(msg);
|
||
}
|
||
} finally {
|
||
if (seq !== requestRef.current) return;
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
}, [keyFile, tab]);
|
||
|
||
/**
|
||
* loadMore — paginate older posts when the user scrolls to the end
|
||
* of the list. Only the "following" and "foryou"/trending-less-useful
|
||
* paths actually support server-side pagination via the `before`
|
||
* cursor; foryou/trending return their ranked top-N which is by
|
||
* design not paginated (users very rarely scroll past 20 hot posts).
|
||
*
|
||
* We key the next page off the oldest post currently in state. If
|
||
* the server returns less than PAGE_SIZE items, we mark the list as
|
||
* exhausted to stop further fetches.
|
||
*/
|
||
const loadMore = useCallback(async () => {
|
||
if (!keyFile || loadingMore || exhausted || refreshing || loading) return;
|
||
if (posts.length === 0) return;
|
||
// foryou / trending are ranked, not ordered — no stable cursor to
|
||
// paginate against in v2.0.0. Skip.
|
||
if (tab === 'foryou' || tab === 'trending') return;
|
||
|
||
const oldest = posts[posts.length - 1];
|
||
const before = oldest?.created_at;
|
||
if (!before) return;
|
||
|
||
setLoadingMore(true);
|
||
const seq = requestRef.current; // don't bump — this is additive
|
||
try {
|
||
const next = await fetchTimeline(keyFile.pub_key, {
|
||
limit: PAGE_SIZE, before,
|
||
});
|
||
if (seq !== requestRef.current) return;
|
||
if (next.length === 0) {
|
||
setExhausted(true);
|
||
return;
|
||
}
|
||
// Dedup by post_id (could overlap on the boundary ts).
|
||
setPosts(prev => {
|
||
const have = new Set(prev.map(p => p.post_id));
|
||
const merged = [...prev];
|
||
for (const p of next) {
|
||
if (!have.has(p.post_id)) merged.push(p);
|
||
}
|
||
return merged;
|
||
});
|
||
if (next.length < PAGE_SIZE) setExhausted(true);
|
||
} catch {
|
||
// Don't escalate to error UI for pagination failures — just stop.
|
||
setExhausted(true);
|
||
} finally {
|
||
setLoadingMore(false);
|
||
}
|
||
}, [keyFile, loadingMore, exhausted, refreshing, loading, posts, 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 — три таба, равномерно распределены по ширине
|
||
(justifyContent: space-between). Каждый Pressable hug'ает
|
||
свой контент — табы НЕ тянутся на 1/3 ширины, а жмутся к
|
||
своему лейблу, что даёт воздух между ними. Индикатор активной
|
||
вкладки — тонкая полоска под лейблом. */}
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 20,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#1f1f1f',
|
||
}}
|
||
>
|
||
{(Object.keys(TAB_LABELS) as TabKey[]).map(key => (
|
||
<Pressable
|
||
key={key}
|
||
onPress={() => setTab(key)}
|
||
style={({ pressed }) => ({
|
||
alignItems: 'center',
|
||
paddingVertical: 16,
|
||
paddingHorizontal: 6,
|
||
opacity: pressed ? 0.6 : 1,
|
||
})}
|
||
>
|
||
<Text
|
||
style={{
|
||
color: tab === key ? '#ffffff' : '#6a6a6a',
|
||
fontWeight: tab === key ? '700' : '500',
|
||
fontSize: 15,
|
||
letterSpacing: -0.1,
|
||
}}
|
||
>
|
||
{TAB_LABELS[key]}
|
||
</Text>
|
||
<View
|
||
style={{
|
||
marginTop: 10,
|
||
width: tab === key ? 28 : 0,
|
||
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}
|
||
/>
|
||
)}
|
||
ItemSeparatorComponent={PostSeparator}
|
||
onEndReached={loadMore}
|
||
onEndReachedThreshold={0.6}
|
||
ListFooterComponent={
|
||
loadingMore ? (
|
||
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
|
||
<ActivityIndicator color="#1d9bf0" size="small" />
|
||
</View>
|
||
) : null
|
||
}
|
||
// Lazy-render tuning: start with one viewport's worth of posts,
|
||
// keep a small window around the visible area. Works together
|
||
// with onEndReached pagination for smooth long-feed scroll.
|
||
initialNumToRender={10}
|
||
maxToRenderPerBatch={8}
|
||
windowSize={7}
|
||
removeClippedSubviews
|
||
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 }
|
||
: { paddingTop: 8, paddingBottom: 24 }
|
||
}
|
||
/>
|
||
|
||
{/* Floating compose button.
|
||
*
|
||
* Pressable's dynamic-function style sometimes drops absolute
|
||
* positioning on re-render on some RN versions — we've seen the
|
||
* button slide to the left edge after the first render. Wrap it
|
||
* in a plain absolute-positioned View so positioning lives on a
|
||
* stable element; the Pressable inside only declares its size
|
||
* and visuals. The parent Feed screen's container ends at the
|
||
* NavBar top (see (app)/_layout.tsx), so bottom: 12 means 12px
|
||
* above the NavBar on every device. */}
|
||
<View
|
||
pointerEvents="box-none"
|
||
style={{
|
||
position: 'absolute',
|
||
right: 12,
|
||
bottom: 12,
|
||
}}
|
||
>
|
||
<Pressable
|
||
onPress={() => router.push('/(app)/compose' as never)}
|
||
style={({ pressed }) => ({
|
||
width: 56, height: 56,
|
||
borderRadius: 28,
|
||
backgroundColor: pressed ? '#1a8cd8' : '#1d9bf0',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 4 },
|
||
shadowOpacity: 0.5,
|
||
shadowRadius: 6,
|
||
elevation: 8,
|
||
})}
|
||
>
|
||
<Ionicons name="create-outline" size={24} color="#ffffff" />
|
||
</Pressable>
|
||
</View>
|
||
</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;
|
||
}
|