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