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