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