a tool for shared writing and social publishing

paginate posts

+216 -72
+10 -1
app/p/[didOrHandle]/ProfilePageLayout.tsx
··· 12 12 import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider"; 13 13 import { colorToString } from "components/ThemeManager/useColorAttribute"; 14 14 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 15 + import type { Cursor } from "./getProfilePosts"; 15 16 16 17 export const ProfilePageLayout = (props: { 17 18 publications: { record: Json; uri: string }[]; 18 19 posts: Post[]; 20 + nextCursor: Cursor | null; 19 21 profile: { 20 22 did: string; 21 23 handle: string | null; ··· 37 39 profile={props.profile} 38 40 publications={props.publications} 39 41 posts={props.posts} 42 + nextCursor={props.nextCursor} 40 43 /> 41 44 ), 42 45 controls: null, ··· 52 55 const ProfilePageContent = (props: { 53 56 publications: { record: Json; uri: string }[]; 54 57 posts: Post[]; 58 + nextCursor: Cursor | null; 55 59 profile: { 56 60 did: string; 57 61 handle: string | null; ··· 102 106 ))} 103 107 </div> 104 108 <ProfileTabs tab={tab} setTab={setTab} /> 105 - <TabContent tab={tab} posts={props.posts} /> 109 + <TabContent 110 + tab={tab} 111 + did={props.profile.did} 112 + posts={props.posts} 113 + nextCursor={props.nextCursor} 114 + /> 106 115 </div> 107 116 ); 108 117 };
+95 -10
app/p/[didOrHandle]/ProfileTabs/Tabs.tsx
··· 2 2 import { profileTabsType } from "../ProfilePageLayout"; 3 3 import { PostListing } from "components/PostListing"; 4 4 import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import type { Cursor } from "../getProfilePosts"; 6 + import { getProfilePosts } from "../getProfilePosts"; 7 + import useSWRInfinite from "swr/infinite"; 8 + import { useEffect, useRef } from "react"; 5 9 6 10 export const ProfileTabs = (props: { 7 11 tab: profileTabsType; ··· 39 43 ); 40 44 }; 41 45 42 - export const TabContent = (props: { tab: profileTabsType; posts: Post[] }) => { 46 + export const TabContent = (props: { 47 + tab: profileTabsType; 48 + did: string; 49 + posts: Post[]; 50 + nextCursor: Cursor | null; 51 + }) => { 43 52 switch (props.tab) { 44 53 case "posts": 45 54 return ( 46 - <div className="flex flex-col gap-2 text-left"> 47 - {props.posts.length === 0 ? ( 48 - <div className="text-tertiary text-center py-4">No posts yet</div> 49 - ) : ( 50 - props.posts.map((post) => ( 51 - <PostListing key={post.documents.uri} {...post} /> 52 - )) 53 - )} 54 - </div> 55 + <ProfilePostsContent 56 + did={props.did} 57 + posts={props.posts} 58 + nextCursor={props.nextCursor} 59 + /> 55 60 ); 56 61 case "comments": 57 62 return <div>comments here!</div>; ··· 59 64 return <div>subscriptions here!</div>; 60 65 } 61 66 }; 67 + 68 + const ProfilePostsContent = (props: { 69 + did: string; 70 + posts: Post[]; 71 + nextCursor: Cursor | null; 72 + }) => { 73 + const getKey = ( 74 + pageIndex: number, 75 + previousPageData: { 76 + posts: Post[]; 77 + nextCursor: Cursor | null; 78 + } | null, 79 + ) => { 80 + // Reached the end 81 + if (previousPageData && !previousPageData.nextCursor) return null; 82 + 83 + // First page, we don't have previousPageData 84 + if (pageIndex === 0) return ["profile-posts", props.did, null] as const; 85 + 86 + // Add the cursor to the key 87 + return ["profile-posts", props.did, previousPageData?.nextCursor] as const; 88 + }; 89 + 90 + const { data, size, setSize, isValidating } = useSWRInfinite( 91 + getKey, 92 + ([_, did, cursor]) => getProfilePosts(did, cursor), 93 + { 94 + fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }], 95 + revalidateFirstPage: false, 96 + }, 97 + ); 98 + 99 + const loadMoreRef = useRef<HTMLDivElement>(null); 100 + 101 + // Set up intersection observer to load more when trigger element is visible 102 + useEffect(() => { 103 + const observer = new IntersectionObserver( 104 + (entries) => { 105 + if (entries[0].isIntersecting && !isValidating) { 106 + const hasMore = data && data[data.length - 1]?.nextCursor; 107 + if (hasMore) { 108 + setSize(size + 1); 109 + } 110 + } 111 + }, 112 + { threshold: 0.1 }, 113 + ); 114 + 115 + if (loadMoreRef.current) { 116 + observer.observe(loadMoreRef.current); 117 + } 118 + 119 + return () => observer.disconnect(); 120 + }, [data, size, setSize, isValidating]); 121 + 122 + const allPosts = data ? data.flatMap((page) => page.posts) : []; 123 + 124 + if (allPosts.length === 0 && !isValidating) { 125 + return <div className="text-tertiary text-center py-4">No posts yet</div>; 126 + } 127 + 128 + return ( 129 + <div className="flex flex-col gap-2 text-left relative"> 130 + {allPosts.map((post) => ( 131 + <PostListing key={post.documents.uri} {...post} /> 132 + ))} 133 + {/* Trigger element for loading more posts */} 134 + <div 135 + ref={loadMoreRef} 136 + className="absolute bottom-96 left-0 w-full h-px pointer-events-none" 137 + aria-hidden="true" 138 + /> 139 + {isValidating && ( 140 + <div className="text-center text-tertiary py-4"> 141 + Loading more posts... 142 + </div> 143 + )} 144 + </div> 145 + ); 146 + };
+95
app/p/[didOrHandle]/getProfilePosts.ts
··· 1 + "use server"; 2 + 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 + import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 6 + 7 + export type Cursor = { 8 + indexed_at: string; 9 + uri: string; 10 + }; 11 + 12 + export async function getProfilePosts( 13 + did: string, 14 + cursor?: Cursor | null, 15 + ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 16 + const limit = 20; 17 + 18 + let query = supabaseServerClient 19 + .from("documents") 20 + .select( 21 + `*, 22 + comments_on_documents(count), 23 + document_mentions_in_bsky(count), 24 + documents_in_publications(publications(*))`, 25 + ) 26 + .like("uri", `at://${did}/%`) 27 + .order("indexed_at", { ascending: false }) 28 + .order("uri", { ascending: false }) 29 + .limit(limit); 30 + 31 + if (cursor) { 32 + query = query.or( 33 + `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 34 + ); 35 + } 36 + 37 + let [{ data: docs }, { data: pubs }, { data: profile }] = await Promise.all([ 38 + query, 39 + supabaseServerClient 40 + .from("publications") 41 + .select("*") 42 + .eq("identity_did", did), 43 + supabaseServerClient 44 + .from("bsky_profiles") 45 + .select("handle") 46 + .eq("did", did) 47 + .single(), 48 + ]); 49 + 50 + // Build a map of publications for quick lookup 51 + let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 52 + for (let pub of pubs || []) { 53 + pubMap.set(pub.uri, pub); 54 + } 55 + 56 + // Transform data to Post[] format 57 + let handle = profile?.handle ? `@${profile.handle}` : null; 58 + let posts: Post[] = []; 59 + 60 + for (let doc of docs || []) { 61 + let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 62 + let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 63 + 64 + let post: Post = { 65 + author: handle, 66 + documents: { 67 + data: doc.data, 68 + uri: doc.uri, 69 + indexed_at: doc.indexed_at, 70 + comments_on_documents: doc.comments_on_documents, 71 + document_mentions_in_bsky: doc.document_mentions_in_bsky, 72 + }, 73 + }; 74 + 75 + if (pub) { 76 + post.publication = { 77 + href: getPublicationURL(pub), 78 + pubRecord: pub.record, 79 + uri: pub.uri, 80 + }; 81 + } 82 + 83 + posts.push(post); 84 + } 85 + 86 + const nextCursor = 87 + posts.length === limit 88 + ? { 89 + indexed_at: posts[posts.length - 1].documents.indexed_at, 90 + uri: posts[posts.length - 1].documents.uri, 91 + } 92 + : null; 93 + 94 + return { posts, nextCursor }; 95 + }
+16 -61
app/p/[didOrHandle]/page.tsx
··· 2 2 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 3 import { ProfilePageLayout } from "./ProfilePageLayout"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 - import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 6 - import type { Post } from "app/(home-pages)/reader/getReaderFeed"; 5 + import { getProfilePosts } from "./getProfilePosts"; 7 6 8 7 export default async function ProfilePage(props: { 9 8 params: Promise<{ didOrHandle: string }>; ··· 30 29 did = resolved; 31 30 } 32 31 33 - // Fetch profile, publications, and documents in parallel 34 - let [{ data: profile }, { data: pubs }, { data: docs }] = await Promise.all([ 35 - supabaseServerClient 36 - .from("bsky_profiles") 37 - .select(`*`) 38 - .eq("did", did) 39 - .single(), 40 - supabaseServerClient 41 - .from("publications") 42 - .select("*") 43 - .eq("identity_did", did), 44 - supabaseServerClient 45 - .from("documents") 46 - .select( 47 - `*, 48 - comments_on_documents(count), 49 - document_mentions_in_bsky(count), 50 - documents_in_publications(publications(*))`, 51 - ) 52 - .like("uri", `at://${did}/%`) 53 - .order("indexed_at", { ascending: false }), 54 - ]); 55 - 56 - // Build a map of publications for quick lookup 57 - let pubMap = new Map<string, NonNullable<typeof pubs>[number]>(); 58 - for (let pub of pubs || []) { 59 - pubMap.set(pub.uri, pub); 60 - } 61 - 62 - // Transform data to Post[] format 63 - let handle = profile?.handle ? `@${profile.handle}` : null; 64 - let posts: Post[] = []; 65 - 66 - for (let doc of docs || []) { 67 - // Find the publication for this document (if any) 68 - let pubFromDoc = doc.documents_in_publications?.[0]?.publications; 69 - let pub = pubFromDoc ? pubMap.get(pubFromDoc.uri) || pubFromDoc : null; 70 - 71 - let post: Post = { 72 - author: handle, 73 - documents: { 74 - data: doc.data, 75 - uri: doc.uri, 76 - indexed_at: doc.indexed_at, 77 - comments_on_documents: doc.comments_on_documents, 78 - document_mentions_in_bsky: doc.document_mentions_in_bsky, 79 - }, 80 - }; 81 - 82 - if (pub) { 83 - post.publication = { 84 - href: getPublicationURL(pub), 85 - pubRecord: pub.record, 86 - uri: pub.uri, 87 - }; 88 - } 89 - 90 - posts.push(post); 91 - } 32 + // Fetch profile, publications, and initial posts in parallel 33 + let [{ data: profile }, { data: pubs }, { posts, nextCursor }] = 34 + await Promise.all([ 35 + supabaseServerClient 36 + .from("bsky_profiles") 37 + .select(`*`) 38 + .eq("did", did) 39 + .single(), 40 + supabaseServerClient 41 + .from("publications") 42 + .select("*") 43 + .eq("identity_did", did), 44 + getProfilePosts(did), 45 + ]); 92 46 93 47 return ( 94 48 <ProfilePageLayout 95 49 profile={profile} 96 50 publications={pubs || []} 97 51 posts={posts} 52 + nextCursor={nextCursor} 98 53 /> 99 54 ); 100 55 }