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