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