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