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 * Custom renderer component that receives resolved post data and status flags.
26 */
27 renderer?: React.ComponentType<BlueskyPostRendererInjectedProps>;
28 /**
29 * React node shown while the post query has not yet produced data or an error.
30 */
31 fallback?: React.ReactNode;
32 /**
33 * React node displayed while the post fetch is actively loading.
34 */
35 loadingIndicator?: React.ReactNode;
36 /**
37 * Preferred color scheme to pass through to renderers.
38 */
39 colorScheme?: "light" | "dark" | "system";
40 /**
41 * Whether the default renderer should show the Bluesky icon.
42 * Defaults to `true`.
43 */
44 showIcon?: boolean;
45 /**
46 * Placement strategy for the icon when it is rendered.
47 * Defaults to `'timestamp'`.
48 */
49 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline";
50}
51
52/**
53 * Values injected by `BlueskyPost` into a downstream renderer component.
54 */
55export type BlueskyPostRendererInjectedProps = {
56 /**
57 * Resolved record payload for the post.
58 */
59 record: FeedPostRecord;
60 /**
61 * `true` while network operations are in-flight.
62 */
63 loading: boolean;
64 /**
65 * Error encountered during loading, if any.
66 */
67 error?: Error;
68 /**
69 * The author's public handle derived from the DID.
70 */
71 authorHandle: string;
72 /**
73 * The DID that owns the post record.
74 */
75 authorDid: string;
76 /**
77 * Resolved URL for the author's avatar blob, if available.
78 */
79 avatarUrl?: string;
80 /**
81 * Preferred color scheme bubbled down to children.
82 */
83 colorScheme?: "light" | "dark" | "system";
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 colorScheme - Preferred color scheme forwarded to downstream components.
115 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`.
116 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`.
117 * @returns A component that renders loading/fallback states and the resolved post.
118 */
119export const BlueskyPost: React.FC<BlueskyPostProps> = ({
120 did: handleOrDid,
121 rkey,
122 renderer,
123 fallback,
124 loadingIndicator,
125 colorScheme,
126 showIcon = true,
127 iconPlacement = "timestamp",
128}) => {
129 const {
130 did: resolvedDid,
131 handle,
132 loading: resolvingIdentity,
133 error: resolutionError,
134 } = useDidResolution(handleOrDid);
135 const repoIdentifier = resolvedDid ?? handleOrDid;
136 const { record: profile } = useAtProtoRecord<ProfileRecord>({
137 did: repoIdentifier,
138 collection: BLUESKY_PROFILE_COLLECTION,
139 rkey: "self",
140 });
141 const avatarCid = getAvatarCid(profile);
142
143 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = useMemo(
144 () => renderer ?? ((props) => <BlueskyPostRenderer {...props} />),
145 [renderer]
146 );
147
148 const displayHandle =
149 handle ?? (handleOrDid.startsWith("did:") ? undefined : handleOrDid);
150 const authorHandle =
151 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid);
152 const atUri = resolvedDid
153 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}`
154 : undefined;
155
156 const Wrapped = useMemo(() => {
157 const WrappedComponent: React.FC<{
158 record: FeedPostRecord;
159 loading: boolean;
160 error?: Error;
161 }> = (props) => {
162 const { url: avatarUrl } = useBlob(repoIdentifier, avatarCid);
163 return (
164 <Comp
165 {...props}
166 authorHandle={authorHandle}
167 authorDid={repoIdentifier}
168 avatarUrl={avatarUrl}
169 colorScheme={colorScheme}
170 iconPlacement={iconPlacement}
171 showIcon={showIcon}
172 atUri={atUri}
173 />
174 );
175 };
176 WrappedComponent.displayName = "BlueskyPostWrappedRenderer";
177 return WrappedComponent;
178 }, [
179 Comp,
180 repoIdentifier,
181 avatarCid,
182 authorHandle,
183 colorScheme,
184 iconPlacement,
185 showIcon,
186 atUri,
187 ]);
188
189 if (!displayHandle && resolvingIdentity) {
190 return <div style={{ padding: 8 }}>Resolving handle…</div>;
191 }
192 if (!displayHandle && resolutionError) {
193 return (
194 <div style={{ padding: 8, color: "crimson" }}>
195 Could not resolve handle.
196 </div>
197 );
198 }
199
200 return (
201 <AtProtoRecord<FeedPostRecord>
202 did={repoIdentifier}
203 collection={BLUESKY_POST_COLLECTION}
204 rkey={rkey}
205 renderer={Wrapped}
206 fallback={fallback}
207 loadingIndicator={loadingIndicator}
208 />
209 );
210};
211
212export default BlueskyPost;