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 DID that owns the post record.
87 */
88 authorDid: string;
89 /**
90 * Resolved URL for the author's avatar blob, if available.
91 */
92 avatarUrl?: string;
93
94 /**
95 * Placement strategy for the Bluesky icon.
96 */
97 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
98 /**
99 * Controls whether the icon should render at all.
100 */
101 showIcon?: boolean;
102 /**
103 * Fully qualified AT URI of the post, when resolvable.
104 */
105 atUri?: string;
106 /**
107 * Optional override for the rendered embed contents.
108 */
109 embed?: React.ReactNode;
110 /**
111 * Whether this post is part of a thread.
112 */
113 isInThread?: boolean;
114 /**
115 * Depth of this post in a thread (0 = root, 1 = first reply, etc.).
116 */
117 threadDepth?: number;
118 /**
119 * Whether to show border even when in thread context.
120 */
121 showThreadBorder?: boolean;
122};
123
124export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post";
125
126const threadContainerStyle: React.CSSProperties = {
127 display: "flex",
128 flexDirection: "column",
129 maxWidth: "600px",
130 width: "100%",
131 background: "var(--atproto-color-bg)",
132 position: "relative",
133 borderRadius: "12px",
134 overflow: "hidden"
135};
136
137const parentPostStyle: React.CSSProperties = {
138 position: "relative",
139};
140
141const replyPostStyle: React.CSSProperties = {
142 position: "relative",
143};
144
145const loadingStyle: React.CSSProperties = {
146 padding: "24px 18px",
147 fontSize: "14px",
148 textAlign: "center",
149 color: "var(--atproto-color-text-secondary)",
150};
151
152/**
153 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
154 * and renders it via a customizable renderer component.
155 *
156 * @param did - DID of the repository that stores the post.
157 * @param rkey - Record key for the post within the feed collection.
158 * @param record - Prefetched record for the post.
159 * @param renderer - Optional renderer component to override the default.
160 * @param fallback - Node rendered before the first fetch attempt resolves.
161 * @param loadingIndicator - Node rendered while the post is loading.
162 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`.
163 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
164 * @returns A component that renders loading/fallback states and the resolved post.
165 */
166export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo(
167 ({
168 did: handleOrDid,
169 rkey,
170 record,
171 renderer,
172 fallback,
173 loadingIndicator,
174 showIcon = true,
175 iconPlacement = "timestamp",
176 showParent = false,
177 recursiveParent = false,
178 }) => {
179 const {
180 did: resolvedDid,
181 handle,
182 loading: resolvingIdentity,
183 error: resolutionError,
184 } = useDidResolution(handleOrDid);
185 const repoIdentifier = resolvedDid ?? handleOrDid;
186 const { record: profile } = useAtProtoRecord<ProfileRecord>({
187 did: repoIdentifier,
188 collection: BLUESKY_PROFILE_COLLECTION,
189 rkey: "self",
190 });
191 const avatar = profile?.avatar;
192 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
193 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
194
195 const {
196 record: fetchedRecord,
197 loading: currentLoading,
198 error: currentError,
199 } = useAtProtoRecord<FeedPostRecord>({
200 did: showParent && !record ? repoIdentifier : "",
201 collection: showParent && !record ? BLUESKY_POST_COLLECTION : "",
202 rkey: showParent && !record ? rkey : "",
203 });
204
205 const currentRecord = record ?? fetchedRecord;
206
207 const parentUri = currentRecord?.reply?.parent?.uri;
208 const parsedParentUri = parentUri ? parseAtUri(parentUri) : null;
209 const parentDid = parsedParentUri?.did;
210 const parentRkey = parsedParentUri?.rkey;
211
212 const {
213 record: parentRecord,
214 loading: parentLoading,
215 error: parentError,
216 } = useAtProtoRecord<FeedPostRecord>({
217 did: showParent && parentDid ? parentDid : "",
218 collection: showParent && parentDid ? BLUESKY_POST_COLLECTION : "",
219 rkey: showParent && parentRkey ? parentRkey : "",
220 });
221
222 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> =
223 useMemo(
224 () =>
225 renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
226 [renderer],
227 );
228
229 const displayHandle =
230 handle ??
231 (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
232 const authorHandle =
233 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
234 const atUri = resolvedDid
235 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
236 : undefined;
237
238 const Wrapped = useMemo(() => {
239 const WrappedComponent: React.FC<{
240 record: FeedPostRecord;
241 loading: boolean;
242 error?: Error;
243 }> = (props) => {
244 const { url: avatarUrlFromBlob } = useBlob(
245 repoIdentifier,
246 avatarCid,
247 );
248 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
249 return (
250 <Comp
251 {...props}
252 authorHandle={authorHandle}
253 authorDid={repoIdentifier}
254 avatarUrl={avatarUrl}
255 iconPlacement={iconPlacement}
256 showIcon={showIcon}
257 atUri={atUri}
258 isInThread
259 threadDepth={showParent ? 1 : 0}
260 showThreadBorder={!showParent && !!props.record?.reply?.parent}
261 />
262 );
263 };
264 WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
265 return WrappedComponent;
266 }, [
267 Comp,
268 repoIdentifier,
269 avatarCid,
270 avatarCdnUrl,
271 authorHandle,
272 iconPlacement,
273 showIcon,
274 atUri,
275 showParent,
276 ]);
277
278 if (!displayHandle && resolvingIdentity) {
279 return <div style={{ padding: 8 }}>Resolving handle…</div>;
280 }
281 if (!displayHandle && resolutionError) {
282 return (
283 <div style={{ padding: 8, color: "crimson" }}>
284 Could not resolve handle.
285 </div>
286 );
287 }
288
289 const renderMainPost = (mainRecord?: FeedPostRecord) => {
290 if (mainRecord !== undefined) {
291 return (
292 <AtProtoRecord<FeedPostRecord>
293 record={mainRecord}
294 renderer={Wrapped}
295 fallback={fallback}
296 loadingIndicator={loadingIndicator}
297 />
298 );
299 }
300
301 return (
302 <AtProtoRecord<FeedPostRecord>
303 did={repoIdentifier}
304 collection={BLUESKY_POST_COLLECTION}
305 rkey={rkey}
306 renderer={Wrapped}
307 fallback={fallback}
308 loadingIndicator={loadingIndicator}
309 />
310 );
311 };
312
313 if (showParent) {
314 if (currentLoading || (parentLoading && !parentRecord)) {
315 return (
316 <div style={threadContainerStyle}>
317 <div style={loadingStyle}>Loading thread…</div>
318 </div>
319 );
320 }
321
322 if (currentError) {
323 return (
324 <div style={{ padding: 8, color: "crimson" }}>
325 Failed to load post.
326 </div>
327 );
328 }
329
330 if (!parentDid || !parentRkey) {
331 return renderMainPost(record);
332 }
333
334 if (parentError) {
335 return (
336 <div style={{ padding: 8, color: "crimson" }}>
337 Failed to load parent post.
338 </div>
339 );
340 }
341
342 return (
343 <div style={threadContainerStyle}>
344 <div style={parentPostStyle}>
345 {recursiveParent && parentRecord?.reply?.parent?.uri ? (
346 <BlueskyPost
347 did={parentDid}
348 rkey={parentRkey}
349 record={parentRecord}
350 showParent={true}
351 recursiveParent={true}
352 showIcon={false}
353 iconPlacement="cardBottomRight"
354 />
355 ) : (
356 <BlueskyPost
357 did={parentDid}
358 rkey={parentRkey}
359 record={parentRecord}
360 showIcon={false}
361 iconPlacement="cardBottomRight"
362 />
363 )}
364 </div>
365
366 <div style={replyPostStyle}>
367 {renderMainPost(record || currentRecord)}
368 </div>
369 </div>
370 );
371 }
372
373 return renderMainPost(record);
374 },
375);
376
377export default BlueskyPost;