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