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 some more

+17 -114
+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: {