an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
1import { 2 queryOptions, 3 useQuery, 4 useInfiniteQuery, 5 type QueryFunctionContext, 6 type UseQueryResult, 7 type InfiniteData 8} from "@tanstack/react-query"; 9import * as ATPAPI from "@atproto/api"; 10 11export function constructIdentityQuery(didorhandle?: string) { 12 return queryOptions({ 13 queryKey: ["identity", didorhandle], 14 queryFn: async () => { 15 if (!didorhandle) return undefined as undefined 16 const res = await fetch( 17 `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 18 ); 19 if (!res.ok) throw new Error("Failed to fetch post"); 20 try { 21 return (await res.json()) as { 22 did: string; 23 handle: string; 24 pds: string; 25 signing_key: string; 26 }; 27 } catch (_e) { 28 return undefined; 29 } 30 }, 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 if (!res.ok) throw new Error("Failed to fetch post"); 69 try { 70 return (await res.json()) as { 71 uri: string; 72 cid: string; 73 value: ATPAPI.AppBskyFeedPost.Record; 74 }; 75 } catch (_e) { 76 return undefined; 77 } 78 }, 79 }); 80} 81export function useQueryPost(uri: string): UseQueryResult< 82 { 83 uri: string; 84 cid: string; 85 value: ATPAPI.AppBskyFeedPost.Record; 86 }, 87 Error 88>; 89export function useQueryPost(): UseQueryResult< 90 undefined, 91 Error 92 > 93export function useQueryPost(uri?: string): 94 UseQueryResult< 95 { 96 uri: string; 97 cid: string; 98 value: ATPAPI.AppBskyFeedPost.Record; 99 } | undefined, 100 Error 101 > 102export function useQueryPost(uri?: string) { 103 return useQuery(constructPostQuery(uri)); 104} 105 106export function constructProfileQuery(uri?: string) { 107 return queryOptions({ 108 queryKey: ["profile", uri], 109 queryFn: async () => { 110 if (!uri) return undefined as undefined 111 const res = await fetch( 112 `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 113 ); 114 if (!res.ok) throw new Error("Failed to fetch post"); 115 try { 116 return (await res.json()) as { 117 uri: string; 118 cid: string; 119 value: ATPAPI.AppBskyActorProfile.Record; 120 }; 121 } catch (_e) { 122 return undefined; 123 } 124 }, 125 }); 126} 127export function useQueryProfile(uri: string): UseQueryResult< 128 { 129 uri: string; 130 cid: string; 131 value: ATPAPI.AppBskyActorProfile.Record; 132 }, 133 Error 134>; 135export function useQueryProfile(): UseQueryResult< 136 undefined, 137 Error 138>; 139export function useQueryProfile(uri?: string): 140 UseQueryResult< 141 { 142 uri: string; 143 cid: string; 144 value: ATPAPI.AppBskyActorProfile.Record; 145 } | undefined, 146 Error 147 > 148export function useQueryProfile(uri?: string) { 149 return useQuery(constructProfileQuery(uri)); 150} 151 152// export function constructConstellationQuery( 153// method: "/links", 154// target: string, 155// collection: string, 156// path: string, 157// cursor?: string 158// ): QueryOptions<linksRecordsResponse, Error>; 159// export function constructConstellationQuery( 160// method: "/links/distinct-dids", 161// target: string, 162// collection: string, 163// path: string, 164// cursor?: string 165// ): QueryOptions<linksDidsResponse, Error>; 166// export function constructConstellationQuery( 167// method: "/links/count", 168// target: string, 169// collection: string, 170// path: string, 171// cursor?: string 172// ): QueryOptions<linksCountResponse, Error>; 173// export function constructConstellationQuery( 174// method: "/links/count/distinct-dids", 175// target: string, 176// collection: string, 177// path: string, 178// cursor?: string 179// ): QueryOptions<linksCountResponse, Error>; 180// export function constructConstellationQuery( 181// method: "/links/all", 182// target: string 183// ): QueryOptions<linksAllResponse, Error>; 184export function constructConstellationQuery(query?:{ 185 method: 186 | "/links" 187 | "/links/distinct-dids" 188 | "/links/count" 189 | "/links/count/distinct-dids" 190 | "/links/all", 191 target: string, 192 collection?: string, 193 path?: string, 194 cursor?: string 195} 196) { 197 // : QueryOptions< 198 // | linksRecordsResponse 199 // | linksDidsResponse 200 // | linksCountResponse 201 // | linksAllResponse 202 // | undefined, 203 // Error 204 // > 205 return queryOptions({ 206 queryKey: ["post", query?.method, query?.target, query?.collection, query?.path, query?.cursor] as const, 207 queryFn: async () => { 208 if (!query) return undefined as undefined 209 const method = query.method 210 const target = query.target 211 const collection = query?.collection 212 const path = query?.path 213 const cursor = query.cursor 214 const res = await fetch( 215 `https://constellation.microcosm.blue${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}` 216 ); 217 if (!res.ok) throw new Error("Failed to fetch post"); 218 try { 219 switch (method) { 220 case "/links": 221 return (await res.json()) as linksRecordsResponse; 222 case "/links/distinct-dids": 223 return (await res.json()) as linksDidsResponse; 224 case "/links/count": 225 return (await res.json()) as linksCountResponse; 226 case "/links/count/distinct-dids": 227 return (await res.json()) as linksCountResponse; 228 case "/links/all": 229 return (await res.json()) as linksAllResponse; 230 default: 231 return undefined; 232 } 233 } catch (_e) { 234 return undefined; 235 } 236 }, 237 }); 238} 239export function useQueryConstellation(query: { 240 method: "/links"; 241 target: string; 242 collection: string; 243 path: string; 244 cursor?: string; 245}): UseQueryResult<linksRecordsResponse, Error>; 246export function useQueryConstellation(query: { 247 method: "/links/distinct-dids"; 248 target: string; 249 collection: string; 250 path: string; 251 cursor?: string; 252}): UseQueryResult<linksDidsResponse, Error>; 253export function useQueryConstellation(query: { 254 method: "/links/count"; 255 target: string; 256 collection: string; 257 path: string; 258 cursor?: string; 259}): UseQueryResult<linksCountResponse, Error>; 260export function useQueryConstellation(query: { 261 method: "/links/count/distinct-dids"; 262 target: string; 263 collection: string; 264 path: string; 265 cursor?: string; 266}): UseQueryResult<linksCountResponse, Error>; 267export function useQueryConstellation(query: { 268 method: "/links/all"; 269 target: string; 270}): UseQueryResult<linksAllResponse, Error>; 271export function useQueryConstellation(): undefined; 272export function useQueryConstellation(query?: { 273 method: 274 | "/links" 275 | "/links/distinct-dids" 276 | "/links/count" 277 | "/links/count/distinct-dids" 278 | "/links/all"; 279 target: string; 280 collection?: string; 281 path?: string; 282 cursor?: string; 283}): 284 | UseQueryResult< 285 | linksRecordsResponse 286 | linksDidsResponse 287 | linksCountResponse 288 | linksAllResponse 289 | undefined, 290 Error 291 > 292 | undefined { 293 //if (!query) return; 294 return useQuery( 295 constructConstellationQuery(query) 296 ); 297} 298 299type linksRecord = { 300 did: string; 301 collection: string; 302 rkey: string; 303}; 304type linksRecordsResponse = { 305 total: string; 306 linking_records: linksRecord[]; 307 cursor?: string; 308}; 309type linksDidsResponse = { 310 total: string; 311 linking_dids: string[]; 312 cursor?: string; 313}; 314type linksCountResponse = { 315 total: string; 316}; 317type linksAllResponse = { 318 links: Record< 319 string, 320 Record< 321 string, 322 { 323 records: number; 324 distinct_dids: number; 325 } 326 > 327 >; 328}; 329 330export function constructFeedSkeletonQuery(options?: { 331 feedUri: string; 332 agent?: ATPAPI.AtpAgent; 333 isAuthed: boolean; 334 pdsUrl?: string; 335 feedServiceDid?: string; 336}) { 337 return queryOptions({ 338 // The query key includes all dependencies to ensure it refetches when they change 339 queryKey: ["feedSkeleton", options?.feedUri, { isAuthed: options?.isAuthed, did: options?.agent?.did }], 340 queryFn: async () => { 341 if (!options) return undefined as undefined 342 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 343 if (isAuthed) { 344 // Authenticated flow 345 if (!agent || !pdsUrl || !feedServiceDid) { 346 throw new Error("Missing required info for authenticated feed fetch."); 347 } 348 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 349 const res = await agent.fetchHandler(url, { 350 method: "GET", 351 headers: { 352 "atproto-proxy": `${feedServiceDid}#bsky_fg`, 353 "Content-Type": "application/json", 354 }, 355 }); 356 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 357 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 358 } else { 359 // Unauthenticated flow (using a public PDS/AppView) 360 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; 361 const res = await fetch(url); 362 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 363 return (await res.json()) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 364 } 365 }, 366 //enabled: !!feedUri && (isAuthed ? !!agent && !!pdsUrl && !!feedServiceDid : true), 367 }); 368} 369 370export function useQueryFeedSkeleton(options?: { 371 feedUri: string; 372 agent?: ATPAPI.AtpAgent; 373 isAuthed: boolean; 374 pdsUrl?: string; 375 feedServiceDid?: string; 376}) { 377 return useQuery(constructFeedSkeletonQuery(options)); 378} 379 380export function constructPreferencesQuery(agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined) { 381 return queryOptions({ 382 queryKey: ['preferences', agent?.did], 383 queryFn: async () => { 384 if (!agent || !pdsUrl) throw new Error("Agent or PDS URL not available"); 385 const url = `${pdsUrl}/xrpc/app.bsky.actor.getPreferences`; 386 const res = await agent.fetchHandler(url, { method: "GET" }); 387 if (!res.ok) throw new Error("Failed to fetch preferences"); 388 return res.json(); 389 }, 390 }); 391} 392export function useQueryPreferences(options: { 393 agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined 394}) { 395 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 396} 397 398 399 400export function constructArbitraryQuery(uri?: string) { 401 return queryOptions({ 402 queryKey: ["post", uri], 403 queryFn: async () => { 404 if (!uri) return undefined as undefined 405 const res = await fetch( 406 `https://slingshot.microcosm.blue/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 407 ); 408 if (!res.ok) throw new Error("Failed to fetch post"); 409 try { 410 return (await res.json()) as { 411 uri: string; 412 cid: string; 413 value: any; 414 }; 415 } catch (_e) { 416 return undefined; 417 } 418 }, 419 }); 420} 421export function useQueryArbitrary(uri: string): UseQueryResult< 422 { 423 uri: string; 424 cid: string; 425 value: any; 426 }, 427 Error 428>; 429export function useQueryArbitrary(): UseQueryResult< 430 undefined, 431 Error 432>; 433export function useQueryArbitrary(uri?: string): UseQueryResult< 434 { 435 uri: string; 436 cid: string; 437 value: any; 438 } | undefined, 439 Error 440>; 441export function useQueryArbitrary(uri?: string) { 442 return useQuery(constructArbitraryQuery(uri)); 443} 444 445export function constructFallbackNothingQuery(){ 446 return queryOptions({ 447 queryKey: ["nothing"], 448 queryFn: async () => { 449 return undefined 450 }, 451 }); 452} 453 454type ListRecordsResponse = { 455 cursor?: string; 456 records: { 457 uri: string; 458 cid: string; 459 value: ATPAPI.AppBskyFeedPost.Record; 460 }[]; 461}; 462 463export function constructAuthorFeedQuery(did: string, pdsUrl: string) { 464 return queryOptions({ 465 queryKey: ['authorFeed', did], 466 queryFn: async ({ pageParam }: QueryFunctionContext) => { 467 const limit = 25; 468 469 const cursor = pageParam as string | undefined; 470 const cursorParam = cursor ? `&cursor=${cursor}` : ''; 471 472 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`; 473 474 const res = await fetch(url); 475 if (!res.ok) throw new Error("Failed to fetch author's posts"); 476 477 return res.json() as Promise<ListRecordsResponse>; 478 }, 479 }); 480} 481 482export function useInfiniteQueryAuthorFeed(did: string | undefined, pdsUrl: string | undefined) { 483 const { queryKey, queryFn } = constructAuthorFeedQuery(did!, pdsUrl!); 484 485 return useInfiniteQuery({ 486 queryKey, 487 queryFn, 488 initialPageParam: undefined as never, // ???? what is this shit 489 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 490 enabled: !!did && !!pdsUrl, 491 }); 492} 493 494type FeedSkeletonPage = ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 495 496export function constructInfiniteFeedSkeletonQuery(options: { 497 feedUri: string; 498 agent?: ATPAPI.AtpAgent; 499 isAuthed: boolean; 500 pdsUrl?: string; 501 feedServiceDid?: string; 502}) { 503 const { feedUri, agent, isAuthed, pdsUrl, feedServiceDid } = options; 504 505 return queryOptions({ 506 queryKey: ["feedSkeleton", feedUri, { isAuthed, did: agent?.did }], 507 508 queryFn: async ({ pageParam }: QueryFunctionContext): Promise<FeedSkeletonPage> => { 509 const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; 510 511 if (isAuthed) { 512 if (!agent || !pdsUrl || !feedServiceDid) { 513 throw new Error("Missing required info for authenticated feed fetch."); 514 } 515 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 516 const res = await agent.fetchHandler(url, { 517 method: "GET", 518 headers: { 519 "atproto-proxy": `${feedServiceDid}#bsky_fg`, 520 "Content-Type": "application/json", 521 }, 522 }); 523 if (!res.ok) throw new Error(`Authenticated feed fetch failed: ${res.statusText}`); 524 return (await res.json()) as FeedSkeletonPage; 525 } else { 526 const url = `https://discover.bsky.app/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 527 const res = await fetch(url); 528 if (!res.ok) throw new Error(`Public feed fetch failed: ${res.statusText}`); 529 return (await res.json()) as FeedSkeletonPage; 530 } 531 }, 532 }); 533} 534 535export function useInfiniteQueryFeedSkeleton(options: { 536 feedUri: string; 537 agent?: ATPAPI.AtpAgent; 538 isAuthed: boolean; 539 pdsUrl?: string; 540 feedServiceDid?: string; 541}) { 542 const { queryKey, queryFn } = constructInfiniteFeedSkeletonQuery(options); 543 544 return useInfiniteQuery({ 545 queryKey, 546 queryFn, 547 initialPageParam: undefined as never, 548 getNextPageParam: (lastPage) => lastPage.cursor as null | undefined, 549 staleTime: Infinity, 550 refetchOnWindowFocus: false, 551 enabled: !!options.feedUri && (options.isAuthed ? !!options.agent && !!options.pdsUrl && !!options.feedServiceDid : true), 552 }); 553}