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