From 1527816d49bfdff2c1fd1e7e03537bacd9db1487 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 12 Dec 2025 12:09:17 +0400 Subject: [PATCH] paginate posts --- app/p/[didOrHandle]/ProfilePageLayout.tsx | 11 ++- app/p/[didOrHandle]/ProfileTabs/Tabs.tsx | 105 +++++++++++++++++++--- app/p/[didOrHandle]/getProfilePosts.ts | 95 ++++++++++++++++++++ app/p/[didOrHandle]/page.tsx | 77 ++++------------ 4 files changed, 216 insertions(+), 72 deletions(-) create mode 100644 app/p/[didOrHandle]/getProfilePosts.ts diff --git a/app/p/[didOrHandle]/ProfilePageLayout.tsx b/app/p/[didOrHandle]/ProfilePageLayout.tsx index 6a456947..4f940ad2 100644 --- a/app/p/[didOrHandle]/ProfilePageLayout.tsx +++ b/app/p/[didOrHandle]/ProfilePageLayout.tsx @@ -12,10 +12,12 @@ import { PubIcon } from "components/ActionBar/Publications"; import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; import { colorToString } from "components/ThemeManager/useColorAttribute"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; +import type { Cursor } from "./getProfilePosts"; export const ProfilePageLayout = (props: { publications: { record: Json; uri: string }[]; posts: Post[]; + nextCursor: Cursor | null; profile: { did: string; handle: string | null; @@ -37,6 +39,7 @@ export const ProfilePageLayout = (props: { profile={props.profile} publications={props.publications} posts={props.posts} + nextCursor={props.nextCursor} /> ), controls: null, @@ -52,6 +55,7 @@ export type profileTabsType = "posts" | "comments" | "subscriptions"; const ProfilePageContent = (props: { publications: { record: Json; uri: string }[]; posts: Post[]; + nextCursor: Cursor | null; profile: { did: string; handle: string | null; @@ -102,7 +106,12 @@ const ProfilePageContent = (props: { ))} - + ); }; diff --git a/app/p/[didOrHandle]/ProfileTabs/Tabs.tsx b/app/p/[didOrHandle]/ProfileTabs/Tabs.tsx index 80989e60..c8d4b1e4 100644 --- a/app/p/[didOrHandle]/ProfileTabs/Tabs.tsx +++ b/app/p/[didOrHandle]/ProfileTabs/Tabs.tsx @@ -2,6 +2,10 @@ import { Tab } from "components/Tab"; import { profileTabsType } from "../ProfilePageLayout"; import { PostListing } from "components/PostListing"; import type { Post } from "app/(home-pages)/reader/getReaderFeed"; +import type { Cursor } from "../getProfilePosts"; +import { getProfilePosts } from "../getProfilePosts"; +import useSWRInfinite from "swr/infinite"; +import { useEffect, useRef } from "react"; export const ProfileTabs = (props: { tab: profileTabsType; @@ -39,19 +43,20 @@ export const ProfileTabs = (props: { ); }; -export const TabContent = (props: { tab: profileTabsType; posts: Post[] }) => { +export const TabContent = (props: { + tab: profileTabsType; + did: string; + posts: Post[]; + nextCursor: Cursor | null; +}) => { switch (props.tab) { case "posts": return ( -
- {props.posts.length === 0 ? ( -
No posts yet
- ) : ( - props.posts.map((post) => ( - - )) - )} -
+ ); case "comments": return
comments here!
; @@ -59,3 +64,83 @@ export const TabContent = (props: { tab: profileTabsType; posts: Post[] }) => { return
subscriptions here!
; } }; + +const ProfilePostsContent = (props: { + did: string; + posts: Post[]; + nextCursor: Cursor | null; +}) => { + const getKey = ( + pageIndex: number, + previousPageData: { + posts: Post[]; + nextCursor: Cursor | null; + } | null, + ) => { + // Reached the end + if (previousPageData && !previousPageData.nextCursor) return null; + + // First page, we don't have previousPageData + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; + + // Add the cursor to the key + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; + }; + + const { data, size, setSize, isValidating } = useSWRInfinite( + getKey, + ([_, did, cursor]) => getProfilePosts(did, cursor), + { + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], + revalidateFirstPage: false, + }, + ); + + const loadMoreRef = useRef(null); + + // Set up intersection observer to load more when trigger element is visible + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !isValidating) { + const hasMore = data && data[data.length - 1]?.nextCursor; + if (hasMore) { + setSize(size + 1); + } + } + }, + { threshold: 0.1 }, + ); + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current); + } + + return () => observer.disconnect(); + }, [data, size, setSize, isValidating]); + + const allPosts = data ? data.flatMap((page) => page.posts) : []; + + if (allPosts.length === 0 && !isValidating) { + return
No posts yet
; + } + + return ( +
+ {allPosts.map((post) => ( + + ))} + {/* Trigger element for loading more posts */} + + ); +}; diff --git a/app/p/[didOrHandle]/getProfilePosts.ts b/app/p/[didOrHandle]/getProfilePosts.ts new file mode 100644 index 00000000..cc9e762d --- /dev/null +++ b/app/p/[didOrHandle]/getProfilePosts.ts @@ -0,0 +1,95 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; +import type { Post } from "app/(home-pages)/reader/getReaderFeed"; + +export type Cursor = { + indexed_at: string; + uri: string; +}; + +export async function getProfilePosts( + did: string, + cursor?: Cursor | null, +): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { + const limit = 20; + + let query = supabaseServerClient + .from("documents") + .select( + `*, + comments_on_documents(count), + document_mentions_in_bsky(count), + documents_in_publications(publications(*))`, + ) + .like("uri", `at://${did}/%`) + .order("indexed_at", { ascending: false }) + .order("uri", { ascending: false }) + .limit(limit); + + if (cursor) { + query = query.or( + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, + ); + } + + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ + query, + supabaseServerClient + .from("publications") + .select("*") + .eq("identity_did", did), + supabaseServerClient + .from("bsky_profiles") + .select("handle") + .eq("did", did) + .single(), + ]); + + // Build a map of publications for quick lookup + let pubMap = new Map[number]>(); + for (let pub of pubs || []) { + pubMap.set(pub.uri, pub); + } + + // Transform data to Post[] format + let handle = profile?.handle ? `@${profile.handle}` : null; + let posts: Post[] = []; + + for (let doc of docs || []) { + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; + + let post: Post = { + author: handle, + documents: { + data: doc.data, + uri: doc.uri, + indexed_at: doc.indexed_at, + comments_on_documents: doc.comments_on_documents, + document_mentions_in_bsky: doc.document_mentions_in_bsky, + }, + }; + + if (pub) { + post.publication = { + href: getPublicationURL(pub), + pubRecord: pub.record, + uri: pub.uri, + }; + } + + posts.push(post); + } + + const nextCursor = + posts.length === limit + ? { + indexed_at: posts[posts.length - 1].documents.indexed_at, + uri: posts[posts.length - 1].documents.uri, + } + : null; + + return { posts, nextCursor }; +} diff --git a/app/p/[didOrHandle]/page.tsx b/app/p/[didOrHandle]/page.tsx index d776f7b0..db3798a9 100644 --- a/app/p/[didOrHandle]/page.tsx +++ b/app/p/[didOrHandle]/page.tsx @@ -2,8 +2,7 @@ import { idResolver } from "app/(home-pages)/reader/idResolver"; import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { ProfilePageLayout } from "./ProfilePageLayout"; import { supabaseServerClient } from "supabase/serverClient"; -import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import type { Post } from "app/(home-pages)/reader/getReaderFeed"; +import { getProfilePosts } from "./getProfilePosts"; export default async function ProfilePage(props: { params: Promise<{ didOrHandle: string }>; @@ -30,71 +29,27 @@ export default async function ProfilePage(props: { did = resolved; } - // Fetch profile, publications, and documents in parallel - let [{ data: profile }, { data: pubs }, { data: docs }] = await Promise.all([ - supabaseServerClient - .from("bsky_profiles") - .select(`*`) - .eq("did", did) - .single(), - supabaseServerClient - .from("publications") - .select("*") - .eq("identity_did", did), - supabaseServerClient - .from("documents") - .select( - `*, - comments_on_documents(count), - document_mentions_in_bsky(count), - documents_in_publications(publications(*))`, - ) - .like("uri", `at://${did}/%`) - .order("indexed_at", { ascending: false }), - ]); - - // Build a map of publications for quick lookup - let pubMap = new Map[number]>(); - for (let pub of pubs || []) { - pubMap.set(pub.uri, pub); - } - - // Transform data to Post[] format - let handle = profile?.handle ? `@${profile.handle}` : null; - let posts: Post[] = []; - - for (let doc of docs || []) { - // Find the publication for this document (if any) - let pubFromDoc = doc.documents_in_publications?.[0]?.publications; - let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; - - let post: Post = { - author: handle, - documents: { - data: doc.data, - uri: doc.uri, - indexed_at: doc.indexed_at, - comments_on_documents: doc.comments_on_documents, - document_mentions_in_bsky: doc.document_mentions_in_bsky, - }, - }; - - if (pub) { - post.publication = { - href: getPublicationURL(pub), - pubRecord: pub.record, - uri: pub.uri, - }; - } - - posts.push(post); - } + // Fetch profile, publications, and initial posts in parallel + let [{ data: profile }, { data: pubs }, { posts, nextCursor }] = + await Promise.all([ + supabaseServerClient + .from("bsky_profiles") + .select(`*`) + .eq("did", did) + .single(), + supabaseServerClient + .from("publications") + .select("*") + .eq("identity_did", did), + getProfilePosts(did), + ]); return ( ); } -- 2.51.2