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