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 */}
+
+ {isValidating && (
+
+ 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 (
);
}