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 } 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