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 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);