q12xo172i: Render post cards in the signed-in home feed

This commit is contained in:
2026-10-19 14:01:17 -06:00
parent 20af24b25d
commit 18e0386a99
9 changed files with 581 additions and 1 deletions
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model";
import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000;
export function Feed({ onSignedOut }: { onSignedOut: () => void }) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
const [pending, setPending] = useState<CardPost[]>([]);
const sentinel = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
useEffect(() => {
shown.current = new Set(posts.map((p) => p.key));
}, [posts]);
const fail = useCallback(
(e: unknown) => {
if (e instanceof FeedLoadError && e.signedOut) onSignedOut();
},
[onSignedOut],
);
const loadFirst = useCallback(async () => {
if (busy.current) return;
busy.current = true;
setStatus("loading");
setLoadMoreFailed(false);
try {
const page = await fetchFeedPage(null);
setPosts(mergePosts(toCardPosts(page.items)));
setNextCursor(page.nextCursor);
setPending([]);
setStatus("ready");
} catch (e) {
fail(e);
setStatus("error");
} finally {
busy.current = false;
}
}, [fail]);
const loadMore = useCallback(async () => {
if (busy.current || !nextCursor) return;
busy.current = true;
setLoadingMore(true);
setLoadMoreFailed(false);
try {
const page = await fetchFeedPage(nextCursor);
setPosts((cur) => mergePosts(cur, toCardPosts(page.items)));
setNextCursor(page.nextCursor);
} catch (e) {
fail(e);
setLoadMoreFailed(true);
} finally {
busy.current = false;
setLoadingMore(false);
}
}, [nextCursor, fail]);
useEffect(() => {
void loadFirst();
}, [loadFirst]);
// Infinite scroll. A fresh observer per change re-checks a sentinel that is
// still in view after a page lands.
useEffect(() => {
const el = sentinel.current;
if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: "400px" },
);
io.observe(el);
return () => io.disconnect();
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
// Real-time: poll the first page while the tab is visible.
useEffect(() => {
if (status !== "ready") return;
let timer: ReturnType<typeof setInterval> | null = null;
const poll = async () => {
try {
const page = await fetchFeedPage(null);
const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key));
if (fresh.length) setPending((cur) => mergePosts(cur, fresh));
} catch (e) {
fail(e);
}
};
const start = () => {
if (timer === null) timer = setInterval(() => void poll(), POLL_MS);
};
const stop = () => {
if (timer !== null) clearInterval(timer);
timer = null;
};
const onVisibility = () => {
if (document.visibilityState === "visible") {
void poll();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [status, fail]);
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
window.scrollTo({ top: 0, behavior: "smooth" });
}
return (
<FeedView
posts={posts}
status={status}
loadingMore={loadingMore}
loadMoreFailed={loadMoreFailed}
newCount={pending.length}
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
onShowNew={showNew}
sentinelRef={sentinel}
/>
);
}