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