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}