A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useState } 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 */ 179export function useBlueskyAppview<T = unknown>({ 180 did: handleOrDid, 181 collection, 182 rkey, 183 appviewService, 184 skipAppview = false, 185}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> { 186 const { 187 did, 188 error: didError, 189 loading: resolvingDid, 190 } = useDidResolution(handleOrDid); 191 const { 192 endpoint: pdsEndpoint, 193 error: endpointError, 194 loading: resolvingEndpoint, 195 } = usePdsEndpoint(did); 196 197 const [record, setRecord] = useState<T | undefined>(); 198 const [loading, setLoading] = useState(false); 199 const [error, setError] = useState<Error | undefined>(); 200 const [source, setSource] = useState<"appview" | "slingshot" | "pds" | undefined>(); 201 202 useEffect(() => { 203 let cancelled = false; 204 205 const assign = (next: Partial<UseBlueskyAppviewResult<T>>) => { 206 if (cancelled) return; 207 setRecord(next.record); 208 setLoading(next.loading ?? false); 209 setError(next.error); 210 setSource(next.source); 211 }; 212 213 // Early returns for missing inputs or resolution errors 214 if (!handleOrDid || !collection || !rkey) { 215 assign({ 216 loading: false, 217 record: undefined, 218 error: undefined, 219 source: undefined, 220 }); 221 return () => { 222 cancelled = true; 223 }; 224 } 225 226 if (didError) { 227 assign({ loading: false, error: didError, source: undefined }); 228 return () => { 229 cancelled = true; 230 }; 231 } 232 233 if (endpointError) { 234 assign({ loading: false, error: endpointError, source: undefined }); 235 return () => { 236 cancelled = true; 237 }; 238 } 239 240 if (resolvingDid || resolvingEndpoint || !did || !pdsEndpoint) { 241 assign({ loading: true, error: undefined, source: undefined }); 242 return () => { 243 cancelled = true; 244 }; 245 } 246 247 // Start fetching 248 assign({ loading: true, error: undefined, source: undefined }); 249 250 (async () => { 251 let lastError: Error | undefined; 252 253 // Tier 1: Try Bluesky appview API 254 if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) { 255 try { 256 const result = await fetchFromAppview<T>( 257 did, 258 collection, 259 rkey, 260 appviewService ?? DEFAULT_APPVIEW_SERVICE, 261 ); 262 if (!cancelled && result) { 263 assign({ 264 record: result, 265 loading: false, 266 source: "appview", 267 }); 268 return; 269 } 270 } catch (err) { 271 lastError = err as Error; 272 // Continue to next tier 273 } 274 } 275 276 // Tier 2: Try Slingshot getRecord 277 try { 278 const result = await fetchFromSlingshot<T>(did, collection, rkey); 279 if (!cancelled && result) { 280 assign({ 281 record: result, 282 loading: false, 283 source: "slingshot", 284 }); 285 return; 286 } 287 } catch (err) { 288 lastError = err as Error; 289 // Continue to next tier 290 } 291 292 // Tier 3: Try PDS directly 293 try { 294 const result = await fetchFromPds<T>( 295 did, 296 collection, 297 rkey, 298 pdsEndpoint, 299 ); 300 if (!cancelled && result) { 301 assign({ 302 record: result, 303 loading: false, 304 source: "pds", 305 }); 306 return; 307 } 308 } catch (err) { 309 lastError = err as Error; 310 } 311 312 // All tiers failed 313 if (!cancelled) { 314 assign({ 315 loading: false, 316 error: 317 lastError ?? 318 new Error("Failed to fetch record from all sources"), 319 source: undefined, 320 }); 321 } 322 })(); 323 324 return () => { 325 cancelled = true; 326 }; 327 }, [ 328 handleOrDid, 329 did, 330 collection, 331 rkey, 332 pdsEndpoint, 333 appviewService, 334 skipAppview, 335 resolvingDid, 336 resolvingEndpoint, 337 didError, 338 endpointError, 339 ]); 340 341 return { 342 record, 343 loading, 344 error, 345 source, 346 }; 347} 348 349/** 350 * Attempts to fetch a record from the Bluesky appview API. 351 * Different collections map to different endpoints with varying response structures. 352 */ 353async function fetchFromAppview<T>( 354 did: string, 355 collection: string, 356 rkey: string, 357 appviewService: string, 358): Promise<T | undefined> { 359 const { rpc } = await createAtprotoClient({ service: appviewService }); 360 const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection]; 361 362 if (!endpoint) { 363 throw new Error(`No appview endpoint mapped for collection ${collection}`); 364 } 365 366 const atUri = `at://${did}/${collection}/${rkey}`; 367 368 // Handle different appview endpoints 369 if (endpoint === "app.bsky.actor.getProfile") { 370 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewProfileResponse }> }).get(endpoint, { 371 params: { actor: did }, 372 }); 373 374 if (!res.ok) throw new Error("Appview profile request failed"); 375 376 // The appview returns avatar/banner as CDN URLs like: 377 // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg 378 // We need to extract the CID and convert to ProfileRecord format 379 const profile = res.data; 380 const avatarCid = extractCidFromCdnUrl(profile.avatar); 381 const bannerCid = extractCidFromCdnUrl(profile.banner); 382 383 // Convert hydrated profile to ProfileRecord format 384 // Store the CDN URL directly so components can use it without re-fetching 385 const record: Record<string, unknown> = { 386 displayName: profile.displayName, 387 description: profile.description, 388 createdAt: profile.createdAt, 389 }; 390 391 if (profile.avatar && avatarCid) { 392 const avatarBlob: BlobWithCdn = { 393 $type: "blob", 394 ref: { $link: avatarCid }, 395 mimeType: "image/jpeg", 396 size: 0, 397 cdnUrl: profile.avatar, 398 }; 399 record.avatar = avatarBlob; 400 } 401 402 if (profile.banner && bannerCid) { 403 const bannerBlob: BlobWithCdn = { 404 $type: "blob", 405 ref: { $link: bannerCid }, 406 mimeType: "image/jpeg", 407 size: 0, 408 cdnUrl: profile.banner, 409 }; 410 record.banner = bannerBlob; 411 } 412 413 return record as T; 414 } 415 416 if (endpoint === "app.bsky.feed.getPostThread") { 417 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewPostThreadResponse<T> }> }).get(endpoint, { 418 params: { uri: atUri, depth: 0 }, 419 }); 420 421 if (!res.ok) throw new Error("Appview post thread request failed"); 422 423 const post = res.data.thread?.post; 424 if (!post?.record) return undefined; 425 426 const record = post.record as Record<string, unknown>; 427 const appviewEmbed = post.embed; 428 429 // If the appview includes embedded images with CDN URLs, inject them into the record 430 if (appviewEmbed && record.embed) { 431 const recordEmbed = record.embed as { $type?: string; images?: Array<Record<string, unknown>>; media?: Record<string, unknown> }; 432 433 // Handle direct image embeds 434 if (appviewEmbed.$type === "app.bsky.embed.images#view" && appviewEmbed.images) { 435 if (recordEmbed.images && Array.isArray(recordEmbed.images)) { 436 recordEmbed.images = recordEmbed.images.map((img: Record<string, unknown>, idx: number) => { 437 const appviewImg = appviewEmbed.images?.[idx]; 438 if (appviewImg?.fullsize) { 439 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 440 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 441 return { 442 ...img, 443 image: { 444 ...(img.image as Record<string, unknown> || {}), 445 cdnUrl: appviewImg.fullsize, 446 ref: { $link: cid || imageObj?.ref?.$link }, 447 }, 448 }; 449 } 450 return img; 451 }); 452 } 453 } 454 455 // Handle recordWithMedia embeds 456 if (appviewEmbed.$type === "app.bsky.embed.recordWithMedia#view" && appviewEmbed.media) { 457 const mediaImages = appviewEmbed.media.images; 458 const mediaEmbedImages = (recordEmbed.media as { images?: Array<Record<string, unknown>> } | undefined)?.images; 459 if (mediaImages && mediaEmbedImages && Array.isArray(mediaEmbedImages)) { 460 (recordEmbed.media as { images: Array<Record<string, unknown>> }).images = mediaEmbedImages.map((img: Record<string, unknown>, idx: number) => { 461 const appviewImg = mediaImages[idx]; 462 if (appviewImg?.fullsize) { 463 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 464 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 465 return { 466 ...img, 467 image: { 468 ...(img.image as Record<string, unknown> || {}), 469 cdnUrl: appviewImg.fullsize, 470 ref: { $link: cid || imageObj?.ref?.$link }, 471 }, 472 }; 473 } 474 return img; 475 }); 476 } 477 } 478 } 479 480 return record as T; 481 } 482 483 // For other endpoints, we might not have a clean way to extract the specific record 484 // Fall through to let the caller try the next tier 485 throw new Error(`Appview endpoint ${endpoint} not fully implemented`); 486} 487 488/** 489 * Attempts to fetch a record from Slingshot's getRecord endpoint. 490 */ 491async function fetchFromSlingshot<T>( 492 did: string, 493 collection: string, 494 rkey: string, 495): Promise<T | undefined> { 496 const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey); 497 if (!res.ok) throw new Error("Slingshot getRecord failed"); 498 return res.data.value; 499} 500 501/** 502 * Attempts to fetch a record directly from the actor's PDS. 503 */ 504async function fetchFromPds<T>( 505 did: string, 506 collection: string, 507 rkey: string, 508 pdsEndpoint: string, 509): Promise<T | undefined> { 510 const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey); 511 if (!res.ok) throw new Error("PDS getRecord failed"); 512 return res.data.value; 513} 514 515/** 516 * Extracts and validates CID from Bluesky CDN URL. 517 * Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format} 518 * 519 * @throws Error if URL format is invalid or CID extraction fails 520 */ 521function extractCidFromCdnUrl(url: string | undefined): string | undefined { 522 if (!url) return undefined; 523 524 try { 525 // Match pattern: /did:plc:xxxxx/CIDHERE@format or /did:web:xxxxx/CIDHERE@format 526 const match = url.match(/\/did:[^/]+\/([^@/]+)@/); 527 const cid = match?.[1]; 528 529 if (!cid) { 530 console.warn(`Failed to extract CID from CDN URL: ${url}`); 531 return undefined; 532 } 533 534 // Basic CID validation - should start with common CID prefixes 535 if (!cid.startsWith("bafk") && !cid.startsWith("bafyb") && !cid.startsWith("Qm")) { 536 console.warn(`Extracted string does not appear to be a valid CID: ${cid} from URL: ${url}`); 537 return undefined; 538 } 539 540 return cid; 541 } catch (err) { 542 console.error(`Error extracting CID from CDN URL: ${url}`, err); 543 return undefined; 544 } 545} 546 547/** 548 * Shared RPC utility for making appview API calls with proper typing. 549 */ 550export async function callAppviewRpc<TResponse>( 551 service: string, 552 nsid: string, 553 params: Record<string, unknown>, 554): Promise<{ ok: boolean; data: TResponse }> { 555 const { rpc } = await createAtprotoClient({ service }); 556 return await (rpc as unknown as { 557 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: TResponse }>; 558 }).get(nsid, { params }); 559} 560 561/** 562 * Shared RPC utility for making getRecord calls (Slingshot or PDS). 563 */ 564export async function callGetRecord<T>( 565 service: string, 566 did: string, 567 collection: string, 568 rkey: string, 569): Promise<{ ok: boolean; data: { value: T } }> { 570 const { rpc } = await createAtprotoClient({ service }); 571 return await (rpc as unknown as { 572 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: { value: T } }>; 573 }).get("com.atproto.repo.getRecord", { 574 params: { repo: did, collection, rkey }, 575 }); 576} 577 578/** 579 * Shared RPC utility for making listRecords calls. 580 */ 581export async function callListRecords<T>( 582 service: string, 583 did: string, 584 collection: string, 585 limit: number, 586 cursor?: string, 587): Promise<{ 588 ok: boolean; 589 data: { 590 records: Array<{ uri: string; rkey?: string; value: T }>; 591 cursor?: string; 592 }; 593}> { 594 const { rpc } = await createAtprotoClient({ service }); 595 return await (rpc as unknown as { 596 get: ( 597 nsid: string, 598 opts: { params: Record<string, unknown> }, 599 ) => Promise<{ 600 ok: boolean; 601 data: { 602 records: Array<{ uri: string; rkey?: string; value: T }>; 603 cursor?: string; 604 }; 605 }>; 606 }).get("com.atproto.repo.listRecords", { 607 params: { 608 repo: did, 609 collection, 610 limit, 611 cursor, 612 reverse: false, 613 }, 614 }); 615} 616 617