···88import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
99import { getAvatarCid } from "../utils/profile";
1010import { formatDidForLabel } from "../utils/at-uri";
1111+import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
11121213/**
1314 * Props for rendering a single Bluesky post with optional customization hooks.
···144145 collection: BLUESKY_PROFILE_COLLECTION,
145146 rkey: "self",
146147 });
147147- const avatarCid = getAvatarCid(profile);
148148+ // Check if the avatar has a CDN URL from the appview (preferred)
149149+ const avatar = profile?.avatar;
150150+ const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
151151+ const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined;
148152149153 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
150154 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
···165169 loading: boolean;
166170 error?: Error;
167171 }> = (props) => {
168168- const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
172172+ const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
173173+ // Use CDN URL from appview if available, otherwise use blob URL
174174+ const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
169175 return (
170176 <Comp
171177 {...props}
···185191 Comp,
186192 repoIdentifier,
187193 avatarCid,
194194+ avatarCdnUrl,
188195 authorHandle,
189196 colorScheme,
190197 iconPlacement,
···226233 />
227234 );
228235};
236236+237237+/**
238238+ * Type guard to check if a blob has a CDN URL from appview.
239239+ */
240240+function isBlobWithCdn(value: unknown): value is BlobWithCdn {
241241+ if (typeof value !== "object" || value === null) return false;
242242+ const obj = value as Record<string, unknown>;
243243+ return (
244244+ obj.$type === "blob" &&
245245+ typeof obj.cdnUrl === "string" &&
246246+ typeof obj.ref === "object" &&
247247+ obj.ref !== null &&
248248+ typeof (obj.ref as { $link?: unknown }).$link === "string"
249249+ );
250250+}
229251230252export default BlueskyPost;
+25-2
lib/components/BlueskyProfile.tsx
···66import { getAvatarCid } from "../utils/profile";
77import { useDidResolution } from "../hooks/useDidResolution";
88import { formatDidForLabel } from "../utils/at-uri";
99+import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
9101011/**
1112 * Props used to render a Bluesky actor profile record.
···122123 loading: boolean;
123124 error?: Error;
124125 }> = (props) => {
125125- const avatarCid = getAvatarCid(props.record);
126126- const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
126126+ // Check if the avatar has a CDN URL from the appview (preferred)
127127+ const avatar = props.record?.avatar;
128128+ const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
129129+ const avatarCid = !avatarCdnUrl ? getAvatarCid(props.record) : undefined;
130130+ const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
131131+132132+ // Use CDN URL from appview if available, otherwise use blob URL
133133+ const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
134134+127135 return (
128136 <Component
129137 {...props}
···157165 />
158166 );
159167};
168168+169169+/**
170170+ * Type guard to check if a blob has a CDN URL from appview.
171171+ */
172172+function isBlobWithCdn(value: unknown): value is BlobWithCdn {
173173+ if (typeof value !== "object" || value === null) return false;
174174+ const obj = value as Record<string, unknown>;
175175+ return (
176176+ obj.$type === "blob" &&
177177+ typeof obj.cdnUrl === "string" &&
178178+ typeof obj.ref === "object" &&
179179+ obj.ref !== null &&
180180+ typeof (obj.ref as { $link?: unknown }).$link === "string"
181181+ );
182182+}
160183161184export default BlueskyProfile;
+34
lib/hooks/useAtProtoRecord.ts
···22import { useDidResolution } from "./useDidResolution";
33import { usePdsEndpoint } from "./usePdsEndpoint";
44import { createAtprotoClient } from "../utils/atproto-client";
55+import { useBlueskyAppview } from "./useBlueskyAppview";
5667/**
78 * Identifier trio required to address an AT Protocol record.
···29303031/**
3132 * React hook that fetches a single AT Protocol record and tracks loading/error state.
3333+ *
3434+ * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy:
3535+ * 1. Try Bluesky appview API first
3636+ * 2. Fall back to Slingshot getRecord
3737+ * 3. Finally query the PDS directly
3838+ *
3939+ * For other collections, queries the PDS directly (with Slingshot fallback via the client handler).
3240 *
3341 * @param did - DID (or handle before resolution) that owns the record.
3442 * @param collection - NSID collection from which to fetch the record.
···4048 collection,
4149 rkey,
4250}: AtProtoRecordKey): AtProtoRecordState<T> {
5151+ // Determine if this is a Bluesky collection that should use the appview
5252+ const isBlueskyCollection = collection?.startsWith("app.bsky.");
5353+5454+ // Use the three-tier fallback for Bluesky collections
5555+ const blueskyResult = useBlueskyAppview<T>({
5656+ did: isBlueskyCollection ? handleOrDid : undefined,
5757+ collection: isBlueskyCollection ? collection : undefined,
5858+ rkey: isBlueskyCollection ? rkey : undefined,
5959+ });
4360 const {
4461 did,
4562 error: didError,
···6279 setState((prev) => ({ ...prev, ...next }));
6380 };
64818282+ // If using Bluesky appview, skip the manual fetch logic
8383+ if (isBlueskyCollection) {
8484+ return () => {
8585+ cancelled = true;
8686+ };
8787+ }
8888+6589 if (!handleOrDid || !collection || !rkey) {
6690 assignState({
6791 loading: false,
···139163 resolvingEndpoint,
140164 didError,
141165 endpointError,
166166+ isBlueskyCollection,
142167 ]);
168168+169169+ // Return Bluesky appview result if it's a Bluesky collection
170170+ if (isBlueskyCollection) {
171171+ return {
172172+ record: blueskyResult.record,
173173+ error: blueskyResult.error,
174174+ loading: blueskyResult.loading,
175175+ };
176176+ }
143177144178 return state;
145179}
+617
lib/hooks/useBlueskyAppview.ts
···11+import { useEffect, useState } from "react";
22+import { useDidResolution } from "./useDidResolution";
33+import { usePdsEndpoint } from "./usePdsEndpoint";
44+import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client";
55+66+/**
77+ * Extended blob reference that includes CDN URL from appview responses.
88+ */
99+export interface BlobWithCdn {
1010+ $type: "blob";
1111+ ref: { $link: string };
1212+ mimeType: string;
1313+ size: number;
1414+ /** CDN URL from Bluesky appview (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg) */
1515+ cdnUrl?: string;
1616+}
1717+1818+1919+2020+/**
2121+ * Appview getProfile response structure.
2222+ */
2323+interface AppviewProfileResponse {
2424+ did: string;
2525+ handle: string;
2626+ displayName?: string;
2727+ description?: string;
2828+ avatar?: string;
2929+ banner?: string;
3030+ createdAt?: string;
3131+ [key: string]: unknown;
3232+}
3333+3434+/**
3535+ * Appview getPostThread response structure.
3636+ */
3737+interface AppviewPostThreadResponse<T = unknown> {
3838+ thread?: {
3939+ post?: {
4040+ record?: T;
4141+ embed?: {
4242+ $type?: string;
4343+ images?: Array<{
4444+ thumb?: string;
4545+ fullsize?: string;
4646+ alt?: string;
4747+ aspectRatio?: { width: number; height: number };
4848+ }>;
4949+ media?: {
5050+ images?: Array<{
5151+ thumb?: string;
5252+ fullsize?: string;
5353+ alt?: string;
5454+ aspectRatio?: { width: number; height: number };
5555+ }>;
5656+ };
5757+ };
5858+ };
5959+ };
6060+}
6161+6262+/**
6363+ * Options for {@link useBlueskyAppview}.
6464+ */
6565+export interface UseBlueskyAppviewOptions {
6666+ /** DID or handle of the actor. */
6767+ did?: string;
6868+ /** NSID collection (e.g., "app.bsky.feed.post"). */
6969+ collection?: string;
7070+ /** Record key within the collection. */
7171+ rkey?: string;
7272+ /** Override for the Bluesky appview service URL. Defaults to public.api.bsky.app. */
7373+ appviewService?: string;
7474+ /** If true, skip the appview and go straight to Slingshot/PDS fallback. */
7575+ skipAppview?: boolean;
7676+}
7777+7878+/**
7979+ * Result returned from {@link useBlueskyAppview}.
8080+ */
8181+export interface UseBlueskyAppviewResult<T = unknown> {
8282+ /** The fetched record value. */
8383+ record?: T;
8484+ /** Indicates whether a fetch is in progress. */
8585+ loading: boolean;
8686+ /** Error encountered during fetch. */
8787+ error?: Error;
8888+ /** Source from which the record was successfully fetched. */
8989+ source?: "appview" | "slingshot" | "pds";
9090+}
9191+9292+export const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
9393+9494+/**
9595+ * Maps Bluesky collection NSIDs to their corresponding appview API endpoints.
9696+ * Only includes endpoints that can fetch individual records (not list endpoints).
9797+ */
9898+const BLUESKY_COLLECTION_TO_ENDPOINT: Record<string, string> = {
9999+ "app.bsky.actor.profile": "app.bsky.actor.getProfile",
100100+ "app.bsky.feed.post": "app.bsky.feed.getPostThread",
101101+102102+};
103103+104104+/**
105105+ * React hook that fetches a Bluesky record with a three-tier fallback strategy:
106106+ * 1. Try the Bluesky appview API endpoint (e.g., getProfile, getPostThread)
107107+ * 2. Fall back to Slingshot's getRecord
108108+ * 3. As a last resort, query the actor's PDS directly
109109+ *
110110+ * The hook automatically handles DID resolution and determines the appropriate API endpoint
111111+ * based on the collection type. The `source` field in the result indicates which tier
112112+ * successfully returned the record.
113113+ *
114114+ * @example
115115+ * ```tsx
116116+ * // Fetch a Bluesky post with automatic fallback
117117+ * import { useBlueskyAppview } from 'atproto-ui';
118118+ * import type { FeedPostRecord } from 'atproto-ui';
119119+ *
120120+ * function MyPost({ did, rkey }: { did: string; rkey: string }) {
121121+ * const { record, loading, error, source } = useBlueskyAppview<FeedPostRecord>({
122122+ * did,
123123+ * collection: 'app.bsky.feed.post',
124124+ * rkey,
125125+ * });
126126+ *
127127+ * if (loading) return <p>Loading post...</p>;
128128+ * if (error) return <p>Error: {error.message}</p>;
129129+ * if (!record) return <p>No post found</p>;
130130+ *
131131+ * return (
132132+ * <article>
133133+ * <p>{record.text}</p>
134134+ * <small>Fetched from: {source}</small>
135135+ * </article>
136136+ * );
137137+ * }
138138+ * ```
139139+ *
140140+ * @example
141141+ * ```tsx
142142+ * // Fetch a Bluesky profile
143143+ * import { useBlueskyAppview } from 'atproto-ui';
144144+ * import type { ProfileRecord } from 'atproto-ui';
145145+ *
146146+ * function MyProfile({ handle }: { handle: string }) {
147147+ * const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
148148+ * did: handle, // Handles are automatically resolved to DIDs
149149+ * collection: 'app.bsky.actor.profile',
150150+ * rkey: 'self',
151151+ * });
152152+ *
153153+ * if (loading) return <p>Loading profile...</p>;
154154+ * if (!record) return null;
155155+ *
156156+ * return (
157157+ * <div>
158158+ * <h2>{record.displayName}</h2>
159159+ * <p>{record.description}</p>
160160+ * </div>
161161+ * );
162162+ * }
163163+ * ```
164164+ *
165165+ * @example
166166+ * ```tsx
167167+ * // Skip the appview and go directly to Slingshot/PDS
168168+ * const { record } = useBlueskyAppview({
169169+ * did: 'did:plc:example',
170170+ * collection: 'app.bsky.feed.post',
171171+ * rkey: '3k2aexample',
172172+ * skipAppview: true, // Bypasses Bluesky API, starts with Slingshot
173173+ * });
174174+ * ```
175175+ *
176176+ * @param options - Configuration object with did, collection, rkey, and optional overrides.
177177+ * @returns {UseBlueskyAppviewResult<T>} Object containing the record, loading state, error, and source.
178178+ */
179179+export function useBlueskyAppview<T = unknown>({
180180+ did: handleOrDid,
181181+ collection,
182182+ rkey,
183183+ appviewService,
184184+ skipAppview = false,
185185+}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
186186+ const {
187187+ did,
188188+ error: didError,
189189+ loading: resolvingDid,
190190+ } = useDidResolution(handleOrDid);
191191+ const {
192192+ endpoint: pdsEndpoint,
193193+ error: endpointError,
194194+ loading: resolvingEndpoint,
195195+ } = usePdsEndpoint(did);
196196+197197+ const [record, setRecord] = useState<T | undefined>();
198198+ const [loading, setLoading] = useState(false);
199199+ const [error, setError] = useState<Error | undefined>();
200200+ const [source, setSource] = useState<"appview" | "slingshot" | "pds" | undefined>();
201201+202202+ useEffect(() => {
203203+ let cancelled = false;
204204+205205+ const assign = (next: Partial<UseBlueskyAppviewResult<T>>) => {
206206+ if (cancelled) return;
207207+ setRecord(next.record);
208208+ setLoading(next.loading ?? false);
209209+ setError(next.error);
210210+ setSource(next.source);
211211+ };
212212+213213+ // Early returns for missing inputs or resolution errors
214214+ if (!handleOrDid || !collection || !rkey) {
215215+ assign({
216216+ loading: false,
217217+ record: undefined,
218218+ error: undefined,
219219+ source: undefined,
220220+ });
221221+ return () => {
222222+ cancelled = true;
223223+ };
224224+ }
225225+226226+ if (didError) {
227227+ assign({ loading: false, error: didError, source: undefined });
228228+ return () => {
229229+ cancelled = true;
230230+ };
231231+ }
232232+233233+ if (endpointError) {
234234+ assign({ loading: false, error: endpointError, source: undefined });
235235+ return () => {
236236+ cancelled = true;
237237+ };
238238+ }
239239+240240+ if (resolvingDid || resolvingEndpoint || !did || !pdsEndpoint) {
241241+ assign({ loading: true, error: undefined, source: undefined });
242242+ return () => {
243243+ cancelled = true;
244244+ };
245245+ }
246246+247247+ // Start fetching
248248+ assign({ loading: true, error: undefined, source: undefined });
249249+250250+ (async () => {
251251+ let lastError: Error | undefined;
252252+253253+ // Tier 1: Try Bluesky appview API
254254+ if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) {
255255+ try {
256256+ const result = await fetchFromAppview<T>(
257257+ did,
258258+ collection,
259259+ rkey,
260260+ appviewService ?? DEFAULT_APPVIEW_SERVICE,
261261+ );
262262+ if (!cancelled && result) {
263263+ assign({
264264+ record: result,
265265+ loading: false,
266266+ source: "appview",
267267+ });
268268+ return;
269269+ }
270270+ } catch (err) {
271271+ lastError = err as Error;
272272+ // Continue to next tier
273273+ }
274274+ }
275275+276276+ // Tier 2: Try Slingshot getRecord
277277+ try {
278278+ const result = await fetchFromSlingshot<T>(did, collection, rkey);
279279+ if (!cancelled && result) {
280280+ assign({
281281+ record: result,
282282+ loading: false,
283283+ source: "slingshot",
284284+ });
285285+ return;
286286+ }
287287+ } catch (err) {
288288+ lastError = err as Error;
289289+ // Continue to next tier
290290+ }
291291+292292+ // Tier 3: Try PDS directly
293293+ try {
294294+ const result = await fetchFromPds<T>(
295295+ did,
296296+ collection,
297297+ rkey,
298298+ pdsEndpoint,
299299+ );
300300+ if (!cancelled && result) {
301301+ assign({
302302+ record: result,
303303+ loading: false,
304304+ source: "pds",
305305+ });
306306+ return;
307307+ }
308308+ } catch (err) {
309309+ lastError = err as Error;
310310+ }
311311+312312+ // All tiers failed
313313+ if (!cancelled) {
314314+ assign({
315315+ loading: false,
316316+ error:
317317+ lastError ??
318318+ new Error("Failed to fetch record from all sources"),
319319+ source: undefined,
320320+ });
321321+ }
322322+ })();
323323+324324+ return () => {
325325+ cancelled = true;
326326+ };
327327+ }, [
328328+ handleOrDid,
329329+ did,
330330+ collection,
331331+ rkey,
332332+ pdsEndpoint,
333333+ appviewService,
334334+ skipAppview,
335335+ resolvingDid,
336336+ resolvingEndpoint,
337337+ didError,
338338+ endpointError,
339339+ ]);
340340+341341+ return {
342342+ record,
343343+ loading,
344344+ error,
345345+ source,
346346+ };
347347+}
348348+349349+/**
350350+ * Attempts to fetch a record from the Bluesky appview API.
351351+ * Different collections map to different endpoints with varying response structures.
352352+ */
353353+async function fetchFromAppview<T>(
354354+ did: string,
355355+ collection: string,
356356+ rkey: string,
357357+ appviewService: string,
358358+): Promise<T | undefined> {
359359+ const { rpc } = await createAtprotoClient({ service: appviewService });
360360+ const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection];
361361+362362+ if (!endpoint) {
363363+ throw new Error(`No appview endpoint mapped for collection ${collection}`);
364364+ }
365365+366366+ const atUri = `at://${did}/${collection}/${rkey}`;
367367+368368+ // Handle different appview endpoints
369369+ if (endpoint === "app.bsky.actor.getProfile") {
370370+ const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewProfileResponse }> }).get(endpoint, {
371371+ params: { actor: did },
372372+ });
373373+374374+ if (!res.ok) throw new Error("Appview profile request failed");
375375+376376+ // The appview returns avatar/banner as CDN URLs like:
377377+ // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg
378378+ // We need to extract the CID and convert to ProfileRecord format
379379+ const profile = res.data;
380380+ const avatarCid = extractCidFromCdnUrl(profile.avatar);
381381+ const bannerCid = extractCidFromCdnUrl(profile.banner);
382382+383383+ // Convert hydrated profile to ProfileRecord format
384384+ // Store the CDN URL directly so components can use it without re-fetching
385385+ const record: Record<string, unknown> = {
386386+ displayName: profile.displayName,
387387+ description: profile.description,
388388+ createdAt: profile.createdAt,
389389+ };
390390+391391+ if (profile.avatar && avatarCid) {
392392+ const avatarBlob: BlobWithCdn = {
393393+ $type: "blob",
394394+ ref: { $link: avatarCid },
395395+ mimeType: "image/jpeg",
396396+ size: 0,
397397+ cdnUrl: profile.avatar,
398398+ };
399399+ record.avatar = avatarBlob;
400400+ }
401401+402402+ if (profile.banner && bannerCid) {
403403+ const bannerBlob: BlobWithCdn = {
404404+ $type: "blob",
405405+ ref: { $link: bannerCid },
406406+ mimeType: "image/jpeg",
407407+ size: 0,
408408+ cdnUrl: profile.banner,
409409+ };
410410+ record.banner = bannerBlob;
411411+ }
412412+413413+ return record as T;
414414+ }
415415+416416+ if (endpoint === "app.bsky.feed.getPostThread") {
417417+ const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewPostThreadResponse<T> }> }).get(endpoint, {
418418+ params: { uri: atUri, depth: 0 },
419419+ });
420420+421421+ if (!res.ok) throw new Error("Appview post thread request failed");
422422+423423+ const post = res.data.thread?.post;
424424+ if (!post?.record) return undefined;
425425+426426+ const record = post.record as Record<string, unknown>;
427427+ const appviewEmbed = post.embed;
428428+429429+ // If the appview includes embedded images with CDN URLs, inject them into the record
430430+ if (appviewEmbed && record.embed) {
431431+ const recordEmbed = record.embed as { $type?: string; images?: Array<Record<string, unknown>>; media?: Record<string, unknown> };
432432+433433+ // Handle direct image embeds
434434+ if (appviewEmbed.$type === "app.bsky.embed.images#view" && appviewEmbed.images) {
435435+ if (recordEmbed.images && Array.isArray(recordEmbed.images)) {
436436+ recordEmbed.images = recordEmbed.images.map((img: Record<string, unknown>, idx: number) => {
437437+ const appviewImg = appviewEmbed.images?.[idx];
438438+ if (appviewImg?.fullsize) {
439439+ const cid = extractCidFromCdnUrl(appviewImg.fullsize);
440440+ const imageObj = img.image as { ref?: { $link?: string } } | undefined;
441441+ return {
442442+ ...img,
443443+ image: {
444444+ ...(img.image as Record<string, unknown> || {}),
445445+ cdnUrl: appviewImg.fullsize,
446446+ ref: { $link: cid || imageObj?.ref?.$link },
447447+ },
448448+ };
449449+ }
450450+ return img;
451451+ });
452452+ }
453453+ }
454454+455455+ // Handle recordWithMedia embeds
456456+ if (appviewEmbed.$type === "app.bsky.embed.recordWithMedia#view" && appviewEmbed.media) {
457457+ const mediaImages = appviewEmbed.media.images;
458458+ const mediaEmbedImages = (recordEmbed.media as { images?: Array<Record<string, unknown>> } | undefined)?.images;
459459+ if (mediaImages && mediaEmbedImages && Array.isArray(mediaEmbedImages)) {
460460+ (recordEmbed.media as { images: Array<Record<string, unknown>> }).images = mediaEmbedImages.map((img: Record<string, unknown>, idx: number) => {
461461+ const appviewImg = mediaImages[idx];
462462+ if (appviewImg?.fullsize) {
463463+ const cid = extractCidFromCdnUrl(appviewImg.fullsize);
464464+ const imageObj = img.image as { ref?: { $link?: string } } | undefined;
465465+ return {
466466+ ...img,
467467+ image: {
468468+ ...(img.image as Record<string, unknown> || {}),
469469+ cdnUrl: appviewImg.fullsize,
470470+ ref: { $link: cid || imageObj?.ref?.$link },
471471+ },
472472+ };
473473+ }
474474+ return img;
475475+ });
476476+ }
477477+ }
478478+ }
479479+480480+ return record as T;
481481+ }
482482+483483+ // For other endpoints, we might not have a clean way to extract the specific record
484484+ // Fall through to let the caller try the next tier
485485+ throw new Error(`Appview endpoint ${endpoint} not fully implemented`);
486486+}
487487+488488+/**
489489+ * Attempts to fetch a record from Slingshot's getRecord endpoint.
490490+ */
491491+async function fetchFromSlingshot<T>(
492492+ did: string,
493493+ collection: string,
494494+ rkey: string,
495495+): Promise<T | undefined> {
496496+ const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey);
497497+ if (!res.ok) throw new Error("Slingshot getRecord failed");
498498+ return res.data.value;
499499+}
500500+501501+/**
502502+ * Attempts to fetch a record directly from the actor's PDS.
503503+ */
504504+async function fetchFromPds<T>(
505505+ did: string,
506506+ collection: string,
507507+ rkey: string,
508508+ pdsEndpoint: string,
509509+): Promise<T | undefined> {
510510+ const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey);
511511+ if (!res.ok) throw new Error("PDS getRecord failed");
512512+ return res.data.value;
513513+}
514514+515515+/**
516516+ * Extracts and validates CID from Bluesky CDN URL.
517517+ * Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format}
518518+ *
519519+ * @throws Error if URL format is invalid or CID extraction fails
520520+ */
521521+function extractCidFromCdnUrl(url: string | undefined): string | undefined {
522522+ if (!url) return undefined;
523523+524524+ try {
525525+ // Match pattern: /did:plc:xxxxx/CIDHERE@format or /did:web:xxxxx/CIDHERE@format
526526+ const match = url.match(/\/did:[^/]+\/([^@/]+)@/);
527527+ const cid = match?.[1];
528528+529529+ if (!cid) {
530530+ console.warn(`Failed to extract CID from CDN URL: ${url}`);
531531+ return undefined;
532532+ }
533533+534534+ // Basic CID validation - should start with common CID prefixes
535535+ if (!cid.startsWith("bafk") && !cid.startsWith("bafyb") && !cid.startsWith("Qm")) {
536536+ console.warn(`Extracted string does not appear to be a valid CID: ${cid} from URL: ${url}`);
537537+ return undefined;
538538+ }
539539+540540+ return cid;
541541+ } catch (err) {
542542+ console.error(`Error extracting CID from CDN URL: ${url}`, err);
543543+ return undefined;
544544+ }
545545+}
546546+547547+/**
548548+ * Shared RPC utility for making appview API calls with proper typing.
549549+ */
550550+export async function callAppviewRpc<TResponse>(
551551+ service: string,
552552+ nsid: string,
553553+ params: Record<string, unknown>,
554554+): Promise<{ ok: boolean; data: TResponse }> {
555555+ const { rpc } = await createAtprotoClient({ service });
556556+ return await (rpc as unknown as {
557557+ get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: TResponse }>;
558558+ }).get(nsid, { params });
559559+}
560560+561561+/**
562562+ * Shared RPC utility for making getRecord calls (Slingshot or PDS).
563563+ */
564564+export async function callGetRecord<T>(
565565+ service: string,
566566+ did: string,
567567+ collection: string,
568568+ rkey: string,
569569+): Promise<{ ok: boolean; data: { value: T } }> {
570570+ const { rpc } = await createAtprotoClient({ service });
571571+ return await (rpc as unknown as {
572572+ get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: { value: T } }>;
573573+ }).get("com.atproto.repo.getRecord", {
574574+ params: { repo: did, collection, rkey },
575575+ });
576576+}
577577+578578+/**
579579+ * Shared RPC utility for making listRecords calls.
580580+ */
581581+export async function callListRecords<T>(
582582+ service: string,
583583+ did: string,
584584+ collection: string,
585585+ limit: number,
586586+ cursor?: string,
587587+): Promise<{
588588+ ok: boolean;
589589+ data: {
590590+ records: Array<{ uri: string; rkey?: string; value: T }>;
591591+ cursor?: string;
592592+ };
593593+}> {
594594+ const { rpc } = await createAtprotoClient({ service });
595595+ return await (rpc as unknown as {
596596+ get: (
597597+ nsid: string,
598598+ opts: { params: Record<string, unknown> },
599599+ ) => Promise<{
600600+ ok: boolean;
601601+ data: {
602602+ records: Array<{ uri: string; rkey?: string; value: T }>;
603603+ cursor?: string;
604604+ };
605605+ }>;
606606+ }).get("com.atproto.repo.listRecords", {
607607+ params: {
608608+ repo: did,
609609+ collection,
610610+ limit,
611611+ cursor,
612612+ reverse: false,
613613+ },
614614+ });
615615+}
616616+617617+
+48-37
lib/hooks/useBlueskyProfile.ts
···11-import { useEffect, useState } from "react";
22-import { usePdsEndpoint } from "./usePdsEndpoint";
33-import { createAtprotoClient } from "../utils/atproto-client";
11+import { useBlueskyAppview } from "./useBlueskyAppview";
22+import type { ProfileRecord } from "../types/bluesky";
4354/**
65 * Minimal profile fields returned by the Bluesky actor profile endpoint.
···24232524/**
2625 * Fetches a Bluesky actor profile for a DID and exposes loading/error state.
2626+ *
2727+ * Uses a three-tier fallback strategy:
2828+ * 1. Try Bluesky appview API (app.bsky.actor.getProfile) - CIDs are extracted from CDN URLs
2929+ * 2. Fall back to Slingshot getRecord
3030+ * 3. Finally query the PDS directly
3131+ *
3232+ * When using the appview, avatar/banner CDN URLs (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg)
3333+ * are automatically parsed to extract CIDs and convert them to standard Blob format for compatibility.
2734 *
2835 * @param did - Actor DID whose profile should be retrieved.
2936 * @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error.
3037 */
3138export function useBlueskyProfile(did: string | undefined) {
3232- const { endpoint } = usePdsEndpoint(did);
3333- const [data, setData] = useState<BlueskyProfileData | undefined>();
3434- const [loading, setLoading] = useState<boolean>(!!did);
3535- const [error, setError] = useState<Error | undefined>();
3939+ const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
4040+ did,
4141+ collection: "app.bsky.actor.profile",
4242+ rkey: "self",
4343+ });
36443737- useEffect(() => {
3838- let cancelled = false;
3939- async function run() {
4040- if (!did || !endpoint) return;
4141- setLoading(true);
4242- try {
4343- const { rpc } = await createAtprotoClient({
4444- service: endpoint,
4545- });
4646- const client = rpc as unknown as {
4747- get: (
4848- nsid: string,
4949- options: { params: { actor: string } },
5050- ) => Promise<{ ok: boolean; data: unknown }>;
5151- };
5252- const res = await client.get("app.bsky.actor.getProfile", {
5353- params: { actor: did },
5454- });
5555- if (!res.ok) throw new Error("Profile request failed");
5656- if (!cancelled) setData(res.data as BlueskyProfileData);
5757- } catch (e) {
5858- if (!cancelled) setError(e as Error);
5959- } finally {
6060- if (!cancelled) setLoading(false);
6161- }
4545+ // Convert ProfileRecord to BlueskyProfileData
4646+ // Note: avatar and banner are Blob objects in the record (from all sources)
4747+ // The appview response is converted to ProfileRecord format by extracting CIDs from CDN URLs
4848+ const data: BlueskyProfileData | undefined = record
4949+ ? {
5050+ did: did || "",
5151+ handle: "",
5252+ displayName: record.displayName,
5353+ description: record.description,
5454+ avatar: extractCidFromProfileBlob(record.avatar),
5555+ banner: extractCidFromProfileBlob(record.banner),
5656+ createdAt: record.createdAt,
6257 }
6363- run();
6464- return () => {
6565- cancelled = true;
6666- };
6767- }, [did, endpoint]);
5858+ : undefined;
68596960 return { data, loading, error };
7061}
6262+6363+/**
6464+ * Helper to extract CID from profile blob (avatar or banner).
6565+ */
6666+function extractCidFromProfileBlob(blob: unknown): string | undefined {
6767+ if (typeof blob !== "object" || blob === null) return undefined;
6868+6969+ const blobObj = blob as {
7070+ ref?: { $link?: string };
7171+ cid?: string;
7272+ };
7373+7474+ if (typeof blobObj.cid === "string") return blobObj.cid;
7575+ if (typeof blobObj.ref === "object" && blobObj.ref !== null) {
7676+ const link = blobObj.ref.$link;
7777+ if (typeof link === "string") return link;
7878+ }
7979+8080+ return undefined;
8181+}
+58-33
lib/hooks/useLatestRecord.ts
···11import { useEffect, useState } from "react";
22import { useDidResolution } from "./useDidResolution";
33import { usePdsEndpoint } from "./usePdsEndpoint";
44-import { createAtprotoClient } from "../utils/atproto-client";
44+import { callListRecords } from "./useBlueskyAppview";
5566/**
77 * Shape of the state returned by {@link useLatestRecord}.
···2020}
21212222/**
2323- * Fetches the most recent record from a collection using `listRecords(limit=1)`.
2323+ * Fetches the most recent record from a collection using `listRecords(limit=3)`.
2424+ *
2525+ * Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly.
2626+ *
2727+ * Records with invalid timestamps (before 2023, when ATProto was created) are automatically
2828+ * skipped, and additional records are fetched to find a valid one.
2429 *
2530 * @param handleOrDid - Handle or DID that owns the collection.
2631 * @param collection - NSID of the collection to query.
···91969297 (async () => {
9398 try {
9494- const { rpc } = await createAtprotoClient({
9595- service: endpoint,
9696- });
9797- const res = await (
9898- rpc as unknown as {
9999- get: (
100100- nsid: string,
101101- opts: {
102102- params: Record<
103103- string,
104104- string | number | boolean
105105- >;
106106- },
107107- ) => Promise<{
108108- ok: boolean;
109109- data: {
110110- records: Array<{
111111- uri: string;
112112- rkey?: string;
113113- value: T;
114114- }>;
115115- };
116116- }>;
117117- }
118118- ).get("com.atproto.repo.listRecords", {
119119- params: { repo: did, collection, limit: 1, reverse: false },
120120- });
121121- if (!res.ok) throw new Error("Failed to list records");
9999+ // Slingshot doesn't support listRecords, so we query PDS directly
100100+ const res = await callListRecords<T>(
101101+ endpoint,
102102+ did,
103103+ collection,
104104+ 3, // Fetch 3 in case some have invalid timestamps
105105+ );
106106+107107+ if (!res.ok) {
108108+ throw new Error("Failed to list records from PDS");
109109+ }
110110+122111 const list = res.data.records;
123112 if (list.length === 0) {
124113 assign({
···129118 });
130119 return;
131120 }
132132- const first = list[0];
133133- const derivedRkey = first.rkey ?? extractRkey(first.uri);
121121+122122+ // Find the first valid record (skip records before 2023)
123123+ const validRecord = list.find((item) => isValidTimestamp(item.value));
124124+125125+ if (!validRecord) {
126126+ console.warn("No valid records found (all had timestamps before 2023)");
127127+ assign({
128128+ loading: false,
129129+ empty: true,
130130+ record: undefined,
131131+ rkey: undefined,
132132+ });
133133+ return;
134134+ }
135135+136136+ const derivedRkey = validRecord.rkey ?? extractRkey(validRecord.uri);
134137 assign({
135135- record: first.value,
138138+ record: validRecord.value,
136139 rkey: derivedRkey,
137140 loading: false,
138141 empty: false,
···164167 const parts = uri.split("/");
165168 return parts[parts.length - 1];
166169}
170170+171171+/**
172172+ * Validates that a record has a reasonable timestamp (not before 2023).
173173+ * ATProto was created in 2023, so any timestamp before that is invalid.
174174+ */
175175+function isValidTimestamp(record: unknown): boolean {
176176+ if (typeof record !== "object" || record === null) return true;
177177+178178+ const recordObj = record as { createdAt?: string; indexedAt?: string };
179179+ const timestamp = recordObj.createdAt || recordObj.indexedAt;
180180+181181+ if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate
182182+183183+ try {
184184+ const date = new Date(timestamp);
185185+ // ATProto was created in 2023, reject anything before that
186186+ return date.getFullYear() >= 2023;
187187+ } catch {
188188+ // If we can't parse the date, consider it valid to avoid false negatives
189189+ return true;
190190+ }
191191+}
+79-83
lib/hooks/usePaginatedRecords.ts
···11import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22import { useDidResolution } from "./useDidResolution";
33import { usePdsEndpoint } from "./usePdsEndpoint";
44-import { createAtprotoClient } from "../utils/atproto-client";
44+import {
55+ DEFAULT_APPVIEW_SERVICE,
66+ callAppviewRpc,
77+ callListRecords
88+} from "./useBlueskyAppview";
59610/**
711 * Record envelope returned by paginated AT Protocol queries.
···7074 pagesCount: number;
7175}
72767373-const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
7777+74787579export type AuthorFeedFilter =
7680 | "posts_with_replies"
···188192 !!actorIdentifier;
189193 if (shouldUseAuthorFeed) {
190194 try {
191191- const { rpc } = await createAtprotoClient({
192192- service:
193193- authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
194194- });
195195- const res = await (
196196- rpc as unknown as {
197197- get: (
198198- nsid: string,
199199- opts: {
200200- params: Record<
201201- string,
202202- | string
203203- | number
204204- | boolean
205205- | undefined
206206- >;
207207- },
208208- ) => Promise<{
209209- ok: boolean;
210210- data: {
211211- feed?: Array<{
212212- post?: {
213213- uri?: string;
214214- record?: T;
215215- reply?: {
216216- parent?: {
217217- uri?: string;
218218- author?: {
219219- handle?: string;
220220- did?: string;
221221- };
222222- };
223223- };
195195+ interface AuthorFeedResponse {
196196+ feed?: Array<{
197197+ post?: {
198198+ uri?: string;
199199+ record?: T;
200200+ reply?: {
201201+ parent?: {
202202+ uri?: string;
203203+ author?: {
204204+ handle?: string;
205205+ did?: string;
224206 };
225225- reason?: AuthorFeedReason;
226226- }>;
227227- cursor?: string;
207207+ };
228208 };
229229- }>;
230230- }
231231- ).get("app.bsky.feed.getAuthorFeed", {
232232- params: {
209209+ };
210210+ reason?: AuthorFeedReason;
211211+ }>;
212212+ cursor?: string;
213213+ }
214214+215215+ const res = await callAppviewRpc<AuthorFeedResponse>(
216216+ authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
217217+ "app.bsky.feed.getAuthorFeed",
218218+ {
233219 actor: actorIdentifier,
234220 limit,
235221 cursor,
236222 filter: authorFeedFilter,
237223 includePins: authorFeedIncludePins,
238224 },
239239- });
225225+ );
240226 if (!res.ok)
241227 throw new Error("Failed to fetch author feed");
242228 const { feed, cursor: feedCursor } = res.data;
···249235 !post.record
250236 )
251237 return acc;
238238+ // Skip records with invalid timestamps (before 2023)
239239+ if (!isValidTimestamp(post.record)) {
240240+ console.warn("Skipping record with invalid timestamp:", post.uri);
241241+ return acc;
242242+ }
252243 acc.push({
253244 uri: post.uri,
254245 rkey: extractRkey(post.uri),
···268259 }
269260270261 if (!mapped) {
271271- const { rpc } = await createAtprotoClient({
272272- service: endpoint,
273273- });
274274- const res = await (
275275- rpc as unknown as {
276276- get: (
277277- nsid: string,
278278- opts: {
279279- params: Record<
280280- string,
281281- string | number | boolean | undefined
282282- >;
283283- },
284284- ) => Promise<{
285285- ok: boolean;
286286- data: {
287287- records: Array<{
288288- uri: string;
289289- rkey?: string;
290290- value: T;
291291- }>;
292292- cursor?: string;
293293- };
294294- }>;
295295- }
296296- ).get("com.atproto.repo.listRecords", {
297297- params: {
298298- repo: did,
299299- collection,
300300- limit,
301301- cursor,
302302- reverse: false,
303303- },
304304- });
305305- if (!res.ok) throw new Error("Failed to list records");
262262+ // Slingshot doesn't support listRecords, query PDS directly
263263+ const res = await callListRecords<T>(
264264+ endpoint,
265265+ did,
266266+ collection,
267267+ limit,
268268+ cursor,
269269+ );
270270+271271+ if (!res.ok) throw new Error("Failed to list records from PDS");
306272 const { records, cursor: repoCursor } = res.data;
307307- mapped = records.map((item) => ({
308308- uri: item.uri,
309309- rkey: item.rkey ?? extractRkey(item.uri),
310310- value: item.value,
311311- }));
273273+ mapped = records
274274+ .filter((item) => {
275275+ if (!isValidTimestamp(item.value)) {
276276+ console.warn("Skipping record with invalid timestamp:", item.uri);
277277+ return false;
278278+ }
279279+ return true;
280280+ })
281281+ .map((item) => ({
282282+ uri: item.uri,
283283+ rkey: item.rkey ?? extractRkey(item.uri),
284284+ value: item.value,
285285+ }));
312286 nextCursor = repoCursor;
313287 }
314288···475449 const parts = uri.split("/");
476450 return parts[parts.length - 1];
477451}
452452+453453+/**
454454+ * Validates that a record has a reasonable timestamp (not before 2023).
455455+ * ATProto was created in 2023, so any timestamp before that is invalid.
456456+ */
457457+function isValidTimestamp(record: unknown): boolean {
458458+ if (typeof record !== "object" || record === null) return true;
459459+460460+ const recordObj = record as { createdAt?: string; indexedAt?: string };
461461+ const timestamp = recordObj.createdAt || recordObj.indexedAt;
462462+463463+ if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate
464464+465465+ try {
466466+ const date = new Date(timestamp);
467467+ // ATProto was created in 2023, reject anything before that
468468+ return date.getFullYear() >= 2023;
469469+ } catch {
470470+ // If we can't parse the date, consider it valid to avoid false negatives
471471+ return true;
472472+ }
473473+}
+1
lib/index.ts
···1717// Hooks
1818export * from "./hooks/useAtProtoRecord";
1919export * from "./hooks/useBlob";
2020+export * from "./hooks/useBlueskyAppview";
2021export * from "./hooks/useBlueskyProfile";
2122export * from "./hooks/useColorScheme";
2223export * from "./hooks/useDidResolution";
+43-2
lib/renderers/BlueskyPostRenderer.tsx
···1313import { useDidResolution } from "../hooks/useDidResolution";
1414import { useBlob } from "../hooks/useBlob";
1515import { BlueskyIcon } from "../components/BlueskyIcon";
1616+import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
16171718export interface BlueskyPostRendererProps {
1819 record: FeedPostRecord;
···490491}
491492492493const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => {
493493- const cid = image.image?.ref?.$link ?? image.image?.cid;
494494- const { url, loading, error } = useBlob(did, cid);
494494+ // Check if the image has a CDN URL from the appview (preferred)
495495+ const imageBlob = image.image;
496496+ const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined;
497497+ const cid = !cdnUrl ? extractCidFromImageBlob(imageBlob) : undefined;
498498+ const { url: urlFromBlob, loading, error } = useBlob(did, cid);
499499+ // Use CDN URL from appview if available, otherwise use blob URL
500500+ const url = cdnUrl || urlFromBlob;
495501 const alt = image.alt?.trim() || "Bluesky attachment";
496502 const palette =
497503 scheme === "dark" ? imagesPalette.dark : imagesPalette.light;
···536542 </figure>
537543 );
538544};
545545+546546+/**
547547+ * Type guard to check if a blob has a CDN URL from appview.
548548+ */
549549+function isBlobWithCdn(value: unknown): value is BlobWithCdn {
550550+ if (typeof value !== "object" || value === null) return false;
551551+ const obj = value as Record<string, unknown>;
552552+ return (
553553+ obj.$type === "blob" &&
554554+ typeof obj.cdnUrl === "string" &&
555555+ typeof obj.ref === "object" &&
556556+ obj.ref !== null &&
557557+ typeof (obj.ref as { $link?: unknown }).$link === "string"
558558+ );
559559+}
560560+561561+/**
562562+ * Helper to extract CID from image blob.
563563+ */
564564+function extractCidFromImageBlob(blob: unknown): string | undefined {
565565+ if (typeof blob !== "object" || blob === null) return undefined;
566566+567567+ const blobObj = blob as {
568568+ ref?: { $link?: string };
569569+ cid?: string;
570570+ };
571571+572572+ if (typeof blobObj.cid === "string") return blobObj.cid;
573573+ if (typeof blobObj.ref === "object" && blobObj.ref !== null) {
574574+ const link = blobObj.ref.$link;
575575+ if (typeof link === "string") return link;
576576+ }
577577+578578+ return undefined;
579579+}
539580540581const imagesBase = {
541582 container: {