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