"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 { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model"; import { PostError, publishAndConfirm } from "@/lib/post-client"; import { DeleteError, sendDelete } from "@/lib/delete-client"; import type { ReactNode } from "react"; import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover"; import type { ComposerProps } from "./Composer"; import { FeedView, type FeedStatus } from "./FeedView"; import { useCompactLayout } from "./layout-context"; const POLL_MS = 45_000; export function Feed({ onSignedOut, feedUserId, person, featuredExperts, trendingMinnects, searchPanel, }: { onSignedOut: () => void; feedUserId?: string | null; /** The signed-in person, shown beside the composer. */ person?: ComposerProps["person"]; /** Shown as a slide after the second post on phones. */ featuredExperts?: DiscoverExpert[]; /** Phone Discover sheet: trending minnects and the Find experts panel. */ trendingMinnects?: DiscoverMinnect[]; searchPanel?: ReactNode; }) { const [posts, setPosts] = useState([]); const [status, setStatus] = useState("loading"); const [nextCursor, setNextCursor] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [refreshing, setRefreshing] = useState(false); const [refreshFailed, setRefreshFailed] = useState(false); const [loadMoreFailed, setLoadMoreFailed] = useState(false); const [pending, setPending] = useState([]); const [likeBusy, setLikeBusy] = useState>(new Set()); const [likeErrors, setLikeErrors] = useState>(new Set()); const [deleteBusy, setDeleteBusy] = useState>(new Set()); const [deleteErrors, setDeleteErrors] = useState>(new Set()); const [draft, setDraft] = useState(""); const [file, setFile] = useState(null); const [posting, setPosting] = useState(false); const [postFailed, setPostFailed] = useState(false); // Id of a post already created but not yet seen on the first page. A retry // re-checks the feed instead of creating a duplicate. const unconfirmed = useRef<{ id: string | null }>({ id: null }); const sentinel = useRef(null); const list = useRef(null); const busy = useRef(false); const shown = useRef>(new Set()); // The list element changes between the panel and the phone reel; the // infinite-scroll observer re-attaches to the new one. const compact = useCompactLayout(); useEffect(() => { shown.current = new Set(posts.map((p) => p.key)); }, [posts]); const fail = useCallback( (e: unknown) => { if (e instanceof FeedLoadError && e.signedOut) onSignedOut(); }, [onSignedOut], ); // quiet: a refresh keeps the list on screen instead of swapping in the spinner. const loadFirst = useCallback(async (quiet = false) => { if (busy.current) return; busy.current = true; setRefreshFailed(false); if (quiet) setRefreshing(true); else 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); const out = firstLoadFailure(quiet); if (out.fullError) setStatus("error"); setRefreshFailed(out.refreshFailed); } finally { busy.current = false; setRefreshing(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(); }, // The list scrolls inside its panel, so it is the observer root. { root: list.current, rootMargin: "400px" }, ); io.observe(el); return () => io.disconnect(); }, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]); // 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 all = toCardPosts(page.items); setPosts((cur) => upgradeVideos(cur, all)); const fresh = all.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], ); const removePost = useCallback( async (post: CardPost) => { const mark = (set: ReadonlySet, on: boolean) => { const next = new Set(set); if (on) next.add(post.key); else next.delete(post.key); return next; }; setDeleteBusy((s) => mark(s, true)); setDeleteErrors((s) => mark(s, false)); try { // Removes the card only after the feed answered 200. if (!post.id) throw new DeleteError(false); await sendDelete(post.id); setPosts((cur) => cur.filter((p) => p.key !== post.key)); setPending((cur) => cur.filter((p) => p.key !== post.key)); } catch (e) { if (e instanceof DeleteError && e.signedOut) onSignedOut(); setDeleteErrors((s) => mark(s, true)); } finally { setDeleteBusy((s) => mark(s, false)); } }, [onSignedOut], ); const publish = useCallback(async () => { const text = draft.trim(); // A post needs either text or a file. if ((!text && !file) || posting) return; setPosting(true); setPostFailed(false); try { const page = await publishAndConfirm(text, file, unconfirmed.current); setPosts(mergePosts(toCardPosts(page.items))); setNextCursor(page.nextCursor); setPending([]); setDraft(""); setFile(null); } catch (e) { if (e instanceof PostError && e.signedOut) onSignedOut(); setPostFailed(true); } finally { setPosting(false); } }, [draft, file, posting, onSignedOut]); function changeDraft(v: string) { // New text is a new post; forget a stale unconfirmed id. if (v !== draft) unconfirmed.current = { id: null }; setDraft(v); } function changeFile(f: File | null) { // A different attachment is a new post; forget a stale unconfirmed id. unconfirmed.current = { id: null }; setFile(f); } function showNew() { setPosts((cur) => mergePosts(cur, pending)); setPending([]); const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches; list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" }); } function refresh() { if (busy.current) return; const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches; list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" }); void loadFirst(true); } return ( (status === "error" ? void loadFirst() : void loadMore())} onShowNew={showNew} onRefresh={refresh} refreshing={refreshing} refreshFailed={refreshFailed} onToggleLike={(p) => void toggleLike(p)} likeBusy={likeBusy} likeErrors={likeErrors} feedUserId={feedUserId} onDelete={(p) => void removePost(p)} deleteBusy={deleteBusy} deleteErrors={deleteErrors} onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }} featuredExperts={featuredExperts} trendingMinnects={trendingMinnects} searchPanel={searchPanel} hasMore={!!nextCursor} sentinelRef={sentinel} listRef={list} /> ); }