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