A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useReducer, useRef } from "react"; 2import { useDidResolution } from "./useDidResolution"; 3import { usePdsEndpoint } from "./usePdsEndpoint"; 4import { createAtprotoClient } from "../utils/atproto-client"; 5import { useAtProto } from "../providers/AtProtoProvider"; 6 7/** 8 * Extended blob reference that includes CDN URL from appview responses. 9 */ 10export interface BlobWithCdn { 11 $type: "blob"; 12 ref: { $link: string }; 13 mimeType: string; 14 size: number; 15 /** CDN URL from Bluesky appview (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg) */ 16 cdnUrl?: string; 17} 18 19 20 21/** 22 * Appview getProfile response structure. 23 */ 24interface AppviewProfileResponse { 25 did: string; 26 handle: string; 27 displayName?: string; 28 description?: string; 29 avatar?: string; 30 banner?: string; 31 createdAt?: string; 32 pronouns?: string; 33 website?: string; 34 [key: string]: unknown; 35} 36 37/** 38 * Appview getPostThread response structure. 39 */ 40interface AppviewPostThreadResponse<T = unknown> { 41 thread?: { 42 post?: { 43 record?: T; 44 embed?: { 45 $type?: string; 46 images?: Array<{ 47 thumb?: string; 48 fullsize?: string; 49 alt?: string; 50 aspectRatio?: { width: number; height: number }; 51 }>; 52 media?: { 53 images?: Array<{ 54 thumb?: string; 55 fullsize?: string; 56 alt?: string; 57 aspectRatio?: { width: number; height: number }; 58 }>; 59 }; 60 }; 61 }; 62 }; 63} 64 65/** 66 * Options for {@link useBlueskyAppview}. 67 */ 68export interface UseBlueskyAppviewOptions { 69 /** DID or handle of the actor. */ 70 did?: string; 71 /** NSID collection (e.g., "app.bsky.feed.post"). */ 72 collection?: string; 73 /** Record key within the collection. */ 74 rkey?: string; 75 /** Override for the Bluesky appview service URL. Defaults to public.api.bsky.app. */ 76 appviewService?: string; 77 /** If true, skip the appview and go straight to Slingshot/PDS fallback. */ 78 skipAppview?: boolean; 79} 80 81/** 82 * Result returned from {@link useBlueskyAppview}. 83 */ 84export interface UseBlueskyAppviewResult<T = unknown> { 85 /** The fetched record value. */ 86 record?: T; 87 /** Indicates whether a fetch is in progress. */ 88 loading: boolean; 89 /** Error encountered during fetch. */ 90 error?: Error; 91 /** Source from which the record was successfully fetched. */ 92 source?: "appview" | "slingshot" | "pds"; 93} 94 95/** 96 * Maps Bluesky collection NSIDs to their corresponding appview API endpoints. 97 * Only includes endpoints that can fetch individual records (not list endpoints). 98 */ 99const BLUESKY_COLLECTION_TO_ENDPOINT: Record<string, string> = { 100 "app.bsky.actor.profile": "app.bsky.actor.getProfile", 101 "app.bsky.feed.post": "app.bsky.feed.getPostThread", 102 103}; 104 105/** 106 * React hook that fetches a Bluesky record with a three-tier fallback strategy: 107 * 1. Try the Bluesky appview API endpoint (e.g., getProfile, getPostThread) 108 * 2. Fall back to Slingshot's getRecord 109 * 3. As a last resort, query the actor's PDS directly 110 * 111 * The hook automatically handles DID resolution and determines the appropriate API endpoint 112 * based on the collection type. The `source` field in the result indicates which tier 113 * successfully returned the record. 114 * 115 * @example 116 * ```tsx 117 * // Fetch a Bluesky post with automatic fallback 118 * import { useBlueskyAppview } from 'atproto-ui'; 119 * import type { FeedPostRecord } from 'atproto-ui'; 120 * 121 * function MyPost({ did, rkey }: { did: string; rkey: string }) { 122 * const { record, loading, error, source } = useBlueskyAppview<FeedPostRecord>({ 123 * did, 124 * collection: 'app.bsky.feed.post', 125 * rkey, 126 * }); 127 * 128 * if (loading) return <p>Loading post...</p>; 129 * if (error) return <p>Error: {error.message}</p>; 130 * if (!record) return <p>No post found</p>; 131 * 132 * return ( 133 * <article> 134 * <p>{record.text}</p> 135 * <small>Fetched from: {source}</small> 136 * </article> 137 * ); 138 * } 139 * ``` 140 * 141 * @example 142 * ```tsx 143 * // Fetch a Bluesky profile 144 * import { useBlueskyAppview } from 'atproto-ui'; 145 * import type { ProfileRecord } from 'atproto-ui'; 146 * 147 * function MyProfile({ handle }: { handle: string }) { 148 * const { record, loading, error } = useBlueskyAppview<ProfileRecord>({ 149 * did: handle, // Handles are automatically resolved to DIDs 150 * collection: 'app.bsky.actor.profile', 151 * rkey: 'self', 152 * }); 153 * 154 * if (loading) return <p>Loading profile...</p>; 155 * if (!record) return null; 156 * 157 * return ( 158 * <div> 159 * <h2>{record.displayName}</h2> 160 * <p>{record.description}</p> 161 * </div> 162 * ); 163 * } 164 * ``` 165 * 166 * @example 167 * ```tsx 168 * // Skip the appview and go directly to Slingshot/PDS 169 * const { record } = useBlueskyAppview({ 170 * did: 'did:plc:example', 171 * collection: 'app.bsky.feed.post', 172 * rkey: '3k2aexample', 173 * skipAppview: true, // Bypasses Bluesky API, starts with Slingshot 174 * }); 175 * ``` 176 * 177 * @param options - Configuration object with did, collection, rkey, and optional overrides. 178 * @returns {UseBlueskyAppviewResult<T>} Object containing the record, loading state, error, and source. 179 */ 180 181// Reducer action types for useBlueskyAppview 182type BlueskyAppviewAction<T> = 183 | { type: "SET_LOADING"; loading: boolean } 184 | { type: "SET_SUCCESS"; record: T; source: "appview" | "slingshot" | "pds" } 185 | { type: "SET_ERROR"; error: Error } 186 | { type: "RESET" }; 187 188// Reducer function for atomic state updates 189function blueskyAppviewReducer<T>( 190 state: UseBlueskyAppviewResult<T>, 191 action: BlueskyAppviewAction<T> 192): UseBlueskyAppviewResult<T> { 193 switch (action.type) { 194 case "SET_LOADING": 195 return { 196 ...state, 197 loading: action.loading, 198 error: undefined, 199 }; 200 case "SET_SUCCESS": 201 return { 202 record: action.record, 203 loading: false, 204 error: undefined, 205 source: action.source, 206 }; 207 case "SET_ERROR": 208 // Only update if error message changed (stabilize error reference) 209 if (state.error?.message === action.error.message) { 210 return state; 211 } 212 return { 213 ...state, 214 loading: false, 215 error: action.error, 216 source: undefined, 217 }; 218 case "RESET": 219 return { 220 record: undefined, 221 loading: false, 222 error: undefined, 223 source: undefined, 224 }; 225 default: 226 return state; 227 } 228} 229 230export function useBlueskyAppview<T = unknown>({ 231 did: handleOrDid, 232 collection, 233 rkey, 234 appviewService, 235 skipAppview = false, 236}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> { 237 const { recordCache, blueskyAppviewService, resolver } = useAtProto(); 238 const effectiveAppviewService = appviewService ?? blueskyAppviewService; 239 const { 240 did, 241 error: didError, 242 loading: resolvingDid, 243 } = useDidResolution(handleOrDid); 244 const { 245 endpoint: pdsEndpoint, 246 error: endpointError, 247 loading: resolvingEndpoint, 248 } = usePdsEndpoint(did); 249 250 const [state, dispatch] = useReducer(blueskyAppviewReducer<T>, { 251 record: undefined, 252 loading: false, 253 error: undefined, 254 source: undefined, 255 }); 256 257 const releaseRef = useRef<(() => void) | undefined>(undefined); 258 259 useEffect(() => { 260 let cancelled = false; 261 262 // Early returns for missing inputs or resolution errors 263 if (!handleOrDid || !collection || !rkey) { 264 if (!cancelled) dispatch({ type: "RESET" }); 265 return () => { 266 cancelled = true; 267 if (releaseRef.current) { 268 releaseRef.current(); 269 releaseRef.current = undefined; 270 } 271 }; 272 } 273 274 if (didError) { 275 if (!cancelled) dispatch({ type: "SET_ERROR", error: didError }); 276 return () => { 277 cancelled = true; 278 if (releaseRef.current) { 279 releaseRef.current(); 280 releaseRef.current = undefined; 281 } 282 }; 283 } 284 285 if (endpointError) { 286 if (!cancelled) dispatch({ type: "SET_ERROR", error: endpointError }); 287 return () => { 288 cancelled = true; 289 if (releaseRef.current) { 290 releaseRef.current(); 291 releaseRef.current = undefined; 292 } 293 }; 294 } 295 296 if (resolvingDid || resolvingEndpoint || !did || !pdsEndpoint) { 297 if (!cancelled) dispatch({ type: "SET_LOADING", loading: true }); 298 return () => { 299 cancelled = true; 300 if (releaseRef.current) { 301 releaseRef.current(); 302 releaseRef.current = undefined; 303 } 304 }; 305 } 306 307 // Start fetching 308 dispatch({ type: "SET_LOADING", loading: true }); 309 310 // Use recordCache.ensure for deduplication and caching 311 const { promise, release } = recordCache.ensure<{ record: T; source: "appview" | "slingshot" | "pds" }>( 312 did, 313 collection, 314 rkey, 315 () => { 316 const controller = new AbortController(); 317 318 const fetchPromise = (async (): Promise<{ record: T; source: "appview" | "slingshot" | "pds" }> => { 319 let lastError: Error | undefined; 320 321 // Tier 1: Try Bluesky appview API 322 if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) { 323 try { 324 const result = await fetchFromAppview<T>( 325 did, 326 collection, 327 rkey, 328 effectiveAppviewService, 329 ); 330 if (result) { 331 return { record: result, source: "appview" }; 332 } 333 } catch (err) { 334 lastError = err as Error; 335 // Continue to next tier 336 } 337 } 338 339 // Tier 2: Try Slingshot getRecord 340 try { 341 const slingshotUrl = resolver.getSlingshotUrl(); 342 const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl); 343 if (result) { 344 return { record: result, source: "slingshot" }; 345 } 346 } catch (err) { 347 lastError = err as Error; 348 // Continue to next tier 349 } 350 351 // Tier 3: Try PDS directly 352 try { 353 const result = await fetchFromPds<T>( 354 did, 355 collection, 356 rkey, 357 pdsEndpoint, 358 ); 359 if (result) { 360 return { record: result, source: "pds" }; 361 } 362 } catch (err) { 363 lastError = err as Error; 364 } 365 366 // All tiers failed - provide helpful error for banned/unreachable Bluesky PDSes 367 if (pdsEndpoint.includes('.bsky.network')) { 368 throw new Error( 369 `Record unavailable. The Bluesky PDS (${pdsEndpoint}) may be unreachable or the account may be banned.` 370 ); 371 } 372 373 throw lastError ?? new Error("Failed to fetch record from all sources"); 374 })(); 375 376 return { 377 promise: fetchPromise, 378 abort: () => controller.abort(), 379 }; 380 } 381 ); 382 383 releaseRef.current = release; 384 385 promise 386 .then(({ record, source }) => { 387 if (!cancelled) { 388 dispatch({ 389 type: "SET_SUCCESS", 390 record, 391 source, 392 }); 393 } 394 }) 395 .catch((err) => { 396 if (!cancelled) { 397 dispatch({ 398 type: "SET_ERROR", 399 error: err instanceof Error ? err : new Error(String(err)), 400 }); 401 } 402 }); 403 404 return () => { 405 cancelled = true; 406 if (releaseRef.current) { 407 releaseRef.current(); 408 releaseRef.current = undefined; 409 } 410 }; 411 }, [ 412 handleOrDid, 413 did, 414 collection, 415 rkey, 416 pdsEndpoint, 417 effectiveAppviewService, 418 skipAppview, 419 resolvingDid, 420 resolvingEndpoint, 421 didError, 422 endpointError, 423 recordCache, 424 resolver, 425 ]); 426 427 return state; 428} 429 430/** 431 * Attempts to fetch a record from the Bluesky appview API. 432 * Different collections map to different endpoints with varying response structures. 433 */ 434async function fetchFromAppview<T>( 435 did: string, 436 collection: string, 437 rkey: string, 438 appviewService: string, 439): Promise<T | undefined> { 440 const { rpc } = await createAtprotoClient({ service: appviewService }); 441 const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection]; 442 443 if (!endpoint) { 444 throw new Error(`No appview endpoint mapped for collection ${collection}`); 445 } 446 447 const atUri = `at://${did}/${collection}/${rkey}`; 448 449 // Handle different appview endpoints 450 if (endpoint === "app.bsky.actor.getProfile") { 451 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewProfileResponse }> }).get(endpoint, { 452 params: { actor: did }, 453 }); 454 455 if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${did}`); 456 457 // The appview returns avatar/banner as CDN URLs like: 458 // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg 459 // We need to extract the CID and convert to ProfileRecord format 460 const profile = res.data; 461 const avatarCid = extractCidFromCdnUrl(profile.avatar); 462 const bannerCid = extractCidFromCdnUrl(profile.banner); 463 464 // Convert hydrated profile to ProfileRecord format 465 // Store the CDN URL directly so components can use it without re-fetching 466 const record: Record<string, unknown> = { 467 displayName: profile.displayName, 468 description: profile.description, 469 createdAt: profile.createdAt, 470 }; 471 472 // Add pronouns and website if they exist 473 if (profile.pronouns) { 474 record.pronouns = profile.pronouns; 475 } 476 477 if (profile.website) { 478 record.website = profile.website; 479 } 480 481 if (profile.avatar && avatarCid) { 482 const avatarBlob: BlobWithCdn = { 483 $type: "blob", 484 ref: { $link: avatarCid }, 485 mimeType: "image/jpeg", 486 size: 0, 487 cdnUrl: profile.avatar, 488 }; 489 record.avatar = avatarBlob; 490 } 491 492 if (profile.banner && bannerCid) { 493 const bannerBlob: BlobWithCdn = { 494 $type: "blob", 495 ref: { $link: bannerCid }, 496 mimeType: "image/jpeg", 497 size: 0, 498 cdnUrl: profile.banner, 499 }; 500 record.banner = bannerBlob; 501 } 502 503 return record as T; 504 } 505 506 if (endpoint === "app.bsky.feed.getPostThread") { 507 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewPostThreadResponse<T> }> }).get(endpoint, { 508 params: { uri: atUri, depth: 0 }, 509 }); 510 511 if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${atUri}`); 512 513 const post = res.data.thread?.post; 514 if (!post?.record) return undefined; 515 516 const record = post.record as Record<string, unknown>; 517 const appviewEmbed = post.embed; 518 519 // If the appview includes embedded images with CDN URLs, inject them into the record 520 if (appviewEmbed && record.embed) { 521 const recordEmbed = record.embed as { $type?: string; images?: Array<Record<string, unknown>>; media?: Record<string, unknown> }; 522 523 // Handle direct image embeds 524 if (appviewEmbed.$type === "app.bsky.embed.images#view" && appviewEmbed.images) { 525 if (recordEmbed.images && Array.isArray(recordEmbed.images)) { 526 recordEmbed.images = recordEmbed.images.map((img: Record<string, unknown>, idx: number) => { 527 const appviewImg = appviewEmbed.images?.[idx]; 528 if (appviewImg?.fullsize) { 529 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 530 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 531 return { 532 ...img, 533 image: { 534 ...(img.image as Record<string, unknown> || {}), 535 cdnUrl: appviewImg.fullsize, 536 ref: { $link: cid || imageObj?.ref?.$link }, 537 }, 538 }; 539 } 540 return img; 541 }); 542 } 543 } 544 545 // Handle recordWithMedia embeds 546 if (appviewEmbed.$type === "app.bsky.embed.recordWithMedia#view" && appviewEmbed.media) { 547 const mediaImages = appviewEmbed.media.images; 548 const mediaEmbedImages = (recordEmbed.media as { images?: Array<Record<string, unknown>> } | undefined)?.images; 549 if (mediaImages && mediaEmbedImages && Array.isArray(mediaEmbedImages)) { 550 (recordEmbed.media as { images: Array<Record<string, unknown>> }).images = mediaEmbedImages.map((img: Record<string, unknown>, idx: number) => { 551 const appviewImg = mediaImages[idx]; 552 if (appviewImg?.fullsize) { 553 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 554 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 555 return { 556 ...img, 557 image: { 558 ...(img.image as Record<string, unknown> || {}), 559 cdnUrl: appviewImg.fullsize, 560 ref: { $link: cid || imageObj?.ref?.$link }, 561 }, 562 }; 563 } 564 return img; 565 }); 566 } 567 } 568 } 569 570 return record as T; 571 } 572 573 // For other endpoints, we might not have a clean way to extract the specific record 574 // Fall through to let the caller try the next tier 575 throw new Error(`Appview endpoint ${endpoint} not fully implemented`); 576} 577 578/** 579 * Attempts to fetch a record from Slingshot's getRecord endpoint. 580 */ 581async function fetchFromSlingshot<T>( 582 did: string, 583 collection: string, 584 rkey: string, 585 slingshotBaseUrl: string, 586): Promise<T | undefined> { 587 const res = await callGetRecord<T>(slingshotBaseUrl, did, collection, rkey); 588 if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`); 589 return res.data.value; 590} 591 592/** 593 * Attempts to fetch a record directly from the actor's PDS. 594 */ 595async function fetchFromPds<T>( 596 did: string, 597 collection: string, 598 rkey: string, 599 pdsEndpoint: string, 600): Promise<T | undefined> { 601 const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey); 602 if (!res.ok) throw new Error(`PDS getRecord failed for ${did}/${collection}/${rkey} at ${pdsEndpoint}`); 603 return res.data.value; 604} 605 606/** 607 * Extracts and validates CID from Bluesky CDN URL. 608 * Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format} 609 * 610 * @throws Error if URL format is invalid or CID extraction fails 611 */ 612function extractCidFromCdnUrl(url: string | undefined): string | undefined { 613 if (!url) return undefined; 614 615 try { 616 // Match pattern: /did:plc:xxxxx/CIDHERE@format or /did:web:xxxxx/CIDHERE@format 617 const match = url.match(/\/did:[^/]+\/([^@/]+)@/); 618 const cid = match?.[1]; 619 620 if (!cid) { 621 console.warn(`Failed to extract CID from CDN URL: ${url}`); 622 return undefined; 623 } 624 625 // Basic CID validation - should start with common CID prefixes 626 if (!cid.startsWith("bafk") && !cid.startsWith("bafyb") && !cid.startsWith("Qm")) { 627 console.warn(`Extracted string does not appear to be a valid CID: ${cid} from URL: ${url}`); 628 return undefined; 629 } 630 631 return cid; 632 } catch (err) { 633 console.error(`Error extracting CID from CDN URL: ${url}`, err); 634 return undefined; 635 } 636} 637 638/** 639 * Shared RPC utility for making appview API calls with proper typing. 640 */ 641export async function callAppviewRpc<TResponse>( 642 service: string, 643 nsid: string, 644 params: Record<string, unknown>, 645): Promise<{ ok: boolean; data: TResponse }> { 646 const { rpc } = await createAtprotoClient({ service }); 647 return await (rpc as unknown as { 648 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: TResponse }>; 649 }).get(nsid, { params }); 650} 651 652/** 653 * Shared RPC utility for making getRecord calls (Slingshot or PDS). 654 */ 655export async function callGetRecord<T>( 656 service: string, 657 did: string, 658 collection: string, 659 rkey: string, 660): Promise<{ ok: boolean; data: { value: T } }> { 661 const { rpc } = await createAtprotoClient({ service }); 662 return await (rpc as unknown as { 663 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: { value: T } }>; 664 }).get("com.atproto.repo.getRecord", { 665 params: { repo: did, collection, rkey }, 666 }); 667} 668 669/** 670 * Shared RPC utility for making listRecords calls. 671 */ 672export async function callListRecords<T>( 673 service: string, 674 did: string, 675 collection: string, 676 limit: number, 677 cursor?: string, 678): Promise<{ 679 ok: boolean; 680 data: { 681 records: Array<{ uri: string; rkey?: string; value: T }>; 682 cursor?: string; 683 }; 684}> { 685 const { rpc } = await createAtprotoClient({ service }); 686 return await (rpc as unknown as { 687 get: ( 688 nsid: string, 689 opts: { params: Record<string, unknown> }, 690 ) => Promise<{ 691 ok: boolean; 692 data: { 693 records: Array<{ uri: string; rkey?: string; value: T }>; 694 cursor?: string; 695 }; 696 }>; 697 }).get("com.atproto.repo.listRecords", { 698 params: { 699 repo: did, 700 collection, 701 limit, 702 cursor, 703 reverse: false, 704 }, 705 }); 706} 707 708