A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo } from "react";
2import { AtProtoRecord } from "../core/AtProtoRecord";
3import { BlueskyPostRenderer } from "../renderers/BlueskyPostRenderer";
4import type { FeedPostRecord, ProfileRecord } from "../types/bluesky";
5import { useDidResolution } from "../hooks/useDidResolution";
6import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
7import { useBlob } from "../hooks/useBlob";
8import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
9import { getAvatarCid } from "../utils/profile";
10import { formatDidForLabel, parseAtUri } from "../utils/at-uri";
11import { isBlobWithCdn } from "../utils/blob";
12
13/**
14 * Props for rendering a single Bluesky post with optional customization hooks.
15 */
16export interface BlueskyPostProps {
17 /**
18 * Decentralized identifier for the repository that owns the post.
19 */
20 did: string;
21 /**
22 * Record key identifying the specific post within the collection.
23 */
24 rkey: string;
25 /**
26 * Prefetched post record. When provided, skips fetching the post from the network.
27 * Note: Profile and avatar data will still be fetched unless a custom renderer is used.
28 */
29 record?: FeedPostRecord;
30 /**
31 * Custom renderer component that receives resolved post data and status flags.
32 */
33 renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
34 /**
35 * React node shown while the post query has not yet produced data or an error.
36 */
37 fallback?: React.ReactNode;
38 /**
39 * React node displayed while the post fetch is actively loading.
40 */
41 loadingIndicator?: React.ReactNode;
42
43 /**
44 * Whether the default renderer should show the Bluesky icon.
45 * Defaults to `true`.
46 */
47 showIcon?: boolean;
48 /**
49 * Placement strategy for the icon when it is rendered.
50 * Defaults to `'timestamp'`.
51 */
52 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
53 /**
54 * Controls whether to show the parent post if this post is a reply.
55 * Defaults to `false`.
56 */
57 showParent?: boolean;
58 /**
59 * Controls whether to recursively show all parent posts to the root.
60 * Only applies when `showParent` is `true`. Defaults to `false`.
61 */
62 recursiveParent?: boolean;
63}
64
65/**
66 * Values injected by `BlueskyPost` into a downstream renderer component.
67 */
68export type BlueskyPostRendererInjectedProps = {
69 /**
70 * Resolved record payload for the post.
71 */
72 record: FeedPostRecord;
73 /**
74 * `true` while network operations are in-flight.
75 */
76 loading: boolean;
77 /**
78 * Error encountered during loading, if any.
79 */
80 error?: Error;
81 /**
82 * The author's public handle derived from the DID.
83 */
84 authorHandle: string;
85 /**
86 * The author's display name from their profile.
87 */
88 authorDisplayName?: string;
89 /**
90 * The DID that owns the post record.
91 */
92 authorDid: string;
93 /**
94 * Resolved URL for the author's avatar blob, if available.
95 */
96 avatarUrl?: string;
97
98 /**
99 * Placement strategy for the Bluesky icon.
100 */
101 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
102 /**
103 * Controls whether the icon should render at all.
104 */
105 showIcon?: boolean;
106 /**
107 * Fully qualified AT URI of the post, when resolvable.
108 */
109 atUri?: string;
110 /**
111 * Optional override for the rendered embed contents.
112 */
113 embed?: React.ReactNode;
114 /**
115 * Whether this post is part of a thread.
116 */
117 isInThread?: boolean;
118 /**
119 * Depth of this post in a thread (0 = root, 1 = first reply, etc.).
120 */
121 threadDepth?: number;
122 /**
123 * Whether to show border even when in thread context.
124 */
125 showThreadBorder?: boolean;
126};
127
128export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post";
129
130const threadContainerStyle: React.CSSProperties = {
131 display: "flex",
132 flexDirection: "column",
133 maxWidth: "600px",
134 width: "100%",
135 background: "var(--atproto-color-bg)",
136 position: "relative",
137 borderRadius: "12px",
138 overflow: "hidden"
139};
140
141const parentPostStyle: React.CSSProperties = {
142 position: "relative",
143};
144
145const replyPostStyle: React.CSSProperties = {
146 position: "relative",
147};
148
149const loadingStyle: React.CSSProperties = {
150 padding: "24px 18px",
151 fontSize: "14px",
152 textAlign: "center",
153 color: "var(--atproto-color-text-secondary)",
154};
155
156/**
157 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
158 * and renders it via a customizable renderer component.
159 *
160 * @param did - DID of the repository that stores the post.
161 * @param rkey - Record key for the post within the feed collection.
162 * @param record - Prefetched record for the post.
163 * @param renderer - Optional renderer component to override the default.
164 * @param fallback - Node rendered before the first fetch attempt resolves.
165 * @param loadingIndicator - Node rendered while the post is loading.
166 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`.
167 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
168 * @returns A component that renders loading/fallback states and the resolved post.
169 */
170export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo(
171 ({
172 did: handleOrDid,
173 rkey,
174 record,
175 renderer,
176 fallback,
177 loadingIndicator,
178 showIcon = true,
179 iconPlacement = "timestamp",
180 showParent = false,
181 recursiveParent = false,
182 }) => {
183 const {
184 did: resolvedDid,
185 handle,
186 loading: resolvingIdentity,
187 error: resolutionError,
188 } = useDidResolution(handleOrDid);
189 const repoIdentifier = resolvedDid ?? handleOrDid;
190 const { record: profile } = useAtProtoRecord<ProfileRecord>({
191 did: repoIdentifier,
192 collection: BLUESKY_PROFILE_COLLECTION,
193 rkey: "self",
194 });
195 const avatar = profile?.avatar;
196 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
197 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
198 const authorDisplayName = profile?.displayName;
199
200 const {
201 record: fetchedRecord,
202 loading: currentLoading,
203 error: currentError,
204 } = useAtProtoRecord<FeedPostRecord>({
205 did: showParent && !record ? repoIdentifier : "",
206 collection: showParent && !record ? BLUESKY_POST_COLLECTION : "",
207 rkey: showParent && !record ? rkey : "",
208 });
209
210 const currentRecord = record ?? fetchedRecord;
211
212 const parentUri = currentRecord?.reply?.parent?.uri;
213 const parsedParentUri = parentUri ? parseAtUri(parentUri) : null;
214 const parentDid = parsedParentUri?.did;
215 const parentRkey = parsedParentUri?.rkey;
216
217 const {
218 record: parentRecord,
219 loading: parentLoading,
220 error: parentError,
221 } = useAtProtoRecord<FeedPostRecord>({
222 did: showParent && parentDid ? parentDid : "",
223 collection: showParent && parentDid ? BLUESKY_POST_COLLECTION : "",
224 rkey: showParent && parentRkey ? parentRkey : "",
225 });
226
227 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> =
228 useMemo(
229 () =>
230 renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
231 [renderer],
232 );
233
234 const displayHandle =
235 handle ??
236 (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
237 const authorHandle =
238 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
239 const atUri = resolvedDid
240 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
241 : undefined;
242
243 const Wrapped = useMemo(() => {
244 const WrappedComponent: React.FC<{
245 record: FeedPostRecord;
246 loading: boolean;
247 error?: Error;
248 }> = (props) => {
249 const { url: avatarUrlFromBlob } = useBlob(
250 repoIdentifier,
251 avatarCid,
252 );
253 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
254 return (
255 <Comp
256 {...props}
257 authorHandle={authorHandle}
258 authorDisplayName={authorDisplayName}
259 authorDid={repoIdentifier}
260 avatarUrl={avatarUrl}
261 iconPlacement={iconPlacement}
262 showIcon={showIcon}
263 atUri={atUri}
264 isInThread
265 threadDepth={showParent ? 1 : 0}
266 showThreadBorder={!showParent && !!props.record?.reply?.parent}
267 />
268 );
269 };
270 WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
271 return WrappedComponent;
272 }, [
273 Comp,
274 repoIdentifier,
275 avatarCid,
276 avatarCdnUrl,
277 authorHandle,
278 authorDisplayName,
279 iconPlacement,
280 showIcon,
281 atUri,
282 showParent,
283 ]);
284
285 const WrappedWithoutIcon = useMemo(() => {
286 const WrappedComponent: React.FC<{
287 record: FeedPostRecord;
288 loading: boolean;
289 error?: Error;
290 }> = (props) => {
291 const { url: avatarUrlFromBlob } = useBlob(
292 repoIdentifier,
293 avatarCid,
294 );
295 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
296 return (
297 <Comp
298 {...props}
299 authorHandle={authorHandle}
300 authorDisplayName={authorDisplayName}
301 authorDid={repoIdentifier}
302 avatarUrl={avatarUrl}
303 iconPlacement={iconPlacement}
304 showIcon={false}
305 atUri={atUri}
306 isInThread
307 threadDepth={showParent ? 1 : 0}
308 showThreadBorder={!showParent && !!props.record?.reply?.parent}
309 />
310 );
311 };
312 WrappedComponent.displayName = "BlueskyPostWrappedRendererWithoutIcon";
313 return WrappedComponent;
314 }, [
315 Comp,
316 repoIdentifier,
317 avatarCid,
318 avatarCdnUrl,
319 authorHandle,
320 authorDisplayName,
321 iconPlacement,
322 atUri,
323 showParent,
324 ]);
325
326 if (!displayHandle && resolvingIdentity) {
327 return <div style={{ padding: 8 }}>Resolving handle…</div>;
328 }
329 if (!displayHandle && resolutionError) {
330 return (
331 <div style={{ padding: 8, color: "crimson" }}>
332 Could not resolve handle.
333 </div>
334 );
335 }
336
337 const renderMainPost = (mainRecord?: FeedPostRecord) => {
338 if (mainRecord !== undefined) {
339 return (
340 <AtProtoRecord<FeedPostRecord>
341 record={mainRecord}
342 renderer={Wrapped}
343 fallback={fallback}
344 loadingIndicator={loadingIndicator}
345 />
346 );
347 }
348
349 return (
350 <AtProtoRecord<FeedPostRecord>
351 did={repoIdentifier}
352 collection={BLUESKY_POST_COLLECTION}
353 rkey={rkey}
354 renderer={Wrapped}
355 fallback={fallback}
356 loadingIndicator={loadingIndicator}
357 />
358 );
359 };
360
361 const renderMainPostWithoutIcon = (mainRecord?: FeedPostRecord) => {
362 if (mainRecord !== undefined) {
363 return (
364 <AtProtoRecord<FeedPostRecord>
365 record={mainRecord}
366 renderer={WrappedWithoutIcon}
367 fallback={fallback}
368 loadingIndicator={loadingIndicator}
369 />
370 );
371 }
372
373 return (
374 <AtProtoRecord<FeedPostRecord>
375 did={repoIdentifier}
376 collection={BLUESKY_POST_COLLECTION}
377 rkey={rkey}
378 renderer={WrappedWithoutIcon}
379 fallback={fallback}
380 loadingIndicator={loadingIndicator}
381 />
382 );
383 };
384
385 if (showParent) {
386 if (currentLoading || (parentLoading && !parentRecord)) {
387 return (
388 <div style={threadContainerStyle}>
389 <div style={loadingStyle}>Loading thread…</div>
390 </div>
391 );
392 }
393
394 if (currentError) {
395 return (
396 <div style={{ padding: 8, color: "crimson" }}>
397 Failed to load post.
398 </div>
399 );
400 }
401
402 if (!parentDid || !parentRkey) {
403 return renderMainPost(record);
404 }
405
406 if (parentError) {
407 return (
408 <div style={{ padding: 8, color: "crimson" }}>
409 Failed to load parent post.
410 </div>
411 );
412 }
413
414 return (
415 <div style={threadContainerStyle}>
416 <div style={parentPostStyle}>
417 {recursiveParent && parentRecord?.reply?.parent?.uri ? (
418 <BlueskyPost
419 did={parentDid}
420 rkey={parentRkey}
421 record={parentRecord}
422 showParent={true}
423 recursiveParent={true}
424 showIcon={showIcon}
425 iconPlacement={iconPlacement}
426 />
427 ) : (
428 <BlueskyPost
429 did={parentDid}
430 rkey={parentRkey}
431 record={parentRecord}
432 showIcon={showIcon}
433 iconPlacement={iconPlacement}
434 />
435 )}
436 </div>
437
438 <div style={replyPostStyle}>
439 {renderMainPostWithoutIcon(record || currentRecord)}
440 </div>
441 </div>
442 );
443 }
444
445 return renderMainPost(record);
446 },
447);
448
449export default BlueskyPost;