···
1
+
import { useEffect, useState } from "react";
2
+
import { useDidResolution } from "./useDidResolution";
3
+
import { usePdsEndpoint } from "./usePdsEndpoint";
4
+
import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client";
7
+
* Extended blob reference that includes CDN URL from appview responses.
9
+
export interface BlobWithCdn {
11
+
ref: { $link: string };
14
+
/** CDN URL from Bluesky appview (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg) */
21
+
* Appview getProfile response structure.
23
+
interface AppviewProfileResponse {
26
+
displayName?: string;
27
+
description?: string;
31
+
[key: string]: unknown;
35
+
* Appview getPostThread response structure.
37
+
interface AppviewPostThreadResponse<T = unknown> {
47
+
aspectRatio?: { width: number; height: number };
54
+
aspectRatio?: { width: number; height: number };
63
+
* Options for {@link useBlueskyAppview}.
65
+
export interface UseBlueskyAppviewOptions {
66
+
/** DID or handle of the actor. */
68
+
/** NSID collection (e.g., "app.bsky.feed.post"). */
69
+
collection?: string;
70
+
/** Record key within the collection. */
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;
79
+
* Result returned from {@link useBlueskyAppview}.
81
+
export interface UseBlueskyAppviewResult<T = unknown> {
82
+
/** The fetched record value. */
84
+
/** Indicates whether a fetch is in progress. */
86
+
/** Error encountered during fetch. */
88
+
/** Source from which the record was successfully fetched. */
89
+
source?: "appview" | "slingshot" | "pds";
92
+
export const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
95
+
* Maps Bluesky collection NSIDs to their corresponding appview API endpoints.
96
+
* Only includes endpoints that can fetch individual records (not list endpoints).
98
+
const 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",
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
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.
116
+
* // Fetch a Bluesky post with automatic fallback
117
+
* import { useBlueskyAppview } from 'atproto-ui';
118
+
* import type { FeedPostRecord } from 'atproto-ui';
120
+
* function MyPost({ did, rkey }: { did: string; rkey: string }) {
121
+
* const { record, loading, error, source } = useBlueskyAppview<FeedPostRecord>({
123
+
* collection: 'app.bsky.feed.post',
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>;
133
+
* <p>{record.text}</p>
134
+
* <small>Fetched from: {source}</small>
142
+
* // Fetch a Bluesky profile
143
+
* import { useBlueskyAppview } from 'atproto-ui';
144
+
* import type { ProfileRecord } from 'atproto-ui';
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',
153
+
* if (loading) return <p>Loading profile...</p>;
154
+
* if (!record) return null;
158
+
* <h2>{record.displayName}</h2>
159
+
* <p>{record.description}</p>
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
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.
179
+
export function useBlueskyAppview<T = unknown>({
184
+
skipAppview = false,
185
+
}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
189
+
loading: resolvingDid,
190
+
} = useDidResolution(handleOrDid);
192
+
endpoint: pdsEndpoint,
193
+
error: endpointError,
194
+
loading: resolvingEndpoint,
195
+
} = usePdsEndpoint(did);
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>();
203
+
let cancelled = false;
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);
213
+
// Early returns for missing inputs or resolution errors
214
+
if (!handleOrDid || !collection || !rkey) {
227
+
assign({ loading: false, error: didError, source: undefined });
233
+
if (endpointError) {
234
+
assign({ loading: false, error: endpointError, source: undefined });
240
+
if (resolvingDid || resolvingEndpoint || !did || !pdsEndpoint) {
241
+
assign({ loading: true, error: undefined, source: undefined });
248
+
assign({ loading: true, error: undefined, source: undefined });
251
+
let lastError: Error | undefined;
253
+
// Tier 1: Try Bluesky appview API
254
+
if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) {
256
+
const result = await fetchFromAppview<T>(
260
+
appviewService ?? DEFAULT_APPVIEW_SERVICE,
262
+
if (!cancelled && result) {
271
+
lastError = err as Error;
272
+
// Continue to next tier
276
+
// Tier 2: Try Slingshot getRecord
278
+
const result = await fetchFromSlingshot<T>(did, collection, rkey);
279
+
if (!cancelled && result) {
283
+
source: "slingshot",
288
+
lastError = err as Error;
289
+
// Continue to next tier
292
+
// Tier 3: Try PDS directly
294
+
const result = await fetchFromPds<T>(
300
+
if (!cancelled && result) {
309
+
lastError = err as Error;
312
+
// All tiers failed
318
+
new Error("Failed to fetch record from all sources"),
350
+
* Attempts to fetch a record from the Bluesky appview API.
351
+
* Different collections map to different endpoints with varying response structures.
353
+
async function fetchFromAppview<T>(
355
+
collection: string,
357
+
appviewService: string,
358
+
): Promise<T | undefined> {
359
+
const { rpc } = await createAtprotoClient({ service: appviewService });
360
+
const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection];
363
+
throw new Error(`No appview endpoint mapped for collection ${collection}`);
366
+
const atUri = `at://${did}/${collection}/${rkey}`;
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 },
374
+
if (!res.ok) throw new Error("Appview profile request failed");
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);
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,
391
+
if (profile.avatar && avatarCid) {
392
+
const avatarBlob: BlobWithCdn = {
394
+
ref: { $link: avatarCid },
395
+
mimeType: "image/jpeg",
397
+
cdnUrl: profile.avatar,
399
+
record.avatar = avatarBlob;
402
+
if (profile.banner && bannerCid) {
403
+
const bannerBlob: BlobWithCdn = {
405
+
ref: { $link: bannerCid },
406
+
mimeType: "image/jpeg",
408
+
cdnUrl: profile.banner,
410
+
record.banner = bannerBlob;
413
+
return record as T;
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 },
421
+
if (!res.ok) throw new Error("Appview post thread request failed");
423
+
const post = res.data.thread?.post;
424
+
if (!post?.record) return undefined;
426
+
const record = post.record as Record<string, unknown>;
427
+
const appviewEmbed = post.embed;
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> };
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;
444
+
...(img.image as Record<string, unknown> || {}),
445
+
cdnUrl: appviewImg.fullsize,
446
+
ref: { $link: cid || imageObj?.ref?.$link },
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;
468
+
...(img.image as Record<string, unknown> || {}),
469
+
cdnUrl: appviewImg.fullsize,
470
+
ref: { $link: cid || imageObj?.ref?.$link },
480
+
return record as T;
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`);
489
+
* Attempts to fetch a record from Slingshot's getRecord endpoint.
491
+
async function fetchFromSlingshot<T>(
493
+
collection: 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");
498
+
return res.data.value;
502
+
* Attempts to fetch a record directly from the actor's PDS.
504
+
async function fetchFromPds<T>(
506
+
collection: 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");
512
+
return res.data.value;
516
+
* Extracts and validates CID from Bluesky CDN URL.
517
+
* Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format}
519
+
* @throws Error if URL format is invalid or CID extraction fails
521
+
function extractCidFromCdnUrl(url: string | undefined): string | undefined {
522
+
if (!url) return undefined;
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];
530
+
console.warn(`Failed to extract CID from CDN URL: ${url}`);
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}`);
542
+
console.error(`Error extracting CID from CDN URL: ${url}`, err);
548
+
* Shared RPC utility for making appview API calls with proper typing.
550
+
export async function callAppviewRpc<TResponse>(
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 });
562
+
* Shared RPC utility for making getRecord calls (Slingshot or PDS).
564
+
export async function callGetRecord<T>(
567
+
collection: 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 },
579
+
* Shared RPC utility for making listRecords calls.
581
+
export async function callListRecords<T>(
584
+
collection: string,
590
+
records: Array<{ uri: string; rkey?: string; value: T }>;
594
+
const { rpc } = await createAtprotoClient({ service });
595
+
return await (rpc as unknown as {
598
+
opts: { params: Record<string, unknown> },
602
+
records: Array<{ uri: string; rkey?: string; value: T }>;
606
+
}).get("com.atproto.repo.listRecords", {