an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
1import { AtUri } from "@atproto/api"; 2import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; 3import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 4import { useAtom } from "jotai"; 5import * as React from "react"; 6 7import defaultpfp from "~/../public/favicon.png"; 8import { Header } from "~/components/Header"; 9import { 10 ReusableTabRoute, 11 useReusableTabScrollRestore, 12} from "~/components/ReusableTabRoute"; 13import { 14 MdiCardsHeartOutline, 15 MdiCommentOutline, 16 MdiRepeat, 17 UniversalPostRendererATURILoader, 18} from "~/components/UniversalPostRenderer"; 19import { useAuth } from "~/providers/UnifiedAuthProvider"; 20import { 21 constellationURLAtom, 22 imgCDNAtom, 23 postInteractionsFiltersAtom, 24} from "~/utils/atoms"; 25import { 26 useInfiniteQueryAuthorFeed, 27 useQueryConstellation, 28 useQueryIdentity, 29 useQueryProfile, 30 yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks, 31} from "~/utils/useQuery"; 32 33import { FollowButton, Mutual } from "./profile.$did"; 34 35export function NotificationsComponent() { 36 return ( 37 <div className=""> 38 <Header 39 title={`Notifications`} 40 backButtonCallback={() => { 41 if (window.history.length > 1) { 42 window.history.back(); 43 } else { 44 window.location.assign("/"); 45 } 46 }} 47 bottomBorderDisabled={true} 48 /> 49 <NotificationsTabs /> 50 </div> 51 ); 52} 53 54export const Route = createFileRoute("/notifications")({ 55 component: NotificationsComponent, 56}); 57 58export default function NotificationsTabs() { 59 return ( 60 <ReusableTabRoute 61 route={`Notifications`} 62 tabs={{ 63 Mentions: <MentionsTab />, 64 Follows: <FollowsTab />, 65 "Post Interactions": <PostInteractionsTab />, 66 }} 67 /> 68 ); 69} 70 71function MentionsTab() { 72 const { agent } = useAuth(); 73 const [constellationurl] = useAtom(constellationURLAtom); 74 const infinitequeryresults = useInfiniteQuery({ 75 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 76 { 77 constellation: constellationurl, 78 method: "/links", 79 target: agent?.did, 80 collection: "app.bsky.feed.post", 81 path: ".facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet#mention].did", 82 } 83 ), 84 enabled: !!agent?.did, 85 }); 86 87 const { 88 data: infiniteMentionsData, 89 fetchNextPage, 90 hasNextPage, 91 isFetchingNextPage, 92 isLoading, 93 isError, 94 error, 95 } = infinitequeryresults; 96 97 const mentionsAturis = React.useMemo(() => { 98 // Get all replies from the standard infinite query 99 return ( 100 infiniteMentionsData?.pages.flatMap( 101 (page) => 102 page?.linking_records.map( 103 (r) => `at://${r.did}/${r.collection}/${r.rkey}` 104 ) ?? [] 105 ) ?? [] 106 ); 107 }, [infiniteMentionsData]); 108 109 useReusableTabScrollRestore("Notifications"); 110 111 if (isLoading) return <LoadingState text="Loading mentions..." />; 112 if (isError) return <ErrorState error={error} />; 113 114 if (!mentionsAturis?.length) return <EmptyState text="No mentions yet." />; 115 116 return ( 117 <> 118 {mentionsAturis.map((m) => ( 119 <UniversalPostRendererATURILoader key={m} atUri={m} /> 120 ))} 121 122 {hasNextPage && ( 123 <button 124 onClick={() => fetchNextPage()} 125 disabled={isFetchingNextPage} 126 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" 127 > 128 {isFetchingNextPage ? "Loading..." : "Load More"} 129 </button> 130 )} 131 </> 132 ); 133} 134 135function FollowsTab() { 136 const { agent } = useAuth(); 137 const [constellationurl] = useAtom(constellationURLAtom); 138 const infinitequeryresults = useInfiniteQuery({ 139 ...yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks( 140 { 141 constellation: constellationurl, 142 method: "/links", 143 target: agent?.did, 144 collection: "app.bsky.graph.follow", 145 path: ".subject", 146 } 147 ), 148 enabled: !!agent?.did, 149 }); 150 151 const { 152 data: infiniteFollowsData, 153 fetchNextPage, 154 hasNextPage, 155 isFetchingNextPage, 156 isLoading, 157 isError, 158 error, 159 } = infinitequeryresults; 160 161 const followsAturis = React.useMemo(() => { 162 // Get all replies from the standard infinite query 163 return ( 164 infiniteFollowsData?.pages.flatMap( 165 (page) => 166 page?.linking_records.map( 167 (r) => `at://${r.did}/${r.collection}/${r.rkey}` 168 ) ?? [] 169 ) ?? [] 170 ); 171 }, [infiniteFollowsData]); 172 173 useReusableTabScrollRestore("Notifications"); 174 175 if (isLoading) return <LoadingState text="Loading follows..." />; 176 if (isError) return <ErrorState error={error} />; 177 178 if (!followsAturis?.length) return <EmptyState text="No follows yet." />; 179 180 return ( 181 <> 182 {followsAturis.map((m) => ( 183 <NotificationItem key={m} notification={m} /> 184 ))} 185 186 {hasNextPage && ( 187 <button 188 onClick={() => fetchNextPage()} 189 disabled={isFetchingNextPage} 190 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" 191 > 192 {isFetchingNextPage ? "Loading..." : "Load More"} 193 </button> 194 )} 195 </> 196 ); 197} 198 199function PostInteractionsTab() { 200 const { agent } = useAuth(); 201 const { data: identity } = useQueryIdentity(agent?.did); 202 const queryClient = useQueryClient(); 203 const { 204 data: postsData, 205 fetchNextPage, 206 hasNextPage, 207 isFetchingNextPage, 208 isLoading: arePostsLoading, 209 } = useInfiniteQueryAuthorFeed(agent?.did, identity?.pds); 210 211 React.useEffect(() => { 212 if (postsData) { 213 postsData.pages.forEach((page) => { 214 page.records.forEach((record) => { 215 if (!queryClient.getQueryData(["post", record.uri])) { 216 queryClient.setQueryData(["post", record.uri], record); 217 } 218 }); 219 }); 220 } 221 }, [postsData, queryClient]); 222 223 const posts = React.useMemo( 224 () => postsData?.pages.flatMap((page) => page.records) ?? [], 225 [postsData] 226 ); 227 228 useReusableTabScrollRestore("Notifications"); 229 230 const [filters] = useAtom(postInteractionsFiltersAtom); 231 const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 232 233 return ( 234 <> 235 <PostInteractionsFilterChipBar /> 236 {!empty && posts.map((m) => ( 237 <PostInteractionsItem key={m.uri} uri={m.uri} /> 238 ))} 239 240 {hasNextPage && ( 241 <button 242 onClick={() => fetchNextPage()} 243 disabled={isFetchingNextPage} 244 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" 245 > 246 {isFetchingNextPage ? "Loading..." : "Load More"} 247 </button> 248 )} 249 </> 250 ); 251} 252 253function PostInteractionsFilterChipBar() { 254 const [filters, setFilters] = useAtom(postInteractionsFiltersAtom); 255 // const empty = (!filters.likes && !filters.quotes && !filters.replies && !filters.reposts); 256 257 // useEffect(() => { 258 // if (empty) { 259 // setFilters((prev) => ({ 260 // ...prev, 261 // likes: true, 262 // })); 263 // } 264 // }, [ 265 // empty, 266 // setFilters, 267 // ]); 268 269 const toggle = (key: keyof typeof filters) => { 270 setFilters((prev) => ({ 271 ...prev, 272 [key]: !prev[key], 273 })); 274 }; 275 276 return ( 277 <div className="flex flex-row flex-wrap gap-2 px-4 pt-4"> 278 <Chip 279 state={filters.likes} 280 text="Likes" 281 onClick={() => toggle("likes")} 282 /> 283 <Chip 284 state={filters.reposts} 285 text="Reposts" 286 onClick={() => toggle("reposts")} 287 /> 288 <Chip 289 state={filters.replies} 290 text="Replies" 291 onClick={() => toggle("replies")} 292 /> 293 <Chip 294 state={filters.quotes} 295 text="Quotes" 296 onClick={() => toggle("quotes")} 297 /> 298 <Chip 299 state={filters.showAll} 300 text="Show All Metrics" 301 onClick={() => toggle("showAll")} 302 /> 303 </div> 304 ); 305} 306 307function Chip({ 308 state, 309 text, 310 onClick, 311}: { 312 state: boolean; 313 text: string; 314 onClick: React.MouseEventHandler<HTMLButtonElement>; 315}) { 316 return ( 317 <button 318 onClick={onClick} 319 className={`relative inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all 320 ${ 321 state 322 ? "bg-primary/20 text-primary bg-gray-200 dark:bg-gray-800 border border-transparent" 323 : "bg-surface-container-low text-on-surface-variant border border-outline" 324 } 325 hover:bg-primary/30 active:scale-[0.97] 326 dark:border-outline-variant 327 `} 328 > 329 {state && ( 330 <IconMdiCheck 331 className="mr-1.5 inline-block w-4 h-4 rounded-full bg-primary" 332 aria-hidden 333 /> 334 )} 335 {text} 336 </button> 337 ); 338} 339 340function PostInteractionsItem({ uri }: { uri: string }) { 341 const [filters] = useAtom(postInteractionsFiltersAtom); 342 const { data: links } = useQueryConstellation({ 343 method: "/links/all", 344 target: uri, 345 }); 346 347 const likes = 348 links?.links?.["app.bsky.feed.like"]?.[".subject.uri"]?.records || 0; 349 const replies = 350 links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]?.records || 0; 351 const reposts = 352 links?.links?.["app.bsky.feed.repost"]?.[".subject.uri"]?.records || 0; 353 const quotes1 = 354 links?.links?.["app.bsky.feed.post"]?.[".embed.record.uri"]?.records || 0; 355 const quotes2 = 356 links?.links?.["app.bsky.feed.post"]?.[".embed.record.record.uri"] 357 ?.records || 0; 358 const quotes = quotes1 + quotes2; 359 360 const all = likes + replies + reposts + quotes; 361 362 const failLikes = filters.likes && likes < 1; 363 const failReposts = filters.reposts && reposts < 1; 364 const failReplies = filters.replies && replies < 1; 365 const failQuotes = filters.quotes && quotes < 1; 366 367 const showLikes = filters.showAll || filters.likes 368 const showReposts = filters.showAll || filters.reposts 369 const showReplies = filters.showAll || filters.replies 370 const showQuotes = filters.showAll || filters.quotes 371 372 const showNone = !showLikes && !showReposts && !showReplies && !showQuotes; 373 374 const fail = failLikes || failReposts || failReplies || failQuotes || showNone; 375 376 377 if (fail) return; 378 379 return ( 380 <div className="flex flex-col"> 381 {/* <span>fail likes {failLikes ? "true" : "false"}</span> 382 <span>fail repost {failReposts ? "true" : "false"}</span> 383 <span>fail reply {failReplies ? "true" : "false"}</span> 384 <span>fail qupte {failQuotes ? "true" : "false"}</span> */} 385 <div className="border rounded-xl mx-4 mt-4 overflow-hidden"> 386 <UniversalPostRendererATURILoader 387 isQuote 388 key={uri} 389 atUri={uri} 390 nopics={true} 391 concise={true} 392 /> 393 <div className="flex flex-col divide-x"> 394 {showLikes &&(<InteractionsButton 395 type={"like"} 396 uri={uri} 397 count={likes} 398 />)} 399 {showReposts && (<InteractionsButton 400 type={"repost"} 401 uri={uri} 402 count={reposts} 403 />)} 404 {showReplies && (<InteractionsButton 405 type={"reply"} 406 uri={uri} 407 count={replies} 408 />)} 409 {showQuotes && (<InteractionsButton 410 type={"quote"} 411 uri={uri} 412 count={quotes} 413 />)} 414 {!all && ( 415 <div className="text-center text-gray-500 dark:text-gray-400 pb-3 pt-2 border-t"> 416 No interactions yet. 417 </div> 418 )} 419 </div> 420 </div> 421 </div> 422 ); 423} 424 425function InteractionsButton({ 426 type, 427 uri, 428 count, 429}: { 430 type: "reply" | "repost" | "like" | "quote"; 431 uri: string; 432 count: number; 433}) { 434 if (!count) return <></>; 435 const aturi = new AtUri(uri); 436 return ( 437 <Link 438 to={ 439 `/profile/$did/post/$rkey` + 440 (type === "like" 441 ? "/liked-by" 442 : type === "repost" 443 ? "/reposted-by" 444 : type === "quote" 445 ? "/quotes" 446 : "") 447 } 448 params={{ 449 did: aturi.host, 450 rkey: aturi.rkey, 451 }} 452 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" 453 > 454 {type === "like" ? ( 455 <MdiCardsHeartOutline height={22} width={22} /> 456 ) : type === "repost" ? ( 457 <MdiRepeat height={22} width={22} /> 458 ) : type === "reply" ? ( 459 <MdiCommentOutline height={22} width={22} /> 460 ) : type === "quote" ? ( 461 <IconMdiMessageReplyTextOutline 462 height={22} 463 width={22} 464 className=" text-gray-400" 465 /> 466 ) : ( 467 <></> 468 )} 469 {type === "like" 470 ? "likes" 471 : type === "reply" 472 ? "replies" 473 : type === "quote" 474 ? "quotes" 475 : type === "repost" 476 ? "reposts" 477 : ""} 478 <div className="flex-1" /> {count} 479 </Link> 480 ); 481} 482 483export function NotificationItem({ notification }: { notification: string }) { 484 const aturi = new AtUri(notification); 485 const navigate = useNavigate(); 486 const { data: identity } = useQueryIdentity(aturi.host); 487 const resolvedDid = identity?.did; 488 const profileUri = resolvedDid 489 ? `at://${resolvedDid}/app.bsky.actor.profile/self` 490 : undefined; 491 const { data: profileRecord } = useQueryProfile(profileUri); 492 const profile = profileRecord?.value; 493 494 const [imgcdn] = useAtom(imgCDNAtom); 495 496 function getAvatarUrl(p: typeof profile) { 497 const link = p?.avatar?.ref?.["$link"]; 498 if (!link || !resolvedDid) return null; 499 return `https://${imgcdn}/img/avatar/plain/${resolvedDid}/${link}@jpeg`; 500 } 501 502 const avatar = getAvatarUrl(profile); 503 504 return ( 505 <div 506 className="flex items-center p-4 cursor-pointer gap-3 justify-around border-b flex-row" 507 onClick={() => 508 aturi && 509 navigate({ 510 to: "/profile/$did", 511 params: { did: aturi.host }, 512 }) 513 } 514 > 515 {/* <div> 516 {aturi.collection === "app.bsky.graph.follow" ? ( 517 <IconMdiAccountPlus /> 518 ) : aturi.collection === "app.bsky.feed.like" ? ( 519 <MdiCardsHeart /> 520 ) : ( 521 <></> 522 )} 523 </div> */} 524 {profile ? ( 525 <img 526 src={avatar || defaultpfp} 527 alt={identity?.handle} 528 className="w-10 h-10 rounded-full" 529 /> 530 ) : ( 531 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" /> 532 )} 533 <div className="flex flex-col min-w-0"> 534 <div className="flex flex-row gap-2 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> 535 <span className="font-medium text-gray-900 dark:text-gray-100 truncate"> 536 {profile?.displayName || identity?.handle || "Someone"} 537 </span> 538 <span className="text-gray-700 dark:text-gray-400 truncate"> 539 @{identity?.handle} 540 </span> 541 </div> 542 <div className="flex flex-row gap-2"> 543 {identity?.did && <Mutual targetdidorhandle={identity?.did} />} 544 {/* <span className="text-sm text-gray-600 dark:text-gray-400"> 545 followed you 546 </span> */} 547 </div> 548 </div> 549 <div className="flex-1" /> 550 {identity?.did && <FollowButton targetdidorhandle={identity?.did} />} 551 </div> 552 ); 553} 554 555export const EmptyState = ({ text }: { text: string }) => ( 556 <div className="py-10 text-center text-gray-500 dark:text-gray-400"> 557 {text} 558 </div> 559); 560 561export const LoadingState = ({ text }: { text: string }) => ( 562 <div className="py-10 text-center text-gray-500 dark:text-gray-400 italic"> 563 {text} 564 </div> 565); 566 567export const ErrorState = ({ error }: { error: unknown }) => ( 568 <div className="py-10 text-center text-red-600 dark:text-red-400"> 569 Error: {(error as Error)?.message || "Something went wrong."} 570 </div> 571);