an appview-less Bluesky client using Constellation and PDS Queries - https://reddwarf.app/
at main 21 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"; 9import { useAtom } from "jotai"; 10 11import { constellationURLAtom, slingshotURLAtom } from "./atoms"; 12 13export function constructIdentityQuery(didorhandle?: string, slingshoturl?: string) { 14 return queryOptions({ 15 queryKey: ["identity", didorhandle], 16 queryFn: async () => { 17 if (!didorhandle) return undefined as undefined 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 const [slingshoturl] = useAtom(slingshotURLAtom) 62 return useQuery(constructIdentityQuery(didorhandle, slingshoturl)); 63} 64 65export function constructPostQuery(uri?: string, slingshoturl?: string) { 66 return queryOptions({ 67 queryKey: ["post", uri], 68 queryFn: async () => { 69 if (!uri) return undefined as undefined 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 const [slingshoturl] = useAtom(slingshotURLAtom) 126 return useQuery(constructPostQuery(uri, slingshoturl)); 127} 128 129export function constructProfileQuery(uri?: string, slingshoturl?: string) { 130 return queryOptions({ 131 queryKey: ["profile", uri], 132 queryFn: async () => { 133 if (!uri) return undefined as undefined 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 const [slingshoturl] = useAtom(slingshotURLAtom) 190 return useQuery(constructProfileQuery(uri, slingshoturl)); 191} 192 193// export function constructConstellationQuery( 194// method: "/links", 195// target: string, 196// collection: string, 197// path: string, 198// cursor?: string 199// ): QueryOptions<linksRecordsResponse, Error>; 200// export function constructConstellationQuery( 201// method: "/links/distinct-dids", 202// target: string, 203// collection: string, 204// path: string, 205// cursor?: string 206// ): QueryOptions<linksDidsResponse, Error>; 207// export function constructConstellationQuery( 208// method: "/links/count", 209// target: string, 210// collection: string, 211// path: string, 212// cursor?: string 213// ): QueryOptions<linksCountResponse, Error>; 214// export function constructConstellationQuery( 215// method: "/links/count/distinct-dids", 216// target: string, 217// collection: string, 218// path: string, 219// cursor?: string 220// ): QueryOptions<linksCountResponse, Error>; 221// export function constructConstellationQuery( 222// method: "/links/all", 223// target: string 224// ): QueryOptions<linksAllResponse, Error>; 225export function constructConstellationQuery(query?:{ 226 constellation: string, 227 method: 228 | "/links" 229 | "/links/distinct-dids" 230 | "/links/count" 231 | "/links/count/distinct-dids" 232 | "/links/all" 233 | "undefined", 234 target: string, 235 collection?: string, 236 path?: string, 237 cursor?: string, 238 dids?: string[] 239} 240) { 241 // : QueryOptions< 242 // | linksRecordsResponse 243 // | linksDidsResponse 244 // | linksCountResponse 245 // | linksAllResponse 246 // | undefined, 247 // Error 248 // > 249 return queryOptions({ 250 queryKey: ["constellation", query?.method, query?.target, query?.collection, query?.path, query?.cursor, query?.dids] as const, 251 queryFn: async () => { 252 if (!query || query.method === "undefined") return undefined as undefined 253 const method = query.method 254 const target = query.target 255 const collection = query?.collection 256 const path = query?.path 257 const cursor = query.cursor 258 const dids = query?.dids 259 const res = await fetch( 260 `https://${query.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("") : ""}` 261 ); 262 if (!res.ok) throw new Error("Failed to fetch post"); 263 try { 264 switch (method) { 265 case "/links": 266 return (await res.json()) as linksRecordsResponse; 267 case "/links/distinct-dids": 268 return (await res.json()) as linksDidsResponse; 269 case "/links/count": 270 return (await res.json()) as linksCountResponse; 271 case "/links/count/distinct-dids": 272 return (await res.json()) as linksCountResponse; 273 case "/links/all": 274 return (await res.json()) as linksAllResponse; 275 default: 276 return undefined; 277 } 278 } catch (_e) { 279 return undefined; 280 } 281 }, 282 // enforce short lifespan 283 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 284 gcTime: /*0//*/5 * 60 * 1000, 285 }); 286} 287// todo do more of these instead of overloads since overloads sucks so much apparently 288export function useQueryConstellationLinksCountDistinctDids(query?: { 289 method: "/links/count/distinct-dids"; 290 target: string; 291 collection: string; 292 path: string; 293 cursor?: string; 294}): UseQueryResult<linksCountResponse, Error> | undefined { 295 //if (!query) return; 296 const [constellationurl] = useAtom(constellationURLAtom) 297 const queryres = useQuery( 298 constructConstellationQuery(query && {constellation: constellationurl, ...query}) 299 ) as unknown as UseQueryResult<linksCountResponse, Error>; 300 if (!query) { 301 return undefined as undefined; 302 } 303 return queryres as UseQueryResult<linksCountResponse, Error>; 304} 305 306export function useQueryConstellation(query: { 307 method: "/links"; 308 target: string; 309 collection: string; 310 path: string; 311 cursor?: string; 312 dids?: string[]; 313}): UseQueryResult<linksRecordsResponse, Error>; 314export function useQueryConstellation(query: { 315 method: "/links/distinct-dids"; 316 target: string; 317 collection: string; 318 path: string; 319 cursor?: string; 320}): UseQueryResult<linksDidsResponse, Error>; 321export function useQueryConstellation(query: { 322 method: "/links/count"; 323 target: string; 324 collection: string; 325 path: string; 326 cursor?: string; 327}): UseQueryResult<linksCountResponse, Error>; 328export function useQueryConstellation(query: { 329 method: "/links/count/distinct-dids"; 330 target: string; 331 collection: string; 332 path: string; 333 cursor?: string; 334}): UseQueryResult<linksCountResponse, Error>; 335export function useQueryConstellation(query: { 336 method: "/links/all"; 337 target: string; 338}): UseQueryResult<linksAllResponse, Error>; 339export function useQueryConstellation(): undefined; 340export function useQueryConstellation(query: { 341 method: "undefined"; 342 target: string; 343}): undefined; 344export function useQueryConstellation(query?: { 345 method: 346 | "/links" 347 | "/links/distinct-dids" 348 | "/links/count" 349 | "/links/count/distinct-dids" 350 | "/links/all" 351 | "undefined"; 352 target: string; 353 collection?: string; 354 path?: string; 355 cursor?: string; 356 dids?: string[]; 357}): 358 | UseQueryResult< 359 | linksRecordsResponse 360 | linksDidsResponse 361 | linksCountResponse 362 | linksAllResponse 363 | undefined, 364 Error 365 > 366 | undefined { 367 //if (!query) return; 368 const [constellationurl] = useAtom(constellationURLAtom) 369 return useQuery( 370 constructConstellationQuery(query && {constellation: constellationurl, ...query}) 371 ); 372} 373 374export type linksRecord = { 375 did: string; 376 collection: string; 377 rkey: string; 378}; 379export type linksRecordsResponse = { 380 total: string; 381 linking_records: linksRecord[]; 382 cursor?: string; 383}; 384type linksDidsResponse = { 385 total: string; 386 linking_dids: string[]; 387 cursor?: string; 388}; 389type linksCountResponse = { 390 total: string; 391}; 392export type linksAllResponse = { 393 links: Record< 394 string, 395 Record< 396 string, 397 { 398 records: number; 399 distinct_dids: number; 400 } 401 > 402 >; 403}; 404 405export function constructFeedSkeletonQuery(options?: { 406 feedUri: string; 407 agent?: ATPAPI.Agent; 408 isAuthed: boolean; 409 pdsUrl?: string; 410 feedServiceDid?: string; 411}) { 412 return queryOptions({ 413 // The query key includes all dependencies to ensure it refetches when they change 414 queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 415 queryFn: async () => { 416 if (!options) return undefined as undefined 417 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 418 if (isAuthed) { 419 // Authenticated flow 420 if (!agent || !pdsUrl || !feedServiceDid) { 421 throw new Error("Missing required info for authenticated feed fetch."); 422 } 423 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 424 const res = await agent.fetchHandler(url, { 425 method: "GET", 426 headers: { 427 "atproto-proxy": `${feedServiceDid}#bsky_fg`, 428 "Content-Type": "application/json", 429 }, 430 }); 431 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 432 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 433 } else { 434 // Unauthenticated flow (using a public PDS/AppView) 435 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 436 const res = await fetch(url); 437 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 438 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 439 } 440 }, 441 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true), 442 }); 443} 444 445export function useQueryFeedSkeleton(options?: { 446 feedUri: string; 447 agent?: ATPAPI.Agent; 448 isAuthed: boolean; 449 pdsUrl?: string; 450 feedServiceDid?: string; 451}) { 452 return useQuery(constructFeedSkeletonQuery(options)); 453} 454 455export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 456 return queryOptions({ 457 queryKey: ['preferences', agent?.did], 458 queryFn: async () => { 459 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 460 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 461 const res = await agent.fetchHandler(url, { method: "GET" }); 462 if (!res.ok) throw new Error("Failed to fetch preferences"); 463 return res.json(); 464 }, 465 }); 466} 467export function useQueryPreferences(options: { 468 agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 469}) { 470 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 471} 472 473 474 475export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 476 return queryOptions({ 477 queryKey: ["arbitrary", uri], 478 queryFn: async () => { 479 if (!uri) return undefined as undefined 480 const res = await fetch( 481 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 482 ); 483 let data: any; 484 try { 485 data = await res.json(); 486 } catch { 487 return undefined; 488 } 489 if (res.status === 400) return undefined; 490 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 491 return undefined; // cache “not found” 492 } 493 try { 494 if (!res.ok) throw new Error("Failed to fetch post"); 495 return (data) as { 496 uri: string; 497 cid: string; 498 value: any; 499 }; 500 } catch (_e) { 501 return undefined; 502 } 503 }, 504 retry: (failureCount, error) => { 505 // dont retry 400 errors 506 if ((error as any)?.message?.includes("400")) return false; 507 return failureCount < 2; 508 }, 509 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 510 gcTime: /*0//*/5 * 60 * 1000, 511 }); 512} 513export function useQueryArbitrary(uri: string): UseQueryResult< 514 { 515 uri: string; 516 cid: string; 517 value: any; 518 }, 519 Error 520>; 521export function useQueryArbitrary(): UseQueryResult< 522 undefined, 523 Error 524>; 525export function useQueryArbitrary(uri?: string): UseQueryResult< 526 { 527 uri: string; 528 cid: string; 529 value: any; 530 } | undefined, 531 Error 532>; 533export function useQueryArbitrary(uri?: string) { 534 const [slingshoturl] = useAtom(slingshotURLAtom) 535 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 536} 537 538export function constructFallbackNothingQuery(){ 539 return queryOptions({ 540 queryKey: ["nothing"], 541 queryFn: async () => { 542 return undefined 543 }, 544 }); 545} 546 547type ListRecordsResponse = { 548 cursor?: string; 549 records: { 550 uri: string; 551 cid: string; 552 value: ATPAPI.AppBskyFeedPost.Record; 553 }[]; 554}; 555 556export function constructAuthorFeedQuery(did: string, pdsUrl: string, collection: string = "app.bsky.feed.post") { 557 return queryOptions({ 558 queryKey: ['authorFeed', did, collection], 559 queryFn: async ({ pageParam }: QueryFunctionContext) => { 560 const limit = 25; 561 562 const cursor = pageParam as string | undefined; 563 const cursorParam = cursor ? `&cursor=${cursor}` : ''; 564 565 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=${limit}${cursorParam}`; 566 567 const res = await fetch(url); 568 if (!res.ok) throw new Error("Failed to fetch author's posts"); 569 570 return res.json() as Promise<ListRecordsResponse>; 571 }, 572 }); 573} 574 575export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined, collection?: string) { 576 const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!, collection); 577 578 return useInfiniteQuery({ 579 queryKey, 580 queryFn, 581 initialPageParam: undefined as never, // ???? what is this shit 582 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 583 enabled: !!did && !!pdsUrl, 584 }); 585} 586 587type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 588 589export function constructInfiniteFeedSkeletonQuery(options: { 590 feedUri: string; 591 agent?: ATPAPI.Agent; 592 isAuthed: boolean; 593 pdsUrl?: string; 594 feedServiceDid?: string; 595 // todo the hell is a unauthedfeedurl 596 unauthedfeedurl?: string; 597}) { 598 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid, unauthedfeedurl } = options; 599 600 return queryOptions({ 601 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 602 603 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 604 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 605 606 if (isAuthed && !unauthedfeedurl) { 607 if (!agent || !pdsUrl || !feedServiceDid) { 608 throw new Error("Missing required info for authenticated feed fetch."); 609 } 610 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 611 const res = await agent.fetchHandler(url, { 612 method: "GET", 613 headers: { 614 "atproto-proxy": `${feedServiceDid}#bsky_fg`, 615 "Content-Type": "application/json", 616 }, 617 }); 618 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 619 return (await res.json()) as FeedSkeletonPage; 620 } else { 621 const url = `https://${unauthedfeedurl ? unauthedfeedurl : "discover.bsky.app"}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 622 const res = await fetch(url); 623 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 624 return (await res.json()) as FeedSkeletonPage; 625 } 626 }, 627 }); 628} 629 630export function useInfiniteQueryFeedSkeleton(options: { 631 feedUri: string; 632 agent?: ATPAPI.Agent; 633 isAuthed: boolean; 634 pdsUrl?: string; 635 feedServiceDid?: string; 636 unauthedfeedurl?: string; 637}) { 638 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 639 640 return {...useInfiniteQuery({ 641 queryKey, 642 queryFn, 643 initialPageParam: undefined as never, 644 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 645 staleTime: Infinity, 646 refetchOnWindowFocus: false, 647 enabled: !!options.feedUri && (options.isAuthed ? (!!options.agent && !!options.pdsUrl || !!options.unauthedfeedurl) && !!options.feedServiceDid : true), 648 }), queryKey: queryKey}; 649} 650 651 652export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 653 constellation: string, 654 method: '/links' 655 target?: string 656 collection: string 657 path: string, 658 staleMult?: number 659}) { 660 const safemult = query?.staleMult ?? 1; 661 // console.log( 662 // 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 663 // query, 664 // ) 665 666 return infiniteQueryOptions({ 667 enabled: !!query?.target, 668 queryKey: [ 669 'reddwarf_constellation', 670 query?.method, 671 query?.target, 672 query?.collection, 673 query?.path, 674 ] as const, 675 676 queryFn: async ({pageParam}: {pageParam?: string}) => { 677 if (!query || !query?.target) return undefined 678 679 const method = query.method 680 const target = query.target 681 const collection = query.collection 682 const path = query.path 683 const cursor = pageParam 684 685 const res = await fetch( 686 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 687 collection ? `&collection=${encodeURIComponent(collection)}` : '' 688 }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 689 cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 690 }`, 691 ) 692 693 if (!res.ok) throw new Error('Failed to fetch') 694 695 return (await res.json()) as linksRecordsResponse 696 }, 697 698 getNextPageParam: lastPage => { 699 return (lastPage as any)?.cursor ?? undefined 700 }, 701 initialPageParam: undefined, 702 staleTime: 5 * 60 * 1000 * safemult, 703 gcTime: 5 * 60 * 1000 * safemult, 704 }) 705}