171 lines
5.4 KiB
TypeScript
171 lines
5.4 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 { PostScrollCard } from "@/components/social/post/post-scroll-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);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadPage();
|
|
}, [loadPage]);
|
|
|
|
// The window virtualiser needs `scrollMargin` = how far down the document the
|
|
// list begins, to map window scroll → which items are visible. `.offsetTop`
|
|
// is relative to the offsetParent (wrong inside the page layout), so measure
|
|
// the document-absolute top (rect.top + scrollY — scroll-invariant). The
|
|
// streak component above loads async and changes height, so re-measure on
|
|
// resize and on any body reflow.
|
|
useEffect(() => {
|
|
const measure = () => {
|
|
const el = parentRef.current;
|
|
if (el) setOffsetTop(el.getBoundingClientRect().top + window.scrollY);
|
|
};
|
|
measure();
|
|
window.addEventListener("resize", measure);
|
|
const ro = new ResizeObserver(measure);
|
|
ro.observe(document.body);
|
|
return () => {
|
|
window.removeEventListener("resize", measure);
|
|
ro.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
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 (
|
|
<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">
|
|
<PostScrollCard 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>
|
|
);
|
|
}
|