"use client"; /** * HomeFeed — the global, personalised feed of community posts. * * Architecture: * • Window-virtualised list (dynamic row heights via measureElement) so a * long feed stays cheap to render. * • Infinite scroll: 10 posts per page, fetched as a bottom sentinel nears * the viewport. The server orders not-yet-liked posts from people you * follow first, then everyone else — seamless, no visible boundary. * • When the feed is exhausted, a "new on the platform" strip (≤5 accounts * that joined in the last 24h) caps it off — hidden if there are none. * * The card's Follow / Follow back affordance is enabled here (enableFollow); * the server hydrates each post's viewer↔author relationship. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { Loader2 } from "lucide-react"; import { feed, type Post, type UserSummary } from "@/lib/social"; import { PostScrollCard } from "@/components/social/post/post-scroll-card"; import { NewUsersStrip } from "./new-users-strip"; const PAGE = 10; export function HomeFeed() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(true); const [newUsers, setNewUsers] = useState([]); const fetchingRef = useRef(false); const offsetRef = useRef(0); const parentRef = useRef(null); const sentinelRef = useRef(null); const [offsetTop, setOffsetTop] = useState(0); const loadPage = useCallback(async () => { if (fetchingRef.current) return; fetchingRef.current = true; try { const rows = (await feed.home(PAGE, offsetRef.current)) ?? []; offsetRef.current += rows.length; setItems((prev) => { const seen = new Set(prev.map((p) => p.id)); return [...prev, ...rows.filter((p) => !seen.has(p.id))]; }); if (rows.length < PAGE) { setHasMore(false); // Feed exhausted → load the discovery strip once. feed .newUsers() .then((u) => setNewUsers(u ?? [])) .catch(() => setNewUsers([])); } } catch { setHasMore(false); } finally { fetchingRef.current = false; setLoading(false); } }, []); // Initial page + measure where the list sits in the document (so the window // virtualiser offsets correctly below the streak component). useEffect(() => { void loadPage(); }, [loadPage]); useEffect(() => { if (parentRef.current) setOffsetTop(parentRef.current.offsetTop); }, [loading]); const virtualizer = useWindowVirtualizer({ count: items.length, estimateSize: () => 680, overscan: 4, scrollMargin: offsetTop, }); // Infinite scroll — fetch the next page as the sentinel approaches. useEffect(() => { const node = sentinelRef.current; if (!node || !hasMore) return; const io = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore && !fetchingRef.current) { void loadPage(); } }, { rootMargin: "600px 0px" }, ); io.observe(node); return () => io.disconnect(); }, [hasMore, loadPage, items.length]); if (loading && items.length === 0) { return (
); } if (!loading && items.length === 0) { return (

Your feed is quiet for now. Follow a few devotees, or share a verse to get things started.

); } const virtualItems = virtualizer.getVirtualItems(); return (
{virtualItems.map((vi) => { const post = items[vi.index]; return (
); })}
{hasMore ? (
) : ( )}
); }