123 lines
4.2 KiB
TypeScript
123 lines
4.2 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);
|
|
|
|
// 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-<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);
|
|
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 };
|
|
}
|