Files
dchain/client-app/app/(app)/feed/index.tsx
vsecoder 6425b5cffb feat(feed/chat): lazy-render + pagination for long scrolls
Server pagination
  - blockchain.PostsByAuthor signature extended with beforeTs int64;
    passing 0 keeps the previous "everything, newest first" behaviour,
    non-zero skips posts with CreatedAt >= beforeTs so clients can
    paginate older results.
  - node.FeedConfig.PostsByAuthor callback type updated; the two
    /feed endpoints that use it (timeline + author) now accept
    `?before=<unix_seconds>` and forward it through. /feed/author
    limit default dropped from 50 to 30 to match the client's page
    size.
  - node/api_common.go: new queryInt64 helper for parsing the cursor
    param safely (matches the queryInt pattern already used).

Client infinite scroll (Feed tab)
  - lib/feed.ts: fetchTimeline / fetchAuthorPosts accept
    `{limit?, before?}` options. Old signatures still work for other
    callers (fetchForYou / fetchTrending / fetchHashtag) — those are
    ranked feeds that don't have a stable cursor so they stay
    single-shot.
  - feed/index.tsx: tracks loadingMore / exhausted state. onEndReached
    (threshold 0.6) fires loadMore() which fetches the next 20 posts
    using the oldest currently-loaded post's created_at as `before`.
    Deduplicates on post_id before appending. Stops when the server
    returns < PAGE_SIZE items. ListFooterComponent shows a small
    spinner during paginated fetches.
  - FlatList lazy-render tuning on all feed lists (index + hashtag):
    initialNumToRender:10, maxToRenderPerBatch:8, windowSize:7,
    removeClippedSubviews — first paint stays quick even with 100+
    posts loaded.

Chat lazy render
  - chats/[id].tsx FlatList: initialNumToRender:25 (~1.5 screens),
    maxToRenderPerBatch:12, windowSize:10, removeClippedSubviews.
    Keeps initial chat open snappy on conversations with thousands
    of messages; RN re-renders a small window around the viewport
    and drops the rest.

Tests
  - chain_test.go updated for new PostsByAuthor signature.
  - All 7 Go packages green.
  - tsc --noEmit clean.

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

432 lines
15 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, PostSeparator } 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 [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
// 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);
// 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. In __DEV__
// fall back to synthetic seed posts so the scroll / tap UI stays
// testable; in production this path shows the empty state.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts(getDevSeedFeed());
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;
}