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}
287export function useQueryConstellation(query: {
288 method: "/links";
289 target: string;
290 collection: string;
291 path: string;
292 cursor?: string;
293 dids?: string[];
294}): UseQueryResult<linksRecordsResponse, Error>;
295export function useQueryConstellation(query: {
296 method: "/links/distinct-dids";
297 target: string;
298 collection: string;
299 path: string;
300 cursor?: string;
301}): UseQueryResult<linksDidsResponse, Error>;
302export function useQueryConstellation(query: {
303 method: "/links/count";
304 target: string;
305 collection: string;
306 path: string;
307 cursor?: string;
308}): UseQueryResult<linksCountResponse, Error>;
309export function useQueryConstellation(query: {
310 method: "/links/count/distinct-dids";
311 target: string;
312 collection: string;
313 path: string;
314 cursor?: string;
315}): UseQueryResult<linksCountResponse, Error>;
316export function useQueryConstellation(query: {
317 method: "/links/all";
318 target: string;
319}): UseQueryResult<linksAllResponse, Error>;
320export function useQueryConstellation(): undefined;
321export function useQueryConstellation(query: {
322 method: "undefined";
323 target: string;
324}): undefined;
325export function useQueryConstellation(query?: {
326 method:
327 | "/links"
328 | "/links/distinct-dids"
329 | "/links/count"
330 | "/links/count/distinct-dids"
331 | "/links/all"
332 | "undefined";
333 target: string;
334 collection?: string;
335 path?: string;
336 cursor?: string;
337 dids?: string[];
338}):
339 | UseQueryResult<
340 | linksRecordsResponse
341 | linksDidsResponse
342 | linksCountResponse
343 | linksAllResponse
344 | undefined,
345 Error
346 >
347 | undefined {
348 //if (!query) return;
349 const [constellationurl] = useAtom(constellationURLAtom)
350 return useQuery(
351 constructConstellationQuery(query && {constellation: constellationurl, ...query})
352 );
353}
354
355type linksRecord = {
356 did: string;
357 collection: string;
358 rkey: string;
359};
360export type linksRecordsResponse = {
361 total: string;
362 linking_records: linksRecord[];
363 cursor?: string;
364};
365type linksDidsResponse = {
366 total: string;
367 linking_dids: string[];
368 cursor?: string;
369};
370type linksCountResponse = {
371 total: string;
372};
373export type linksAllResponse = {
374 links: Record<
375 string,
376 Record<
377 string,
378 {
379 records: number;
380 distinct_dids: number;
381 }
382 >
383 >;
384};
385
386export function constructFeedSkeletonQuery(options?: {
387 feedUri: string;
388 agent?: ATPAPI.Agent;
389 isAuthed: boolean;
390 pdsUrl?: string;
391 feedServiceDid?: string;
392}) {
393 return queryOptions({
394 // The query key includes all dependencies to ensure it refetches when they change
395 queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
396 queryFn: async () => {
397 if (!options) return undefined as undefined
398 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
399 if (isAuthed) {
400 // Authenticated flow
401 if (!agent || !pdsUrl || !feedServiceDid) {
402 throw new Error("Missing required info for authenticated feed fetch.");
403 }
404 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
405 const res = await agent.fetchHandler(url, {
406 method: "GET",
407 headers: {
408 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
409 "Content-Type": "application/json",
410 },
411 });
412 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
413 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
414 } else {
415 // Unauthenticated flow (using a public PDS/AppView)
416 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
417 const res = await fetch(url);
418 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
419 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
420 }
421 },
422 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true),
423 });
424}
425
426export function useQueryFeedSkeleton(options?: {
427 feedUri: string;
428 agent?: ATPAPI.Agent;
429 isAuthed: boolean;
430 pdsUrl?: string;
431 feedServiceDid?: string;
432}) {
433 return useQuery(constructFeedSkeletonQuery(options));
434}
435
436export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
437 return queryOptions({
438 queryKey: ['preferences', agent?.did],
439 queryFn: async () => {
440 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
441 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
442 const res = await agent.fetchHandler(url, { method: "GET" });
443 if (!res.ok) throw new Error("Failed to fetch preferences");
444 return res.json();
445 },
446 });
447}
448export function useQueryPreferences(options: {
449 agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
450}) {
451 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
452}
453
454
455
456export function constructArbitraryQuery(uri?: string, slingshoturl?: string) {
457 return queryOptions({
458 queryKey: ["arbitrary", uri],
459 queryFn: async () => {
460 if (!uri) return undefined as undefined
461 const res = await fetch(
462 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
463 );
464 let data: any;
465 try {
466 data = await res.json();
467 } catch {
468 return undefined;
469 }
470 if (res.status === 400) return undefined;
471 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
472 return undefined; // cache “not found”
473 }
474 try {
475 if (!res.ok) throw new Error("Failed to fetch post");
476 return (data) as {
477 uri: string;
478 cid: string;
479 value: any;
480 };
481 } catch (_e) {
482 return undefined;
483 }
484 },
485 retry: (failureCount, error) => {
486 // dont retry 400 errors
487 if ((error as any)?.message?.includes("400")) return false;
488 return failureCount < 2;
489 },
490 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
491 gcTime: /*0//*/5 * 60 * 1000,
492 });
493}
494export function useQueryArbitrary(uri: string): UseQueryResult<
495 {
496 uri: string;
497 cid: string;
498 value: any;
499 },
500 Error
501>;
502export function useQueryArbitrary(): UseQueryResult<
503 undefined,
504 Error
505>;
506export function useQueryArbitrary(uri?: string): UseQueryResult<
507 {
508 uri: string;
509 cid: string;
510 value: any;
511 } | undefined,
512 Error
513>;
514export function useQueryArbitrary(uri?: string) {
515 const [slingshoturl] = useAtom(slingshotURLAtom)
516 return useQuery(constructArbitraryQuery(uri, slingshoturl));
517}
518
519export function constructFallbackNothingQuery(){
520 return queryOptions({
521 queryKey: ["nothing"],
522 queryFn: async () => {
523 return undefined
524 },
525 });
526}
527
528type ListRecordsResponse = {
529 cursor?: string;
530 records: {
531 uri: string;
532 cid: string;
533 value: ATPAPI.AppBskyFeedPost.Record;
534 }[];
535};
536
537export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
538 return queryOptions({
539 queryKey: ['authorFeed', did],
540 queryFn: async ({ pageParam }: QueryFunctionContext) => {
541 const limit = 25;
542
543 const cursor = pageParam as string | undefined;
544 const cursorParam = cursor ? `&cursor=${cursor}` : '';
545
546 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
547
548 const res = await fetch(url);
549 if (!res.ok) throw new Error("Failed to fetch author's posts");
550
551 return res.json() as Promise<ListRecordsResponse>;
552 },
553 });
554}
555
556export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
557 const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
558
559 return useInfiniteQuery({
560 queryKey,
561 queryFn,
562 initialPageParam: undefined as never, // ???? what is this shit
563 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
564 enabled: !!did && !!pdsUrl,
565 });
566}
567
568type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
569
570export function constructInfiniteFeedSkeletonQuery(options: {
571 feedUri: string;
572 agent?: ATPAPI.Agent;
573 isAuthed: boolean;
574 pdsUrl?: string;
575 feedServiceDid?: string;
576}) {
577 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
578
579 return queryOptions({
580 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
581
582 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
583 const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
584
585 if (isAuthed) {
586 if (!agent || !pdsUrl || !feedServiceDid) {
587 throw new Error("Missing required info for authenticated feed fetch.");
588 }
589 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
590 const res = await agent.fetchHandler(url, {
591 method: "GET",
592 headers: {
593 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
594 "Content-Type": "application/json",
595 },
596 });
597 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
598 return (await res.json()) as FeedSkeletonPage;
599 } else {
600 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
601 const res = await fetch(url);
602 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
603 return (await res.json()) as FeedSkeletonPage;
604 }
605 },
606 });
607}
608
609export function useInfiniteQueryFeedSkeleton(options: {
610 feedUri: string;
611 agent?: ATPAPI.Agent;
612 isAuthed: boolean;
613 pdsUrl?: string;
614 feedServiceDid?: string;
615}) {
616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
617
618 return {...useInfiniteQuery({
619 queryKey,
620 queryFn,
621 initialPageParam: undefined as never,
622 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
623 staleTime: Infinity,
624 refetchOnWindowFocus: false,
625 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
626 }), queryKey: queryKey};
627}
628
629
630export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
631 constellation: string,
632 method: '/links'
633 target?: string
634 collection: string
635 path: string
636}) {
637 console.log(
638 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
639 query,
640 )
641
642 return infiniteQueryOptions({
643 enabled: !!query?.target,
644 queryKey: [
645 'reddwarf_constellation',
646 query?.method,
647 query?.target,
648 query?.collection,
649 query?.path,
650 ] as const,
651
652 queryFn: async ({pageParam}: {pageParam?: string}) => {
653 if (!query || !query?.target) return undefined
654
655 const method = query.method
656 const target = query.target
657 const collection = query.collection
658 const path = query.path
659 const cursor = pageParam
660
661 const res = await fetch(
662 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${
663 collection ? `&collection=${encodeURIComponent(collection)}` : ''
664 }${path ? `&path=${encodeURIComponent(path)}` : ''}${
665 cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
666 }`,
667 )
668
669 if (!res.ok) throw new Error('Failed to fetch')
670
671 return (await res.json()) as linksRecordsResponse
672 },
673
674 getNextPageParam: lastPage => {
675 return (lastPage as any)?.cursor ?? undefined
676 },
677 initialPageParam: undefined,
678 staleTime: 5 * 60 * 1000,
679 gcTime: 5 * 60 * 1000,
680 })
681}