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 } 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
55/**
56 * Values injected by `BlueskyPost` into a downstream renderer component.
57 */
58export type BlueskyPostRendererInjectedProps = {
59 /**
60 * Resolved record payload for the post.
61 */
62 record: FeedPostRecord;
63 /**
64 * `true` while network operations are in-flight.
65 */
66 loading: boolean;
67 /**
68 * Error encountered during loading, if any.
69 */
70 error?: Error;
71 /**
72 * The author's public handle derived from the DID.
73 */
74 authorHandle: string;
75 /**
76 * The DID that owns the post record.
77 */
78 authorDid: string;
79 /**
80 * Resolved URL for the author's avatar blob, if available.
81 */
82 avatarUrl?: string;
83
84 /**
85 * Placement strategy for the Bluesky icon.
86 */
87 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
88 /**
89 * Controls whether the icon should render at all.
90 */
91 showIcon?: boolean;
92 /**
93 * Fully qualified AT URI of the post, when resolvable.
94 */
95 atUri?: string;
96 /**
97 * Optional override for the rendered embed contents.
98 */
99 embed?: React.ReactNode;
100};
101
102/** NSID for the canonical Bluesky feed post collection. */
103export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post";
104
105/**
106 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar,
107 * and renders it via a customizable renderer component.
108 *
109 * @param did - DID of the repository that stores the post.
110 * @param rkey - Record key for the post within the feed collection.
111 * @param renderer - Optional renderer component to override the default.
112 * @param fallback - Node rendered before the first fetch attempt resolves.
113 * @param loadingIndicator - Node rendered while the post is loading.
114 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`.
115 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
116 * @returns A component that renders loading/fallback states and the resolved post.
117 */
118export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo(({
119 did: handleOrDid,
120 rkey,
121 record,
122 renderer,
123 fallback,
124 loadingIndicator,
125 showIcon = true,
126 iconPlacement = "timestamp",
127}) => {
128 const {
129 did: resolvedDid,
130 handle,
131 loading: resolvingIdentity,
132 error: resolutionError,
133 } = useDidResolution(handleOrDid);
134 const repoIdentifier = resolvedDid ?? handleOrDid;
135 const { record: profile } = useAtProtoRecord<ProfileRecord>({
136 did: repoIdentifier,
137 collection: BLUESKY_PROFILE_COLLECTION,
138 rkey: "self",
139 });
140 const avatar = profile?.avatar;
141 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
142 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile);
143
144 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
145 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
146 [renderer]
147 );
148
149 const displayHandle =
150 handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
151 const authorHandle =
152 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
153 const atUri = resolvedDid
154 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
155 : undefined;
156
157 const Wrapped = useMemo(() => {
158 const WrappedComponent: React.FC<{
159 record: FeedPostRecord;
160 loading: boolean;
161 error?: Error;
162 }> = (props) => {
163 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
164 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
165 return (
166 <Comp
167 {...props}
168 authorHandle={authorHandle}
169 authorDid={repoIdentifier}
170 avatarUrl={avatarUrl}
171 iconPlacement={iconPlacement}
172 showIcon={showIcon}
173 atUri={atUri}
174 />
175 );
176 };
177 WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
178 return WrappedComponent;
179 }, [
180 Comp,
181 repoIdentifier,
182 avatarCid,
183 avatarCdnUrl,
184 authorHandle,
185 iconPlacement,
186 showIcon,
187 atUri,
188 ]);
189
190 if (!displayHandle && resolvingIdentity) {
191 return <div style={{ padding: 8 }}>Resolving handle…</div>;
192 }
193 if (!displayHandle && resolutionError) {
194 return (
195 <div style={{ padding: 8, color: "crimson" }}>
196 Could not resolve handle.
197 </div>
198 );
199 }
200
201
202 if (record !== undefined) {
203 return (
204 <AtProtoRecord<FeedPostRecord>
205 record={record}
206 renderer={Wrapped}
207 fallback={fallback}
208 loadingIndicator={loadingIndicator}
209 />
210 );
211 }
212
213 return (
214 <AtProtoRecord<FeedPostRecord>
215 did={repoIdentifier}
216 collection={BLUESKY_POST_COLLECTION}
217 rkey={rkey}
218 renderer={Wrapped}
219 fallback={fallback}
220 loadingIndicator={loadingIndicator}
221 />
222 );
223});
224
225export default BlueskyPost;