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>
This commit is contained in:
vsecoder
2026-04-18 21:51:43 +03:00
parent 1c6622e809
commit 6425b5cffb
8 changed files with 137 additions and 14 deletions

View File

@@ -455,6 +455,13 @@ export default function ChatScreen() {
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
ListEmptyComponent={() => (
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',

View File

@@ -46,8 +46,12 @@ export default function FeedScreen() {
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);
@@ -56,19 +60,20 @@ export default function FeedScreen() {
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, 40);
items = await fetchTimeline(keyFile.pub_key, { limit: PAGE_SIZE });
break;
case 'foryou':
items = await fetchForYou(keyFile.pub_key, 40);
items = await fetchForYou(keyFile.pub_key, PAGE_SIZE);
break;
case 'trending':
items = await fetchTrending(24, 40);
items = await fetchTrending(24, PAGE_SIZE);
break;
}
if (seq !== requestRef.current) return; // stale response
@@ -80,6 +85,9 @@ export default function FeedScreen() {
}
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>();
@@ -102,6 +110,7 @@ export default function FeedScreen() {
// testable; in production this path shows the empty state.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts(getDevSeedFeed());
setExhausted(true);
} else {
setError(msg);
}
@@ -112,6 +121,57 @@ export default function FeedScreen() {
}
}, [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) => {
@@ -219,6 +279,22 @@ export default function FeedScreen() {
/>
)}
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}

View File

@@ -95,6 +95,10 @@ export default function HashtagScreen() {
/>
)}
ItemSeparatorComponent={PostSeparator}
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
refreshControl={
<RefreshControl
refreshing={refreshing}