feat: add feed
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NewUsersStrip — the "new on the platform" discovery row shown at the very
|
||||
* end of the home feed. Up to 5 accounts that joined in the last 24h; each
|
||||
* avatar links to the user's profile. Renders nothing when the list is empty
|
||||
* (the feed simply ends).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import type { UserSummary } from "@/lib/social";
|
||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
|
||||
export function NewUsersStrip({ users }: { users: UserSummary[] }) {
|
||||
if (users.length === 0) return null;
|
||||
return (
|
||||
<section className="mt-2 rounded-xl bg-card ring-1 ring-foreground/10 p-5">
|
||||
<div className="mb-4 flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary shrink-0"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
New to Prabhupāda Says
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Welcome them — follow the devotees who just joined.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-4">
|
||||
{users.map((u) => (
|
||||
<li key={u.handle}>
|
||||
<Link
|
||||
href={`/u/${u.handle}`}
|
||||
className="group flex w-20 flex-col items-center gap-1.5 text-center"
|
||||
>
|
||||
<UserAvatar
|
||||
src={u.avatar_url}
|
||||
name={u.name}
|
||||
className="size-14 group-hover:ring-foreground/30 transition"
|
||||
/>
|
||||
<span className="block w-full truncate text-xs font-medium text-foreground">
|
||||
{u.name}
|
||||
</span>
|
||||
<span className="-mt-1 block w-full truncate text-[11px] text-muted-foreground">
|
||||
@{u.handle}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user