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