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

Configure Feed

Select the types of activity you want to include in your feed.

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: {