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