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<T>( 312 did, 313 collection, 314 rkey, 315 () => { 316 const controller = new AbortController(); 317 318 const fetchPromise = (async () => { 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 result; 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 result; 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 result; 361 } 362 } catch (err) { 363 lastError = err as Error; 364 } 365 366 // All tiers failed 367 throw lastError ?? new Error("Failed to fetch record from all sources"); 368 })(); 369 370 return { 371 promise: fetchPromise, 372 abort: () => controller.abort(), 373 }; 374 } 375 ); 376 377 releaseRef.current = release; 378 379 promise 380 .then((record) => { 381 if (!cancelled) { 382 dispatch({ 383 type: "SET_SUCCESS", 384 record, 385 source: "appview", 386 }); 387 } 388 }) 389 .catch((err) => { 390 if (!cancelled) { 391 dispatch({ 392 type: "SET_ERROR", 393 error: err instanceof Error ? err : new Error(String(err)), 394 }); 395 } 396 }); 397 398 return () => { 399 cancelled = true; 400 if (releaseRef.current) { 401 releaseRef.current(); 402 releaseRef.current = undefined; 403 } 404 }; 405 }, [ 406 handleOrDid, 407 did, 408 collection, 409 rkey, 410 pdsEndpoint, 411 effectiveAppviewService, 412 skipAppview, 413 resolvingDid, 414 resolvingEndpoint, 415 didError, 416 endpointError, 417 recordCache, 418 resolver, 419 ]); 420 421 return state; 422} 423 424/** 425 * Attempts to fetch a record from the Bluesky appview API. 426 * Different collections map to different endpoints with varying response structures. 427 */ 428async function fetchFromAppview<T>( 429 did: string, 430 collection: string, 431 rkey: string, 432 appviewService: string, 433): Promise<T | undefined> { 434 const { rpc } = await createAtprotoClient({ service: appviewService }); 435 const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection]; 436 437 if (!endpoint) { 438 throw new Error(`No appview endpoint mapped for collection ${collection}`); 439 } 440 441 const atUri = `at://${did}/${collection}/${rkey}`; 442 443 // Handle different appview endpoints 444 if (endpoint === "app.bsky.actor.getProfile") { 445 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewProfileResponse }> }).get(endpoint, { 446 params: { actor: did }, 447 }); 448 449 if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${did}`); 450 451 // The appview returns avatar/banner as CDN URLs like: 452 // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg 453 // We need to extract the CID and convert to ProfileRecord format 454 const profile = res.data; 455 const avatarCid = extractCidFromCdnUrl(profile.avatar); 456 const bannerCid = extractCidFromCdnUrl(profile.banner); 457 458 // Convert hydrated profile to ProfileRecord format 459 // Store the CDN URL directly so components can use it without re-fetching 460 const record: Record<string, unknown> = { 461 displayName: profile.displayName, 462 description: profile.description, 463 createdAt: profile.createdAt, 464 }; 465 466 // Add pronouns and website if they exist 467 if (profile.pronouns) { 468 record.pronouns = profile.pronouns; 469 } 470 471 if (profile.website) { 472 record.website = profile.website; 473 } 474 475 if (profile.avatar && avatarCid) { 476 const avatarBlob: BlobWithCdn = { 477 $type: "blob", 478 ref: { $link: avatarCid }, 479 mimeType: "image/jpeg", 480 size: 0, 481 cdnUrl: profile.avatar, 482 }; 483 record.avatar = avatarBlob; 484 } 485 486 if (profile.banner && bannerCid) { 487 const bannerBlob: BlobWithCdn = { 488 $type: "blob", 489 ref: { $link: bannerCid }, 490 mimeType: "image/jpeg", 491 size: 0, 492 cdnUrl: profile.banner, 493 }; 494 record.banner = bannerBlob; 495 } 496 497 return record as T; 498 } 499 500 if (endpoint === "app.bsky.feed.getPostThread") { 501 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewPostThreadResponse<T> }> }).get(endpoint, { 502 params: { uri: atUri, depth: 0 }, 503 }); 504 505 if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${atUri}`); 506 507 const post = res.data.thread?.post; 508 if (!post?.record) return undefined; 509 510 const record = post.record as Record<string, unknown>; 511 const appviewEmbed = post.embed; 512 513 // If the appview includes embedded images with CDN URLs, inject them into the record 514 if (appviewEmbed && record.embed) { 515 const recordEmbed = record.embed as { $type?: string; images?: Array<Record<string, unknown>>; media?: Record<string, unknown> }; 516 517 // Handle direct image embeds 518 if (appviewEmbed.$type === "app.bsky.embed.images#view" && appviewEmbed.images) { 519 if (recordEmbed.images && Array.isArray(recordEmbed.images)) { 520 recordEmbed.images = recordEmbed.images.map((img: Record<string, unknown>, idx: number) => { 521 const appviewImg = appviewEmbed.images?.[idx]; 522 if (appviewImg?.fullsize) { 523 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 524 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 525 return { 526 ...img, 527 image: { 528 ...(img.image as Record<string, unknown> || {}), 529 cdnUrl: appviewImg.fullsize, 530 ref: { $link: cid || imageObj?.ref?.$link }, 531 }, 532 }; 533 } 534 return img; 535 }); 536 } 537 } 538 539 // Handle recordWithMedia embeds 540 if (appviewEmbed.$type === "app.bsky.embed.recordWithMedia#view" && appviewEmbed.media) { 541 const mediaImages = appviewEmbed.media.images; 542 const mediaEmbedImages = (recordEmbed.media as { images?: Array<Record<string, unknown>> } | undefined)?.images; 543 if (mediaImages && mediaEmbedImages && Array.isArray(mediaEmbedImages)) { 544 (recordEmbed.media as { images: Array<Record<string, unknown>> }).images = mediaEmbedImages.map((img: Record<string, unknown>, idx: number) => { 545 const appviewImg = mediaImages[idx]; 546 if (appviewImg?.fullsize) { 547 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 548 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 549 return { 550 ...img, 551 image: { 552 ...(img.image as Record<string, unknown> || {}), 553 cdnUrl: appviewImg.fullsize, 554 ref: { $link: cid || imageObj?.ref?.$link }, 555 }, 556 }; 557 } 558 return img; 559 }); 560 } 561 } 562 } 563 564 return record as T; 565 } 566 567 // For other endpoints, we might not have a clean way to extract the specific record 568 // Fall through to let the caller try the next tier 569 throw new Error(`Appview endpoint ${endpoint} not fully implemented`); 570} 571 572/** 573 * Attempts to fetch a record from Slingshot's getRecord endpoint. 574 */ 575async function fetchFromSlingshot<T>( 576 did: string, 577 collection: string, 578 rkey: string, 579 slingshotBaseUrl: string, 580): Promise<T | undefined> { 581 const res = await callGetRecord<T>(slingshotBaseUrl, did, collection, rkey); 582 if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`); 583 return res.data.value; 584} 585 586/** 587 * Attempts to fetch a record directly from the actor's PDS. 588 */ 589async function fetchFromPds<T>( 590 did: string, 591 collection: string, 592 rkey: string, 593 pdsEndpoint: string, 594): Promise<T | undefined> { 595 const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey); 596 if (!res.ok) throw new Error(`PDS getRecord failed for ${did}/${collection}/${rkey} at ${pdsEndpoint}`); 597 return res.data.value; 598} 599 600/** 601 * Extracts and validates CID from Bluesky CDN URL. 602 * Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format} 603 * 604 * @throws Error if URL format is invalid or CID extraction fails 605 */ 606function extractCidFromCdnUrl(url: string | undefined): string | undefined { 607 if (!url) return undefined; 608 609 try { 610 // Match pattern: /did:plc:xxxxx/CIDHERE@format or /did:web:xxxxx/CIDHERE@format 611 const match = url.match(/\/did:[^/]+\/([^@/]+)@/); 612 const cid = match?.[1]; 613 614 if (!cid) { 615 console.warn(`Failed to extract CID from CDN URL: ${url}`); 616 return undefined; 617 } 618 619 // Basic CID validation - should start with common CID prefixes 620 if (!cid.startsWith("bafk") && !cid.startsWith("bafyb") && !cid.startsWith("Qm")) { 621 console.warn(`Extracted string does not appear to be a valid CID: ${cid} from URL: ${url}`); 622 return undefined; 623 } 624 625 return cid; 626 } catch (err) { 627 console.error(`Error extracting CID from CDN URL: ${url}`, err); 628 return undefined; 629 } 630} 631 632/** 633 * Shared RPC utility for making appview API calls with proper typing. 634 */ 635export async function callAppviewRpc<TResponse>( 636 service: string, 637 nsid: string, 638 params: Record<string, unknown>, 639): Promise<{ ok: boolean; data: TResponse }> { 640 const { rpc } = await createAtprotoClient({ service }); 641 return await (rpc as unknown as { 642 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: TResponse }>; 643 }).get(nsid, { params }); 644} 645 646/** 647 * Shared RPC utility for making getRecord calls (Slingshot or PDS). 648 */ 649export async function callGetRecord<T>( 650 service: string, 651 did: string, 652 collection: string, 653 rkey: string, 654): Promise<{ ok: boolean; data: { value: T } }> { 655 const { rpc } = await createAtprotoClient({ service }); 656 return await (rpc as unknown as { 657 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: { value: T } }>; 658 }).get("com.atproto.repo.getRecord", { 659 params: { repo: did, collection, rkey }, 660 }); 661} 662 663/** 664 * Shared RPC utility for making listRecords calls. 665 */ 666export async function callListRecords<T>( 667 service: string, 668 did: string, 669 collection: string, 670 limit: number, 671 cursor?: string, 672): Promise<{ 673 ok: boolean; 674 data: { 675 records: Array<{ uri: string; rkey?: string; value: T }>; 676 cursor?: string; 677 }; 678}> { 679 const { rpc } = await createAtprotoClient({ service }); 680 return await (rpc as unknown as { 681 get: ( 682 nsid: string, 683 opts: { params: Record<string, unknown> }, 684 ) => Promise<{ 685 ok: boolean; 686 data: { 687 records: Array<{ uri: string; rkey?: string; value: T }>; 688 cursor?: string; 689 }; 690 }>; 691 }).get("com.atproto.repo.listRecords", { 692 params: { 693 repo: did, 694 collection, 695 limit, 696 cursor, 697 reverse: false, 698 }, 699 }); 700} 701 702