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

+929 -159
+24 -2
lib/components/BlueskyPost.tsx
··· 8 8 import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile"; 9 9 import { getAvatarCid } from "../utils/profile"; 10 10 import { formatDidForLabel } from "../utils/at-uri"; 11 + import type { BlobWithCdn } from "../hooks/useBlueskyAppview"; 11 12 12 13 /** 13 14 * Props for rendering a single Bluesky post with optional customization hooks. ··· 144 145 collection: BLUESKY_PROFILE_COLLECTION, 145 146 rkey: "self", 146 147 }); 147 - const avatarCid = getAvatarCid(profile); 148 + // Check if the avatar has a CDN URL from the appview (preferred) 149 + const avatar = profile?.avatar; 150 + const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 151 + const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined; 148 152 149 153 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo( 150 154 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />), ··· 165 169 loading: boolean; 166 170 error?: Error; 167 171 }> = (props) => { 168 - const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid); 172 + const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 173 + // Use CDN URL from appview if available, otherwise use blob URL 174 + const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 169 175 return ( 170 176 <Comp 171 177 {...props} ··· 185 191 Comp, 186 192 repoIdentifier, 187 193 avatarCid, 194 + avatarCdnUrl, 188 195 authorHandle, 189 196 colorScheme, 190 197 iconPlacement, ··· 226 233 /> 227 234 ); 228 235 }; 236 + 237 + /** 238 + * Type guard to check if a blob has a CDN URL from appview. 239 + */ 240 + function isBlobWithCdn(value: unknown): value is BlobWithCdn { 241 + if (typeof value !== "object" || value === null) return false; 242 + const obj = value as Record<string, unknown>; 243 + return ( 244 + obj.$type === "blob" && 245 + typeof obj.cdnUrl === "string" && 246 + typeof obj.ref === "object" && 247 + obj.ref !== null && 248 + typeof (obj.ref as { $link?: unknown }).$link === "string" 249 + ); 250 + } 229 251 230 252 export default BlueskyPost;
+25 -2
lib/components/BlueskyProfile.tsx
··· 6 6 import { getAvatarCid } from "../utils/profile"; 7 7 import { useDidResolution } from "../hooks/useDidResolution"; 8 8 import { formatDidForLabel } from "../utils/at-uri"; 9 + import type { BlobWithCdn } from "../hooks/useBlueskyAppview"; 9 10 10 11 /** 11 12 * Props used to render a Bluesky actor profile record. ··· 122 123 loading: boolean; 123 124 error?: Error; 124 125 }> = (props) => { 125 - const avatarCid = getAvatarCid(props.record); 126 - const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid); 126 + // Check if the avatar has a CDN URL from the appview (preferred) 127 + const avatar = props.record?.avatar; 128 + const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 129 + const avatarCid = !avatarCdnUrl ? getAvatarCid(props.record) : undefined; 130 + const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 131 + 132 + // Use CDN URL from appview if available, otherwise use blob URL 133 + const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 134 + 127 135 return ( 128 136 <Component 129 137 {...props} ··· 157 165 /> 158 166 ); 159 167 }; 168 + 169 + /** 170 + * Type guard to check if a blob has a CDN URL from appview. 171 + */ 172 + function isBlobWithCdn(value: unknown): value is BlobWithCdn { 173 + if (typeof value !== "object" || value === null) return false; 174 + const obj = value as Record<string, unknown>; 175 + return ( 176 + obj.$type === "blob" && 177 + typeof obj.cdnUrl === "string" && 178 + typeof obj.ref === "object" && 179 + obj.ref !== null && 180 + typeof (obj.ref as { $link?: unknown }).$link === "string" 181 + ); 182 + } 160 183 161 184 export default BlueskyProfile;
+34
lib/hooks/useAtProtoRecord.ts
··· 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 4 import { createAtprotoClient } from "../utils/atproto-client"; 5 + import { useBlueskyAppview } from "./useBlueskyAppview"; 5 6 6 7 /** 7 8 * Identifier trio required to address an AT Protocol record. ··· 29 30 30 31 /** 31 32 * React hook that fetches a single AT Protocol record and tracks loading/error state. 33 + * 34 + * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy: 35 + * 1. Try Bluesky appview API first 36 + * 2. Fall back to Slingshot getRecord 37 + * 3. Finally query the PDS directly 38 + * 39 + * For other collections, queries the PDS directly (with Slingshot fallback via the client handler). 32 40 * 33 41 * @param did - DID (or handle before resolution) that owns the record. 34 42 * @param collection - NSID collection from which to fetch the record. ··· 40 48 collection, 41 49 rkey, 42 50 }: AtProtoRecordKey): AtProtoRecordState<T> { 51 + // Determine if this is a Bluesky collection that should use the appview 52 + const isBlueskyCollection = collection?.startsWith("app.bsky."); 53 + 54 + // Use the three-tier fallback for Bluesky collections 55 + const blueskyResult = useBlueskyAppview<T>({ 56 + did: isBlueskyCollection ? handleOrDid : undefined, 57 + collection: isBlueskyCollection ? collection : undefined, 58 + rkey: isBlueskyCollection ? rkey : undefined, 59 + }); 43 60 const { 44 61 did, 45 62 error: didError, ··· 62 79 setState((prev) => ({ ...prev, ...next })); 63 80 }; 64 81 82 + // If using Bluesky appview, skip the manual fetch logic 83 + if (isBlueskyCollection) { 84 + return () => { 85 + cancelled = true; 86 + }; 87 + } 88 + 65 89 if (!handleOrDid || !collection || !rkey) { 66 90 assignState({ 67 91 loading: false, ··· 139 163 resolvingEndpoint, 140 164 didError, 141 165 endpointError, 166 + isBlueskyCollection, 142 167 ]); 168 + 169 + // Return Bluesky appview result if it's a Bluesky collection 170 + if (isBlueskyCollection) { 171 + return { 172 + record: blueskyResult.record, 173 + error: blueskyResult.error, 174 + loading: blueskyResult.loading, 175 + }; 176 + } 143 177 144 178 return state; 145 179 }
+617
lib/hooks/useBlueskyAppview.ts
··· 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"; 5 + 6 + /** 7 + * Extended blob reference that includes CDN URL from appview responses. 8 + */ 9 + export 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 + */ 23 + interface 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 + */ 37 + interface 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 + */ 65 + export 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 + */ 81 + export 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 + 92 + export 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 + */ 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", 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 + */ 179 + export 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 + */ 353 + async 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 profile request failed"); 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 post thread request failed"); 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 + */ 491 + async 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"); 498 + return res.data.value; 499 + } 500 + 501 + /** 502 + * Attempts to fetch a record directly from the actor's PDS. 503 + */ 504 + async 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"); 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 + */ 521 + function 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 + */ 550 + export 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 + */ 564 + export 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 + */ 581 + export 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 +
+48 -37
lib/hooks/useBlueskyProfile.ts
··· 1 - import { useEffect, useState } from "react"; 2 - import { usePdsEndpoint } from "./usePdsEndpoint"; 3 - import { createAtprotoClient } from "../utils/atproto-client"; 1 + import { useBlueskyAppview } from "./useBlueskyAppview"; 2 + import type { ProfileRecord } from "../types/bluesky"; 4 3 5 4 /** 6 5 * Minimal profile fields returned by the Bluesky actor profile endpoint. ··· 24 23 25 24 /** 26 25 * Fetches a Bluesky actor profile for a DID and exposes loading/error state. 26 + * 27 + * Uses a three-tier fallback strategy: 28 + * 1. Try Bluesky appview API (app.bsky.actor.getProfile) - CIDs are extracted from CDN URLs 29 + * 2. Fall back to Slingshot getRecord 30 + * 3. Finally query the PDS directly 31 + * 32 + * When using the appview, avatar/banner CDN URLs (e.g., https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkreixxx@jpeg) 33 + * are automatically parsed to extract CIDs and convert them to standard Blob format for compatibility. 27 34 * 28 35 * @param did - Actor DID whose profile should be retrieved. 29 36 * @returns {{ data: BlueskyProfileData | undefined; loading: boolean; error: Error | undefined }} Object exposing the profile payload, loading flag, and any error. 30 37 */ 31 38 export function useBlueskyProfile(did: string | undefined) { 32 - const { endpoint } = usePdsEndpoint(did); 33 - const [data, setData] = useState<BlueskyProfileData | undefined>(); 34 - const [loading, setLoading] = useState<boolean>(!!did); 35 - const [error, setError] = useState<Error | undefined>(); 39 + const { record, loading, error } = useBlueskyAppview<ProfileRecord>({ 40 + did, 41 + collection: "app.bsky.actor.profile", 42 + rkey: "self", 43 + }); 36 44 37 - useEffect(() => { 38 - let cancelled = false; 39 - async function run() { 40 - if (!did || !endpoint) return; 41 - setLoading(true); 42 - try { 43 - const { rpc } = await createAtprotoClient({ 44 - service: endpoint, 45 - }); 46 - const client = rpc as unknown as { 47 - get: ( 48 - nsid: string, 49 - options: { params: { actor: string } }, 50 - ) => Promise<{ ok: boolean; data: unknown }>; 51 - }; 52 - const res = await client.get("app.bsky.actor.getProfile", { 53 - params: { actor: did }, 54 - }); 55 - if (!res.ok) throw new Error("Profile request failed"); 56 - if (!cancelled) setData(res.data as BlueskyProfileData); 57 - } catch (e) { 58 - if (!cancelled) setError(e as Error); 59 - } finally { 60 - if (!cancelled) setLoading(false); 61 - } 45 + // Convert ProfileRecord to BlueskyProfileData 46 + // Note: avatar and banner are Blob objects in the record (from all sources) 47 + // The appview response is converted to ProfileRecord format by extracting CIDs from CDN URLs 48 + const data: BlueskyProfileData | undefined = record 49 + ? { 50 + did: did || "", 51 + handle: "", 52 + displayName: record.displayName, 53 + description: record.description, 54 + avatar: extractCidFromProfileBlob(record.avatar), 55 + banner: extractCidFromProfileBlob(record.banner), 56 + createdAt: record.createdAt, 62 57 } 63 - run(); 64 - return () => { 65 - cancelled = true; 66 - }; 67 - }, [did, endpoint]); 58 + : undefined; 68 59 69 60 return { data, loading, error }; 70 61 } 62 + 63 + /** 64 + * Helper to extract CID from profile blob (avatar or banner). 65 + */ 66 + function extractCidFromProfileBlob(blob: unknown): string | undefined { 67 + if (typeof blob !== "object" || blob === null) return undefined; 68 + 69 + const blobObj = blob as { 70 + ref?: { $link?: string }; 71 + cid?: string; 72 + }; 73 + 74 + if (typeof blobObj.cid === "string") return blobObj.cid; 75 + if (typeof blobObj.ref === "object" && blobObj.ref !== null) { 76 + const link = blobObj.ref.$link; 77 + if (typeof link === "string") return link; 78 + } 79 + 80 + return undefined; 81 + }
+58 -33
lib/hooks/useLatestRecord.ts
··· 1 1 import { useEffect, useState } from "react"; 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 - import { createAtprotoClient } from "../utils/atproto-client"; 4 + import { callListRecords } from "./useBlueskyAppview"; 5 5 6 6 /** 7 7 * Shape of the state returned by {@link useLatestRecord}. ··· 20 20 } 21 21 22 22 /** 23 - * Fetches the most recent record from a collection using `listRecords(limit=1)`. 23 + * Fetches the most recent record from a collection using `listRecords(limit=3)`. 24 + * 25 + * Note: Slingshot does not support listRecords, so this always queries the actor's PDS directly. 26 + * 27 + * Records with invalid timestamps (before 2023, when ATProto was created) are automatically 28 + * skipped, and additional records are fetched to find a valid one. 24 29 * 25 30 * @param handleOrDid - Handle or DID that owns the collection. 26 31 * @param collection - NSID of the collection to query. ··· 91 96 92 97 (async () => { 93 98 try { 94 - const { rpc } = await createAtprotoClient({ 95 - service: endpoint, 96 - }); 97 - const res = await ( 98 - rpc as unknown as { 99 - get: ( 100 - nsid: string, 101 - opts: { 102 - params: Record< 103 - string, 104 - string | number | boolean 105 - >; 106 - }, 107 - ) => Promise<{ 108 - ok: boolean; 109 - data: { 110 - records: Array<{ 111 - uri: string; 112 - rkey?: string; 113 - value: T; 114 - }>; 115 - }; 116 - }>; 117 - } 118 - ).get("com.atproto.repo.listRecords", { 119 - params: { repo: did, collection, limit: 1, reverse: false }, 120 - }); 121 - if (!res.ok) throw new Error("Failed to list records"); 99 + // Slingshot doesn't support listRecords, so we query PDS directly 100 + const res = await callListRecords<T>( 101 + endpoint, 102 + did, 103 + collection, 104 + 3, // Fetch 3 in case some have invalid timestamps 105 + ); 106 + 107 + if (!res.ok) { 108 + throw new Error("Failed to list records from PDS"); 109 + } 110 + 122 111 const list = res.data.records; 123 112 if (list.length === 0) { 124 113 assign({ ··· 129 118 }); 130 119 return; 131 120 } 132 - const first = list[0]; 133 - const derivedRkey = first.rkey ?? extractRkey(first.uri); 121 + 122 + // Find the first valid record (skip records before 2023) 123 + const validRecord = list.find((item) => isValidTimestamp(item.value)); 124 + 125 + if (!validRecord) { 126 + console.warn("No valid records found (all had timestamps before 2023)"); 127 + assign({ 128 + loading: false, 129 + empty: true, 130 + record: undefined, 131 + rkey: undefined, 132 + }); 133 + return; 134 + } 135 + 136 + const derivedRkey = validRecord.rkey ?? extractRkey(validRecord.uri); 134 137 assign({ 135 - record: first.value, 138 + record: validRecord.value, 136 139 rkey: derivedRkey, 137 140 loading: false, 138 141 empty: false, ··· 164 167 const parts = uri.split("/"); 165 168 return parts[parts.length - 1]; 166 169 } 170 + 171 + /** 172 + * Validates that a record has a reasonable timestamp (not before 2023). 173 + * ATProto was created in 2023, so any timestamp before that is invalid. 174 + */ 175 + function isValidTimestamp(record: unknown): boolean { 176 + if (typeof record !== "object" || record === null) return true; 177 + 178 + const recordObj = record as { createdAt?: string; indexedAt?: string }; 179 + const timestamp = recordObj.createdAt || recordObj.indexedAt; 180 + 181 + if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate 182 + 183 + try { 184 + const date = new Date(timestamp); 185 + // ATProto was created in 2023, reject anything before that 186 + return date.getFullYear() >= 2023; 187 + } catch { 188 + // If we can't parse the date, consider it valid to avoid false negatives 189 + return true; 190 + } 191 + }
+79 -83
lib/hooks/usePaginatedRecords.ts
··· 1 1 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 - import { createAtprotoClient } from "../utils/atproto-client"; 4 + import { 5 + DEFAULT_APPVIEW_SERVICE, 6 + callAppviewRpc, 7 + callListRecords 8 + } from "./useBlueskyAppview"; 5 9 6 10 /** 7 11 * Record envelope returned by paginated AT Protocol queries. ··· 70 74 pagesCount: number; 71 75 } 72 76 73 - const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app"; 77 + 74 78 75 79 export type AuthorFeedFilter = 76 80 | "posts_with_replies" ··· 188 192 !!actorIdentifier; 189 193 if (shouldUseAuthorFeed) { 190 194 try { 191 - const { rpc } = await createAtprotoClient({ 192 - service: 193 - authorFeedService ?? DEFAULT_APPVIEW_SERVICE, 194 - }); 195 - const res = await ( 196 - rpc as unknown as { 197 - get: ( 198 - nsid: string, 199 - opts: { 200 - params: Record< 201 - string, 202 - | string 203 - | number 204 - | boolean 205 - | undefined 206 - >; 207 - }, 208 - ) => Promise<{ 209 - ok: boolean; 210 - data: { 211 - feed?: Array<{ 212 - post?: { 213 - uri?: string; 214 - record?: T; 215 - reply?: { 216 - parent?: { 217 - uri?: string; 218 - author?: { 219 - handle?: string; 220 - did?: string; 221 - }; 222 - }; 223 - }; 195 + interface AuthorFeedResponse { 196 + feed?: Array<{ 197 + post?: { 198 + uri?: string; 199 + record?: T; 200 + reply?: { 201 + parent?: { 202 + uri?: string; 203 + author?: { 204 + handle?: string; 205 + did?: string; 224 206 }; 225 - reason?: AuthorFeedReason; 226 - }>; 227 - cursor?: string; 207 + }; 228 208 }; 229 - }>; 230 - } 231 - ).get("app.bsky.feed.getAuthorFeed", { 232 - params: { 209 + }; 210 + reason?: AuthorFeedReason; 211 + }>; 212 + cursor?: string; 213 + } 214 + 215 + const res = await callAppviewRpc<AuthorFeedResponse>( 216 + authorFeedService ?? DEFAULT_APPVIEW_SERVICE, 217 + "app.bsky.feed.getAuthorFeed", 218 + { 233 219 actor: actorIdentifier, 234 220 limit, 235 221 cursor, 236 222 filter: authorFeedFilter, 237 223 includePins: authorFeedIncludePins, 238 224 }, 239 - }); 225 + ); 240 226 if (!res.ok) 241 227 throw new Error("Failed to fetch author feed"); 242 228 const { feed, cursor: feedCursor } = res.data; ··· 249 235 !post.record 250 236 ) 251 237 return acc; 238 + // Skip records with invalid timestamps (before 2023) 239 + if (!isValidTimestamp(post.record)) { 240 + console.warn("Skipping record with invalid timestamp:", post.uri); 241 + return acc; 242 + } 252 243 acc.push({ 253 244 uri: post.uri, 254 245 rkey: extractRkey(post.uri), ··· 268 259 } 269 260 270 261 if (!mapped) { 271 - const { rpc } = await createAtprotoClient({ 272 - service: endpoint, 273 - }); 274 - const res = await ( 275 - rpc as unknown as { 276 - get: ( 277 - nsid: string, 278 - opts: { 279 - params: Record< 280 - string, 281 - string | number | boolean | undefined 282 - >; 283 - }, 284 - ) => Promise<{ 285 - ok: boolean; 286 - data: { 287 - records: Array<{ 288 - uri: string; 289 - rkey?: string; 290 - value: T; 291 - }>; 292 - cursor?: string; 293 - }; 294 - }>; 295 - } 296 - ).get("com.atproto.repo.listRecords", { 297 - params: { 298 - repo: did, 299 - collection, 300 - limit, 301 - cursor, 302 - reverse: false, 303 - }, 304 - }); 305 - if (!res.ok) throw new Error("Failed to list records"); 262 + // Slingshot doesn't support listRecords, query PDS directly 263 + const res = await callListRecords<T>( 264 + endpoint, 265 + did, 266 + collection, 267 + limit, 268 + cursor, 269 + ); 270 + 271 + if (!res.ok) throw new Error("Failed to list records from PDS"); 306 272 const { records, cursor: repoCursor } = res.data; 307 - mapped = records.map((item) => ({ 308 - uri: item.uri, 309 - rkey: item.rkey ?? extractRkey(item.uri), 310 - value: item.value, 311 - })); 273 + mapped = records 274 + .filter((item) => { 275 + if (!isValidTimestamp(item.value)) { 276 + console.warn("Skipping record with invalid timestamp:", item.uri); 277 + return false; 278 + } 279 + return true; 280 + }) 281 + .map((item) => ({ 282 + uri: item.uri, 283 + rkey: item.rkey ?? extractRkey(item.uri), 284 + value: item.value, 285 + })); 312 286 nextCursor = repoCursor; 313 287 } 314 288 ··· 475 449 const parts = uri.split("/"); 476 450 return parts[parts.length - 1]; 477 451 } 452 + 453 + /** 454 + * Validates that a record has a reasonable timestamp (not before 2023). 455 + * ATProto was created in 2023, so any timestamp before that is invalid. 456 + */ 457 + function isValidTimestamp(record: unknown): boolean { 458 + if (typeof record !== "object" || record === null) return true; 459 + 460 + const recordObj = record as { createdAt?: string; indexedAt?: string }; 461 + const timestamp = recordObj.createdAt || recordObj.indexedAt; 462 + 463 + if (!timestamp || typeof timestamp !== "string") return true; // No timestamp to validate 464 + 465 + try { 466 + const date = new Date(timestamp); 467 + // ATProto was created in 2023, reject anything before that 468 + return date.getFullYear() >= 2023; 469 + } catch { 470 + // If we can't parse the date, consider it valid to avoid false negatives 471 + return true; 472 + } 473 + }
+1
lib/index.ts
··· 17 17 // Hooks 18 18 export * from "./hooks/useAtProtoRecord"; 19 19 export * from "./hooks/useBlob"; 20 + export * from "./hooks/useBlueskyAppview"; 20 21 export * from "./hooks/useBlueskyProfile"; 21 22 export * from "./hooks/useColorScheme"; 22 23 export * from "./hooks/useDidResolution";
+43 -2
lib/renderers/BlueskyPostRenderer.tsx
··· 13 13 import { useDidResolution } from "../hooks/useDidResolution"; 14 14 import { useBlob } from "../hooks/useBlob"; 15 15 import { BlueskyIcon } from "../components/BlueskyIcon"; 16 + import type { BlobWithCdn } from "../hooks/useBlueskyAppview"; 16 17 17 18 export interface BlueskyPostRendererProps { 18 19 record: FeedPostRecord; ··· 490 491 } 491 492 492 493 const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => { 493 - const cid = image.image?.ref?.$link ?? image.image?.cid; 494 - const { url, loading, error } = useBlob(did, cid); 494 + // Check if the image has a CDN URL from the appview (preferred) 495 + const imageBlob = image.image; 496 + const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined; 497 + const cid = !cdnUrl ? extractCidFromImageBlob(imageBlob) : undefined; 498 + const { url: urlFromBlob, loading, error } = useBlob(did, cid); 499 + // Use CDN URL from appview if available, otherwise use blob URL 500 + const url = cdnUrl || urlFromBlob; 495 501 const alt = image.alt?.trim() || "Bluesky attachment"; 496 502 const palette = 497 503 scheme === "dark" ? imagesPalette.dark : imagesPalette.light; ··· 536 542 </figure> 537 543 ); 538 544 }; 545 + 546 + /** 547 + * Type guard to check if a blob has a CDN URL from appview. 548 + */ 549 + function isBlobWithCdn(value: unknown): value is BlobWithCdn { 550 + if (typeof value !== "object" || value === null) return false; 551 + const obj = value as Record<string, unknown>; 552 + return ( 553 + obj.$type === "blob" && 554 + typeof obj.cdnUrl === "string" && 555 + typeof obj.ref === "object" && 556 + obj.ref !== null && 557 + typeof (obj.ref as { $link?: unknown }).$link === "string" 558 + ); 559 + } 560 + 561 + /** 562 + * Helper to extract CID from image blob. 563 + */ 564 + function extractCidFromImageBlob(blob: unknown): string | undefined { 565 + if (typeof blob !== "object" || blob === null) return undefined; 566 + 567 + const blobObj = blob as { 568 + ref?: { $link?: string }; 569 + cid?: string; 570 + }; 571 + 572 + if (typeof blobObj.cid === "string") return blobObj.cid; 573 + if (typeof blobObj.ref === "object" && blobObj.ref !== null) { 574 + const link = blobObj.ref.$link; 575 + if (typeof link === "string") return link; 576 + } 577 + 578 + return undefined; 579 + } 539 580 540 581 const imagesBase = { 541 582 container: {