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