an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
1import { AtUri } from "@atproto/api"; 2import * as TabsPrimitive from "@radix-ui/react-tabs"; 3import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 4import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5import { useAtom } from "jotai"; 6import * as React from "react"; 7import { useEffect, useLayoutEffect } from "react"; 8 9import defaultpfp from "~/../public/favicon.png"; 10import { Header } from "~/components/Header"; 11import { 12 MdiCardsHeartOutline, 13 MdiCommentOutline, 14 MdiRepeat, 15 UniversalPostRendererATURILoader, 16} from "~/components/UniversalPostRenderer"; 17import { useAuth } from "~/providers/UnifiedAuthProvider"; 18import { 19 constellationURLAtom, 20 imgCDNAtom, 21 isAtTopAtom, 22 notificationsScrollAtom, 23} from "~/utils/atoms"; 24import { 25 useInfiniteQueryAuthorFeed, 26 useQueryConstellation, 27 useQueryIdentity, 28 useQueryProfile, 29 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 30} from "~/utils/useQuery"; 31 32import { FollowButton, Mutual } from "./profile.$did"; 33 34export function NotificationsComponent() { 35 return ( 36 <div className=""> 37 <Header 38 title={`Notifications`} 39 backButtonCallback={() => { 40 if (window.history.length > 1) { 41 window.history.back(); 42 } else { 43 window.location.assign("/"); 44 } 45 }} 46 bottomBorderDisabled={true} 47 /> 48 <NotificationsTabs /> 49 </div> 50 ); 51} 52 53export const Route = createFileRoute("/notifications")({ 54 component: NotificationsComponent, 55}); 56 57export 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 134function MentionsTab() { 135 const { agent } = useAuth(); 136 const [constellationurl] = useAtom(constellationURLAtom); 137 const infinitequeryresults = useInfiniteQuery({ 138 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 139 { 140 constellation: constellationurl, 141 method: "/links", 142 target: agent?.did, 143 collection: "app.bsky.feed.post", 144 path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", 145 } 146 ), 147 enabled: !!agent?.did, 148 }); 149 150 const { 151 data: infiniteMentionsData, 152 fetchNextPage, 153 hasNextPage, 154 isFetchingNextPage, 155 isLoading, 156 isError, 157 error, 158 } = infinitequeryresults; 159 160 const mentionsAturis = React.useMemo(() => { 161 // Get all replies from the standard infinite query 162 return ( 163 infiniteMentionsData?.pages.flatMap( 164 (page) => 165 page?.linking_records.map( 166 (r) => `at://${r.did}/${r.collection}/${r.rkey}` 167 ) ?? [] 168 ) ?? [] 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} />; 181 182 if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />; 183 184 return ( 185 <> 186 {mentionsAturis.map((m) => ( 187 <UniversalPostRendererATURILoader key={m} atUri={m} /> 188 ))} 189 190 {hasNextPage && ( 191 <button 192 onClick={() => fetchNextPage()} 193 disabled={isFetchingNextPage} 194 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 disabled:opacity-50" 195 > 196 {isFetchingNextPage ? "Loading..." : "Load More"} 197 </button> 198 )} 199 </> 200 ); 201} 202 203function FollowsTab() { 204 const { agent } = useAuth(); 205 const [constellationurl] = useAtom(constellationURLAtom); 206 const infinitequeryresults = useInfiniteQuery({ 207 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 208 { 209 constellation: constellationurl, 210 method: "/links", 211 target: agent?.did, 212 collection: "app.bsky.graph.follow", 213 path: ".subject", 214 } 215 ), 216 enabled: !!agent?.did, 217 }); 218 219 const { 220 data: infiniteFollowsData, 221 fetchNextPage, 222 hasNextPage, 223 isFetchingNextPage, 224 isLoading, 225 isError, 226 error, 227 } = infinitequeryresults; 228 229 const followsAturis = React.useMemo(() => { 230 // Get all replies from the standard infinite query 231 return ( 232 infiniteFollowsData?.pages.flatMap( 233 (page) => 234 page?.linking_records.map( 235 (r) => `at://${r.did}/${r.collection}/${r.rkey}` 236 ) ?? [] 237 ) ?? [] 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} />; 250 251 if (!followsAturis?.length) return <EmptyState text="No mentions yet." />; 252 253 return ( 254 <> 255 {followsAturis.map((m) => ( 256 <NotificationItem key={m} notification={m} /> 257 ))} 258 259 {hasNextPage && ( 260 <button 261 onClick={() => fetchNextPage()} 262 disabled={isFetchingNextPage} 263 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 disabled:opacity-50" 264 > 265 {isFetchingNextPage ? "Loading..." : "Load More"} 266 </button> 267 )} 268 </> 269 ); 270} 271 272function PostInteractionsTab() { 273 const { agent } = useAuth(); 274 const { data: identity } = useQueryIdentity(agent?.did); 275 const queryClient = useQueryClient(); 276 const { 277 data: postsData, 278 fetchNextPage, 279 hasNextPage, 280 isFetchingNextPage, 281 isLoading: arePostsLoading, 282 } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); 283 284 React.useEffect(() => { 285 if (postsData) { 286 postsData.pages.forEach((page) => { 287 page.records.forEach((record) => { 288 if (!queryClient.getQueryData(["post", record.uri])) { 289 queryClient.setQueryData(["post", record.uri], record); 290 } 291 }); 292 }); 293 } 294 }, [postsData, queryClient]); 295 296 const posts = React.useMemo( 297 () => postsData?.pages.flatMap((page) => page.records) ?? [], 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 <> 310 {posts.map((m) => ( 311 <PostInteractionsItem key={m.uri} uri={m.uri} /> 312 ))} 313 314 {hasNextPage && ( 315 <button 316 onClick={() => fetchNextPage()} 317 disabled={isFetchingNextPage} 318 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 disabled:opacity-50" 319 > 320 {isFetchingNextPage ? "Loading..." : "Load More"} 321 </button> 322 )} 323 </> 324 ); 325} 326 327const ORDER: ("like" | "repost" | "reply" | "quote")[] = [ 328 "like", 329 "repost", 330 "reply", 331 "quote", 332]; 333 334function PostInteractionsItem({ uri }: { uri: string }) { 335 const { data: links } = useQueryConstellation({ 336 method: "/links/all", 337 target: uri, 338 }); 339 340 const likes = 341 links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 342 const replies = 343 links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 344 const reposts = 345 links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 346 const quotes1 = 347 links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 348 const quotes2 = 349 links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 350 ?.records || 0; 351 const quotes = quotes1 + quotes2; 352 353 const all = likes + replies + reposts + quotes; 354 355 return ( 356 <div className="flex flex-col"> 357 <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 358 <UniversalPostRendererATURILoader 359 isQuote 360 key={uri} 361 atUri={uri} 362 nopics={true} 363 concise={true} 364 /> 365 <div className="flex flex-col divide-x"> 366 <InteractionsButton 367 key={likes} 368 type={"like"} 369 uri={uri} 370 count={likes} 371 /> 372 <InteractionsButton 373 key={reposts} 374 type={"repost"} 375 uri={uri} 376 count={reposts} 377 /> 378 <InteractionsButton 379 key={replies} 380 type={"reply"} 381 uri={uri} 382 count={replies} 383 /> 384 <InteractionsButton 385 key={quotes} 386 type={"quote"} 387 uri={uri} 388 count={quotes} 389 /> 390 {!all && ( 391 <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 392 No interactions yet. 393 </div> 394 )} 395 </div> 396 </div> 397 </div> 398 ); 399} 400 401function InteractionsButton({ 402 type, 403 uri, 404 count, 405}: { 406 type: "reply" | "repost" | "like" | "quote"; 407 uri: string; 408 count: number; 409}) { 410 if (!count) return <></>; 411 const aturi = new AtUri(uri); 412 return ( 413 <Link 414 to={ 415 `/profile/$did/post/$rkey` + 416 (type === "like" 417 ? "/liked-by" 418 : type === "repost" 419 ? "/reposted-by" 420 : type === "quote" 421 ? "/quotes" 422 : "") 423 } 424 params={{ 425 did: aturi.host, 426 rkey: aturi.rkey, 427 }} 428 className="flex-1 border-t py-2 px-4 flex flex-row items-center gap-2 transition-colors hover:bg-gray-100 hover:dark:bg-gray-800" 429 > 430 {type === "like" ? ( 431 <MdiCardsHeartOutline height={22} width={22} /> 432 ) : type === "repost" ? ( 433 <MdiRepeat height={22} width={22} /> 434 ) : type === "reply" ? ( 435 <MdiCommentOutline height={22} width={22} /> 436 ) : type === "quote" ? ( 437 <IconMdiMessageReplyTextOutline 438 height={22} 439 width={22} 440 className=" text-gray-400" 441 /> 442 ) : ( 443 <></> 444 )} 445 {type === "like" 446 ? "likes" 447 : type === "reply" 448 ? "replies" 449 : type === "quote" 450 ? "quotes" 451 : type === "repost" 452 ? "reposts" 453 : ""} 454 <div className="flex-1" /> {count} 455 </Link> 456 ); 457} 458 459export function NotificationItem({ notification }: { notification: string }) { 460 const aturi = new AtUri(notification); 461 const navigate = useNavigate(); 462 const { data: identity } = useQueryIdentity(aturi.host); 463 const resolvedDid = identity?.did; 464 const profileUri = resolvedDid 465 ? `at://${resolvedDid}/app.bsky.actor.profile/self` 466 : undefined; 467 const { data: profileRecord } = useQueryProfile(profileUri); 468 const profile = profileRecord?.value; 469 470 const [imgcdn] = useAtom(imgCDNAtom); 471 472 function getAvatarUrl(p: typeof profile) { 473 const link = p?.avatar?.ref?.["$link"]; 474 if (!link || !resolvedDid) return null; 475 return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 476 } 477 478 const avatar = getAvatarUrl(profile); 479 480 return ( 481 <div 482 className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 483 onClick={() => 484 aturi && 485 navigate({ 486 to: "/profile/$did", 487 params: { did: aturi.host }, 488 }) 489 } 490 > 491 {/* <div> 492 {aturi.collection === "app.bsky.graph.follow" ? ( 493 <IconMdiAccountPlus /> 494 ) : aturi.collection === "app.bsky.feed.like" ? ( 495 <MdiCardsHeart /> 496 ) : ( 497 <></> 498 )} 499 </div> */} 500 {profile ? ( 501 <img 502 src={avatar || defaultpfp} 503 alt={identity?.handle} 504 className="w-10 h-10 rounded-full" 505 /> 506 ) : ( 507 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 508 )} 509 <div className="flex flex-col min-w-0"> 510 <div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 511 <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 512 {profile?.displayName || identity?.handle || "Someone"} 513 </span> 514 <span className="text-gray-700 dark:text-gray-400 truncate"> 515 @{identity?.handle} 516 </span> 517 </div> 518 <div className="flex flex-row gap-2"> 519 {identity?.did && <Mutual targetdidorhandle={identity?.did} />} 520 {/* <span className="text-sm text-gray-600 dark:text-gray-400"> 521 followed you 522 </span> */} 523 </div> 524 </div> 525 <div className="flex-1" /> 526 {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 527 </div> 528 ); 529} 530 531export const EmptyState = ({ text }: { text: string }) => ( 532 <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 533 {text} 534 </div> 535); 536 537export const LoadingState = ({ text }: { text: string }) => ( 538 <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 539 {text} 540 </div> 541); 542 543export const ErrorState = ({ error }: { error: unknown }) => ( 544 <div className="py-10 text-center text-red-600 dark:text-red-400"> 545 Error: {(error as Error)?.message || "Something went wrong."} 546 </div> 547);