+2
-19
lib/components/BlueskyPost.tsx
+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
+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
+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
+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
+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
+1
lib/index.ts
+2
-39
lib/renderers/BlueskyPostRenderer.tsx
+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: {