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