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}