A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 20 kB view raw
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 240 // Only use this hook for Bluesky collections (app.bsky.*) 241 const isBlueskyCollection = collection?.startsWith("app.bsky."); 242 243 const { 244 did, 245 error: didError, 246 loading: resolvingDid, 247 } = useDidResolution(handleOrDid); 248 const { 249 endpoint: pdsEndpoint, 250 error: endpointError, 251 loading: resolvingEndpoint, 252 } = usePdsEndpoint(did); 253 254 const [state, dispatch] = useReducer(blueskyAppviewReducer<T>, { 255 record: undefined, 256 loading: false, 257 error: undefined, 258 source: undefined, 259 }); 260 261 const releaseRef = useRef<(() => void) | undefined>(undefined); 262 263 useEffect(() => { 264 let cancelled = false; 265 266 // Early returns for missing inputs or resolution errors 267 if (!handleOrDid || !collection || !rkey) { 268 if (!cancelled) dispatch({ type: "RESET" }); 269 return () => { 270 cancelled = true; 271 if (releaseRef.current) { 272 releaseRef.current(); 273 releaseRef.current = undefined; 274 } 275 }; 276 } 277 278 // Return early if not a Bluesky collection - this hook should not be used for other lexicons 279 if (!isBlueskyCollection) { 280 if (!cancelled) dispatch({ type: "RESET" }); 281 return () => { 282 cancelled = true; 283 if (releaseRef.current) { 284 releaseRef.current(); 285 releaseRef.current = undefined; 286 } 287 }; 288 } 289 290 if (didError) { 291 if (!cancelled) dispatch({ type: "SET_ERROR", error: didError }); 292 return () => { 293 cancelled = true; 294 if (releaseRef.current) { 295 releaseRef.current(); 296 releaseRef.current = undefined; 297 } 298 }; 299 } 300 301 if (endpointError) { 302 if (!cancelled) dispatch({ type: "SET_ERROR", error: endpointError }); 303 return () => { 304 cancelled = true; 305 if (releaseRef.current) { 306 releaseRef.current(); 307 releaseRef.current = undefined; 308 } 309 }; 310 } 311 312 if (resolvingDid || resolvingEndpoint || !did || !pdsEndpoint) { 313 if (!cancelled) dispatch({ type: "SET_LOADING", loading: true }); 314 return () => { 315 cancelled = true; 316 if (releaseRef.current) { 317 releaseRef.current(); 318 releaseRef.current = undefined; 319 } 320 }; 321 } 322 323 // Start fetching 324 dispatch({ type: "SET_LOADING", loading: true }); 325 326 // Use recordCache.ensure for deduplication and caching 327 const { promise, release } = recordCache.ensure<{ record: T; source: "appview" | "slingshot" | "pds" }>( 328 did, 329 collection, 330 rkey, 331 () => { 332 const controller = new AbortController(); 333 334 const fetchPromise = (async (): Promise<{ record: T; source: "appview" | "slingshot" | "pds" }> => { 335 let lastError: Error | undefined; 336 337 // Tier 1: Try Bluesky appview API 338 if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) { 339 try { 340 const result = await fetchFromAppview<T>( 341 did, 342 collection, 343 rkey, 344 effectiveAppviewService, 345 ); 346 if (result) { 347 return { record: result, source: "appview" }; 348 } 349 } catch (err) { 350 lastError = err as Error; 351 // Continue to next tier 352 } 353 } 354 355 // Tier 2: Try Slingshot getRecord 356 try { 357 const slingshotUrl = resolver.getSlingshotUrl(); 358 const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl); 359 if (result) { 360 return { record: result, source: "slingshot" }; 361 } 362 } catch (err) { 363 lastError = err as Error; 364 // Continue to next tier 365 } 366 367 // Tier 3: Try PDS directly 368 try { 369 const result = await fetchFromPds<T>( 370 did, 371 collection, 372 rkey, 373 pdsEndpoint, 374 ); 375 if (result) { 376 return { record: result, source: "pds" }; 377 } 378 } catch (err) { 379 lastError = err as Error; 380 } 381 382 // All tiers failed - provide helpful error for banned/unreachable Bluesky PDSes 383 if (pdsEndpoint.includes('.bsky.network')) { 384 throw new Error( 385 `Record unavailable. The Bluesky PDS (${pdsEndpoint}) may be unreachable or the account may be banned.` 386 ); 387 } 388 389 throw lastError ?? new Error("Failed to fetch record from all sources"); 390 })(); 391 392 return { 393 promise: fetchPromise, 394 abort: () => controller.abort(), 395 }; 396 } 397 ); 398 399 releaseRef.current = release; 400 401 promise 402 .then(({ record, source }) => { 403 if (!cancelled) { 404 dispatch({ 405 type: "SET_SUCCESS", 406 record, 407 source, 408 }); 409 } 410 }) 411 .catch((err) => { 412 if (!cancelled) { 413 dispatch({ 414 type: "SET_ERROR", 415 error: err instanceof Error ? err : new Error(String(err)), 416 }); 417 } 418 }); 419 420 return () => { 421 cancelled = true; 422 if (releaseRef.current) { 423 releaseRef.current(); 424 releaseRef.current = undefined; 425 } 426 }; 427 }, [ 428 handleOrDid, 429 did, 430 collection, 431 rkey, 432 pdsEndpoint, 433 effectiveAppviewService, 434 skipAppview, 435 resolvingDid, 436 resolvingEndpoint, 437 didError, 438 endpointError, 439 recordCache, 440 resolver, 441 ]); 442 443 return state; 444} 445 446/** 447 * Attempts to fetch a record from the Bluesky appview API. 448 * Different collections map to different endpoints with varying response structures. 449 */ 450async function fetchFromAppview<T>( 451 did: string, 452 collection: string, 453 rkey: string, 454 appviewService: string, 455): Promise<T | undefined> { 456 const { rpc } = await createAtprotoClient({ service: appviewService }); 457 const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection]; 458 459 if (!endpoint) { 460 throw new Error(`No appview endpoint mapped for collection ${collection}`); 461 } 462 463 const atUri = `at://${did}/${collection}/${rkey}`; 464 465 // Handle different appview endpoints 466 if (endpoint === "app.bsky.actor.getProfile") { 467 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewProfileResponse }> }).get(endpoint, { 468 params: { actor: did }, 469 }); 470 471 if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${did}`); 472 473 // The appview returns avatar/banner as CDN URLs like: 474 // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg 475 // We need to extract the CID and convert to ProfileRecord format 476 const profile = res.data; 477 const avatarCid = extractCidFromCdnUrl(profile.avatar); 478 const bannerCid = extractCidFromCdnUrl(profile.banner); 479 480 // Convert hydrated profile to ProfileRecord format 481 // Store the CDN URL directly so components can use it without re-fetching 482 const record: Record<string, unknown> = { 483 displayName: profile.displayName, 484 description: profile.description, 485 createdAt: profile.createdAt, 486 }; 487 488 // Add pronouns and website if they exist 489 if (profile.pronouns) { 490 record.pronouns = profile.pronouns; 491 } 492 493 if (profile.website) { 494 record.website = profile.website; 495 } 496 497 if (profile.avatar && avatarCid) { 498 const avatarBlob: BlobWithCdn = { 499 $type: "blob", 500 ref: { $link: avatarCid }, 501 mimeType: "image/jpeg", 502 size: 0, 503 cdnUrl: profile.avatar, 504 }; 505 record.avatar = avatarBlob; 506 } 507 508 if (profile.banner && bannerCid) { 509 const bannerBlob: BlobWithCdn = { 510 $type: "blob", 511 ref: { $link: bannerCid }, 512 mimeType: "image/jpeg", 513 size: 0, 514 cdnUrl: profile.banner, 515 }; 516 record.banner = bannerBlob; 517 } 518 519 return record as T; 520 } 521 522 if (endpoint === "app.bsky.feed.getPostThread") { 523 const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewPostThreadResponse<T> }> }).get(endpoint, { 524 params: { uri: atUri, depth: 0 }, 525 }); 526 527 if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${atUri}`); 528 529 const post = res.data.thread?.post; 530 if (!post?.record) return undefined; 531 532 const record = post.record as Record<string, unknown>; 533 const appviewEmbed = post.embed; 534 535 // If the appview includes embedded images with CDN URLs, inject them into the record 536 if (appviewEmbed && record.embed) { 537 const recordEmbed = record.embed as { $type?: string; images?: Array<Record<string, unknown>>; media?: Record<string, unknown> }; 538 539 // Handle direct image embeds 540 if (appviewEmbed.$type === "app.bsky.embed.images#view" && appviewEmbed.images) { 541 if (recordEmbed.images && Array.isArray(recordEmbed.images)) { 542 recordEmbed.images = recordEmbed.images.map((img: Record<string, unknown>, idx: number) => { 543 const appviewImg = appviewEmbed.images?.[idx]; 544 if (appviewImg?.fullsize) { 545 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 546 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 547 return { 548 ...img, 549 image: { 550 ...(img.image as Record<string, unknown> || {}), 551 cdnUrl: appviewImg.fullsize, 552 ref: { $link: cid || imageObj?.ref?.$link }, 553 }, 554 }; 555 } 556 return img; 557 }); 558 } 559 } 560 561 // Handle recordWithMedia embeds 562 if (appviewEmbed.$type === "app.bsky.embed.recordWithMedia#view" && appviewEmbed.media) { 563 const mediaImages = appviewEmbed.media.images; 564 const mediaEmbedImages = (recordEmbed.media as { images?: Array<Record<string, unknown>> } | undefined)?.images; 565 if (mediaImages && mediaEmbedImages && Array.isArray(mediaEmbedImages)) { 566 (recordEmbed.media as { images: Array<Record<string, unknown>> }).images = mediaEmbedImages.map((img: Record<string, unknown>, idx: number) => { 567 const appviewImg = mediaImages[idx]; 568 if (appviewImg?.fullsize) { 569 const cid = extractCidFromCdnUrl(appviewImg.fullsize); 570 const imageObj = img.image as { ref?: { $link?: string } } | undefined; 571 return { 572 ...img, 573 image: { 574 ...(img.image as Record<string, unknown> || {}), 575 cdnUrl: appviewImg.fullsize, 576 ref: { $link: cid || imageObj?.ref?.$link }, 577 }, 578 }; 579 } 580 return img; 581 }); 582 } 583 } 584 } 585 586 return record as T; 587 } 588 589 // For other endpoints, we might not have a clean way to extract the specific record 590 // Fall through to let the caller try the next tier 591 throw new Error(`Appview endpoint ${endpoint} not fully implemented`); 592} 593 594/** 595 * Attempts to fetch a record from Slingshot's getRecord endpoint. 596 */ 597async function fetchFromSlingshot<T>( 598 did: string, 599 collection: string, 600 rkey: string, 601 slingshotBaseUrl: string, 602): Promise<T | undefined> { 603 const res = await callGetRecord<T>(slingshotBaseUrl, did, collection, rkey); 604 if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`); 605 return res.data.value; 606} 607 608/** 609 * Attempts to fetch a record directly from the actor's PDS. 610 */ 611async function fetchFromPds<T>( 612 did: string, 613 collection: string, 614 rkey: string, 615 pdsEndpoint: string, 616): Promise<T | undefined> { 617 const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey); 618 if (!res.ok) throw new Error(`PDS getRecord failed for ${did}/${collection}/${rkey} at ${pdsEndpoint}`); 619 return res.data.value; 620} 621 622/** 623 * Extracts and validates CID from Bluesky CDN URL. 624 * Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format} 625 * 626 * @throws Error if URL format is invalid or CID extraction fails 627 */ 628function extractCidFromCdnUrl(url: string | undefined): string | undefined { 629 if (!url) return undefined; 630 631 try { 632 // Match pattern: /did:plc:xxxxx/CIDHERE@format or /did:web:xxxxx/CIDHERE@format 633 const match = url.match(/\/did:[^/]+\/([^@/]+)@/); 634 const cid = match?.[1]; 635 636 if (!cid) { 637 console.warn(`Failed to extract CID from CDN URL: ${url}`); 638 return undefined; 639 } 640 641 // Basic CID validation - should start with common CID prefixes 642 if (!cid.startsWith("bafk") && !cid.startsWith("bafyb") && !cid.startsWith("Qm")) { 643 console.warn(`Extracted string does not appear to be a valid CID: ${cid} from URL: ${url}`); 644 return undefined; 645 } 646 647 return cid; 648 } catch (err) { 649 console.error(`Error extracting CID from CDN URL: ${url}`, err); 650 return undefined; 651 } 652} 653 654/** 655 * Shared RPC utility for making appview API calls with proper typing. 656 */ 657export async function callAppviewRpc<TResponse>( 658 service: string, 659 nsid: string, 660 params: Record<string, unknown>, 661): Promise<{ ok: boolean; data: TResponse }> { 662 const { rpc } = await createAtprotoClient({ service }); 663 return await (rpc as unknown as { 664 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: TResponse }>; 665 }).get(nsid, { params }); 666} 667 668/** 669 * Shared RPC utility for making getRecord calls (Slingshot or PDS). 670 */ 671export async function callGetRecord<T>( 672 service: string, 673 did: string, 674 collection: string, 675 rkey: string, 676): Promise<{ ok: boolean; data: { value: T } }> { 677 const { rpc } = await createAtprotoClient({ service }); 678 return await (rpc as unknown as { 679 get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: { value: T } }>; 680 }).get("com.atproto.repo.getRecord", { 681 params: { repo: did, collection, rkey }, 682 }); 683} 684 685/** 686 * Shared RPC utility for making listRecords calls. 687 */ 688export async function callListRecords<T>( 689 service: string, 690 did: string, 691 collection: string, 692 limit: number, 693 cursor?: string, 694): Promise<{ 695 ok: boolean; 696 data: { 697 records: Array<{ uri: string; rkey?: string; value: T }>; 698 cursor?: string; 699 }; 700}> { 701 const { rpc } = await createAtprotoClient({ service }); 702 703 const params: Record<string, unknown> = { 704 repo: did, 705 collection, 706 limit, 707 cursor, 708 reverse: false, 709 }; 710 711 return await (rpc as unknown as { 712 get: ( 713 nsid: string, 714 opts: { params: Record<string, unknown> }, 715 ) => Promise<{ 716 ok: boolean; 717 data: { 718 records: Array<{ uri: string; rkey?: string; value: T }>; 719 cursor?: string; 720 }; 721 }>; 722 }).get("com.atproto.repo.listRecords", { 723 params, 724 }); 725} 726 727