Files
spsays-training/components/social/post/use-post-scroll-feed.ts
T
2026-06-21 20:00:14 +05:30

130 lines
4.7 KiB
TypeScript

"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-<id>`. Loading the whole
* timeline and then scrolling to the hash fails for old posts (they aren't
* loaded yet). Instead we:
* • read `#post-<id>` 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<Post[]>;
const cursorOf = (p: Post): FeedCursor => ({
created_at: p.created_at,
id: p.id,
});
export function usePostScrollFeed(fetchPage: FetchPage) {
const [feed, setFeed] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const fetchingRef = useRef(false);
const startedRef = useRef(false);
const cursorRef = useRef<FeedCursor | null>(null);
const anchorIdRef = useRef<string | null>(null);
// Every post id we've already appended — used to dedupe AND to detect a
// stalled cursor (a page that adds nothing new), which we treat as the end
// so a backend ordering hiccup can never become an infinite fetch loop.
const idsRef = useRef<Set<string>>(new Set());
// 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)) ?? [];
const fresh = rows.filter((p) => !idsRef.current.has(p.id));
if (fresh.length > 0) {
fresh.forEach((p) => idsRef.current.add(p.id));
// rows come back newest→oldest; the oldest fresh row is the next cursor.
cursorRef.current = cursorOf(fresh[fresh.length - 1]);
setFeed((prev) => [...prev, ...fresh]);
}
// Stop on a short page OR a page that produced nothing new (stalled).
if (rows.length < PAGE || fresh.length === 0) 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-<id>, 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);
idsRef.current.add(anchor.id);
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 };
}