A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

refactor to seperate out useBlueskyAppview, have components and useAtProtoRecord to use it

+24 -2
lib/components/BlueskyPost.tsx
···
import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
import { getAvatarCid } from "../utils/profile";
import { formatDidForLabel } from "../utils/at-uri";
+
import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
/**
* Props for rendering a single Bluesky post with optional customization hooks.
···
collection: BLUESKY_PROFILE_COLLECTION,
rkey: "self",
});
-
const avatarCid = getAvatarCid(profile);
+
// Check if the avatar has a CDN URL from the appview (preferred)
+
const avatar = profile?.avatar;
+
const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
+
const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined;
const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
() => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
···
loading: boolean;
error?: Error;
}> = (props) => {
-
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
+
const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
+
// Use CDN URL from appview if available, otherwise use blob URL
+
const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
return (
<Comp
{...props}
···
Comp,
repoIdentifier,
avatarCid,
+
avatarCdnUrl,
authorHandle,
colorScheme,
iconPlacement,
···
/>
);
};
+
+
/**
+
* Type guard to check if a blob has a CDN URL from appview.
+
*/
+
function isBlobWithCdn(value: unknown): value is BlobWithCdn {
+
if (typeof value !== "object" || value === null) return false;
+
const obj = value as Record<string, unknown>;
+
return (
+
obj.$type === "blob" &&
+
typeof obj.cdnUrl === "string" &&
+
typeof obj.ref === "object" &&
+
obj.ref !== null &&
+
typeof (obj.ref as { $link?: unknown }).$link === "string"
+
);
+
}
export default BlueskyPost;
+25 -2
lib/components/BlueskyProfile.tsx
···
import { getAvatarCid } from "../utils/profile";
import { useDidResolution } from "../hooks/useDidResolution";
import { formatDidForLabel } from "../utils/at-uri";
+
import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
/**
* Props used to render a Bluesky actor profile record.
···
loading: boolean;
error?: Error;
}> = (props) => {
-
const avatarCid = getAvatarCid(props.record);
-
const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
+
// Check if the avatar has a CDN URL from the appview (preferred)
+
const avatar = props.record?.avatar;
+
const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
+
const avatarCid = !avatarCdnUrl ? getAvatarCid(props.record) : undefined;
+
const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
+
+
// Use CDN URL from appview if available, otherwise use blob URL
+
const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
+
return (
<Component
{...props}
···
/>
);
};
+
+
/**
+
* Type guard to check if a blob has a CDN URL from appview.
+
*/
+
function isBlobWithCdn(value: unknown): value is BlobWithCdn {
+
if (typeof value !== "object" || value === null) return false;
+
const obj = value as Record<string, unknown>;
+
return (
+
obj.$type === "blob" &&
+
typeof obj.cdnUrl === "string" &&
+
typeof obj.ref === "object" &&
+
obj.ref !== null &&
+
typeof (obj.ref as { $link?: unknown }).$link === "string"
+
);
+
}
export default BlueskyProfile;
+34
lib/hooks/useAtProtoRecord.ts
···
import { useDidResolution } from "./useDidResolution";
import { usePdsEndpoint } from "./usePdsEndpoint";
import { createAtprotoClient } from "../utils/atproto-client";
+
import { useBlueskyAppview } from "./useBlueskyAppview";
/**
* Identifier trio required to address an AT Protocol record.
···
/**
* React hook that fetches a single AT Protocol record and tracks loading/error state.
+
*
+
* For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy:
+
* 1. Try Bluesky appview API first
+
* 2. Fall back to Slingshot getRecord
+
* 3. Finally query the PDS directly
+
*
+
* For other collections, queries the PDS directly (with Slingshot fallback via the client handler).
*
* @param did - DID (or handle before resolution) that owns the record.
* @param collection - NSID collection from which to fetch the record.
···
collection,
rkey,
}: AtProtoRecordKey): AtProtoRecordState<T> {
+
// Determine if this is a Bluesky collection that should use the appview
+
const isBlueskyCollection = collection?.startsWith("app.bsky.");
+
+
// Use the three-tier fallback for Bluesky collections
+
const blueskyResult = useBlueskyAppview<T>({
+
did: isBlueskyCollection ? handleOrDid : undefined,
+
collection: isBlueskyCollection ? collection : undefined,
+
rkey: isBlueskyCollection ? rkey : undefined,
+
});
const {
did,
error: didError,
···
setState((prev) => ({ ...prev, ...next }));
};
+
// If using Bluesky appview, skip the manual fetch logic
+
if (isBlueskyCollection) {
+
return () => {
+
cancelled = true;
+
};
+
}
+
if (!handleOrDid || !collection || !rkey) {
assignState({
loading: false,
···
resolvingEndpoint,
didError,
endpointError,
+
isBlueskyCollection,
]);
+
+
// Return Bluesky appview result if it's a Bluesky collection
+
if (isBlueskyCollection) {
+
return {
+
record: blueskyResult.record,
+
error: blueskyResult.error,
+
loading: blueskyResult.loading,
+
};
+
}
return state;
}
+617
lib/hooks/useBlueskyAppview.ts
···
+
import { useEffect, useState } from "react";
+
import { useDidResolution } from "./useDidResolution";
+
import { usePdsEndpoint } from "./usePdsEndpoint";
+
import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client";
+
+
/**
+
* Extended blob reference that includes CDN URL from appview responses.
+
*/
+
export interface BlobWithCdn {
+
$type: "blob";
+
ref: { $link: string };
+
mimeType: string;
+
size: number;
+
/** CDN URL from Bluesky appview (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg) */
+
cdnUrl?: string;
+
}
+
+
+
+
/**
+
* Appview getProfile response structure.
+
*/
+
interface AppviewProfileResponse {
+
did: string;
+
handle: string;
+
displayName?: string;
+
description?: string;
+
avatar?: string;
+
banner?: string;
+
createdAt?: string;
+
[key: string]: unknown;
+
}
+
+
/**
+
* Appview getPostThread response structure.
+
*/
+
interface AppviewPostThreadResponse<T = unknown> {
+
thread?: {
+
post?: {
+
record?: T;
+
embed?: {
+
$type?: string;
+
images?: Array<{
+
thumb?: string;
+
fullsize?: string;
+
alt?: string;
+
aspectRatio?: { width: number; height: number };
+
}>;
+
media?: {
+
images?: Array<{
+
thumb?: string;
+
fullsize?: string;
+
alt?: string;
+
aspectRatio?: { width: number; height: number };
+
}>;
+
};
+
};
+
};
+
};
+
}
+
+
/**
+
* Options for {@link useBlueskyAppview}.
+
*/
+
export interface UseBlueskyAppviewOptions {
+
/** DID or handle of the actor. */
+
did?: string;
+
/** NSID collection (e.g., "app.bsky.feed.post"). */
+
collection?: string;
+
/** Record key within the collection. */
+
rkey?: string;
+
/** Override for the Bluesky appview service URL. Defaults to public.api.bsky.app. */
+
appviewService?: string;
+
/** If true, skip the appview and go straight to Slingshot/PDS fallback. */
+
skipAppview?: boolean;
+
}
+
+
/**
+
* Result returned from {@link useBlueskyAppview}.
+
*/
+
export interface UseBlueskyAppviewResult<T = unknown> {
+
/** The fetched record value. */
+
record?: T;
+
/** Indicates whether a fetch is in progress. */
+
loading: boolean;
+
/** Error encountered during fetch. */
+
error?: Error;
+
/** Source from which the record was successfully fetched. */
+
source?: "appview" | "slingshot" | "pds";
+
}
+
+
export const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
+
+
/**
+
* Maps Bluesky collection NSIDs to their corresponding appview API endpoints.
+
* Only includes endpoints that can fetch individual records (not list endpoints).
+
*/
+
const BLUESKY_COLLECTION_TO_ENDPOINT: Record<string, string> = {
+
"app.bsky.actor.profile": "app.bsky.actor.getProfile",
+
"app.bsky.feed.post": "app.bsky.feed.getPostThread",
+
+
};
+
+
/**
+
* React hook that fetches a Bluesky record with a three-tier fallback strategy:
+
* 1. Try the Bluesky appview API endpoint (e.g., getProfile, getPostThread)
+
* 2. Fall back to Slingshot's getRecord
+
* 3. As a last resort, query the actor's PDS directly
+
*
+
* The hook automatically handles DID resolution and determines the appropriate API endpoint
+
* based on the collection type. The `source` field in the result indicates which tier
+
* successfully returned the record.
+
*
+
* @example
+
* ```tsx
+
* // Fetch a Bluesky post with automatic fallback
+
* import { useBlueskyAppview } from 'atproto-ui';
+
* import type { FeedPostRecord } from 'atproto-ui';
+
*
+
* function MyPost({ did, rkey }: { did: string; rkey: string }) {
+
* const { record, loading, error, source } = useBlueskyAppview<FeedPostRecord>({
+
* did,
+
* collection: 'app.bsky.feed.post',
+
* rkey,
+
* });
+
*
+
* if (loading) return <p>Loading post...</p>;
+
* if (error) return <p>Error: {error.message}</p>;
+
* if (!record) return <p>No post found</p>;
+
*
+
* return (
+
* <article>
+
* <p>{record.text}</p>
+
* <small>Fetched from: {source}</small>
+
* </article>
+
* );
+
* }
+
* ```
+
*
+
* @example
+
* ```tsx
+
* // Fetch a Bluesky profile
+
* import { useBlueskyAppview } from 'atproto-ui';
+
* import type { ProfileRecord } from 'atproto-ui';
+
*
+
* function MyProfile({ handle }: { handle: string }) {
+
* const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
+
* did: handle, // Handles are automatically resolved to DIDs
+
* collection: 'app.bsky.actor.profile',
+
* rkey: 'self',
+
* });
+
*
+
* if (loading) return <p>Loading profile...</p>;
+
* if (!record) return null;
+
*
+
* return (
+
* <div>
+
* <h2>{record.displayName}</h2>
+
* <p>{record.description}</p>
+
* </div>
+
* );
+
* }
+
* ```
+
*
+
* @example
+
* ```tsx
+
* // Skip the appview and go directly to Slingshot/PDS
+
* const { record } = useBlueskyAppview({
+
* did: 'did:plc:example',
+
* collection: 'app.bsky.feed.post',
+
* rkey: '3k2aexample',
+
* skipAppview: true, // Bypasses Bluesky API, starts with Slingshot
+
* });
+
* ```
+
*
+
* @param options - Configuration object with did, collection, rkey, and optional overrides.
+
* @returns {UseBlueskyAppviewResult<T>} Object containing the record, loading state, error, and source.
+
*/
+
export function useBlueskyAppview<T = unknown>({
+
did: handleOrDid,
+
collection,
+
rkey,
+
appviewService,
+
skipAppview = false,
+
}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
+
const {
+
did,
+
error: didError,
+
loading: resolvingDid,
+
} = useDidResolution(handleOrDid);
+
const {
+
endpoint: pdsEndpoint,
+
error: endpointError,
+
loading: resolvingEndpoint,
+
} = usePdsEndpoint(did);
+
+
const [record, setRecord] = useState<T | undefined>();
+
const [loading, setLoading] = useState(false);
+
const [error, setError] = useState<Error | undefined>();
+
const [source, setSource] = useState<"appview" | "slingshot" | "pds" | undefined>();
+
+
useEffect(() => {
+
let cancelled = false;
+
+
const assign = (next: Partial<UseBlueskyAppviewResult<T>>) => {
+
if (cancelled) return;
+
setRecord(next.record);
+
setLoading(next.loading ?? false);
+
setError(next.error);
+
setSource(next.source);
+
};
+
+
// Early returns for missing inputs or resolution errors
+
if (!handleOrDid || !collection || !rkey) {
+
assign({
+
loading: false,
+
record: undefined,
+
error: undefined,
+
source: undefined,
+
});
+
return () => {
+
cancelled = true;
+
};
+
}
+
+
if (didError) {
+
assign({ loading: false, error: didError, source: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
+
+
if (endpointError) {
+
assign({ loading: false, error: endpointError, source: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
+
+
if (resolvingDid || resolvingEndpoint || !did || !pdsEndpoint) {
+
assign({ loading: true, error: undefined, source: undefined });
+
return () => {
+
cancelled = true;
+
};
+
}
+
+
// Start fetching
+
assign({ loading: true, error: undefined, source: undefined });
+
+
(async () => {
+
let lastError: Error | undefined;
+
+
// Tier 1: Try Bluesky appview API
+
if (!skipAppview && BLUESKY_COLLECTION_TO_ENDPOINT[collection]) {
+
try {
+
const result = await fetchFromAppview<T>(
+
did,
+
collection,
+
rkey,
+
appviewService ?? DEFAULT_APPVIEW_SERVICE,
+
);
+
if (!cancelled && result) {
+
assign({
+
record: result,
+
loading: false,
+
source: "appview",
+
});
+
return;
+
}
+
} catch (err) {
+
lastError = err as Error;
+
// Continue to next tier
+
}
+
}
+
+
// Tier 2: Try Slingshot getRecord
+
try {
+
const result = await fetchFromSlingshot<T>(did, collection, rkey);
+
if (!cancelled && result) {
+
assign({
+
record: result,
+
loading: false,
+
source: "slingshot",
+
});
+
return;
+
}
+
} catch (err) {
+
lastError = err as Error;
+
// Continue to next tier
+
}
+
+
// Tier 3: Try PDS directly
+
try {
+
const result = await fetchFromPds<T>(
+
did,
+
collection,
+
rkey,
+
pdsEndpoint,
+
);
+
if (!cancelled && result) {
+
assign({
+
record: result,
+
loading: false,
+
source: "pds",
+
});
+
return;
+
}
+
} catch (err) {
+
lastError = err as Error;
+
}
+
+
// All tiers failed
+
if (!cancelled) {
+
assign({
+
loading: false,
+
error:
+
lastError ??
+
new Error("Failed to fetch record from all sources"),
+
source: undefined,
+
});
+
}
+
})();
+
+
return () => {
+
cancelled = true;
+
};
+
}, [
+
handleOrDid,
+
did,
+
collection,
+
rkey,
+
pdsEndpoint,
+
appviewService,
+
skipAppview,
+
resolvingDid,
+
resolvingEndpoint,
+
didError,
+
endpointError,
+
]);
+
+
return {
+
record,
+
loading,
+
error,
+
source,
+
};
+
}
+
+
/**
+
* Attempts to fetch a record from the Bluesky appview API.
+
* Different collections map to different endpoints with varying response structures.
+
*/
+
async function fetchFromAppview<T>(
+
did: string,
+
collection: string,
+
rkey: string,
+
appviewService: string,
+
): Promise<T | undefined> {
+
const { rpc } = await createAtprotoClient({ service: appviewService });
+
const endpoint = BLUESKY_COLLECTION_TO_ENDPOINT[collection];
+
+
if (!endpoint) {
+
throw new Error(`No appview endpoint mapped for collection ${collection}`);
+
}
+
+
const atUri = `at://${did}/${collection}/${rkey}`;
+
+
// Handle different appview endpoints
+
if (endpoint === "app.bsky.actor.getProfile") {
+
const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewProfileResponse }> }).get(endpoint, {
+
params: { actor: did },
+
});
+
+
if (!res.ok) throw new Error("Appview profile request failed");
+
+
// The appview returns avatar/banner as CDN URLs like:
+
// https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg
+
// We need to extract the CID and convert to ProfileRecord format
+
const profile = res.data;
+
const avatarCid = extractCidFromCdnUrl(profile.avatar);
+
const bannerCid = extractCidFromCdnUrl(profile.banner);
+
+
// Convert hydrated profile to ProfileRecord format
+
// Store the CDN URL directly so components can use it without re-fetching
+
const record: Record<string, unknown> = {
+
displayName: profile.displayName,
+
description: profile.description,
+
createdAt: profile.createdAt,
+
};
+
+
if (profile.avatar && avatarCid) {
+
const avatarBlob: BlobWithCdn = {
+
$type: "blob",
+
ref: { $link: avatarCid },
+
mimeType: "image/jpeg",
+
size: 0,
+
cdnUrl: profile.avatar,
+
};
+
record.avatar = avatarBlob;
+
}
+
+
if (profile.banner && bannerCid) {
+
const bannerBlob: BlobWithCdn = {
+
$type: "blob",
+
ref: { $link: bannerCid },
+
mimeType: "image/jpeg",
+
size: 0,
+
cdnUrl: profile.banner,
+
};
+
record.banner = bannerBlob;
+
}
+
+
return record as T;
+
}
+
+
if (endpoint === "app.bsky.feed.getPostThread") {
+
const res = await (rpc as unknown as { get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: AppviewPostThreadResponse<T> }> }).get(endpoint, {
+
params: { uri: atUri, depth: 0 },
+
});
+
+
if (!res.ok) throw new Error("Appview post thread request failed");
+
+
const post = res.data.thread?.post;
+
if (!post?.record) return undefined;
+
+
const record = post.record as Record<string, unknown>;
+
const appviewEmbed = post.embed;
+
+
// If the appview includes embedded images with CDN URLs, inject them into the record
+
if (appviewEmbed && record.embed) {
+
const recordEmbed = record.embed as { $type?: string; images?: Array<Record<string, unknown>>; media?: Record<string, unknown> };
+
+
// Handle direct image embeds
+
if (appviewEmbed.$type === "app.bsky.embed.images#view" && appviewEmbed.images) {
+
if (recordEmbed.images && Array.isArray(recordEmbed.images)) {
+
recordEmbed.images = recordEmbed.images.map((img: Record<string, unknown>, idx: number) => {
+
const appviewImg = appviewEmbed.images?.[idx];
+
if (appviewImg?.fullsize) {
+
const cid = extractCidFromCdnUrl(appviewImg.fullsize);
+
const imageObj = img.image as { ref?: { $link?: string } } | undefined;
+
return {
+
...img,
+
image: {
+
...(img.image as Record<string, unknown> || {}),
+
cdnUrl: appviewImg.fullsize,
+
ref: { $link: cid || imageObj?.ref?.$link },
+
},
+
};
+
}
+
return img;
+
});
+
}
+
}
+
+
// Handle recordWithMedia embeds
+
if (appviewEmbed.$type === "app.bsky.embed.recordWithMedia#view" && appviewEmbed.media) {
+
const mediaImages = appviewEmbed.media.images;
+
const mediaEmbedImages = (recordEmbed.media as { images?: Array<Record<string, unknown>> } | undefined)?.images;
+
if (mediaImages && mediaEmbedImages && Array.isArray(mediaEmbedImages)) {
+
(recordEmbed.media as { images: Array<Record<string, unknown>> }).images = mediaEmbedImages.map((img: Record<string, unknown>, idx: number) => {
+
const appviewImg = mediaImages[idx];
+
if (appviewImg?.fullsize) {
+
const cid = extractCidFromCdnUrl(appviewImg.fullsize);
+
const imageObj = img.image as { ref?: { $link?: string } } | undefined;
+
return {
+
...img,
+
image: {
+
...(img.image as Record<string, unknown> || {}),
+
cdnUrl: appviewImg.fullsize,
+
ref: { $link: cid || imageObj?.ref?.$link },
+
},
+
};
+
}
+
return img;
+
});
+
}
+
}
+
}
+
+
return record as T;
+
}
+
+
// For other endpoints, we might not have a clean way to extract the specific record
+
// Fall through to let the caller try the next tier
+
throw new Error(`Appview endpoint ${endpoint} not fully implemented`);
+
}
+
+
/**
+
* Attempts to fetch a record from Slingshot's getRecord endpoint.
+
*/
+
async function fetchFromSlingshot<T>(
+
did: string,
+
collection: string,
+
rkey: string,
+
): Promise<T | undefined> {
+
const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey);
+
if (!res.ok) throw new Error("Slingshot getRecord failed");
+
return res.data.value;
+
}
+
+
/**
+
* Attempts to fetch a record directly from the actor's PDS.
+
*/
+
async function fetchFromPds<T>(
+
did: string,
+
collection: string,
+
rkey: string,
+
pdsEndpoint: string,
+
): Promise<T | undefined> {
+
const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey);
+
if (!res.ok) throw new Error("PDS getRecord failed");
+
return res.data.value;
+
}
+
+
/**
+
* Extracts and validates CID from Bluesky CDN URL.
+
* Format: https://cdn.bsky.app/img/{type}/plain/{did}/{cid}@{format}
+
*
+
* @throws Error if URL format is invalid or CID extraction fails
+
*/
+
function extractCidFromCdnUrl(url: string | undefined): string | undefined {
+
if (!url) return undefined;
+
+
try {
+
// Match pattern: /did:plc:xxxxx/CIDHERE@format or /did:web:xxxxx/CIDHERE@format
+
const match = url.match(/\/did:[^/]+\/([^@/]+)@/);
+
const cid = match?.[1];
+
+
if (!cid) {
+
console.warn(`Failed to extract CID from CDN URL: ${url}`);
+
return undefined;
+
}
+
+
// Basic CID validation - should start with common CID prefixes
+
if (!cid.startsWith("bafk") && !cid.startsWith("bafyb") && !cid.startsWith("Qm")) {
+
console.warn(`Extracted string does not appear to be a valid CID: ${cid} from URL: ${url}`);
+
return undefined;
+
}
+
+
return cid;
+
} catch (err) {
+
console.error(`Error extracting CID from CDN URL: ${url}`, err);
+
return undefined;
+
}
+
}
+
+
/**
+
* Shared RPC utility for making appview API calls with proper typing.
+
*/
+
export async function callAppviewRpc<TResponse>(
+
service: string,
+
nsid: string,
+
params: Record<string, unknown>,
+
): Promise<{ ok: boolean; data: TResponse }> {
+
const { rpc } = await createAtprotoClient({ service });
+
return await (rpc as unknown as {
+
get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: TResponse }>;
+
}).get(nsid, { params });
+
}
+
+
/**
+
* Shared RPC utility for making getRecord calls (Slingshot or PDS).
+
*/
+
export async function callGetRecord<T>(
+
service: string,
+
did: string,
+
collection: string,
+
rkey: string,
+
): Promise<{ ok: boolean; data: { value: T } }> {
+
const { rpc } = await createAtprotoClient({ service });
+
return await (rpc as unknown as {
+
get: (nsid: string, opts: { params: Record<string, unknown> }) => Promise<{ ok: boolean; data: { value: T } }>;
+
}).get("com.atproto.repo.getRecord", {
+
params: { repo: did, collection, rkey },
+
});
+
}
+
+
/**
+
* Shared RPC utility for making listRecords calls.
+
*/
+
export async function callListRecords<T>(
+
service: string,
+
did: string,
+
collection: string,
+
limit: number,
+
cursor?: string,
+
): Promise<{
+
ok: boolean;
+
data: {
+
records: Array<{ uri: string; rkey?: string; value: T }>;
+
cursor?: string;
+
};
+
}> {
+
const { rpc } = await createAtprotoClient({ service });
+
return await (rpc as unknown as {
+
get: (
+
nsid: string,
+
opts: { params: Record<string, unknown> },
+
) => Promise<{
+
ok: boolean;
+
data: {
+
records: Array<{ uri: string; rkey?: string; value: T }>;
+
cursor?: string;
+
};
+
}>;
+
}).get("com.atproto.repo.listRecords", {
+
params: {
+
repo: did,
+
collection,
+
limit,
+
cursor,
+
reverse: false,
+
},
+
});
+
}
+
+
+48 -37
lib/hooks/useBlueskyProfile.ts
···
-
import { useEffect, useState } from "react";
-
import { usePdsEndpoint } from "./usePdsEndpoint";
-
import { createAtprotoClient } from "../utils/atproto-client";
+
import { useBlueskyAppview } from "./useBlueskyAppview";
+
import type { ProfileRecord } from "../types/bluesky";
/**
* Minimal profile fields returned by the Bluesky actor profile endpoint.
···
/**
* Fetches a Bluesky actor profile for a DID and exposes loading/error state.
+
*
+
* Uses a three-tier fallback strategy:
+
* 1. Try Bluesky appview API (app.bsky.actor.getProfile) - CIDs are extracted from CDN URLs
+
* 2. Fall back to Slingshot getRecord
+
* 3. Finally query the PDS directly
+
*
+
* When using the appview, avatar/banner CDN URLs (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg)
+
* are automatically parsed to extract CIDs and convert them to standard Blob format for compatibility.
*
* @param did - Actor DID whose profile should be retrieved.
* @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error.
*/
export function useBlueskyProfile(did: string | undefined) {
-
const { endpoint } = usePdsEndpoint(did);
-
const [data, setData] = useState<BlueskyProfileData | undefined>();
-
const [loading, setLoading] = useState<boolean>(!!did);
-
const [error, setError] = useState<Error | undefined>();
+
const { record, loading, error } = useBlueskyAppview<ProfileRecord>({
+
did,
+
collection: "app.bsky.actor.profile",
+
rkey: "self",
+
});
-
useEffect(() => {
-
let cancelled = false;
-
async function run() {
-
if (!did || !endpoint) return;
-
setLoading(true);
-
try {
-
const { rpc } = await createAtprotoClient({
-
service: endpoint,
-
});
-
const client = rpc as unknown as {
-
get: (
-
nsid: string,
-
options: { params: { actor: string } },
-
) => Promise<{ ok: boolean; data: unknown }>;
-
};
-
const res = await client.get("app.bsky.actor.getProfile", {
-
params: { actor: did },
-
});
-
if (!res.ok) throw new Error("Profile request failed");
-
if (!cancelled) setData(res.data as BlueskyProfileData);
-
} catch (e) {
-
if (!cancelled) setError(e as Error);
-
} finally {
-
if (!cancelled) setLoading(false);
-
}
+
// Convert ProfileRecord to BlueskyProfileData
+
// Note: avatar and banner are Blob objects in the record (from all sources)
+
// The appview response is converted to ProfileRecord format by extracting CIDs from CDN URLs
+
const data: BlueskyProfileData | undefined = record
+
? {
+
did: did || "",
+
handle: "",
+
displayName: record.displayName,
+
description: record.description,
+
avatar: extractCidFromProfileBlob(record.avatar),
+
banner: extractCidFromProfileBlob(record.banner),
+
createdAt: record.createdAt,
}
-
run();
-
return () => {
-
cancelled = true;
-
};
-
}, [did, endpoint]);
+
: undefined;
return { data, loading, error };
}
+
+
/**
+
* Helper to extract CID from profile blob (avatar or banner).
+
*/
+
function extractCidFromProfileBlob(blob: unknown): string | undefined {
+
if (typeof blob !== "object" || blob === null) return undefined;
+
+
const blobObj = blob as {
+
ref?: { $link?: string };
+
cid?: string;
+
};
+
+
if (typeof blobObj.cid === "string") return blobObj.cid;
+
if (typeof blobObj.ref === "object" && blobObj.ref !== null) {
+
const link = blobObj.ref.$link;
+
if (typeof link === "string") return link;
+
}
+
+
return undefined;
+
}
+58 -33
lib/hooks/useLatestRecord.ts
···
import { useEffect, useState } from "react";
import { useDidResolution } from "./useDidResolution";
import { usePdsEndpoint } from "./usePdsEndpoint";
-
import { createAtprotoClient } from "../utils/atproto-client";
+
import { callListRecords } from "./useBlueskyAppview";
/**
* Shape of the state returned by {@link useLatestRecord}.
···
}
/**
-
* Fetches the most recent record from a collection using `listRecords(limit=1)`.
+
* Fetches the most recent record from a collection using `listRecords(limit=3)`.
+
*
+
* Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly.
+
*
+
* Records with invalid timestamps (before 2023, when ATProto was created) are automatically
+
* skipped, and additional records are fetched to find a valid one.
*
* @param handleOrDid - Handle or DID that owns the collection.
* @param collection - NSID of the collection to query.
···
(async () => {
try {
-
const { rpc } = await createAtprotoClient({
-
service: endpoint,
-
});
-
const res = await (
-
rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: {
-
params: Record<
-
string,
-
string | number | boolean
-
>;
-
},
-
) => Promise<{
-
ok: boolean;
-
data: {
-
records: Array<{
-
uri: string;
-
rkey?: string;
-
value: T;
-
}>;
-
};
-
}>;
-
}
-
).get("com.atproto.repo.listRecords", {
-
params: { repo: did, collection, limit: 1, reverse: false },
-
});
-
if (!res.ok) throw new Error("Failed to list records");
+
// Slingshot doesn't support listRecords, so we query PDS directly
+
const res = await callListRecords<T>(
+
endpoint,
+
did,
+
collection,
+
3, // Fetch 3 in case some have invalid timestamps
+
);
+
+
if (!res.ok) {
+
throw new Error("Failed to list records from PDS");
+
}
+
const list = res.data.records;
if (list.length === 0) {
assign({
···
});
return;
}
-
const first = list[0];
-
const derivedRkey = first.rkey ?? extractRkey(first.uri);
+
+
// Find the first valid record (skip records before 2023)
+
const validRecord = list.find((item) => isValidTimestamp(item.value));
+
+
if (!validRecord) {
+
console.warn("No valid records found (all had timestamps before 2023)");
+
assign({
+
loading: false,
+
empty: true,
+
record: undefined,
+
rkey: undefined,
+
});
+
return;
+
}
+
+
const derivedRkey = validRecord.rkey ?? extractRkey(validRecord.uri);
assign({
-
record: first.value,
+
record: validRecord.value,
rkey: derivedRkey,
loading: false,
empty: false,
···
const parts = uri.split("/");
return parts[parts.length - 1];
}
+
+
/**
+
* Validates that a record has a reasonable timestamp (not before 2023).
+
* ATProto was created in 2023, so any timestamp before that is invalid.
+
*/
+
function isValidTimestamp(record: unknown): boolean {
+
if (typeof record !== "object" || record === null) return true;
+
+
const recordObj = record as { createdAt?: string; indexedAt?: string };
+
const timestamp = recordObj.createdAt || recordObj.indexedAt;
+
+
if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate
+
+
try {
+
const date = new Date(timestamp);
+
// ATProto was created in 2023, reject anything before that
+
return date.getFullYear() >= 2023;
+
} catch {
+
// If we can't parse the date, consider it valid to avoid false negatives
+
return true;
+
}
+
}
+79 -83
lib/hooks/usePaginatedRecords.ts
···
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDidResolution } from "./useDidResolution";
import { usePdsEndpoint } from "./usePdsEndpoint";
-
import { createAtprotoClient } from "../utils/atproto-client";
+
import {
+
DEFAULT_APPVIEW_SERVICE,
+
callAppviewRpc,
+
callListRecords
+
} from "./useBlueskyAppview";
/**
* Record envelope returned by paginated AT Protocol queries.
···
pagesCount: number;
}
-
const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
+
export type AuthorFeedFilter =
| "posts_with_replies"
···
!!actorIdentifier;
if (shouldUseAuthorFeed) {
try {
-
const { rpc } = await createAtprotoClient({
-
service:
-
authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
-
});
-
const res = await (
-
rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: {
-
params: Record<
-
string,
-
| string
-
| number
-
| boolean
-
| undefined
-
>;
-
},
-
) => Promise<{
-
ok: boolean;
-
data: {
-
feed?: Array<{
-
post?: {
-
uri?: string;
-
record?: T;
-
reply?: {
-
parent?: {
-
uri?: string;
-
author?: {
-
handle?: string;
-
did?: string;
-
};
-
};
-
};
+
interface AuthorFeedResponse {
+
feed?: Array<{
+
post?: {
+
uri?: string;
+
record?: T;
+
reply?: {
+
parent?: {
+
uri?: string;
+
author?: {
+
handle?: string;
+
did?: string;
};
-
reason?: AuthorFeedReason;
-
}>;
-
cursor?: string;
+
};
};
-
}>;
-
}
-
).get("app.bsky.feed.getAuthorFeed", {
-
params: {
+
};
+
reason?: AuthorFeedReason;
+
}>;
+
cursor?: string;
+
}
+
+
const res = await callAppviewRpc<AuthorFeedResponse>(
+
authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
+
"app.bsky.feed.getAuthorFeed",
+
{
actor: actorIdentifier,
limit,
cursor,
filter: authorFeedFilter,
includePins: authorFeedIncludePins,
},
-
});
+
);
if (!res.ok)
throw new Error("Failed to fetch author feed");
const { feed, cursor: feedCursor } = res.data;
···
!post.record
)
return acc;
+
// Skip records with invalid timestamps (before 2023)
+
if (!isValidTimestamp(post.record)) {
+
console.warn("Skipping record with invalid timestamp:", post.uri);
+
return acc;
+
}
acc.push({
uri: post.uri,
rkey: extractRkey(post.uri),
···
}
if (!mapped) {
-
const { rpc } = await createAtprotoClient({
-
service: endpoint,
-
});
-
const res = await (
-
rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: {
-
params: Record<
-
string,
-
string | number | boolean | undefined
-
>;
-
},
-
) => Promise<{
-
ok: boolean;
-
data: {
-
records: Array<{
-
uri: string;
-
rkey?: string;
-
value: T;
-
}>;
-
cursor?: string;
-
};
-
}>;
-
}
-
).get("com.atproto.repo.listRecords", {
-
params: {
-
repo: did,
-
collection,
-
limit,
-
cursor,
-
reverse: false,
-
},
-
});
-
if (!res.ok) throw new Error("Failed to list records");
+
// Slingshot doesn't support listRecords, query PDS directly
+
const res = await callListRecords<T>(
+
endpoint,
+
did,
+
collection,
+
limit,
+
cursor,
+
);
+
+
if (!res.ok) throw new Error("Failed to list records from PDS");
const { records, cursor: repoCursor } = res.data;
-
mapped = records.map((item) => ({
-
uri: item.uri,
-
rkey: item.rkey ?? extractRkey(item.uri),
-
value: item.value,
-
}));
+
mapped = records
+
.filter((item) => {
+
if (!isValidTimestamp(item.value)) {
+
console.warn("Skipping record with invalid timestamp:", item.uri);
+
return false;
+
}
+
return true;
+
})
+
.map((item) => ({
+
uri: item.uri,
+
rkey: item.rkey ?? extractRkey(item.uri),
+
value: item.value,
+
}));
nextCursor = repoCursor;
}
···
const parts = uri.split("/");
return parts[parts.length - 1];
}
+
+
/**
+
* Validates that a record has a reasonable timestamp (not before 2023).
+
* ATProto was created in 2023, so any timestamp before that is invalid.
+
*/
+
function isValidTimestamp(record: unknown): boolean {
+
if (typeof record !== "object" || record === null) return true;
+
+
const recordObj = record as { createdAt?: string; indexedAt?: string };
+
const timestamp = recordObj.createdAt || recordObj.indexedAt;
+
+
if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate
+
+
try {
+
const date = new Date(timestamp);
+
// ATProto was created in 2023, reject anything before that
+
return date.getFullYear() >= 2023;
+
} catch {
+
// If we can't parse the date, consider it valid to avoid false negatives
+
return true;
+
}
+
}
+1
lib/index.ts
···
// Hooks
export * from "./hooks/useAtProtoRecord";
export * from "./hooks/useBlob";
+
export * from "./hooks/useBlueskyAppview";
export * from "./hooks/useBlueskyProfile";
export * from "./hooks/useColorScheme";
export * from "./hooks/useDidResolution";
+43 -2
lib/renderers/BlueskyPostRenderer.tsx
···
import { useDidResolution } from "../hooks/useDidResolution";
import { useBlob } from "../hooks/useBlob";
import { BlueskyIcon } from "../components/BlueskyIcon";
+
import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
export interface BlueskyPostRendererProps {
record: FeedPostRecord;
···
}
const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => {
-
const cid = image.image?.ref?.$link ?? image.image?.cid;
-
const { url, loading, error } = useBlob(did, cid);
+
// Check if the image has a CDN URL from the appview (preferred)
+
const imageBlob = image.image;
+
const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined;
+
const cid = !cdnUrl ? extractCidFromImageBlob(imageBlob) : undefined;
+
const { url: urlFromBlob, loading, error } = useBlob(did, cid);
+
// Use CDN URL from appview if available, otherwise use blob URL
+
const url = cdnUrl || urlFromBlob;
const alt = image.alt?.trim() || "Bluesky attachment";
const palette =
scheme === "dark" ? imagesPalette.dark : imagesPalette.light;
···
</figure>
);
};
+
+
/**
+
* Type guard to check if a blob has a CDN URL from appview.
+
*/
+
function isBlobWithCdn(value: unknown): value is BlobWithCdn {
+
if (typeof value !== "object" || value === null) return false;
+
const obj = value as Record<string, unknown>;
+
return (
+
obj.$type === "blob" &&
+
typeof obj.cdnUrl === "string" &&
+
typeof obj.ref === "object" &&
+
obj.ref !== null &&
+
typeof (obj.ref as { $link?: unknown }).$link === "string"
+
);
+
}
+
+
/**
+
* Helper to extract CID from image blob.
+
*/
+
function extractCidFromImageBlob(blob: unknown): string | undefined {
+
if (typeof blob !== "object" || blob === null) return undefined;
+
+
const blobObj = blob as {
+
ref?: { $link?: string };
+
cid?: string;
+
};
+
+
if (typeof blobObj.cid === "string") return blobObj.cid;
+
if (typeof blobObj.ref === "object" && blobObj.ref !== null) {
+
const link = blobObj.ref.$link;
+
if (typeof link === "string") return link;
+
}
+
+
return undefined;
+
}
const imagesBase = {
container: {