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