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