···88import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
99import { getAvatarCid } from "../utils/profile";
1010import { formatDidForLabel } from "../utils/at-uri";
1111-import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
1111+import { isBlobWithCdn } from "../utils/blob";
12121313/**
1414 * Props for rendering a single Bluesky post with optional customization hooks.
···145145 collection: BLUESKY_PROFILE_COLLECTION,
146146 rkey: "self",
147147 });
148148- // Check if the avatar has a CDN URL from the appview (preferred)
149148 const avatar = profile?.avatar;
150149 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
151151- const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined;
150150+ const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
152151153152 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
154153 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
···170169 error?: Error;
171170 }> = (props) => {
172171 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
173173- // Use CDN URL from appview if available, otherwise use blob URL
174172 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
175173 return (
176174 <Comp
···233231 />
234232 );
235233};
236236-237237-/**
238238- * Type guard to check if a blob has a CDN URL from appview.
239239- */
240240-function isBlobWithCdn(value: unknown): value is BlobWithCdn {
241241- if (typeof value !== "object" || value === null) return false;
242242- const obj = value as Record<string, unknown>;
243243- return (
244244- obj.$type === "blob" &&
245245- typeof obj.cdnUrl === "string" &&
246246- typeof obj.ref === "object" &&
247247- obj.ref !== null &&
248248- typeof (obj.ref as { $link?: unknown }).$link === "string"
249249- );
250250-}
251234252235export default BlueskyPost;
+2-19
lib/components/BlueskyProfile.tsx
···66import { getAvatarCid } from "../utils/profile";
77import { useDidResolution } from "../hooks/useDidResolution";
88import { formatDidForLabel } from "../utils/at-uri";
99-import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
99+import { isBlobWithCdn } from "../utils/blob";
10101111/**
1212 * Props used to render a Bluesky actor profile record.
···126126 // Check if the avatar has a CDN URL from the appview (preferred)
127127 const avatar = props.record?.avatar;
128128 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
129129- const avatarCid = !avatarCdnUrl ? getAvatarCid(props.record) : undefined;
129129+ const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(props.record);
130130 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
131131-132132- // Use CDN URL from appview if available, otherwise use blob URL
133131 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
134132135133 return (
···165163 />
166164 );
167165};
168168-169169-/**
170170- * Type guard to check if a blob has a CDN URL from appview.
171171- */
172172-function isBlobWithCdn(value: unknown): value is BlobWithCdn {
173173- if (typeof value !== "object" || value === null) return false;
174174- const obj = value as Record<string, unknown>;
175175- return (
176176- obj.$type === "blob" &&
177177- typeof obj.cdnUrl === "string" &&
178178- typeof obj.ref === "object" &&
179179- obj.ref !== null &&
180180- typeof (obj.ref as { $link?: unknown }).$link === "string"
181181- );
182182-}
183166184167export default BlueskyProfile;
+3-11
lib/hooks/useAtProtoRecord.ts
···4848 collection,
4949 rkey,
5050}: AtProtoRecordKey): AtProtoRecordState<T> {
5151- // Determine if this is a Bluesky collection that should use the appview
5251 const isBlueskyCollection = collection?.startsWith("app.bsky.");
53525454- // Use the three-tier fallback for Bluesky collections
5353+ // Always call all hooks (React rules) - conditionally use results
5554 const blueskyResult = useBlueskyAppview<T>({
5655 did: isBlueskyCollection ? handleOrDid : undefined,
5756 collection: isBlueskyCollection ? collection : undefined,
5857 rkey: isBlueskyCollection ? rkey : undefined,
5958 });
5959+6060 const {
6161 did,
6262 error: didError,
···7878 if (cancelled) return;
7979 setState((prev) => ({ ...prev, ...next }));
8080 };
8181-8282- // If using Bluesky appview, skip the manual fetch logic
8383- if (isBlueskyCollection) {
8484- return () => {
8585- cancelled = true;
8686- };
8787- }
88818982 if (!handleOrDid || !collection || !rkey) {
9083 assignState({
···163156 resolvingEndpoint,
164157 didError,
165158 endpointError,
166166- isBlueskyCollection,
167159 ]);
168160169169- // Return Bluesky appview result if it's a Bluesky collection
161161+ // Return Bluesky result for app.bsky.* collections
170162 if (isBlueskyCollection) {
171163 return {
172164 record: blueskyResult.record,
+4-4
lib/hooks/useBlueskyAppview.ts
···371371 params: { actor: did },
372372 });
373373374374- if (!res.ok) throw new Error("Appview profile request failed");
374374+ if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${did}`);
375375376376 // The appview returns avatar/banner as CDN URLs like:
377377 // https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg
···418418 params: { uri: atUri, depth: 0 },
419419 });
420420421421- if (!res.ok) throw new Error("Appview post thread request failed");
421421+ if (!res.ok) throw new Error(`Appview ${endpoint} request failed for ${atUri}`);
422422423423 const post = res.data.thread?.post;
424424 if (!post?.record) return undefined;
···494494 rkey: string,
495495): Promise<T | undefined> {
496496 const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey);
497497- if (!res.ok) throw new Error("Slingshot getRecord failed");
497497+ if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`);
498498 return res.data.value;
499499}
500500···508508 pdsEndpoint: string,
509509): Promise<T | undefined> {
510510 const res = await callGetRecord<T>(pdsEndpoint, did, collection, rkey);
511511- if (!res.ok) throw new Error("PDS getRecord failed");
511511+ if (!res.ok) throw new Error(`PDS getRecord failed for ${did}/${collection}/${rkey} at ${pdsEndpoint}`);
512512 return res.data.value;
513513}
514514
+3-22
lib/hooks/useBlueskyProfile.ts
···11import { useBlueskyAppview } from "./useBlueskyAppview";
22import type { ProfileRecord } from "../types/bluesky";
33+import { extractCidFromBlob } from "../utils/blob";
3445/**
56 * Minimal profile fields returned by the Bluesky actor profile endpoint.
···5152 handle: "",
5253 displayName: record.displayName,
5354 description: record.description,
5454- avatar: extractCidFromProfileBlob(record.avatar),
5555- banner: extractCidFromProfileBlob(record.banner),
5555+ avatar: extractCidFromBlob(record.avatar),
5656+ banner: extractCidFromBlob(record.banner),
5657 createdAt: record.createdAt,
5758 }
5859 : undefined;
59606061 return { data, loading, error };
6161-}
6262-6363-/**
6464- * Helper to extract CID from profile blob (avatar or banner).
6565- */
6666-function extractCidFromProfileBlob(blob: unknown): string | undefined {
6767- if (typeof blob !== "object" || blob === null) return undefined;
6868-6969- const blobObj = blob as {
7070- ref?: { $link?: string };
7171- cid?: string;
7272- };
7373-7474- if (typeof blobObj.cid === "string") return blobObj.cid;
7575- if (typeof blobObj.ref === "object" && blobObj.ref !== null) {
7676- const link = blobObj.ref.$link;
7777- if (typeof link === "string") return link;
7878- }
7979-8080- return undefined;
8162}
+1
lib/index.ts
···3838// Utilities
3939export * from "./utils/at-uri";
4040export * from "./utils/atproto-client";
4141+export * from "./utils/blob";
4142export * from "./utils/profile";
+2-39
lib/renderers/BlueskyPostRenderer.tsx
···1313import { useDidResolution } from "../hooks/useDidResolution";
1414import { useBlob } from "../hooks/useBlob";
1515import { BlueskyIcon } from "../components/BlueskyIcon";
1616-import type { BlobWithCdn } from "../hooks/useBlueskyAppview";
1616+import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob";
17171818export interface BlueskyPostRendererProps {
1919 record: FeedPostRecord;
···491491}
492492493493const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => {
494494- // Check if the image has a CDN URL from the appview (preferred)
495494 const imageBlob = image.image;
496495 const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined;
497497- const cid = !cdnUrl ? extractCidFromImageBlob(imageBlob) : undefined;
496496+ const cid = cdnUrl ? undefined : extractCidFromBlob(imageBlob);
498497 const { url: urlFromBlob, loading, error } = useBlob(did, cid);
499499- // Use CDN URL from appview if available, otherwise use blob URL
500498 const url = cdnUrl || urlFromBlob;
501499 const alt = image.alt?.trim() || "Bluesky attachment";
502500 const palette =
···542540 </figure>
543541 );
544542};
545545-546546-/**
547547- * Type guard to check if a blob has a CDN URL from appview.
548548- */
549549-function isBlobWithCdn(value: unknown): value is BlobWithCdn {
550550- if (typeof value !== "object" || value === null) return false;
551551- const obj = value as Record<string, unknown>;
552552- return (
553553- obj.$type === "blob" &&
554554- typeof obj.cdnUrl === "string" &&
555555- typeof obj.ref === "object" &&
556556- obj.ref !== null &&
557557- typeof (obj.ref as { $link?: unknown }).$link === "string"
558558- );
559559-}
560560-561561-/**
562562- * Helper to extract CID from image blob.
563563- */
564564-function extractCidFromImageBlob(blob: unknown): string | undefined {
565565- if (typeof blob !== "object" || blob === null) return undefined;
566566-567567- const blobObj = blob as {
568568- ref?: { $link?: string };
569569- cid?: string;
570570- };
571571-572572- if (typeof blobObj.cid === "string") return blobObj.cid;
573573- if (typeof blobObj.ref === "object" && blobObj.ref !== null) {
574574- const link = blobObj.ref.$link;
575575- if (typeof link === "string") return link;
576576- }
577577-578578- return undefined;
579579-}
580543581544const imagesBase = {
582545 container: {