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 type { BlobWithCdn } from "../hooks/useBlueskyAppview";
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 // Check if the avatar has a CDN URL from the appview (preferred)
149 const avatar = profile?.avatar;
150 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined;
151 const avatarCid = !avatarCdnUrl ? getAvatarCid(profile) : undefined;
152
153 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
154 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
155 [renderer]
156 );
157
158 const displayHandle =
159 handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
160 const authorHandle =
161 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
162 const atUri = resolvedDid
163 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
164 : undefined;
165
166 const Wrapped = useMemo(() => {
167 const WrappedComponent: React.FC<{
168 record: FeedPostRecord;
169 loading: boolean;
170 error?: Error;
171 }> = (props) => {
172 const { url: avatarUrlFromBlob } = useBlob(repoIdentifier, avatarCid);
173 // Use CDN URL from appview if available, otherwise use blob URL
174 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob;
175 return (
176 <Comp
177 {...props}
178 authorHandle={authorHandle}
179 authorDid={repoIdentifier}
180 avatarUrl={avatarUrl}
181 colorScheme={colorScheme}
182 iconPlacement={iconPlacement}
183 showIcon={showIcon}
184 atUri={atUri}
185 />
186 );
187 };
188 WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
189 return WrappedComponent;
190 }, [
191 Comp,
192 repoIdentifier,
193 avatarCid,
194 avatarCdnUrl,
195 authorHandle,
196 colorScheme,
197 iconPlacement,
198 showIcon,
199 atUri,
200 ]);
201
202 if (!displayHandle && resolvingIdentity) {
203 return <div style={{ padding: 8 }}>Resolving handle…</div>;
204 }
205 if (!displayHandle && resolutionError) {
206 return (
207 <div style={{ padding: 8, color: "crimson" }}>
208 Could not resolve handle.
209 </div>
210 );
211 }
212
213
214 if (record !== undefined) {
215 return (
216 <AtProtoRecord<FeedPostRecord>
217 record={record}
218 renderer={Wrapped}
219 fallback={fallback}
220 loadingIndicator={loadingIndicator}
221 />
222 );
223 }
224
225 return (
226 <AtProtoRecord<FeedPostRecord>
227 did={repoIdentifier}
228 collection={BLUESKY_POST_COLLECTION}
229 rkey={rkey}
230 renderer={Wrapped}
231 fallback={fallback}
232 loadingIndicator={loadingIndicator}
233 />
234 );
235};
236
237/**
238 * Type guard to check if a blob has a CDN URL from appview.
239 */
240function isBlobWithCdn(value: unknown): value is BlobWithCdn {
241 if (typeof value !== "object" || value === null) return false;
242 const obj = value as Record<string, unknown>;
243 return (
244 obj.$type === "blob" &&
245 typeof obj.cdnUrl === "string" &&
246 typeof obj.ref === "object" &&
247 obj.ref !== null &&
248 typeof (obj.ref as { $link?: unknown }).$link === "string"
249 );
250}
251
252export default BlueskyPost;