an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
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";
9
10import { constellationURLAtom, slingshotURLAtom, store } from "./atoms";
11
12export function constructIdentityQuery(didorhandle?: string) {
13 return queryOptions({
14 queryKey: ["identity", didorhandle],
15 queryFn: async () => {
16 if (!didorhandle) return undefined as undefined
17 const slingshoturl = store.get(slingshotURLAtom)
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 return useQuery(constructIdentityQuery(didorhandle));
62}
63
64export function constructPostQuery(uri?: string) {
65 return queryOptions({
66 queryKey: ["post", uri],
67 queryFn: async () => {
68 if (!uri) return undefined as undefined
69 const slingshoturl = store.get(slingshotURLAtom)
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 return useQuery(constructPostQuery(uri));
126}
127
128export function constructProfileQuery(uri?: string) {
129 return queryOptions({
130 queryKey: ["profile", uri],
131 queryFn: async () => {
132 if (!uri) return undefined as undefined
133 const slingshoturl = store.get(slingshotURLAtom)
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 return useQuery(constructProfileQuery(uri));
190}
191
192// export function constructConstellationQuery(
193// method: "/links",
194// target: string,
195// collection: string,
196// path: string,
197// cursor?: string
198// ): QueryOptions<linksRecordsResponse, Error>;
199// export function constructConstellationQuery(
200// method: "/links/distinct-dids",
201// target: string,
202// collection: string,
203// path: string,
204// cursor?: string
205// ): QueryOptions<linksDidsResponse, Error>;
206// export function constructConstellationQuery(
207// method: "/links/count",
208// target: string,
209// collection: string,
210// path: string,
211// cursor?: string
212// ): QueryOptions<linksCountResponse, Error>;
213// export function constructConstellationQuery(
214// method: "/links/count/distinct-dids",
215// target: string,
216// collection: string,
217// path: string,
218// cursor?: string
219// ): QueryOptions<linksCountResponse, Error>;
220// export function constructConstellationQuery(
221// method: "/links/all",
222// target: string
223// ): QueryOptions<linksAllResponse, Error>;
224export function constructConstellationQuery(query?:{
225 method:
226 | "/links"
227 | "/links/distinct-dids"
228 | "/links/count"
229 | "/links/count/distinct-dids"
230 | "/links/all"
231 | "undefined",
232 target: string,
233 collection?: string,
234 path?: string,
235 cursor?: string,
236 dids?: string[]
237}
238) {
239 // : QueryOptions<
240 // | linksRecordsResponse
241 // | linksDidsResponse
242 // | linksCountResponse
243 // | linksAllResponse
244 // | undefined,
245 // Error
246 // >
247 return queryOptions({
248 queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const,
249 queryFn: async () => {
250 if (!query || query.method === "undefined") return undefined as undefined
251 const method = query.method
252 const target = query.target
253 const collection = query?.collection
254 const path = query?.path
255 const cursor = query.cursor
256 const dids = query?.dids
257 const constellation = store.get(constellationURLAtom);
258 const res = await fetch(
259 `https://${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("") : ""}`
260 );
261 if (!res.ok) throw new Error("Failed to fetch post");
262 try {
263 switch (method) {
264 case "/links":
265 return (await res.json()) as linksRecordsResponse;
266 case "/links/distinct-dids":
267 return (await res.json()) as linksDidsResponse;
268 case "/links/count":
269 return (await res.json()) as linksCountResponse;
270 case "/links/count/distinct-dids":
271 return (await res.json()) as linksCountResponse;
272 case "/links/all":
273 return (await res.json()) as linksAllResponse;
274 default:
275 return undefined;
276 }
277 } catch (_e) {
278 return undefined;
279 }
280 },
281 // enforce short lifespan
282 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
283 gcTime: /*0//*/5 * 60 * 1000,
284 });
285}
286export function useQueryConstellation(query: {
287 method: "/links";
288 target: string;
289 collection: string;
290 path: string;
291 cursor?: string;
292 dids?: string[];
293}): UseQueryResult<linksRecordsResponse, Error>;
294export function useQueryConstellation(query: {
295 method: "/links/distinct-dids";
296 target: string;
297 collection: string;
298 path: string;
299 cursor?: string;
300}): UseQueryResult<linksDidsResponse, Error>;
301export function useQueryConstellation(query: {
302 method: "/links/count";
303 target: string;
304 collection: string;
305 path: string;
306 cursor?: string;
307}): UseQueryResult<linksCountResponse, Error>;
308export function useQueryConstellation(query: {
309 method: "/links/count/distinct-dids";
310 target: string;
311 collection: string;
312 path: string;
313 cursor?: string;
314}): UseQueryResult<linksCountResponse, Error>;
315export function useQueryConstellation(query: {
316 method: "/links/all";
317 target: string;
318}): UseQueryResult<linksAllResponse, Error>;
319export function useQueryConstellation(): undefined;
320export function useQueryConstellation(query: {
321 method: "undefined";
322 target: string;
323}): undefined;
324export function useQueryConstellation(query?: {
325 method:
326 | "/links"
327 | "/links/distinct-dids"
328 | "/links/count"
329 | "/links/count/distinct-dids"
330 | "/links/all"
331 | "undefined";
332 target: string;
333 collection?: string;
334 path?: string;
335 cursor?: string;
336 dids?: string[];
337}):
338 | UseQueryResult<
339 | linksRecordsResponse
340 | linksDidsResponse
341 | linksCountResponse
342 | linksAllResponse
343 | undefined,
344 Error
345 >
346 | undefined {
347 //if (!query) return;
348 return useQuery(
349 constructConstellationQuery(query)
350 );
351}
352
353type linksRecord = {
354 did: string;
355 collection: string;
356 rkey: string;
357};
358export type linksRecordsResponse = {
359 total: string;
360 linking_records: linksRecord[];
361 cursor?: string;
362};
363type linksDidsResponse = {
364 total: string;
365 linking_dids: string[];
366 cursor?: string;
367};
368type linksCountResponse = {
369 total: string;
370};
371export type linksAllResponse = {
372 links: Record<
373 string,
374 Record<
375 string,
376 {
377 records: number;
378 distinct_dids: number;
379 }
380 >
381 >;
382};
383
384export function constructFeedSkeletonQuery(options?: {
385 feedUri: string;
386 agent?: ATPAPI.Agent;
387 isAuthed: boolean;
388 pdsUrl?: string;
389 feedServiceDid?: string;
390}) {
391 return queryOptions({
392 // The query key includes all dependencies to ensure it refetches when they change
393 queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }],
394 queryFn: async () => {
395 if (!options) return undefined as undefined
396 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
397 if (isAuthed) {
398 // Authenticated flow
399 if (!agent || !pdsUrl || !feedServiceDid) {
400 throw new Error("Missing required info for authenticated feed fetch.");
401 }
402 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
403 const res = await agent.fetchHandler(url, {
404 method: "GET",
405 headers: {
406 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
407 "Content-Type": "application/json",
408 },
409 });
410 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
411 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
412 } else {
413 // Unauthenticated flow (using a public PDS/AppView)
414 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
415 const res = await fetch(url);
416 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
417 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
418 }
419 },
420 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true),
421 });
422}
423
424export function useQueryFeedSkeleton(options?: {
425 feedUri: string;
426 agent?: ATPAPI.Agent;
427 isAuthed: boolean;
428 pdsUrl?: string;
429 feedServiceDid?: string;
430}) {
431 return useQuery(constructFeedSkeletonQuery(options));
432}
433
434export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) {
435 return queryOptions({
436 queryKey: ['preferences', agent?.did],
437 queryFn: async () => {
438 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available");
439 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`;
440 const res = await agent.fetchHandler(url, { method: "GET" });
441 if (!res.ok) throw new Error("Failed to fetch preferences");
442 return res.json();
443 },
444 });
445}
446export function useQueryPreferences(options: {
447 agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined
448}) {
449 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl));
450}
451
452
453
454export function constructArbitraryQuery(uri?: string) {
455 return queryOptions({
456 queryKey: ["arbitrary", uri],
457 queryFn: async () => {
458 if (!uri) return undefined as undefined
459 const slingshoturl = store.get(slingshotURLAtom)
460 const res = await fetch(
461 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
462 );
463 let data: any;
464 try {
465 data = await res.json();
466 } catch {
467 return undefined;
468 }
469 if (res.status === 400) return undefined;
470 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) {
471 return undefined; // cache “not found”
472 }
473 try {
474 if (!res.ok) throw new Error("Failed to fetch post");
475 return (data) as {
476 uri: string;
477 cid: string;
478 value: any;
479 };
480 } catch (_e) {
481 return undefined;
482 }
483 },
484 retry: (failureCount, error) => {
485 // dont retry 400 errors
486 if ((error as any)?.message?.includes("400")) return false;
487 return failureCount < 2;
488 },
489 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes
490 gcTime: /*0//*/5 * 60 * 1000,
491 });
492}
493export function useQueryArbitrary(uri: string): UseQueryResult<
494 {
495 uri: string;
496 cid: string;
497 value: any;
498 },
499 Error
500>;
501export function useQueryArbitrary(): UseQueryResult<
502 undefined,
503 Error
504>;
505export function useQueryArbitrary(uri?: string): UseQueryResult<
506 {
507 uri: string;
508 cid: string;
509 value: any;
510 } | undefined,
511 Error
512>;
513export function useQueryArbitrary(uri?: string) {
514 return useQuery(constructArbitraryQuery(uri));
515}
516
517export function constructFallbackNothingQuery(){
518 return queryOptions({
519 queryKey: ["nothing"],
520 queryFn: async () => {
521 return undefined
522 },
523 });
524}
525
526type ListRecordsResponse = {
527 cursor?: string;
528 records: {
529 uri: string;
530 cid: string;
531 value: ATPAPI.AppBskyFeedPost.Record;
532 }[];
533};
534
535export function constructAuthorFeedQuery(did: string, pdsUrl: string) {
536 return queryOptions({
537 queryKey: ['authorFeed', did],
538 queryFn: async ({ pageParam }: QueryFunctionContext) => {
539 const limit = 25;
540
541 const cursor = pageParam as string | undefined;
542 const cursorParam = cursor ? `&cursor=${cursor}` : '';
543
544 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`;
545
546 const res = await fetch(url);
547 if (!res.ok) throw new Error("Failed to fetch author's posts");
548
549 return res.json() as Promise<ListRecordsResponse>;
550 },
551 });
552}
553
554export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) {
555 const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!);
556
557 return useInfiniteQuery({
558 queryKey,
559 queryFn,
560 initialPageParam: undefined as never, // ???? what is this shit
561 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
562 enabled: !!did && !!pdsUrl,
563 });
564}
565
566type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema;
567
568export function constructInfiniteFeedSkeletonQuery(options: {
569 feedUri: string;
570 agent?: ATPAPI.Agent;
571 isAuthed: boolean;
572 pdsUrl?: string;
573 feedServiceDid?: string;
574}) {
575 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options;
576
577 return queryOptions({
578 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }],
579
580 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => {
581 const cursorParam = pageParam ? `&cursor=${pageParam}` : "";
582
583 if (isAuthed) {
584 if (!agent || !pdsUrl || !feedServiceDid) {
585 throw new Error("Missing required info for authenticated feed fetch.");
586 }
587 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
588 const res = await agent.fetchHandler(url, {
589 method: "GET",
590 headers: {
591 "atproto-proxy": `${feedServiceDid}#bsky_fg`,
592 "Content-Type": "application/json",
593 },
594 });
595 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`);
596 return (await res.json()) as FeedSkeletonPage;
597 } else {
598 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
599 const res = await fetch(url);
600 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`);
601 return (await res.json()) as FeedSkeletonPage;
602 }
603 },
604 });
605}
606
607export function useInfiniteQueryFeedSkeleton(options: {
608 feedUri: string;
609 agent?: ATPAPI.Agent;
610 isAuthed: boolean;
611 pdsUrl?: string;
612 feedServiceDid?: string;
613}) {
614 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options);
615
616 return useInfiniteQuery({
617 queryKey,
618 queryFn,
619 initialPageParam: undefined as never,
620 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined,
621 staleTime: Infinity,
622 refetchOnWindowFocus: false,
623 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true),
624 });
625}
626
627
628export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: {
629 method: '/links'
630 target?: string
631 collection: string
632 path: string
633}) {
634 const constellationHost = store.get(constellationURLAtom)
635 console.log(
636 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks',
637 query,
638 )
639
640 return infiniteQueryOptions({
641 enabled: !!query?.target,
642 queryKey: [
643 'reddwarf_constellation',
644 query?.method,
645 query?.target,
646 query?.collection,
647 query?.path,
648 ] as const,
649
650 queryFn: async ({pageParam}: {pageParam?: string}) => {
651 if (!query || !query?.target) return undefined
652
653 const method = query.method
654 const target = query.target
655 const collection = query.collection
656 const path = query.path
657 const cursor = pageParam
658
659 const res = await fetch(
660 `https://${constellationHost}${method}?target=${encodeURIComponent(target)}${
661 collection ? `&collection=${encodeURIComponent(collection)}` : ''
662 }${path ? `&path=${encodeURIComponent(path)}` : ''}${
663 cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
664 }`,
665 )
666
667 if (!res.ok) throw new Error('Failed to fetch')
668
669 return (await res.json()) as linksRecordsResponse
670 },
671
672 getNextPageParam: lastPage => {
673 return (lastPage as any)?.cursor ?? undefined
674 },
675 initialPageParam: undefined,
676 staleTime: 5 * 60 * 1000,
677 gcTime: 5 * 60 * 1000,
678 })
679}