an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at button 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"; 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} 287export function useQueryConstellation(query: { 288 method: "/links"; 289 target: string; 290 collection: string; 291 path: string; 292 cursor?: string; 293 dids?: string[]; 294}): UseQueryResult<linksRecordsResponse, Error>; 295export function useQueryConstellation(query: { 296 method: "/links/distinct-dids"; 297 target: string; 298 collection: string; 299 path: string; 300 cursor?: string; 301}): UseQueryResult<linksDidsResponse, Error>; 302export function useQueryConstellation(query: { 303 method: "/links/count"; 304 target: string; 305 collection: string; 306 path: string; 307 cursor?: string; 308}): UseQueryResult<linksCountResponse, Error>; 309export function useQueryConstellation(query: { 310 method: "/links/count/distinct-dids"; 311 target: string; 312 collection: string; 313 path: string; 314 cursor?: string; 315}): UseQueryResult<linksCountResponse, Error>; 316export function useQueryConstellation(query: { 317 method: "/links/all"; 318 target: string; 319}): UseQueryResult<linksAllResponse, Error>; 320export function useQueryConstellation(): undefined; 321export function useQueryConstellation(query: { 322 method: "undefined"; 323 target: string; 324}): undefined; 325export function useQueryConstellation(query?: { 326 method: 327 | "/links" 328 | "/links/distinct-dids" 329 | "/links/count" 330 | "/links/count/distinct-dids" 331 | "/links/all" 332 | "undefined"; 333 target: string; 334 collection?: string; 335 path?: string; 336 cursor?: string; 337 dids?: string[]; 338}): 339 | UseQueryResult< 340 | linksRecordsResponse 341 | linksDidsResponse 342 | linksCountResponse 343 | linksAllResponse 344 | undefined, 345 Error 346 > 347 | undefined { 348 //if (!query) return; 349 const [constellationurl] = useAtom(constellationURLAtom) 350 return useQuery( 351 constructConstellationQuery(query && {constellation: constellationurl, ...query}) 352 ); 353} 354 355type linksRecord = { 356 did: string; 357 collection: string; 358 rkey: string; 359}; 360export type linksRecordsResponse = { 361 total: string; 362 linking_records: linksRecord[]; 363 cursor?: string; 364}; 365type linksDidsResponse = { 366 total: string; 367 linking_dids: string[]; 368 cursor?: string; 369}; 370type linksCountResponse = { 371 total: string; 372}; 373export type linksAllResponse = { 374 links: Record< 375 string, 376 Record< 377 string, 378 { 379 records: number; 380 distinct_dids: number; 381 } 382 > 383 >; 384}; 385 386export function constructFeedSkeletonQuery(options?: { 387 feedUri: string; 388 agent?: ATPAPI.Agent; 389 isAuthed: boolean; 390 pdsUrl?: string; 391 feedServiceDid?: string; 392}) { 393 return queryOptions({ 394 // The query key includes all dependencies to ensure it refetches when they change 395 queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 396 queryFn: async () => { 397 if (!options) return undefined as undefined 398 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 399 if (isAuthed) { 400 // Authenticated flow 401 if (!agent || !pdsUrl || !feedServiceDid) { 402 throw new Error("Missing required info for authenticated feed fetch."); 403 } 404 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 405 const res = await agent.fetchHandler(url, { 406 method: "GET", 407 headers: { 408 "atproto-proxy": `${feedServiceDid}#bsky_fg`, 409 "Content-Type": "application/json", 410 }, 411 }); 412 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 413 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 414 } else { 415 // Unauthenticated flow (using a public PDS/AppView) 416 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 417 const res = await fetch(url); 418 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 419 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 420 } 421 }, 422 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true), 423 }); 424} 425 426export function useQueryFeedSkeleton(options?: { 427 feedUri: string; 428 agent?: ATPAPI.Agent; 429 isAuthed: boolean; 430 pdsUrl?: string; 431 feedServiceDid?: string; 432}) { 433 return useQuery(constructFeedSkeletonQuery(options)); 434} 435 436export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 437 return queryOptions({ 438 queryKey: ['preferences', agent?.did], 439 queryFn: async () => { 440 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 441 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 442 const res = await agent.fetchHandler(url, { method: "GET" }); 443 if (!res.ok) throw new Error("Failed to fetch preferences"); 444 return res.json(); 445 }, 446 }); 447} 448export function useQueryPreferences(options: { 449 agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 450}) { 451 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 452} 453 454 455 456export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { 457 return queryOptions({ 458 queryKey: ["arbitrary", uri], 459 queryFn: async () => { 460 if (!uri) return undefined as undefined 461 const res = await fetch( 462 `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 463 ); 464 let data: any; 465 try { 466 data = await res.json(); 467 } catch { 468 return undefined; 469 } 470 if (res.status === 400) return undefined; 471 if (data?.error === "InvalidRequest" && data.message?.includes("Could not find repo")) { 472 return undefined; // cache “not found” 473 } 474 try { 475 if (!res.ok) throw new Error("Failed to fetch post"); 476 return (data) as { 477 uri: string; 478 cid: string; 479 value: any; 480 }; 481 } catch (_e) { 482 return undefined; 483 } 484 }, 485 retry: (failureCount, error) => { 486 // dont retry 400 errors 487 if ((error as any)?.message?.includes("400")) return false; 488 return failureCount < 2; 489 }, 490 staleTime: /*0,//*/ 5 * 60 * 1000, // 5 minutes 491 gcTime: /*0//*/5 * 60 * 1000, 492 }); 493} 494export function useQueryArbitrary(uri: string): UseQueryResult< 495 { 496 uri: string; 497 cid: string; 498 value: any; 499 }, 500 Error 501>; 502export function useQueryArbitrary(): UseQueryResult< 503 undefined, 504 Error 505>; 506export function useQueryArbitrary(uri?: string): UseQueryResult< 507 { 508 uri: string; 509 cid: string; 510 value: any; 511 } | undefined, 512 Error 513>; 514export function useQueryArbitrary(uri?: string) { 515 const [slingshoturl] = useAtom(slingshotURLAtom) 516 return useQuery(constructArbitraryQuery(uri, slingshoturl)); 517} 518 519export function constructFallbackNothingQuery(){ 520 return queryOptions({ 521 queryKey: ["nothing"], 522 queryFn: async () => { 523 return undefined 524 }, 525 }); 526} 527 528type ListRecordsResponse = { 529 cursor?: string; 530 records: { 531 uri: string; 532 cid: string; 533 value: ATPAPI.AppBskyFeedPost.Record; 534 }[]; 535}; 536 537export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 538 return queryOptions({ 539 queryKey: ['authorFeed', did], 540 queryFn: async ({ pageParam }: QueryFunctionContext) => { 541 const limit = 25; 542 543 const cursor = pageParam as string | undefined; 544 const cursorParam = cursor ? `&cursor=${cursor}` : ''; 545 546 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 547 548 const res = await fetch(url); 549 if (!res.ok) throw new Error("Failed to fetch author's posts"); 550 551 return res.json() as Promise<ListRecordsResponse>; 552 }, 553 }); 554} 555 556export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 557 const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 558 559 return useInfiniteQuery({ 560 queryKey, 561 queryFn, 562 initialPageParam: undefined as never, // ???? what is this shit 563 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 564 enabled: !!did && !!pdsUrl, 565 }); 566} 567 568type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 569 570export function constructInfiniteFeedSkeletonQuery(options: { 571 feedUri: string; 572 agent?: ATPAPI.Agent; 573 isAuthed: boolean; 574 pdsUrl?: string; 575 feedServiceDid?: string; 576}) { 577 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 578 579 return queryOptions({ 580 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 581 582 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 583 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 584 585 if (isAuthed) { 586 if (!agent || !pdsUrl || !feedServiceDid) { 587 throw new Error("Missing required info for authenticated feed fetch."); 588 } 589 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 590 const res = await agent.fetchHandler(url, { 591 method: "GET", 592 headers: { 593 "atproto-proxy": `${feedServiceDid}#bsky_fg`, 594 "Content-Type": "application/json", 595 }, 596 }); 597 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 598 return (await res.json()) as FeedSkeletonPage; 599 } else { 600 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 601 const res = await fetch(url); 602 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 603 return (await res.json()) as FeedSkeletonPage; 604 } 605 }, 606 }); 607} 608 609export function useInfiniteQueryFeedSkeleton(options: { 610 feedUri: string; 611 agent?: ATPAPI.Agent; 612 isAuthed: boolean; 613 pdsUrl?: string; 614 feedServiceDid?: string; 615}) { 616 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 617 618 return {...useInfiniteQuery({ 619 queryKey, 620 queryFn, 621 initialPageParam: undefined as never, 622 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 623 staleTime: Infinity, 624 refetchOnWindowFocus: false, 625 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 626 }), queryKey: queryKey}; 627} 628 629 630export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks(query?: { 631 constellation: string, 632 method: '/links' 633 target?: string 634 collection: string 635 path: string 636}) { 637 console.log( 638 'yknowIReallyHateThisButWhateverGuardedConstructConstellationInfiniteQueryLinks', 639 query, 640 ) 641 642 return infiniteQueryOptions({ 643 enabled: !!query?.target, 644 queryKey: [ 645 'reddwarf_constellation', 646 query?.method, 647 query?.target, 648 query?.collection, 649 query?.path, 650 ] as const, 651 652 queryFn: async ({pageParam}: {pageParam?: string}) => { 653 if (!query || !query?.target) return undefined 654 655 const method = query.method 656 const target = query.target 657 const collection = query.collection 658 const path = query.path 659 const cursor = pageParam 660 661 const res = await fetch( 662 `https://${query.constellation}${method}?target=${encodeURIComponent(target)}${ 663 collection ? `&collection=${encodeURIComponent(collection)}` : '' 664 }${path ? `&path=${encodeURIComponent(path)}` : ''}${ 665 cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' 666 }`, 667 ) 668 669 if (!res.ok) throw new Error('Failed to fetch') 670 671 return (await res.json()) as linksRecordsResponse 672 }, 673 674 getNextPageParam: lastPage => { 675 return (lastPage as any)?.cursor ?? undefined 676 }, 677 initialPageParam: undefined, 678 staleTime: 5 * 60 * 1000, 679 gcTime: 5 * 60 * 1000, 680 }) 681}