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

refactor some more

+2 -19
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 + import { isBlobWithCdn } from "../utils/blob"; 12 12 13 13 /** 14 14 * Props for rendering a single Bluesky post with optional customization hooks. ··· 145 145 collection: BLUESKY_PROFILE_COLLECTION, 146 146 rkey: "self", 147 147 }); 148 - // Check if the avatar has a CDN URL from the appview (preferred) 149 148 const avatar = profile?.avatar; 150 149 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 151 - const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined; 150 + const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile); 152 151 153 152 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo( 154 153 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />), ··· 170 169 error?: Error; 171 170 }> = (props) => { 172 171 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 173 - // Use CDN URL from appview if available, otherwise use blob URL 174 172 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 175 173 return ( 176 174 <Comp ··· 233 231 /> 234 232 ); 235 233 }; 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 - } 251 234 252 235 export default BlueskyPost;
+2 -19
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 + import { isBlobWithCdn } from "../utils/blob"; 10 10 11 11 /** 12 12 * Props used to render a Bluesky actor profile record. ··· 126 126 // Check if the avatar has a CDN URL from the appview (preferred) 127 127 const avatar = props.record?.avatar; 128 128 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 129 - const avatarCid = !avatarCdnUrl ? getAvatarCid(props.record) : undefined; 129 + const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(props.record); 130 130 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid); 131 - 132 - // Use CDN URL from appview if available, otherwise use blob URL 133 131 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 134 132 135 133 return ( ··· 165 163 /> 166 164 ); 167 165 }; 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 - } 183 166 184 167 export default BlueskyProfile;
+3 -11
lib/hooks/useAtProtoRecord.ts
··· 48 48 collection, 49 49 rkey, 50 50 }: AtProtoRecordKey): AtProtoRecordState<T> { 51 - // Determine if this is a Bluesky collection that should use the appview 52 51 const isBlueskyCollection = collection?.startsWith("app.bsky."); 53 52 54 - // Use the three-tier fallback for Bluesky collections 53 + // Always call all hooks (React rules) - conditionally use results 55 54 const blueskyResult = useBlueskyAppview<T>({ 56 55 did: isBlueskyCollection ? handleOrDid : undefined, 57 56 collection: isBlueskyCollection ? collection : undefined, 58 57 rkey: isBlueskyCollection ? rkey : undefined, 59 58 }); 59 + 60 60 const { 61 61 did, 62 62 error: didError, ··· 78 78 if (cancelled) return; 79 79 setState((prev) => ({ ...prev, ...next })); 80 80 }; 81 - 82 - // If using Bluesky appview, skip the manual fetch logic 83 - if (isBlueskyCollection) { 84 - return () => { 85 - cancelled = true; 86 - }; 87 - } 88 81 89 82 if (!handleOrDid || !collection || !rkey) { 90 83 assignState({ ··· 163 156 resolvingEndpoint, 164 157 didError, 165 158 endpointError, 166 - isBlueskyCollection, 167 159 ]); 168 160 169 - // Return Bluesky appview result if it's a Bluesky collection 161 + // Return Bluesky result for app.bsky.* collections 170 162 if (isBlueskyCollection) { 171 163 return { 172 164 record: blueskyResult.record,
+4 -4
lib/hooks/useBlueskyAppview.ts
··· 371 371 params: { actor: did }, 372 372 }); 373 373 374 - if (!res.ok) throw new Error("Appview profile request failed"); 374 + if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${did}`); 375 375 376 376 // The appview returns avatar/banner as CDN URLs like: 377 377 // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg ··· 418 418 params: { uri: atUri, depth: 0 }, 419 419 }); 420 420 421 - if (!res.ok) throw new Error("Appview post thread request failed"); 421 + if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${atUri}`); 422 422 423 423 const post = res.data.thread?.post; 424 424 if (!post?.record) return undefined; ··· 494 494 rkey: string, 495 495 ): Promise<T | undefined> { 496 496 const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey); 497 - if (!res.ok) throw new Error("Slingshot getRecord failed"); 497 + if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`); 498 498 return res.data.value; 499 499 } 500 500 ··· 508 508 pdsEndpoint: string, 509 509 ): Promise<T | undefined> { 510 510 const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey); 511 - if (!res.ok) throw new Error("PDS getRecord failed"); 511 + if (!res.ok) throw new Error(`PDS getRecord failed for ${did}/${collection}/${rkey} at ${pdsEndpoint}`); 512 512 return res.data.value; 513 513 } 514 514
+3 -22
lib/hooks/useBlueskyProfile.ts
··· 1 1 import { useBlueskyAppview } from "./useBlueskyAppview"; 2 2 import type { ProfileRecord } from "../types/bluesky"; 3 + import { extractCidFromBlob } from "../utils/blob"; 3 4 4 5 /** 5 6 * Minimal profile fields returned by the Bluesky actor profile endpoint. ··· 51 52 handle: "", 52 53 displayName: record.displayName, 53 54 description: record.description, 54 - avatar: extractCidFromProfileBlob(record.avatar), 55 - banner: extractCidFromProfileBlob(record.banner), 55 + avatar: extractCidFromBlob(record.avatar), 56 + banner: extractCidFromBlob(record.banner), 56 57 createdAt: record.createdAt, 57 58 } 58 59 : undefined; 59 60 60 61 return { data, loading, error }; 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 62 }
+1
lib/index.ts
··· 38 38 // Utilities 39 39 export * from "./utils/at-uri"; 40 40 export * from "./utils/atproto-client"; 41 + export * from "./utils/blob"; 41 42 export * from "./utils/profile";
+2 -39
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 + import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob"; 17 17 18 18 export interface BlueskyPostRendererProps { 19 19 record: FeedPostRecord; ··· 491 491 } 492 492 493 493 const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => { 494 - // Check if the image has a CDN URL from the appview (preferred) 495 494 const imageBlob = image.image; 496 495 const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined; 497 - const cid = !cdnUrl ? extractCidFromImageBlob(imageBlob) : undefined; 496 + const cid = cdnUrl ? undefined : extractCidFromBlob(imageBlob); 498 497 const { url: urlFromBlob, loading, error } = useBlob(did, cid); 499 - // Use CDN URL from appview if available, otherwise use blob URL 500 498 const url = cdnUrl || urlFromBlob; 501 499 const alt = image.alt?.trim() || "Bluesky attachment"; 502 500 const palette = ··· 542 540 </figure> 543 541 ); 544 542 }; 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 - } 580 543 581 544 const imagesBase = { 582 545 container: {