"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-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([]); const [status, setStatus] = useState("loading"); const [nextCursor, setNextCursor] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [loadMoreFailed, setLoadMoreFailed] = useState(false); const [pending, setPending] = useState([]); const [likeBusy, setLikeBusy] = useState>(new Set()); const [likeErrors, setLikeErrors] = useState>(new Set()); const sentinel = useRef(null); const busy = useRef(false); const shown = useRef>(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 | 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]); const toggleLike = useCallback( async (post: CardPost) => { if (!post.id) return; const wanted = !post.isLiked; const mark = (set: ReadonlySet, on: boolean) => { const next = new Set(set); if (on) next.add(post.key); else next.delete(post.key); return next; }; setLikeBusy((s) => mark(s, true)); setLikeErrors((s) => mark(s, false)); try { const out = await sendLike(post.id, wanted); setPosts((cur) => cur.map((p) => (p.key === post.key ? applyLikeOutcome(p, wanted, out) : p))); } catch (e) { fail(e instanceof LikeError && e.signedOut ? new FeedLoadError(true) : e); setLikeErrors((s) => mark(s, true)); } finally { setLikeBusy((s) => mark(s, false)); } }, [fail], ); function showNew() { setPosts((cur) => mergePosts(cur, pending)); setPending([]); window.scrollTo({ top: 0, behavior: "smooth" }); } return ( (status === "error" ? void loadFirst() : void loadMore())} onShowNew={showNew} onToggleLike={(p) => void toggleLike(p)} likeBusy={likeBusy} likeErrors={likeErrors} sentinelRef={sentinel} /> ); }