diff --git a/app/(app)/u/[handle]/posts-grid.tsx b/app/(app)/u/[handle]/posts-grid.tsx index 582dd7d5..396a41d5 100644 --- a/app/(app)/u/[handle]/posts-grid.tsx +++ b/app/(app)/u/[handle]/posts-grid.tsx @@ -3,18 +3,20 @@ /** * PostsGrid — client-side body for /u/[handle] (Posts tab). * - * Split out from page.tsx so the page itself can be a server component - * that exports `generateMetadata` for the public-profile OG card. The - * /u/[handle]/layout.tsx still owns ProfileHeader + tab strip + the - * outer max-w-3xl container; this grid just renders the post grid - * inside that shell. Tapping a thumbnail jumps to - * /u/[handle]/posts#post- where the Instagram-style scroll feed - * shows the full passages. + * Split out from page.tsx so the page itself can be a server component that + * exports `generateMetadata` for the public-profile OG card. The + * /u/[handle]/layout.tsx still owns ProfileHeader + tab strip + the outer + * container (and 404s unknown handles); this just renders the post grid. + * + * Window-virtualised, infinite-scroll 2-up grid (usePostScrollFeed keyset-pages + * the user's posts). Tapping a thumbnail jumps to /u/[handle]/posts#post- + * where the scroll feed shows the full passages. */ -import { use, useEffect, useState } from "react"; -import { profile, type Post } from "@/lib/social"; -import { PostViews } from "@/components/social/post/post-views"; +import { use } from "react"; +import { profile } from "@/lib/social"; +import { VirtualPostGrid } from "@/components/social/post/virtual-post-grid"; +import { usePostScrollFeed } from "@/components/social/post/use-post-scroll-feed"; import { usePublicProfile } from "./profile-context"; export function PostsGrid({ @@ -24,34 +26,18 @@ export function PostsGrid({ }) { const { handle } = use(params); const data = usePublicProfile(); - const [feed, setFeed] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - profile - .feed(handle, { limit: 30 }) - .then((rows) => { - if (!cancelled) setFeed(rows ?? []); - }) - .catch(() => { - // Layout already 404'd if the handle is unknown; if the feed - // itself fails we just show an empty grid rather than blocking - // the page with an error toast. - }) - .finally(() => !cancelled && setLoading(false)); - return () => { - cancelled = true; - }; - }, [handle]); - - if (loading) { - return

Loading…

; - } + const { feed, loading, hasMore, sentinelRef } = usePostScrollFeed( + (before, limit) => + profile.feed(handle, { limit, before: before ?? undefined }), + 24, + ); return ( - `/u/${data.handle}/posts#post-${p.id}`} emptyMessage="No posts yet — quotes shared by this user will appear here." /> diff --git a/app/(app)/u/me/page.tsx b/app/(app)/u/me/page.tsx index 1c8afdb5..eb24137b 100644 --- a/app/(app)/u/me/page.tsx +++ b/app/(app)/u/me/page.tsx @@ -3,47 +3,39 @@ /** * /u/me — owner Posts grid (the default tab). * - * Grid-only — no Grid/Scroll toggle. Tapping a thumbnail jumps to - * /u/me/posts#post- where the Instagram-style scroll feed renders - * the full passages. + * Window-virtualised, infinite-scroll 2-up grid (usePostScrollFeed keyset-pages + * the owner's posts; VirtualPostGrid renders + windows them). Tapping a + * thumbnail jumps to /u/me/posts#post- where the scroll feed renders the + * full passages. * * Editing the profile lives on its own page (/settings/profile), reached * from the header's "Edit profile" button. */ -import { useEffect, useState } from "react"; -import { LayoutGrid } from "lucide-react"; +import { useState } from "react"; import { useAuth } from "@/components/auth/auth-provider"; -import { PostGridCard } from "@/components/social/post/post-grid-card"; +import { VirtualPostGrid } from "@/components/social/post/virtual-post-grid"; +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 MyProfilePage() { const { user } = useAuth(); + // Defer the authed /posts/mine fetch until we have a user. + if (!user) return null; + return ; +} - const [feed, setFeed] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - // Post id pending confirmation — modal opens when non-null and runs - // the actual delete on confirm. +function OwnerGrid() { + const { feed, setFeed, loading, hasMore, error, setError, sentinelRef } = + usePostScrollFeed( + (before, limit) => posts.mine({ limit, before: before ?? undefined }), + 24, + ); const [pendingDelete, setPendingDelete] = useState(null); - 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]); - - // Card delete buttons stage an id; ConfirmModal runs `performDelete` - // when the user confirms. We throw on failure so the modal stays open - // with an inline error message. + // Card delete buttons stage an id; ConfirmModal runs `performDelete` when the + // user confirms. We throw on failure so the modal stays open with an error. async function performDelete() { const id = pendingDelete; if (!id) return; @@ -64,29 +56,20 @@ export default function MyProfilePage() { setFeed((rows) => rows.map((p) => (p.id === updated.id ? updated : p))); } - if (!user) return null; - return (
- {error ? ( -

{error}

- ) : loading ? ( -

Loading…

- ) : feed.length === 0 ? ( - - ) : ( -
- {feed.map((p) => ( - setPendingDelete(id)} - onUpdated={onPostUpdated} - href={`/u/me/posts#post-${p.id}`} - /> - ))} -
- )} + {error ?

{error}

: null} + + `/u/me/posts#post-${p.id}`} + onDelete={(id) => setPendingDelete(id)} + onUpdated={onPostUpdated} + emptyMessage="You haven't posted anything yet. Select any passage in the knowledgebase and use the toolbar to share it to your feed." + /> ); } - -function EmptyPosts() { - return ( -
- -

- You haven't posted anything yet. Select any passage in the - knowledgebase and use the toolbar to share it to your feed. -

-
- ); -} diff --git a/components/social/post/use-post-scroll-feed.ts b/components/social/post/use-post-scroll-feed.ts index ff1f416b..237ce54b 100644 --- a/components/social/post/use-post-scroll-feed.ts +++ b/components/social/post/use-post-scroll-feed.ts @@ -14,15 +14,14 @@ * 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. + * the scroll feeds (posts.mine / profile.feed) AND the virtualised profile + * grids — anything that keyset-pages a user's posts. `setFeed` is returned so + * the owner views 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 => ({ @@ -30,7 +29,7 @@ const cursorOf = (p: Post): FeedCursor => ({ id: p.id, }); -export function usePostScrollFeed(fetchPage: FetchPage) { +export function usePostScrollFeed(fetchPage: FetchPage, pageSize = 10) { const [feed, setFeed] = useState([]); const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(true); @@ -51,7 +50,7 @@ export function usePostScrollFeed(fetchPage: FetchPage) { if (fetchingRef.current || !hasMore) return; fetchingRef.current = true; try { - const rows = (await fetchPage(cursorRef.current, PAGE)) ?? []; + const rows = (await fetchPage(cursorRef.current, pageSize)) ?? []; const fresh = rows.filter((p) => !idsRef.current.has(p.id)); if (fresh.length > 0) { fresh.forEach((p) => idsRef.current.add(p.id)); @@ -60,7 +59,7 @@ export function usePostScrollFeed(fetchPage: FetchPage) { 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); + if (rows.length < pageSize || fresh.length === 0) setHasMore(false); } catch { setHasMore(false); setError((e) => e ?? "Could not load posts."); @@ -68,7 +67,7 @@ export function usePostScrollFeed(fetchPage: FetchPage) { fetchingRef.current = false; setLoading(false); } - }, [fetchPage, hasMore]); + }, [fetchPage, hasMore, pageSize]); // First load: anchor-first when arriving with #post-, else newest page. useEffect(() => { diff --git a/components/social/post/virtual-post-grid.tsx b/components/social/post/virtual-post-grid.tsx new file mode 100644 index 00000000..85610b15 --- /dev/null +++ b/components/social/post/virtual-post-grid.tsx @@ -0,0 +1,133 @@ +"use client"; + +/** + * VirtualPostGrid — window-virtualised, infinite-scroll 2-up thumbnail grid for + * the profile Posts tabs (/u/me and /u/[handle]). + * + * Mirrors HomeFeed's window virtualization, but the unit of virtualization is a + * GRID ROW (two square cards) rather than a single card. Data (keyset paging + + * the bottom sentinel) comes from usePostScrollFeed; this component only owns + * rendering + windowing, so a profile with hundreds of posts stays cheap. + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { Loader2 } from "lucide-react"; +import type { Post } from "@/lib/social"; +import { PostGridCard } from "./post-grid-card"; + +const COLS = 2; + +export function VirtualPostGrid({ + posts, + loading, + hasMore, + sentinelRef, + emptyMessage, + hrefFor, + onDelete, + onUpdated, +}: { + posts: Post[]; + loading: boolean; + hasMore: boolean; + /** Bottom sentinel observed by usePostScrollFeed to load the next page. */ + sentinelRef: React.RefObject; + emptyMessage?: string; + hrefFor?: (post: Post) => string; + onDelete?: (id: string) => void; + onUpdated?: (updated: Post) => void; +}) { + // Chunk the flat post list into rows of COLS for row-level virtualization. + const rows = useMemo(() => { + const out: Post[][] = []; + for (let i = 0; i < posts.length; i += COLS) out.push(posts.slice(i, i + COLS)); + return out; + }, [posts]); + + // Document-absolute top of the grid, so the window virtualizer can map window + // scroll → visible rows (the profile header/tabs sit above us). Re-measured + // on resize and any body reflow. Same approach as HomeFeed. + const parentRef = useRef(null); + const [offsetTop, setOffsetTop] = useState(0); + useEffect(() => { + const measure = () => { + const el = parentRef.current; + if (el) setOffsetTop(el.getBoundingClientRect().top + window.scrollY); + }; + measure(); + window.addEventListener("resize", measure); + const ro = new ResizeObserver(measure); + ro.observe(document.body); + return () => { + window.removeEventListener("resize", measure); + ro.disconnect(); + }; + }, []); + + const virtualizer = useWindowVirtualizer({ + count: rows.length, + estimateSize: () => 240, // square card + gap; measureElement corrects it + overscan: 6, + scrollMargin: offsetTop, + }); + + if (loading && posts.length === 0) { + return ( +
+ +
+ ); + } + + if (!loading && posts.length === 0) { + return ( +

+ {emptyMessage ?? "Nothing here yet."} +

+ ); + } + + const virtualRows = virtualizer.getVirtualItems(); + + return ( +
+
+ {virtualRows.map((vr) => { + const row = rows[vr.index]; + return ( +
+
+ {row.map((p) => ( + + ))} +
+
+ ); + })} +
+ + {hasMore ? ( +
+ +
+ ) : null} +
+ ); +}