tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
paginate posts
awarm.space
3 months ago
1527816d
2d0665ee
+216
-72
4 changed files
expand all
collapse all
unified
split
app
p
[didOrHandle]
ProfilePageLayout.tsx
ProfileTabs
Tabs.tsx
getProfilePosts.ts
page.tsx
+10
-1
app/p/[didOrHandle]/ProfilePageLayout.tsx
···
12
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
13
import { colorToString } from "components/ThemeManager/useColorAttribute";
14
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
0
15
16
export const ProfilePageLayout = (props: {
17
publications: { record: Json; uri: string }[];
18
posts: Post[];
0
19
profile: {
20
did: string;
21
handle: string | null;
···
37
profile={props.profile}
38
publications={props.publications}
39
posts={props.posts}
0
40
/>
41
),
42
controls: null,
···
52
const ProfilePageContent = (props: {
53
publications: { record: Json; uri: string }[];
54
posts: Post[];
0
55
profile: {
56
did: string;
57
handle: string | null;
···
102
))}
103
</div>
104
<ProfileTabs tab={tab} setTab={setTab} />
105
-
<TabContent tab={tab} posts={props.posts} />
0
0
0
0
0
106
</div>
107
);
108
};
···
12
import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
13
import { colorToString } from "components/ThemeManager/useColorAttribute";
14
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
15
+
import type { Cursor } from "./getProfilePosts";
16
17
export const ProfilePageLayout = (props: {
18
publications: { record: Json; uri: string }[];
19
posts: Post[];
20
+
nextCursor: Cursor | null;
21
profile: {
22
did: string;
23
handle: string | null;
···
39
profile={props.profile}
40
publications={props.publications}
41
posts={props.posts}
42
+
nextCursor={props.nextCursor}
43
/>
44
),
45
controls: null,
···
55
const ProfilePageContent = (props: {
56
publications: { record: Json; uri: string }[];
57
posts: Post[];
58
+
nextCursor: Cursor | null;
59
profile: {
60
did: string;
61
handle: string | null;
···
106
))}
107
</div>
108
<ProfileTabs tab={tab} setTab={setTab} />
109
+
<TabContent
110
+
tab={tab}
111
+
did={props.profile.did}
112
+
posts={props.posts}
113
+
nextCursor={props.nextCursor}
114
+
/>
115
</div>
116
);
117
};
+95
-10
app/p/[didOrHandle]/ProfileTabs/Tabs.tsx
···
2
import { profileTabsType } from "../ProfilePageLayout";
3
import { PostListing } from "components/PostListing";
4
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
0
0
0
0
5
6
export const ProfileTabs = (props: {
7
tab: profileTabsType;
···
39
);
40
};
41
42
-
export const TabContent = (props: { tab: profileTabsType; posts: Post[] }) => {
0
0
0
0
0
43
switch (props.tab) {
44
case "posts":
45
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
);
56
case "comments":
57
return <div>comments here!</div>;
···
59
return <div>subscriptions here!</div>;
60
}
61
};
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
2
import { profileTabsType } from "../ProfilePageLayout";
3
import { PostListing } from "components/PostListing";
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";
9
10
export const ProfileTabs = (props: {
11
tab: profileTabsType;
···
43
);
44
};
45
46
+
export const TabContent = (props: {
47
+
tab: profileTabsType;
48
+
did: string;
49
+
posts: Post[];
50
+
nextCursor: Cursor | null;
51
+
}) => {
52
switch (props.tab) {
53
case "posts":
54
return (
55
+
<ProfilePostsContent
56
+
did={props.did}
57
+
posts={props.posts}
58
+
nextCursor={props.nextCursor}
59
+
/>
0
0
0
0
60
);
61
case "comments":
62
return <div>comments here!</div>;
···
64
return <div>subscriptions here!</div>;
65
}
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
3
import { ProfilePageLayout } from "./ProfilePageLayout";
4
import { supabaseServerClient } from "supabase/serverClient";
5
-
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
6
-
import type { Post } from "app/(home-pages)/reader/getReaderFeed";
7
8
export default async function ProfilePage(props: {
9
params: Promise<{ didOrHandle: string }>;
···
30
did = resolved;
31
}
32
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
-
}
92
93
return (
94
<ProfilePageLayout
95
profile={profile}
96
publications={pubs || []}
97
posts={posts}
0
98
/>
99
);
100
}
···
2
import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
3
import { ProfilePageLayout } from "./ProfilePageLayout";
4
import { supabaseServerClient } from "supabase/serverClient";
5
+
import { getProfilePosts } from "./getProfilePosts";
0
6
7
export default async function ProfilePage(props: {
8
params: Promise<{ didOrHandle: string }>;
···
29
did = resolved;
30
}
31
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
+
]);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
46
47
return (
48
<ProfilePageLayout
49
profile={profile}
50
publications={pubs || []}
51
posts={posts}
52
+
nextCursor={nextCursor}
53
/>
54
);
55
}