an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

profile tabs

rimar1337 0883da1a 9d9b2b83

Changed files
+450 -171
src
routes
utils
+13 -94
src/routes/notifications.tsx
··· 1 1 import { AtUri } from "@atproto/api"; 2 - import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 2 import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 4 3 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 4 import { useAtom } from "jotai"; 6 5 import * as React from "react"; 7 - import { useEffect, useLayoutEffect } from "react"; 8 6 9 7 import defaultpfp from "~/../public/favicon.png"; 10 8 import { Header } from "~/components/Header"; 9 + import { ReusableTabRoute, useReusableTabScrollRestore } from "~/components/ReusableTabRoute"; 11 10 import { 12 11 MdiCardsHeartOutline, 13 12 MdiCommentOutline, ··· 18 17 import { 19 18 constellationURLAtom, 20 19 imgCDNAtom, 21 - isAtTopAtom, 22 - notificationsScrollAtom, 23 20 } from "~/utils/atoms"; 24 21 import { 25 22 useInfiniteQueryAuthorFeed, ··· 55 52 }); 56 53 57 54 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 55 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> 56 + <ReusableTabRoute 57 + route={`Notifications`} 58 + tabs={{ 59 + Mentions: <MentionsTab />, 60 + Follows: <FollowsTab />, 61 + "Post Interactions": <PostInteractionsTab />, 62 + }} 63 + /> 131 64 ); 132 65 } 133 66 ··· 169 102 ); 170 103 }, [infiniteMentionsData]); 171 104 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]); 105 + 106 + useReusableTabScrollRestore("Notifications"); 178 107 179 108 if (isLoading) return <LoadingState text="Loading mentions..." />; 180 109 if (isError) return <ErrorState error={error} />; ··· 238 167 ); 239 168 }, [infiniteFollowsData]); 240 169 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]); 170 + useReusableTabScrollRestore("Notifications"); 247 171 248 172 if (isLoading) return <LoadingState text="Loading mentions..." />; 249 173 if (isError) return <ErrorState error={error} />; ··· 298 222 [postsData] 299 223 ); 300 224 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]); 225 + useReusableTabScrollRestore("Notifications"); 307 226 308 227 return ( 309 228 <>
+432 -72
src/routes/profile.$did/index.tsx
··· 1 1 import { RichText } from "@atproto/api"; 2 + import * as ATPAPI from "@atproto/api"; 2 3 import { useQueryClient } from "@tanstack/react-query"; 3 4 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 5 import { useAtom } from "jotai"; 5 6 import React, { type ReactNode, useEffect, useState } from "react"; 6 7 8 + import defaultpfp from "~/../public/favicon.png"; 7 9 import { Header } from "~/components/Header"; 8 10 import { 11 + ReusableTabRoute, 12 + useReusableTabScrollRestore, 13 + } from "~/components/ReusableTabRoute"; 14 + import { 9 15 renderTextWithFacets, 10 16 UniversalPostRendererATURILoader, 11 17 } from "~/components/UniversalPostRenderer"; ··· 18 24 } from "~/utils/followState"; 19 25 import { 20 26 useInfiniteQueryAuthorFeed, 27 + useQueryConstellation, 21 28 useQueryIdentity, 22 29 useQueryProfile, 23 30 } from "~/utils/useQuery"; ··· 29 36 function ProfileComponent() { 30 37 // booo bad this is not always the did it might be a handle, use identity.did instead 31 38 const { did } = Route.useParams(); 39 + const { agent } = useAuth(); 32 40 const navigate = useNavigate(); 33 41 const queryClient = useQueryClient(); 34 42 const { ··· 47 55 const { data: profileRecord } = useQueryProfile(profileUri); 48 56 const profile = profileRecord?.value; 49 57 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 58 const [imgcdn] = useAtom(imgCDNAtom); 76 59 77 60 function getAvatarUrl(p: typeof profile) { ··· 90 73 const handle = resolvedHandle ? `@${resolvedHandle}` : resolvedDid || did; 91 74 const description = profile?.description || ""; 92 75 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 - } 76 + const isReady = !!resolvedDid && !isIdentityLoading && !!profileRecord; 112 77 113 78 return ( 114 - <> 79 + <div className=""> 115 80 <Header 116 81 title={`Profile`} 117 82 backButtonCallback={() => { ··· 121 86 window.location.assign("/"); 122 87 } 123 88 }} 89 + bottomBorderDisabled={true} 124 90 /> 125 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"> 126 92 <Link ··· 191 157 </div> 192 158 </div> 193 159 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 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... 198 177 </div> 199 - <div> 200 - {posts.map((post) => ( 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 ( 201 311 <UniversalPostRendererATURILoader 202 - key={post.uri} 203 - atUri={post.uri} 312 + key={repostRecord.subject.uri} 313 + atUri={repostRecord.subject.uri} 204 314 feedviewpost={true} 315 + repostedby={repost.uri} 205 316 /> 206 - ))} 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" /> 207 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 + } 208 451 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 - )} 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 227 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 + )} 228 588 </> 229 589 ); 230 590 }
+5 -5
src/utils/useQuery.ts
··· 534 534 }[]; 535 535 }; 536 536 537 - export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 537 + export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 538 538 return queryOptions({ 539 - queryKey: ['authorFeed', did], 539 + queryKey: ['authorFeed', did, collection], 540 540 queryFn: async ({ pageParam }: QueryFunctionContext) => { 541 541 const limit = 25; 542 542 543 543 const cursor = pageParam as string | undefined; 544 544 const cursorParam = cursor ? `&cursor=${cursor}` : ''; 545 545 546 - const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 546 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 547 547 548 548 const res = await fetch(url); 549 549 if (!res.ok) throw new Error("Failed to fetch author's posts"); ··· 553 553 }); 554 554 } 555 555 556 - export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 557 - const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 556 + export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 557 + const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 558 558 559 559 return useInfiniteQuery({ 560 560 queryKey,