diff --git a/blockchain/chain.go b/blockchain/chain.go index f3f615c..71b8852 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -570,7 +570,11 @@ func (c *Chain) Post(postID string) (*PostRecord, error) { // PostsByAuthor returns the last `limit` posts by the given author, newest // first. Iterates `postbyauthor::...` in reverse order. If limit // ≤ 0, defaults to 50; capped at 200. -func (c *Chain) PostsByAuthor(authorPub string, limit int) ([]*PostRecord, error) { +// +// If beforeTs > 0, skip posts with CreatedAt >= beforeTs — used by the +// timeline/author endpoints to paginate older results. Pass 0 for the +// first page (everything, newest first). +func (c *Chain) PostsByAuthor(authorPub string, beforeTs int64, limit int) ([]*PostRecord, error) { if limit <= 0 { limit = 50 } @@ -606,6 +610,9 @@ func (c *Chain) PostsByAuthor(authorPub string, limit int) ([]*PostRecord, error if rec.Deleted { continue } + if beforeTs > 0 && rec.CreatedAt >= beforeTs { + continue + } out = append(out, rec) } return nil diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go index d58e67b..9d413fe 100644 --- a/blockchain/chain_test.go +++ b/blockchain/chain_test.go @@ -854,7 +854,7 @@ func TestFeedCreatePost(t *testing.T) { } // PostsByAuthor should list it. - posts, err := c.PostsByAuthor(alice.PubKeyHex(), 10) + posts, err := c.PostsByAuthor(alice.PubKeyHex(), 0, 10) if err != nil { t.Fatalf("PostsByAuthor: %v", err) } diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx index e7d2aa0..28eb0c9 100644 --- a/client-app/app/(app)/chats/[id].tsx +++ b/client-app/app/(app)/chats/[id].tsx @@ -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={() => ( >(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(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(); @@ -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 ? ( + + + + ) : 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={ )} ItemSeparatorComponent={PostSeparator} + initialNumToRender={10} + maxToRenderPerBatch={8} + windowSize={7} + removeClippedSubviews refreshControl={ { } catch { /* ignore */ } } -export async function fetchAuthorPosts(pub: string, limit = 30): Promise { - const resp = await getJSON(`/feed/author/${pub}?limit=${limit}`); +export async function fetchAuthorPosts( + pub: string, opts: { limit?: number; before?: number } = {}, +): Promise { + const limit = opts.limit ?? 30; + const qs = opts.before + ? `?limit=${limit}&before=${opts.before}` + : `?limit=${limit}`; + const resp = await getJSON(`/feed/author/${pub}${qs}`); return resp.posts ?? []; } -export async function fetchTimeline(followerPub: string, limit = 30): Promise { - const resp = await getJSON(`/feed/timeline?follower=${followerPub}&limit=${limit}`); +export async function fetchTimeline( + followerPub: string, opts: { limit?: number; before?: number } = {}, +): Promise { + const limit = opts.limit ?? 30; + let qs = `?follower=${followerPub}&limit=${limit}`; + if (opts.before) qs += `&before=${opts.before}`; + const resp = await getJSON(`/feed/timeline${qs}`); return resp.posts ?? []; } diff --git a/node/api_common.go b/node/api_common.go index 37bd2a7..c574815 100644 --- a/node/api_common.go +++ b/node/api_common.go @@ -38,6 +38,20 @@ func queryInt(r *http.Request, key string, def int) int { return n } +// queryInt64 reads a non-negative int64 query param — typically a unix +// timestamp cursor for pagination. Returns def when missing or invalid. +func queryInt64(r *http.Request, key string, def int64) int64 { + s := r.URL.Query().Get(key) + if s == "" { + return def + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n < 0 { + return def + } + return n +} + // queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid. func queryIntMin0(r *http.Request, key string) int { s := r.URL.Query().Get(key) diff --git a/node/api_feed.go b/node/api_feed.go index a68cbbb..39d0494 100644 --- a/node/api_feed.go +++ b/node/api_feed.go @@ -72,7 +72,7 @@ type FeedConfig struct { GetPost func(postID string) (*blockchain.PostRecord, error) LikeCount func(postID string) (uint64, error) HasLiked func(postID, likerPub string) (bool, error) - PostsByAuthor func(authorPub string, limit int) ([]*blockchain.PostRecord, error) + PostsByAuthor func(authorPub string, beforeTs int64, limit int) ([]*blockchain.PostRecord, error) Following func(followerPub string) ([]string, error) } @@ -440,13 +440,14 @@ func feedAuthor(cfg FeedConfig) http.HandlerFunc { jsonErr(w, fmt.Errorf("author pub required"), 400) return } - limit := queryInt(r, "limit", 50) + limit := queryInt(r, "limit", 30) + beforeTs := queryInt64(r, "before", 0) // pagination cursor (unix seconds) // Prefer chain-authoritative list (includes soft-deleted flag) so // clients can't be fooled by a stale relay that has an already- // deleted post. If chain isn't wired, fall back to relay index. if cfg.PostsByAuthor != nil { - records, err := cfg.PostsByAuthor(pub, limit) + records, err := cfg.PostsByAuthor(pub, beforeTs, limit) if err != nil { jsonErr(w, err, 500) return @@ -461,6 +462,8 @@ func feedAuthor(cfg FeedConfig) http.HandlerFunc { jsonOK(w, map[string]any{"author": pub, "count": len(out), "posts": out}) return } + // Fallback: relay index (no chain). Doesn't support `before` yet; + // the chain-authoritative path above is what production serves. ids, err := cfg.Mailbox.PostsByAuthor(pub, limit) if err != nil { jsonErr(w, err, 500) @@ -556,7 +559,8 @@ func feedTimeline(cfg FeedConfig) http.HandlerFunc { jsonErr(w, fmt.Errorf("timeline requires chain queries"), 503) return } - limit := queryInt(r, "limit", 50) + limit := queryInt(r, "limit", 30) + beforeTs := queryInt64(r, "before", 0) // pagination cursor perAuthor := limit if perAuthor > 30 { perAuthor = 30 @@ -569,7 +573,7 @@ func feedTimeline(cfg FeedConfig) http.HandlerFunc { } var merged []*blockchain.PostRecord for _, target := range following { - posts, err := cfg.PostsByAuthor(target, perAuthor) + posts, err := cfg.PostsByAuthor(target, beforeTs, perAuthor) if err != nil { continue }