Files
spsays-training/components/feed/home-feed.tsx
T
2026-06-14 00:18:31 +05:30

155 lines
4.8 KiB
TypeScript

"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 { PostCard } from "@/components/social/post/post-card";
import { NewUsersStrip } from "./new-users-strip";
const PAGE = 10;
export function HomeFeed() {
const [items, setItems] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [newUsers, setNewUsers] = useState<UserSummary[]>([]);
const fetchingRef = useRef(false);
const offsetRef = useRef(0);
const parentRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(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: () => 460,
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 (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!loading && items.length === 0) {
return (
<div className="space-y-4">
<p className="rounded-xl bg-card ring-1 ring-foreground/10 px-5 py-10 text-center text-sm text-muted-foreground">
Your feed is quiet for now. Follow a few devotees, or share a verse to
get things started.
</p>
<NewUsersStrip users={newUsers} />
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<div>
<div
ref={parentRef}
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((vi) => {
const post = items[vi.index];
return (
<div
key={post.id}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${vi.start - offsetTop}px)` }}
>
<div className="pb-4">
<PostCard post={post} enableFollow />
</div>
</div>
);
})}
</div>
{hasMore ? (
<div ref={sentinelRef} className="flex justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : (
<NewUsersStrip users={newUsers} />
)}
</div>
);
}