diff --git a/app/(app)/u/[handle]/posts-grid.tsx b/app/(app)/u/[handle]/posts-grid.tsx index 95150836..582dd7d5 100644 --- a/app/(app)/u/[handle]/posts-grid.tsx +++ b/app/(app)/u/[handle]/posts-grid.tsx @@ -30,7 +30,7 @@ export function PostsGrid({ useEffect(() => { let cancelled = false; profile - .feed(handle, 30) + .feed(handle, { limit: 30 }) .then((rows) => { if (!cancelled) setFeed(rows ?? []); }) diff --git a/app/(app)/u/[handle]/posts/scroll-feed.tsx b/app/(app)/u/[handle]/posts/scroll-feed.tsx index fad8d7fb..3abeffbe 100644 --- a/app/(app)/u/[handle]/posts/scroll-feed.tsx +++ b/app/(app)/u/[handle]/posts/scroll-feed.tsx @@ -5,19 +5,20 @@ * * Split out from page.tsx so the page itself can be a server component * exporting `generateMetadata` for the OG card. The layout owns the - * ProfileHeader + tabs + outer container; this component only renders - * the inner scroll column (the Instagram-style feed of full posts). + * ProfileHeader + tabs + outer container (and 404s for unknown handles); + * this component only renders the inner scroll column. * - * Hash-anchor behaviour: when arriving with `#post-`, we scroll to - * that post once the feed has loaded. Used by the SharePostSheet flow - * — share short URL resolves to /u//posts#post-, recipient - * lands here, and we scroll to the post that was actually shared. + * Anchor-first + keyset infinite scroll via usePostScrollFeed: arriving with + * `#post-` (from a grid thumbnail or a shared link) fetches that post + * directly so it lands at the top instantly — even for very old posts — then + * pages older posts beneath it as you scroll. */ -import { use, useEffect, useRef, useState } from "react"; -import { notFound } from "next/navigation"; -import { profile, SocialError, type Post } from "@/lib/social"; +import { use } from "react"; +import { Loader2 } from "lucide-react"; +import { profile, type Post } from "@/lib/social"; import { PostScrollCard } from "@/components/social/post/post-scroll-card"; +import { usePostScrollFeed } from "@/components/social/post/use-post-scroll-feed"; export function ScrollFeed({ params, @@ -25,54 +26,21 @@ export function ScrollFeed({ params: Promise<{ handle: string }>; }) { const { handle } = use(params); - const [feed, setFeed] = useState([]); - const [loading, setLoading] = useState(true); - const [missing, setMissing] = useState(false); - const scrolledToHashRef = useRef(false); - - useEffect(() => { - let cancelled = false; - profile - .feed(handle, 60) - .then((rows) => { - if (cancelled) return; - setFeed(rows ?? []); - }) - .catch((err) => { - if (err instanceof SocialError && err.status === 404) { - setMissing(true); - } - }) - .finally(() => !cancelled && setLoading(false)); - return () => { - cancelled = true; - }; - }, [handle]); - - useEffect(() => { - if (loading || feed.length === 0 || scrolledToHashRef.current) return; - if (typeof window === "undefined") return; - const hash = window.location.hash; - if (!hash) return; - scrolledToHashRef.current = true; - requestAnimationFrame(() => { - const el = document.getElementById(hash.slice(1)); - if (el) el.scrollIntoView({ block: "start", behavior: "auto" }); - }); - }, [loading, feed.length]); - - if (missing) notFound(); + const { feed, setFeed, loading, hasMore, sentinelRef } = usePostScrollFeed( + (before, limit) => + profile.feed(handle, { limit, before: before ?? undefined }), + ); // When the owner views their own profile here and hides a post, drop it // from the feed immediately (it moves to their Hidden tab). No-op for // other viewers, who never see the Hide control. function onHiddenChange(id: string, hidden: boolean) { - if (hidden) setFeed((rows) => rows.filter((p) => p.id !== id)); + if (hidden) setFeed((rows: Post[]) => rows.filter((p) => p.id !== id)); } return (
- {loading ? ( + {loading && feed.length === 0 ? (

Loading…

) : feed.length === 0 ? (

No posts yet.

@@ -81,6 +49,12 @@ export function ScrollFeed({ )) )} + + {hasMore && feed.length > 0 ? ( +
+ +
+ ) : null}
); } diff --git a/app/(app)/u/me/posts/page.tsx b/app/(app)/u/me/posts/page.tsx index 2208c067..72a80fbe 100644 --- a/app/(app)/u/me/posts/page.tsx +++ b/app/(app)/u/me/posts/page.tsx @@ -4,53 +4,34 @@ * /u/me/posts — the owner's Instagram-style scroll feed. * * Reached by tapping a thumbnail from the /u/me Posts grid. The clicked - * post's id arrives as a `#post-` hash, and `scroll-mt-*` on each - * card spaces it below the sticky header so the anchor lands cleanly. + * post's id arrives as a `#post-` hash; usePostScrollFeed fetches that + * post directly so it lands at the top instantly (even for old posts), then + * keyset-pages older posts beneath it as you scroll. * * Owner controls (Delete + Update background) are mounted per-card. */ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { PostScrollCard } from "@/components/social/post/post-scroll-card"; +import { usePostScrollFeed } from "@/components/social/post/use-post-scroll-feed"; import { ConfirmModal } from "@/components/ui/confirm-modal"; import { posts, SocialError, type Post } from "@/lib/social"; export default function MyPostsFeedPage() { const { user } = useAuth(); - const [feed, setFeed] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + // Defer the feed (which calls the authed /posts/mine) until we have a user. + if (!user) return null; + return ; +} + +function OwnerScrollFeed() { + const { feed, setFeed, loading, hasMore, error, setError, sentinelRef } = + usePostScrollFeed((before, limit) => + posts.mine({ limit, before: before ?? undefined }), + ); const [pendingDelete, setPendingDelete] = useState(null); - const scrolledToHashRef = useRef(false); - - useEffect(() => { - if (!user) return; - posts - .mine() - .then((rows) => setFeed(rows ?? [])) - .catch((err) => - setError( - err instanceof SocialError ? err.message : "Could not load posts.", - ), - ) - .finally(() => setLoading(false)); - }, [user]); - - // Hash anchor: when the feed mounts, jump to the post identified in the - // URL hash. Defer one tick so the cards have laid out and `scroll-mt-*` - // can position the anchor below the sticky header. - useEffect(() => { - if (loading || feed.length === 0 || scrolledToHashRef.current) return; - if (typeof window === "undefined") return; - const hash = window.location.hash; - if (!hash) return; - scrolledToHashRef.current = true; - requestAnimationFrame(() => { - const el = document.getElementById(hash.slice(1)); - if (el) el.scrollIntoView({ block: "start", behavior: "auto" }); - }); - }, [loading, feed.length]); async function performDelete() { const id = pendingDelete; @@ -79,13 +60,11 @@ export default function MyPostsFeedPage() { if (hidden) setFeed((rows) => rows.filter((p) => p.id !== id)); } - if (!user) return null; - return (
{error ? (

{error}

- ) : loading ? ( + ) : loading && feed.length === 0 ? (

Loading…

) : feed.length === 0 ? (

No posts yet.

@@ -101,6 +80,12 @@ export default function MyPostsFeedPage() { )) )} + {hasMore && feed.length > 0 ? ( +
+ +
+ ) : null} + setPendingDelete(null)} diff --git a/components/social/post/use-post-scroll-feed.ts b/components/social/post/use-post-scroll-feed.ts new file mode 100644 index 00000000..57cce4bd --- /dev/null +++ b/components/social/post/use-post-scroll-feed.ts @@ -0,0 +1,122 @@ +"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 }; +} diff --git a/lib/social.ts b/lib/social.ts index c5e2078f..567d33c6 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -206,6 +206,22 @@ export type Feedback = { // ─── Posts ─────────────────────────────────────────────────────────────── +/** Keyset cursor for the scroll feed — the last seen post's sort key. */ +export type FeedCursor = { created_at: string; id: string }; + +/** Paging options for a scroll feed: a keyset `before` cursor (preferred) or + * a plain `limit` for the first/grid page. */ +export type FeedPageParams = { limit?: number; before?: FeedCursor }; + +function feedPageQuery({ limit = 20, before }: FeedPageParams): string { + const qs = new URLSearchParams({ limit: String(limit) }); + if (before) { + qs.set("before", before.created_at); + qs.set("before_id", before.id); + } + return `?${qs}`; +} + export const posts = { create(input: { caption: string; @@ -271,8 +287,10 @@ export const posts = { body: JSON.stringify({ image_urls }), }); }, - mine() { - return call("/posts/mine"); + /** The caller's own posts (scroll feed). With `before` it keyset-pages + * posts older than that cursor; without it, the newest page. */ + mine(opts: FeedPageParams = {}) { + return call(`/posts/mine${feedPageQuery(opts)}`); }, /** The caller's hidden posts (owner-only) — drives the /u/me Hidden tab. */ hidden() { @@ -549,12 +567,13 @@ export const profile = { get(handle: string) { return call(`/u/${encodeURIComponent(handle)}`); }, - feed(handle: string, limit = 20, offset = 0) { - const qs = new URLSearchParams({ - limit: String(limit), - offset: String(offset), - }); - return call(`/u/${encodeURIComponent(handle)}/feed?${qs}`); + /** A user's public scroll feed. With `before` it keyset-pages posts older + * than that cursor (infinite scroll); otherwise the newest page. The grid + * still passes plain `limit`. */ + feed(handle: string, opts: FeedPageParams = {}) { + return call( + `/u/${encodeURIComponent(handle)}/feed${feedPageQuery(opts)}`, + ); }, /** Posts that tag this user (the Mentions tab), newest first. */ mentionsFeed(handle: string, limit = 30, offset = 0) {