tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
profile tabs
rimar1337
4 months ago
0883da1a
9d9b2b83
+450
-171
3 changed files
expand all
collapse all
unified
split
src
routes
notifications.tsx
profile.$did
index.tsx
utils
useQuery.ts
+13
-94
src/routes/notifications.tsx
···
1
import { AtUri } from "@atproto/api";
2
-
import * as TabsPrimitive from "@radix-ui/react-tabs";
3
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
5
import { useAtom } from "jotai";
6
import * as React from "react";
7
-
import { useEffect, useLayoutEffect } from "react";
8
9
import defaultpfp from "~/../public/favicon.png";
10
import { Header } from "~/components/Header";
0
11
import {
12
MdiCardsHeartOutline,
13
MdiCommentOutline,
···
18
import {
19
constellationURLAtom,
20
imgCDNAtom,
21
-
isAtTopAtom,
22
-
notificationsScrollAtom,
23
} from "~/utils/atoms";
24
import {
25
useInfiniteQueryAuthorFeed,
···
55
});
56
57
export default function NotificationsTabs() {
58
-
const [notifState, setNotifState] = useAtom(notificationsScrollAtom);
59
-
const activeTab = notifState.activeTab;
60
-
const [isAtTop] = useAtom(isAtTopAtom);
61
-
62
-
const handleValueChange = (newTab: string) => {
63
-
console.log(newTab);
64
-
setNotifState((prev) => {
65
-
const wow = {
66
-
...prev,
67
-
scrollPositions: {
68
-
...prev.scrollPositions,
69
-
[prev.activeTab]: window.scrollY,
70
-
},
71
-
activeTab: newTab,
72
-
};
73
-
//console.log(wow);
74
-
return wow;
75
-
});
76
-
};
77
-
78
-
useLayoutEffect(() => {
79
-
return () => {
80
-
setNotifState((prev) => {
81
-
const wow = {
82
-
...prev,
83
-
scrollPositions: {
84
-
...prev.scrollPositions,
85
-
[activeTab]: window.scrollY,
86
-
},
87
-
};
88
-
//console.log(wow);
89
-
return wow;
90
-
});
91
-
};
92
-
// eslint-disable-next-line react-hooks/exhaustive-deps
93
-
}, []);
94
-
95
return (
96
-
<TabsPrimitive.Root
97
-
value={activeTab}
98
-
onValueChange={handleValueChange}
99
-
className={`w-full`}
100
-
>
101
-
<TabsPrimitive.List
102
-
className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none sm:dark:bg-gray-950 sm:bg-white border-gray-200 dark:border-gray-700`}
103
-
>
104
-
<TabsPrimitive.Trigger
105
-
value="mentions"
106
-
className="m3tab"
107
-
// styling is in app.css
108
-
>
109
-
Mentions
110
-
</TabsPrimitive.Trigger>
111
-
<TabsPrimitive.Trigger value="follows" className="m3tab">
112
-
Follows
113
-
</TabsPrimitive.Trigger>
114
-
<TabsPrimitive.Trigger value="postInteractions" className="m3tab">
115
-
Post Interactions
116
-
</TabsPrimitive.Trigger>
117
-
</TabsPrimitive.List>
118
-
119
-
<TabsPrimitive.Content value="mentions" className="flex-1">
120
-
{activeTab === "mentions" && <MentionsTab />}
121
-
</TabsPrimitive.Content>
122
-
123
-
<TabsPrimitive.Content value="follows" className="flex-1">
124
-
{activeTab === "follows" && <FollowsTab />}
125
-
</TabsPrimitive.Content>
126
-
127
-
<TabsPrimitive.Content value="postInteractions" className="flex-1">
128
-
{activeTab === "postInteractions" && <PostInteractionsTab />}
129
-
</TabsPrimitive.Content>
130
-
</TabsPrimitive.Root>
131
);
132
}
133
···
169
);
170
}, [infiniteMentionsData]);
171
172
-
const [notifState] = useAtom(notificationsScrollAtom);
173
-
const activeTab = notifState.activeTab;
174
-
useEffect(() => {
175
-
const savedY = notifState.scrollPositions[activeTab] ?? 0;
176
-
window.scrollTo(0, savedY);
177
-
}, [activeTab, notifState.scrollPositions]);
178
179
if (isLoading) return <LoadingState text="Loading mentions..." />;
180
if (isError) return <ErrorState error={error} />;
···
238
);
239
}, [infiniteFollowsData]);
240
241
-
const [notifState] = useAtom(notificationsScrollAtom);
242
-
const activeTab = notifState.activeTab;
243
-
useEffect(() => {
244
-
const savedY = notifState.scrollPositions[activeTab] ?? 0;
245
-
window.scrollTo(0, savedY);
246
-
}, [activeTab, notifState.scrollPositions]);
247
248
if (isLoading) return <LoadingState text="Loading mentions..." />;
249
if (isError) return <ErrorState error={error} />;
···
298
[postsData]
299
);
300
301
-
const [notifState] = useAtom(notificationsScrollAtom);
302
-
const activeTab = notifState.activeTab;
303
-
useEffect(() => {
304
-
const savedY = notifState.scrollPositions[activeTab] ?? 0;
305
-
window.scrollTo(0, savedY);
306
-
}, [activeTab, notifState.scrollPositions]);
307
308
return (
309
<>
···
1
import { AtUri } from "@atproto/api";
0
2
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import * as React from "react";
0
6
7
import defaultpfp from "~/../public/favicon.png";
8
import { Header } from "~/components/Header";
9
+
import { ReusableTabRoute, useReusableTabScrollRestore } from "~/components/ReusableTabRoute";
10
import {
11
MdiCardsHeartOutline,
12
MdiCommentOutline,
···
17
import {
18
constellationURLAtom,
19
imgCDNAtom,
0
0
20
} from "~/utils/atoms";
21
import {
22
useInfiniteQueryAuthorFeed,
···
52
});
53
54
export default function NotificationsTabs() {
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
55
return (
56
+
<ReusableTabRoute
57
+
route={`Notifications`}
58
+
tabs={{
59
+
Mentions: <MentionsTab />,
60
+
Follows: <FollowsTab />,
61
+
"Post Interactions": <PostInteractionsTab />,
62
+
}}
63
+
/>
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
64
);
65
}
66
···
102
);
103
}, [infiniteMentionsData]);
104
105
+
106
+
useReusableTabScrollRestore("Notifications");
0
0
0
0
107
108
if (isLoading) return <LoadingState text="Loading mentions..." />;
109
if (isError) return <ErrorState error={error} />;
···
167
);
168
}, [infiniteFollowsData]);
169
170
+
useReusableTabScrollRestore("Notifications");
0
0
0
0
0
171
172
if (isLoading) return <LoadingState text="Loading mentions..." />;
173
if (isError) return <ErrorState error={error} />;
···
222
[postsData]
223
);
224
225
+
useReusableTabScrollRestore("Notifications");
0
0
0
0
0
226
227
return (
228
<>
+432
-72
src/routes/profile.$did/index.tsx
···
1
import { RichText } from "@atproto/api";
0
2
import { useQueryClient } from "@tanstack/react-query";
3
import { createFileRoute, useNavigate } from "@tanstack/react-router";
4
import { useAtom } from "jotai";
5
import React, { type ReactNode, useEffect, useState } from "react";
6
0
7
import { Header } from "~/components/Header";
8
import {
0
0
0
0
9
renderTextWithFacets,
10
UniversalPostRendererATURILoader,
11
} from "~/components/UniversalPostRenderer";
···
18
} from "~/utils/followState";
19
import {
20
useInfiniteQueryAuthorFeed,
0
21
useQueryIdentity,
22
useQueryProfile,
23
} from "~/utils/useQuery";
···
29
function ProfileComponent() {
30
// booo bad this is not always the did it might be a handle, use identity.did instead
31
const { did } = Route.useParams();
0
32
const navigate = useNavigate();
33
const queryClient = useQueryClient();
34
const {
···
47
const { data: profileRecord } = useQueryProfile(profileUri);
48
const profile = profileRecord?.value;
49
50
-
const {
51
-
data: postsData,
52
-
fetchNextPage,
53
-
hasNextPage,
54
-
isFetchingNextPage,
55
-
isLoading: arePostsLoading,
56
-
} = useInfiniteQueryAuthorFeed(resolvedDid, pdsUrl);
57
-
58
-
React.useEffect(() => {
59
-
if (postsData) {
60
-
postsData.pages.forEach((page) => {
61
-
page.records.forEach((record) => {
62
-
if (!queryClient.getQueryData(["post", record.uri])) {
63
-
queryClient.setQueryData(["post", record.uri], record);
64
-
}
65
-
});
66
-
});
67
-
}
68
-
}, [postsData, queryClient]);
69
-
70
-
const posts = React.useMemo(
71
-
() => postsData?.pages.flatMap((page) => page.records) ?? [],
72
-
[postsData]
73
-
);
74
-
75
const [imgcdn] = useAtom(imgCDNAtom);
76
77
function getAvatarUrl(p: typeof profile) {
···
90
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
91
const description = profile?.description || "";
92
93
-
if (isIdentityLoading) {
94
-
return (
95
-
<div className="p-4 text-center text-gray-500">Resolving profile...</div>
96
-
);
97
-
}
98
-
99
-
if (identityError) {
100
-
return (
101
-
<div className="p-4 text-center text-red-500">
102
-
Error: {identityError.message}
103
-
</div>
104
-
);
105
-
}
106
-
107
-
if (!resolvedDid) {
108
-
return (
109
-
<div className="p-4 text-center text-gray-500">Profile not found.</div>
110
-
);
111
-
}
112
113
return (
114
-
<>
115
<Header
116
title={`Profile`}
117
backButtonCallback={() => {
···
121
window.location.assign("/");
122
}
123
}}
0
124
/>
125
{/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
126
<Link
···
191
</div>
192
</div>
193
194
-
{/* Posts Section */}
195
-
<div className="max-w-2xl mx-auto">
196
-
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
197
-
Posts
0
0
0
0
0
0
0
0
0
0
0
0
0
198
</div>
199
-
<div>
200
-
{posts.map((post) => (
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
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
201
<UniversalPostRendererATURILoader
202
-
key={post.uri}
203
-
atUri={post.uri}
204
feedviewpost={true}
0
205
/>
206
-
))}
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
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
207
</div>
0
0
0
0
0
0
0
0
0
0
0
0
208
209
-
{/* Loading and "Load More" states */}
210
-
{arePostsLoading && posts.length === 0 && (
211
-
<div className="p-4 text-center text-gray-500">Loading posts...</div>
212
-
)}
213
-
{isFetchingNextPage && (
214
-
<div className="p-4 text-center text-gray-500">Loading more...</div>
215
-
)}
216
-
{hasNextPage && !isFetchingNextPage && (
217
-
<button
218
-
onClick={() => fetchNextPage()}
219
-
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
220
-
>
221
-
Load More Posts
222
-
</button>
223
-
)}
224
-
{posts.length === 0 && !arePostsLoading && (
225
-
<div className="p-4 text-center text-gray-500">No posts found.</div>
226
-
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
227
</div>
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
0
0
0
0
0
0
0
0
228
</>
229
);
230
}
···
1
import { RichText } from "@atproto/api";
2
+
import * as ATPAPI from "@atproto/api";
3
import { useQueryClient } from "@tanstack/react-query";
4
import { createFileRoute, useNavigate } from "@tanstack/react-router";
5
import { useAtom } from "jotai";
6
import React, { type ReactNode, useEffect, useState } from "react";
7
8
+
import defaultpfp from "~/../public/favicon.png";
9
import { Header } from "~/components/Header";
10
import {
11
+
ReusableTabRoute,
12
+
useReusableTabScrollRestore,
13
+
} from "~/components/ReusableTabRoute";
14
+
import {
15
renderTextWithFacets,
16
UniversalPostRendererATURILoader,
17
} from "~/components/UniversalPostRenderer";
···
24
} from "~/utils/followState";
25
import {
26
useInfiniteQueryAuthorFeed,
27
+
useQueryConstellation,
28
useQueryIdentity,
29
useQueryProfile,
30
} from "~/utils/useQuery";
···
36
function ProfileComponent() {
37
// booo bad this is not always the did it might be a handle, use identity.did instead
38
const { did } = Route.useParams();
39
+
const { agent } = useAuth();
40
const navigate = useNavigate();
41
const queryClient = useQueryClient();
42
const {
···
55
const { data: profileRecord } = useQueryProfile(profileUri);
56
const profile = profileRecord?.value;
57
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
58
const [imgcdn] = useAtom(imgCDNAtom);
59
60
function getAvatarUrl(p: typeof profile) {
···
73
const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did;
74
const description = profile?.description || "";
75
76
+
const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
77
78
return (
79
+
<div className="">
80
<Header
81
title={`Profile`}
82
backButtonCallback={() => {
···
86
window.location.assign("/");
87
}
88
}}
89
+
bottomBorderDisabled={true}
90
/>
91
{/* <div className="flex gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-700">
92
<Link
···
157
</div>
158
</div>
159
160
+
{/* this should not be rendered until its ready (the top profile layout is stable) */}
161
+
{isReady ? (
162
+
<ReusableTabRoute
163
+
route={`Profile` + did}
164
+
tabs={{
165
+
Posts: <PostsTab did={did} />,
166
+
Reposts: <RepostsTab did={did} />,
167
+
Feeds: <FeedsTab did={did} />,
168
+
Lists: <ListsTab did={did} />,
169
+
...(identity?.did === agent?.did
170
+
? { Likes: <SelfLikesTab did={did} /> }
171
+
: {}),
172
+
}}
173
+
/>
174
+
) : isIdentityLoading ? (
175
+
<div className="p-4 text-center text-gray-500">
176
+
Resolving profile...
177
</div>
178
+
) : identityError ? (
179
+
<div className="p-4 text-center text-red-500">
180
+
Error: {identityError.message}
181
+
</div>
182
+
) : !resolvedDid ? (
183
+
<div className="p-4 text-center text-gray-500">Profile not found.</div>
184
+
) : (
185
+
<div className="p-4 text-center text-gray-500">
186
+
Loading profile content...
187
+
</div>
188
+
)}
189
+
</div>
190
+
);
191
+
}
192
+
193
+
function PostsTab({ did }: { did: string }) {
194
+
useReusableTabScrollRestore(`Profile` + did);
195
+
const queryClient = useQueryClient();
196
+
const {
197
+
data: identity,
198
+
isLoading: isIdentityLoading,
199
+
error: identityError,
200
+
} = useQueryIdentity(did);
201
+
202
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
203
+
204
+
const {
205
+
data: postsData,
206
+
fetchNextPage,
207
+
hasNextPage,
208
+
isFetchingNextPage,
209
+
isLoading: arePostsLoading,
210
+
} = useInfiniteQueryAuthorFeed(resolvedDid, identity?.pds);
211
+
212
+
React.useEffect(() => {
213
+
if (postsData) {
214
+
postsData.pages.forEach((page) => {
215
+
page.records.forEach((record) => {
216
+
if (!queryClient.getQueryData(["post", record.uri])) {
217
+
queryClient.setQueryData(["post", record.uri], record);
218
+
}
219
+
});
220
+
});
221
+
}
222
+
}, [postsData, queryClient]);
223
+
224
+
const posts = React.useMemo(
225
+
() => postsData?.pages.flatMap((page) => page.records) ?? [],
226
+
[postsData]
227
+
);
228
+
229
+
return (
230
+
<>
231
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
232
+
Posts
233
+
</div>
234
+
<div>
235
+
{posts.map((post) => (
236
+
<UniversalPostRendererATURILoader
237
+
key={post.uri}
238
+
atUri={post.uri}
239
+
feedviewpost={true}
240
+
/>
241
+
))}
242
+
</div>
243
+
244
+
{/* Loading and "Load More" states */}
245
+
{arePostsLoading && posts.length === 0 && (
246
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
247
+
)}
248
+
{isFetchingNextPage && (
249
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
250
+
)}
251
+
{hasNextPage && !isFetchingNextPage && (
252
+
<button
253
+
onClick={() => fetchNextPage()}
254
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
255
+
>
256
+
Load More Posts
257
+
</button>
258
+
)}
259
+
{posts.length === 0 && !arePostsLoading && (
260
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
261
+
)}
262
+
</>
263
+
);
264
+
}
265
+
266
+
function RepostsTab({ did }: { did: string }) {
267
+
useReusableTabScrollRestore(`Profile` + did);
268
+
const {
269
+
data: identity,
270
+
isLoading: isIdentityLoading,
271
+
error: identityError,
272
+
} = useQueryIdentity(did);
273
+
274
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
275
+
276
+
const {
277
+
data: repostsData,
278
+
fetchNextPage,
279
+
hasNextPage,
280
+
isFetchingNextPage,
281
+
isLoading: arePostsLoading,
282
+
} = useInfiniteQueryAuthorFeed(
283
+
resolvedDid,
284
+
identity?.pds,
285
+
"app.bsky.feed.repost"
286
+
);
287
+
288
+
const reposts = React.useMemo(
289
+
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
290
+
[repostsData]
291
+
);
292
+
293
+
return (
294
+
<>
295
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
296
+
Reposts
297
+
</div>
298
+
<div>
299
+
{reposts.map((repost) => {
300
+
if (
301
+
!repost ||
302
+
!repost?.value ||
303
+
!repost?.value?.subject ||
304
+
// @ts-expect-error blehhhhh
305
+
!repost?.value?.subject?.uri
306
+
)
307
+
return;
308
+
const repostRecord =
309
+
repost.value as unknown as ATPAPI.AppBskyFeedRepost.Record;
310
+
return (
311
<UniversalPostRendererATURILoader
312
+
key={repostRecord.subject.uri}
313
+
atUri={repostRecord.subject.uri}
314
feedviewpost={true}
315
+
repostedby={repost.uri}
316
/>
317
+
);
318
+
})}
319
+
</div>
320
+
321
+
{/* Loading and "Load More" states */}
322
+
{arePostsLoading && reposts.length === 0 && (
323
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
324
+
)}
325
+
{isFetchingNextPage && (
326
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
327
+
)}
328
+
{hasNextPage && !isFetchingNextPage && (
329
+
<button
330
+
onClick={() => fetchNextPage()}
331
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
332
+
>
333
+
Load More Posts
334
+
</button>
335
+
)}
336
+
{reposts.length === 0 && !arePostsLoading && (
337
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
338
+
)}
339
+
</>
340
+
);
341
+
}
342
+
343
+
function FeedsTab({ did }: { did: string }) {
344
+
useReusableTabScrollRestore(`Profile` + did);
345
+
const {
346
+
data: identity,
347
+
isLoading: isIdentityLoading,
348
+
error: identityError,
349
+
} = useQueryIdentity(did);
350
+
351
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
352
+
353
+
const {
354
+
data: feedsData,
355
+
fetchNextPage,
356
+
hasNextPage,
357
+
isFetchingNextPage,
358
+
isLoading: arePostsLoading,
359
+
} = useInfiniteQueryAuthorFeed(
360
+
resolvedDid,
361
+
identity?.pds,
362
+
"app.bsky.feed.generator"
363
+
);
364
+
365
+
const feeds = React.useMemo(
366
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
367
+
[feedsData]
368
+
);
369
+
370
+
return (
371
+
<>
372
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
373
+
Feeds
374
+
</div>
375
+
<div>
376
+
{feeds.map((feed) => {
377
+
if (!feed || !feed?.value) return;
378
+
const feedGenRecord =
379
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
380
+
return <FeedItemRender feed={feed as any} key={feed.uri} />;
381
+
})}
382
+
</div>
383
+
384
+
{/* Loading and "Load More" states */}
385
+
{arePostsLoading && feeds.length === 0 && (
386
+
<div className="p-4 text-center text-gray-500">Loading feeds...</div>
387
+
)}
388
+
{isFetchingNextPage && (
389
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
390
+
)}
391
+
{hasNextPage && !isFetchingNextPage && (
392
+
<button
393
+
onClick={() => fetchNextPage()}
394
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
395
+
>
396
+
Load More Feeds
397
+
</button>
398
+
)}
399
+
{feeds.length === 0 && !arePostsLoading && (
400
+
<div className="p-4 text-center text-gray-500">No feeds found.</div>
401
+
)}
402
+
</>
403
+
);
404
+
}
405
+
406
+
function FeedItemRender({
407
+
feed,
408
+
listmode
409
+
}: {
410
+
feed: { uri: string; cid: string; value: ATPAPI.AppBskyFeedGenerator.Record };
411
+
listmode?: boolean;
412
+
}) {
413
+
const name = listmode ? feed.value?.name as string : feed.value?.displayName as string;
414
+
const aturi = new ATPAPI.AtUri(feed.uri);
415
+
const {data: identity} = useQueryIdentity(aturi.host);
416
+
const resolvedDid = identity?.did;
417
+
const [imgcdn] = useAtom(imgCDNAtom);
418
+
419
+
function getAvatarThumbnailUrl(f: typeof feed) {
420
+
const link = f?.value.avatar?.ref?.["$link"];
421
+
if (!link || !resolvedDid) return null;
422
+
return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`;
423
+
}
424
+
425
+
// @ts-expect-error overloads sucks
426
+
const {data: likes} = useQueryConstellation(!listmode ? {
427
+
target: feed.uri,
428
+
method: "/links/count",
429
+
collection: "app.bsky.feed.like",
430
+
path: ".subject.uri"
431
+
} : undefined)
432
+
433
+
return (
434
+
<div className="px-4 py-4 border-b flex flex-col gap-1">
435
+
<div className="flex flex-row gap-3">
436
+
<div className="min-w-10 min-h-10">
437
+
<img src={getAvatarThumbnailUrl(feed) || defaultpfp} className="h-10 w-10 rounded border" />
438
</div>
439
+
<div className="flex flex-col">
440
+
<span className="">{name}</span>
441
+
<span className=" text-sm px-1.5 py-0.5 text-gray-500 bg-gray-200 dark:text-gray-400 dark:bg-gray-800 rounded-lg flex flex-row items-center justify-center">{feed.value.did || aturi.rkey}</span>
442
+
</div>
443
+
<div className="flex-1" />
444
+
{/* <div className="button bg-red-500 rounded-full min-w-[60px]" /> */}
445
+
</div>
446
+
<span className=" text-sm">{feed.value?.description}</span>
447
+
{!listmode && (<span className=" text-sm dark:text-gray-400 text-gray-500">Liked by {(likes as unknown as any)?.total as number || 0} users</span>)}
448
+
</div>
449
+
);
450
+
}
451
452
+
453
+
function ListsTab({ did }: { did: string }) {
454
+
useReusableTabScrollRestore(`Profile` + did);
455
+
const {
456
+
data: identity,
457
+
isLoading: isIdentityLoading,
458
+
error: identityError,
459
+
} = useQueryIdentity(did);
460
+
461
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
462
+
463
+
const {
464
+
data: feedsData,
465
+
fetchNextPage,
466
+
hasNextPage,
467
+
isFetchingNextPage,
468
+
isLoading: arePostsLoading,
469
+
} = useInfiniteQueryAuthorFeed(
470
+
resolvedDid,
471
+
identity?.pds,
472
+
"app.bsky.graph.list"
473
+
);
474
+
475
+
const feeds = React.useMemo(
476
+
() => feedsData?.pages.flatMap((page) => page.records) ?? [],
477
+
[feedsData]
478
+
);
479
+
480
+
return (
481
+
<>
482
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
483
+
Feeds
484
</div>
485
+
<div>
486
+
{feeds.map((feed) => {
487
+
if (!feed || !feed?.value) return;
488
+
const feedGenRecord =
489
+
feed.value as unknown as ATPAPI.AppBskyFeedGenerator.Record;
490
+
return <FeedItemRender listmode={true} feed={feed as any} key={feed.uri} />;
491
+
})}
492
+
</div>
493
+
494
+
{/* Loading and "Load More" states */}
495
+
{arePostsLoading && feeds.length === 0 && (
496
+
<div className="p-4 text-center text-gray-500">Loading lists...</div>
497
+
)}
498
+
{isFetchingNextPage && (
499
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
500
+
)}
501
+
{hasNextPage && !isFetchingNextPage && (
502
+
<button
503
+
onClick={() => fetchNextPage()}
504
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
505
+
>
506
+
Load More Lists
507
+
</button>
508
+
)}
509
+
{feeds.length === 0 && !arePostsLoading && (
510
+
<div className="p-4 text-center text-gray-500">No lists found.</div>
511
+
)}
512
+
</>
513
+
);
514
+
}
515
+
516
+
function SelfLikesTab({ did }: { did: string }) {
517
+
useReusableTabScrollRestore(`Profile` + did);
518
+
const {
519
+
data: identity,
520
+
isLoading: isIdentityLoading,
521
+
error: identityError,
522
+
} = useQueryIdentity(did);
523
+
524
+
const resolvedDid = did.startsWith("did:") ? did : identity?.did;
525
+
526
+
const {
527
+
data: repostsData,
528
+
fetchNextPage,
529
+
hasNextPage,
530
+
isFetchingNextPage,
531
+
isLoading: arePostsLoading,
532
+
} = useInfiniteQueryAuthorFeed(
533
+
resolvedDid,
534
+
identity?.pds,
535
+
"app.bsky.feed.like"
536
+
);
537
+
538
+
const reposts = React.useMemo(
539
+
() => repostsData?.pages.flatMap((page) => page.records) ?? [],
540
+
[repostsData]
541
+
);
542
+
543
+
return (
544
+
<>
545
+
<div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4">
546
+
Likes
547
+
</div>
548
+
<div>
549
+
{reposts.map((repost) => {
550
+
if (
551
+
!repost ||
552
+
!repost?.value ||
553
+
!repost?.value?.subject ||
554
+
// @ts-expect-error blehhhhh
555
+
!repost?.value?.subject?.uri
556
+
)
557
+
return;
558
+
const repostRecord =
559
+
repost.value as unknown as ATPAPI.AppBskyFeedLike.Record;
560
+
return (
561
+
<UniversalPostRendererATURILoader
562
+
key={repostRecord.subject.uri}
563
+
atUri={repostRecord.subject.uri}
564
+
feedviewpost={true}
565
+
/>
566
+
);
567
+
})}
568
+
</div>
569
+
570
+
{/* Loading and "Load More" states */}
571
+
{arePostsLoading && reposts.length === 0 && (
572
+
<div className="p-4 text-center text-gray-500">Loading posts...</div>
573
+
)}
574
+
{isFetchingNextPage && (
575
+
<div className="p-4 text-center text-gray-500">Loading more...</div>
576
+
)}
577
+
{hasNextPage && !isFetchingNextPage && (
578
+
<button
579
+
onClick={() => fetchNextPage()}
580
+
className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold"
581
+
>
582
+
Load More Posts
583
+
</button>
584
+
)}
585
+
{reposts.length === 0 && !arePostsLoading && (
586
+
<div className="p-4 text-center text-gray-500">No posts found.</div>
587
+
)}
588
</>
589
);
590
}
+5
-5
src/utils/useQuery.ts
···
534
}[];
535
};
536
537
-
export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
538
return queryOptions({
539
-
queryKey: ['authorFeed', did],
540
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
const limit = 25;
542
543
const cursor = pageParam as string | undefined;
544
const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
546
-
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
547
548
const res = await fetch(url);
549
if (!res.ok) throw new Error("Failed to fetch author's posts");
···
553
});
554
}
555
556
-
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
557
-
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
558
559
return useInfiniteQuery({
560
queryKey,
···
534
}[];
535
};
536
537
+
export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
538
return queryOptions({
539
+
queryKey: ['authorFeed', did, collection],
540
queryFn: async ({ pageParam }: QueryFunctionContext) => {
541
const limit = 25;
542
543
const cursor = pageParam as string | undefined;
544
const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
546
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
547
548
const res = await fetch(url);
549
if (!res.ok) throw new Error("Failed to fetch author's posts");
···
553
});
554
}
555
556
+
export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
557
+
const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
558
559
return useInfiniteQuery({
560
queryKey,