"use client"; /** * usePostScrollFeed — anchor-first, keyset-paginated loader for the * Instagram-style profile scroll feeds (/u/me/posts and /u/[handle]/posts). * * Why: the grid thumbnails link to `…/posts#post-`. Loading the whole * timeline and then scrolling to the hash fails for old posts (they aren't * loaded yet). Instead we: * • read `#post-` and fetch THAT post directly (posts.get) so it renders * at the top instantly, then page the posts older than it; * • with no hash, start from the newest page; * • load older pages as a bottom sentinel nears the viewport (dedupe by id, * stop when a page is short) — the same idiom as the home feed. * * `fetchPage(before, limit)` is supplied by the caller so the same hook serves * the owner feed (posts.mine) and the public feed (profile.feed). `setFeed` is * returned so the owner page can optimistically prune on delete/hide. */ import { useCallback, useEffect, useRef, useState } from "react"; import { posts as postsApi, type FeedCursor, type Post } from "@/lib/social"; const PAGE = 10; type FetchPage = (before: FeedCursor | null, limit: number) => Promise; const cursorOf = (p: Post): FeedCursor => ({ created_at: p.created_at, id: p.id, }); export function usePostScrollFeed(fetchPage: FetchPage) { const [feed, setFeed] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); const sentinelRef = useRef(null); const fetchingRef = useRef(false); const startedRef = useRef(false); const cursorRef = useRef(null); const anchorIdRef = useRef(null); // Append the next older page, keyed off the last loaded post. const loadOlder = useCallback(async () => { if (fetchingRef.current || !hasMore) return; fetchingRef.current = true; try { const rows = (await fetchPage(cursorRef.current, PAGE)) ?? []; setFeed((prev) => { const seen = new Set(prev.map((p) => p.id)); const merged = [...prev, ...rows.filter((p) => !seen.has(p.id))]; if (merged.length > 0) cursorRef.current = cursorOf(merged[merged.length - 1]); return merged; }); if (rows.length < PAGE) setHasMore(false); } catch { setHasMore(false); setError((e) => e ?? "Could not load posts."); } finally { fetchingRef.current = false; setLoading(false); } }, [fetchPage, hasMore]); // First load: anchor-first when arriving with #post-, else newest page. useEffect(() => { if (startedRef.current) return; startedRef.current = true; const hash = typeof window !== "undefined" ? window.location.hash : ""; const anchorId = hash.startsWith("#post-") ? hash.slice("#post-".length) : ""; (async () => { if (anchorId) { try { const anchor = await postsApi.get(anchorId); if (anchor) { anchorIdRef.current = anchor.id; cursorRef.current = cursorOf(anchor); setFeed([anchor]); setLoading(false); await loadOlder(); // posts older than the anchor return; } } catch { // Anchor missing/deleted — fall through to the newest page. } } await loadOlder(); })(); }, [loadOlder]); // Scroll the anchored post into view once it's in the DOM. useEffect(() => { const id = anchorIdRef.current; if (!id || feed.length === 0) return; anchorIdRef.current = null; requestAnimationFrame(() => { const el = document.getElementById(`post-${id}`); if (el) el.scrollIntoView({ block: "start", behavior: "auto" }); }); }, [feed.length]); // Infinite scroll: load older as the sentinel nears the viewport. useEffect(() => { const el = sentinelRef.current; if (!el || !hasMore) return; const io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) void loadOlder(); }, { rootMargin: "600px" }, ); io.observe(el); return () => io.disconnect(); }, [loadOlder, hasMore]); return { feed, setFeed, loading, hasMore, error, setError, sentinelRef }; }