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