an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import * as ATPAPI from "@atproto/api";
2import {
3 infiniteQueryOptions,
4 type QueryFunctionContext,
5 queryOptions,
6 useInfiniteQuery,
7 useQuery,
8 type UseQueryResult} from "@tanstack/react-query";
9import { useAtom } from "jotai";
10
11import { constellationURLAtom, slingshotURLAtom } from "./atoms";
12
13export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) {
14 return queryOptions({
15 queryKey: ["identity", didorhandle],
16 queryFn: async () => {
17 if (!didorhandle) return undefined as undefined
18 const res = await fetch(
19 `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
20 );
21 if (!res.ok) throw new Error("Failed to fetch post");
22 try {
23 return (await res.json()) as {
24 did: string;
25 handle: string;
26 pds: string;
27 signing_key: string;
28 };
29 } catch (_e) {
30 return undefined;
31 }
32 },
33 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
34 gcTime: /*0//*/5 * 60 * 1000,
35 });
36}
37export function useQueryIdentity(didorhandle: string): UseQueryResult<
38 {
39 did: string;
40 handle: string;
41 pds: string;
42 signing_key: string;
43 },
44 Error
45>;
46export function useQueryIdentity(): UseQueryResult<
47 undefined,
48 Error
49 >
50export function useQueryIdentity(didorhandle?: string):
51 UseQueryResult<
52 {
53 did: string;
54 handle: string;
55 pds: string;
56 signing_key: string;
57 } | undefined,
58 Error
59 >
60export function useQueryIdentity(didorhandle?: string) {
61 const [slingshoturl] = useAtom(slingshotURLAtom)
62 return useQuery(constructIdentityQuery(didorhandle, slingshoturl));
63}
64
65export function constructPostQuery(uri?: string, slingshoturl?: string) {
66 return queryOptions({
67 queryKey: ["post", uri],
68 queryFn: async () => {
69 if (!uri) return undefined as undefined
70 const res = await fetch(
71 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
72 );
73 let data: any;
74 try {
75 data = await res.json();
76 } catch {
77 return undefined;
78 }
79 if (res.status === 400) return undefined;
80 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
81 return undefined; // cache “not found”
82 }
83 try {
84 if (!res.ok) throw new Error("Failed to fetch post");
85 return (data) as {
86 uri: string;
87 cid: string;
88 value: any;
89 };
90 } catch (_e) {
91 return undefined;
92 }
93 },
94 retry: (failureCount, error) => {
95 // dont retry 400 errors
96 if ((error as any)?.message?.includes("400")) return false;
97 return failureCount < 2;
98 },
99 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
100 gcTime: /*0//*/5 * 60 * 1000,
101 });
102}
103export function useQueryPost(uri: string): UseQueryResult<
104 {
105 uri: string;
106 cid: string;
107 value: ATPAPI.AppBskyFeedPost.Record;
108 },
109 Error
110>;
111export function useQueryPost(): UseQueryResult<
112 undefined,
113 Error
114 >
115export function useQueryPost(uri?: string):
116 UseQueryResult<
117 {
118 uri: string;
119 cid: string;
120 value: ATPAPI.AppBskyFeedPost.Record;
121 } | undefined,
122 Error
123 >
124export function useQueryPost(uri?: string) {
125 const [slingshoturl] = useAtom(slingshotURLAtom)
126 return useQuery(constructPostQuery(uri, slingshoturl));
127}
128
129export function constructProfileQuery(uri?: string, slingshoturl?: string) {
130 return queryOptions({
131 queryKey: ["profile", uri],
132 queryFn: async () => {
133 if (!uri) return undefined as undefined
134 const res = await fetch(
135 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
136 );
137 let data: any;
138 try {
139 data = await res.json();
140 } catch {
141 return undefined;
142 }
143 if (res.status === 400) return undefined;
144 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
145 return undefined; // cache “not found”
146 }
147 try {
148 if (!res.ok) throw new Error("Failed to fetch post");
149 return (data) as {
150 uri: string;
151 cid: string;
152 value: any;
153 };
154 } catch (_e) {
155 return undefined;
156 }
157 },
158 retry: (failureCount, error) => {
159 // dont retry 400 errors
160 if ((error as any)?.message?.includes("400")) return false;
161 return failureCount < 2;
162 },
163 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
164 gcTime: /*0//*/5 * 60 * 1000,
165 });
166}
167export function useQueryProfile(uri: string): UseQueryResult<
168 {
169 uri: string;
170 cid: string;
171 value: ATPAPI.AppBskyActorProfile.Record;
172 },
173 Error
174>;
175export function useQueryProfile(): UseQueryResult<
176 undefined,
177 Error
178>;
179export function useQueryProfile(uri?: string):
180 UseQueryResult<
181 {
182 uri: string;
183 cid: string;
184 value: ATPAPI.AppBskyActorProfile.Record;
185 } | undefined,
186 Error
187 >
188export function useQueryProfile(uri?: string) {
189 const [slingshoturl] = useAtom(slingshotURLAtom)
190 return useQuery(constructProfileQuery(uri, slingshoturl));
191}
192
193// export function constructConstellationQuery(
194// method: "/links",
195// target: string,
196// collection: string,
197// path: string,
198// cursor?: string
199// ): QueryOptions<linksRecordsResponse, Error>;
200// export function constructConstellationQuery(
201// method: "/links/distinct-dids",
202// target: string,
203// collection: string,
204// path: string,
205// cursor?: string
206// ): QueryOptions<linksDidsResponse, Error>;
207// export function constructConstellationQuery(
208// method: "/links/count",
209// target: string,
210// collection: string,
211// path: string,
212// cursor?: string
213// ): QueryOptions<linksCountResponse, Error>;
214// export function constructConstellationQuery(
215// method: "/links/count/distinct-dids",
216// target: string,
217// collection: string,
218// path: string,
219// cursor?: string
220// ): QueryOptions<linksCountResponse, Error>;
221// export function constructConstellationQuery(
222// method: "/links/all",
223// target: string
224// ): QueryOptions<linksAllResponse, Error>;
225export function constructConstellationQuery(query?:{
226 constellation: string,
227 method:
228 | "/links"
229 | "/links/distinct-dids"
230 | "/links/count"
231 | "/links/count/distinct-dids"
232 | "/links/all"
233 | "undefined",
234 target: string,
235 collection?: string,
236 path?: string,
237 cursor?: string,
238 dids?: string[]
239}
240) {
241 // : QueryOptions<
242 // | linksRecordsResponse
243 // | linksDidsResponse
244 // | linksCountResponse
245 // | linksAllResponse
246 // | undefined,
247 // Error
248 // >
249 return queryOptions({
250 queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
251 queryFn: async () => {
252 if (!query || query.method === "undefined") return undefined as undefined
253 const method = query.method
254 const target = query.target
255 const collection = query?.collection
256 const path = query?.path
257 const cursor = query.cursor
258 const dids = query?.dids
259 const res = await fetch(
260 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
261 );
262 if (!res.ok) throw new Error("Failed to fetch post");
263 try {
264 switch (method) {
265 case "/links":
266 return (await res.json()) as linksRecordsResponse;
267 case "/links/distinct-dids":
268 return (await res.json()) as linksDidsResponse;
269 case "/links/count":
270 return (await res.json()) as linksCountResponse;
271 case "/links/count/distinct-dids":
272 return (await res.json()) as linksCountResponse;
273 case "/links/all":
274 return (await res.json()) as linksAllResponse;
275 default:
276 return undefined;
277 }
278 } catch (_e) {
279 return undefined;
280 }
281 },
282 // enforce short lifespan
283 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
284 gcTime: /*0//*/5 * 60 * 1000,
285 });
286}
287// todo do more of these instead of overloads since overloads sucks so much apparently
288export function useQueryConstellationLinksCountDistinctDids(query?: {
289 method: "/links/count/distinct-dids";
290 target: string;
291 collection: string;
292 path: string;
293 cursor?: string;
294}): UseQueryResult<linksCountResponse, Error> | undefined {
295 //if (!query) return;
296 const [constellationurl] = useAtom(constellationURLAtom)
297 const queryres = useQuery(
298 constructConstellationQuery(query && {constellation: constellationurl, ...query})
299 ) as unknown as UseQueryResult<linksCountResponse, Error>;
300 if (!query) {
301 return undefined as undefined;
302 }
303 return queryres as UseQueryResult<linksCountResponse, Error>;
304}
305
306export function useQueryConstellation(query: {
307 method: "/links";
308 target: string;
309 collection: string;
310 path: string;
311 cursor?: string;
312 dids?: string[];
313}): UseQueryResult<linksRecordsResponse, Error>;
314export function useQueryConstellation(query: {
315 method: "/links/distinct-dids";
316 target: string;
317 collection: string;
318 path: string;
319 cursor?: string;
320}): UseQueryResult<linksDidsResponse, Error>;
321export function useQueryConstellation(query: {
322 method: "/links/count";
323 target: string;
324 collection: string;
325 path: string;
326 cursor?: string;
327}): UseQueryResult<linksCountResponse, Error>;
328export function useQueryConstellation(query: {
329 method: "/links/count/distinct-dids";
330 target: string;
331 collection: string;
332 path: string;
333 cursor?: string;
334}): UseQueryResult<linksCountResponse, Error>;
335export function useQueryConstellation(query: {
336 method: "/links/all";
337 target: string;
338}): UseQueryResult<linksAllResponse, Error>;
339export function useQueryConstellation(): undefined;
340export function useQueryConstellation(query: {
341 method: "undefined";
342 target: string;
343}): undefined;
344export function useQueryConstellation(query?: {
345 method:
346 | "/links"
347 | "/links/distinct-dids"
348 | "/links/count"
349 | "/links/count/distinct-dids"
350 | "/links/all"
351 | "undefined";
352 target: string;
353 collection?: string;
354 path?: string;
355 cursor?: string;
356 dids?: string[];
357}):
358 | UseQueryResult<
359 | linksRecordsResponse
360 | linksDidsResponse
361 | linksCountResponse
362 | linksAllResponse
363 | undefined,
364 Error
365 >
366 | undefined {
367 //if (!query) return;
368 const [constellationurl] = useAtom(constellationURLAtom)
369 return useQuery(
370 constructConstellationQuery(query && {constellation: constellationurl, ...query})
371 );
372}
373
374export type linksRecord = {
375 did: string;
376 collection: string;
377 rkey: string;
378};
379export type linksRecordsResponse = {
380 total: string;
381 linking_records: linksRecord[];
382 cursor?: string;
383};
384type linksDidsResponse = {
385 total: string;
386 linking_dids: string[];
387 cursor?: string;
388};
389type linksCountResponse = {
390 total: string;
391};
392export type linksAllResponse = {
393 links: Record<
394 string,
395 Record<
396 string,
397 {
398 records: number;
399 distinct_dids: number;
400 }
401 >
402 >;
403};
404
405export function constructFeedSkeletonQuery(options?: {
406 feedUri: string;
407 agent?: ATPAPI.Agent;
408 isAuthed: boolean;
409 pdsUrl?: string;
410 feedServiceDid?: string;
411}) {
412 return queryOptions({
413 // The query key includes all dependencies to ensure it refetches when they change
414 queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
415 queryFn: async () => {
416 if (!options) return undefined as undefined
417 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
418 if (isAuthed) {
419 // Authenticated flow
420 if (!agent || !pdsUrl || !feedServiceDid) {
421 throw new Error("Missing required info for authenticated feed fetch.");
422 }
423 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
424 const res = await agent.fetchHandler(url, {
425 method: "GET",
426 headers: {
427 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
428 "Content-Type": "application/json",
429 },
430 });
431 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
432 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
433 } else {
434 // Unauthenticated flow (using a public PDS/AppView)
435 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
436 const res = await fetch(url);
437 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
438 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
439 }
440 },
441 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true),
442 });
443}
444
445export function useQueryFeedSkeleton(options?: {
446 feedUri: string;
447 agent?: ATPAPI.Agent;
448 isAuthed: boolean;
449 pdsUrl?: string;
450 feedServiceDid?: string;
451}) {
452 return useQuery(constructFeedSkeletonQuery(options));
453}
454
455export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
456 return queryOptions({
457 queryKey: ['preferences', agent?.did],
458 queryFn: async () => {
459 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
460 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
461 const res = await agent.fetchHandler(url, { method: "GET" });
462 if (!res.ok) throw new Error("Failed to fetch preferences");
463 return res.json();
464 },
465 });
466}
467export function useQueryPreferences(options: {
468 agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
469}) {
470 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
471}
472
473
474
475export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
476 return queryOptions({
477 queryKey: ["arbitrary", uri],
478 queryFn: async () => {
479 if (!uri) return undefined as undefined
480 const res = await fetch(
481 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
482 );
483 let data: any;
484 try {
485 data = await res.json();
486 } catch {
487 return undefined;
488 }
489 if (res.status === 400) return undefined;
490 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
491 return undefined; // cache “not found”
492 }
493 try {
494 if (!res.ok) throw new Error("Failed to fetch post");
495 return (data) as {
496 uri: string;
497 cid: string;
498 value: any;
499 };
500 } catch (_e) {
501 return undefined;
502 }
503 },
504 retry: (failureCount, error) => {
505 // dont retry 400 errors
506 if ((error as any)?.message?.includes("400")) return false;
507 return failureCount < 2;
508 },
509 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
510 gcTime: /*0//*/5 * 60 * 1000,
511 });
512}
513export function useQueryArbitrary(uri: string): UseQueryResult<
514 {
515 uri: string;
516 cid: string;
517 value: any;
518 },
519 Error
520>;
521export function useQueryArbitrary(): UseQueryResult<
522 undefined,
523 Error
524>;
525export function useQueryArbitrary(uri?: string): UseQueryResult<
526 {
527 uri: string;
528 cid: string;
529 value: any;
530 } | undefined,
531 Error
532>;
533export function useQueryArbitrary(uri?: string) {
534 const [slingshoturl] = useAtom(slingshotURLAtom)
535 return useQuery(constructArbitraryQuery(uri, slingshoturl));
536}
537
538export function constructFallbackNothingQuery(){
539 return queryOptions({
540 queryKey: ["nothing"],
541 queryFn: async () => {
542 return undefined
543 },
544 });
545}
546
547type ListRecordsResponse = {
548 cursor?: string;
549 records: {
550 uri: string;
551 cid: string;
552 value: ATPAPI.AppBskyFeedPost.Record;
553 }[];
554};
555
556export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") {
557 return queryOptions({
558 queryKey: ['authorFeed', did, collection],
559 queryFn: async ({ pageParam }: QueryFunctionContext) => {
560 const limit = 25;
561
562 const cursor = pageParam as string | undefined;
563 const cursorParam = cursor ? `&cursor=${cursor}` : '';
564
565 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`;
566
567 const res = await fetch(url);
568 if (!res.ok) throw new Error("Failed to fetch author's posts");
569
570 return res.json() as Promise<ListRecordsResponse>;
571 },
572 });
573}
574
575export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) {
576 const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection);
577
578 return useInfiniteQuery({
579 queryKey,
580 queryFn,
581 initialPageParam: undefined as never, // ???? what is this shit
582 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
583 enabled: !!did && !!pdsUrl,
584 });
585}
586
587type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
588
589export function constructInfiniteFeedSkeletonQuery(options: {
590 feedUri: string;
591 agent?: ATPAPI.Agent;
592 isAuthed: boolean;
593 pdsUrl?: string;
594 feedServiceDid?: string;
595 // todo the hell is a unauthedfeedurl
596 unauthedfeedurl?: string;
597}) {
598 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options;
599
600 return queryOptions({
601 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
602
603 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
604 const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
605
606 if (isAuthed && !unauthedfeedurl) {
607 if (!agent || !pdsUrl || !feedServiceDid) {
608 throw new Error("Missing required info for authenticated feed fetch.");
609 }
610 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
611 const res = await agent.fetchHandler(url, {
612 method: "GET",
613 headers: {
614 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
615 "Content-Type": "application/json",
616 },
617 });
618 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
619 return (await res.json()) as FeedSkeletonPage;
620 } else {
621 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
622 const res = await fetch(url);
623 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
624 return (await res.json()) as FeedSkeletonPage;
625 }
626 },
627 });
628}
629
630export function useInfiniteQueryFeedSkeleton(options: {
631 feedUri: string;
632 agent?: ATPAPI.Agent;
633 isAuthed: boolean;
634 pdsUrl?: string;
635 feedServiceDid?: string;
636 unauthedfeedurl?: string;
637}) {
638 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
639
640 return {...useInfiniteQuery({
641 queryKey,
642 queryFn,
643 initialPageParam: undefined as never,
644 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
645 staleTime: Infinity,
646 refetchOnWindowFocus: false,
647 enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true),
648 }), queryKey: queryKey};
649}
650
651
652export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
653 constellation: string,
654 method: '/links'
655 target?: string
656 collection: string
657 path: string,
658 staleMult?: number
659}) {
660 const safemult = query?.staleMult || 1;
661 // console.log(
662 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
663 // query,
664 // )
665
666 return infiniteQueryOptions({
667 enabled: !!query?.target,
668 queryKey: [
669 'reddwarf_constellation',
670 query?.method,
671 query?.target,
672 query?.collection,
673 query?.path,
674 ] as const,
675
676 queryFn: async ({pageParam}: {pageParam?: string}) => {
677 if (!query || !query?.target) return undefined
678
679 const method = query.method
680 const target = query.target
681 const collection = query.collection
682 const path = query.path
683 const cursor = pageParam
684
685 const res = await fetch(
686 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
687 collection ? `&collection=${encodeURIComponent(collection)}` : ''
688 }${path ? `&path=${encodeURIComponent(path)}` : ''}${
689 cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
690 }`,
691 )
692
693 if (!res.ok) throw new Error('Failed to fetch')
694
695 return (await res.json()) as linksRecordsResponse
696 },
697
698 getNextPageParam: lastPage => {
699 return (lastPage as any)?.cursor ?? undefined
700 },
701 initialPageParam: undefined,
702 staleTime: 5 * 60 * 1000 * safemult,
703 gcTime: 5 * 60 * 1000 * safemult,
704 })
705}