an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
93
fork

Configure Feed

Select the types of activity you want to include in your feed.

profile tabs

rimar1337 0883da1a 9d9b2b83

+450 -171
+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,