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, parseAtUri } 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 * Controls whether to show the parent post if this post is a reply. 55 * Defaults to `false`. 56 */ 57 showParent?: boolean; 58 /** 59 * Controls whether to recursively show all parent posts to the root. 60 * Only applies when `showParent` is `true`. Defaults to `false`. 61 */ 62 recursiveParent?: boolean; 63} 64 65/** 66 * Values injected by `BlueskyPost` into a downstream renderer component. 67 */ 68export type BlueskyPostRendererInjectedProps = { 69 /** 70 * Resolved record payload for the post. 71 */ 72 record: FeedPostRecord; 73 /** 74 * `true` while network operations are in-flight. 75 */ 76 loading: boolean; 77 /** 78 * Error encountered during loading, if any. 79 */ 80 error?: Error; 81 /** 82 * The author's public handle derived from the DID. 83 */ 84 authorHandle: string; 85 /** 86 * The DID that owns the post record. 87 */ 88 authorDid: string; 89 /** 90 * Resolved URL for the author's avatar blob, if available. 91 */ 92 avatarUrl?: string; 93 94 /** 95 * Placement strategy for the Bluesky icon. 96 */ 97 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; 98 /** 99 * Controls whether the icon should render at all. 100 */ 101 showIcon?: boolean; 102 /** 103 * Fully qualified AT URI of the post, when resolvable. 104 */ 105 atUri?: string; 106 /** 107 * Optional override for the rendered embed contents. 108 */ 109 embed?: React.ReactNode; 110 /** 111 * Whether this post is part of a thread. 112 */ 113 isInThread?: boolean; 114 /** 115 * Depth of this post in a thread (0 = root, 1 = first reply, etc.). 116 */ 117 threadDepth?: number; 118 /** 119 * Whether to show border even when in thread context. 120 */ 121 showThreadBorder?: boolean; 122}; 123 124export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post"; 125 126const threadContainerStyle: React.CSSProperties = { 127 display: "flex", 128 flexDirection: "column", 129 maxWidth: "600px", 130 width: "100%", 131 background: "var(--atproto-color-bg)", 132 position: "relative", 133 borderRadius: "12px", 134 overflow: "hidden" 135}; 136 137const parentPostStyle: React.CSSProperties = { 138 position: "relative", 139}; 140 141const replyPostStyle: React.CSSProperties = { 142 position: "relative", 143}; 144 145const loadingStyle: React.CSSProperties = { 146 padding: "24px 18px", 147 fontSize: "14px", 148 textAlign: "center", 149 color: "var(--atproto-color-text-secondary)", 150}; 151 152/** 153 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar, 154 * and renders it via a customizable renderer component. 155 * 156 * @param did - DID of the repository that stores the post. 157 * @param rkey - Record key for the post within the feed collection. 158 * @param record - Prefetched record for the post. 159 * @param renderer - Optional renderer component to override the default. 160 * @param fallback - Node rendered before the first fetch attempt resolves. 161 * @param loadingIndicator - Node rendered while the post is loading. 162 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`. 163 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`. 164 * @returns A component that renders loading/fallback states and the resolved post. 165 */ 166export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo( 167 ({ 168 did: handleOrDid, 169 rkey, 170 record, 171 renderer, 172 fallback, 173 loadingIndicator, 174 showIcon = true, 175 iconPlacement = "timestamp", 176 showParent = false, 177 recursiveParent = false, 178 }) => { 179 const { 180 did: resolvedDid, 181 handle, 182 loading: resolvingIdentity, 183 error: resolutionError, 184 } = useDidResolution(handleOrDid); 185 const repoIdentifier = resolvedDid ?? handleOrDid; 186 const { record: profile } = useAtProtoRecord<ProfileRecord>({ 187 did: repoIdentifier, 188 collection: BLUESKY_PROFILE_COLLECTION, 189 rkey: "self", 190 }); 191 const avatar = profile?.avatar; 192 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 193 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile); 194 195 const { 196 record: fetchedRecord, 197 loading: currentLoading, 198 error: currentError, 199 } = useAtProtoRecord<FeedPostRecord>({ 200 did: showParent && !record ? repoIdentifier : "", 201 collection: showParent && !record ? BLUESKY_POST_COLLECTION : "", 202 rkey: showParent && !record ? rkey : "", 203 }); 204 205 const currentRecord = record ?? fetchedRecord; 206 207 const parentUri = currentRecord?.reply?.parent?.uri; 208 const parsedParentUri = parentUri ? parseAtUri(parentUri) : null; 209 const parentDid = parsedParentUri?.did; 210 const parentRkey = parsedParentUri?.rkey; 211 212 const { 213 record: parentRecord, 214 loading: parentLoading, 215 error: parentError, 216 } = useAtProtoRecord<FeedPostRecord>({ 217 did: showParent && parentDid ? parentDid : "", 218 collection: showParent && parentDid ? BLUESKY_POST_COLLECTION : "", 219 rkey: showParent && parentRkey ? parentRkey : "", 220 }); 221 222 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = 223 useMemo( 224 () => 225 renderer ?? ((props) => <BlueskyPostRenderer {...props} />), 226 [renderer], 227 ); 228 229 const displayHandle = 230 handle ?? 231 (handleOrDid.startsWith("did:") ? undefined : handleOrDid); 232 const authorHandle = 233 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 234 const atUri = resolvedDid 235 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` 236 : undefined; 237 238 const Wrapped = useMemo(() => { 239 const WrappedComponent: React.FC<{ 240 record: FeedPostRecord; 241 loading: boolean; 242 error?: Error; 243 }> = (props) => { 244 const { url: avatarUrlFromBlob } = useBlob( 245 repoIdentifier, 246 avatarCid, 247 ); 248 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 249 return ( 250 <Comp 251 {...props} 252 authorHandle={authorHandle} 253 authorDid={repoIdentifier} 254 avatarUrl={avatarUrl} 255 iconPlacement={iconPlacement} 256 showIcon={showIcon} 257 atUri={atUri} 258 isInThread 259 threadDepth={showParent ? 1 : 0} 260 showThreadBorder={!showParent && !!props.record?.reply?.parent} 261 /> 262 ); 263 }; 264 WrappedComponent.displayName = "BlueskyPostWrappedRenderer"; 265 return WrappedComponent; 266 }, [ 267 Comp, 268 repoIdentifier, 269 avatarCid, 270 avatarCdnUrl, 271 authorHandle, 272 iconPlacement, 273 showIcon, 274 atUri, 275 showParent, 276 ]); 277 278 if (!displayHandle && resolvingIdentity) { 279 return <div style={{ padding: 8 }}>Resolving handle</div>; 280 } 281 if (!displayHandle && resolutionError) { 282 return ( 283 <div style={{ padding: 8, color: "crimson" }}> 284 Could not resolve handle. 285 </div> 286 ); 287 } 288 289 const renderMainPost = (mainRecord?: FeedPostRecord) => { 290 if (mainRecord !== undefined) { 291 return ( 292 <AtProtoRecord<FeedPostRecord> 293 record={mainRecord} 294 renderer={Wrapped} 295 fallback={fallback} 296 loadingIndicator={loadingIndicator} 297 /> 298 ); 299 } 300 301 return ( 302 <AtProtoRecord<FeedPostRecord> 303 did={repoIdentifier} 304 collection={BLUESKY_POST_COLLECTION} 305 rkey={rkey} 306 renderer={Wrapped} 307 fallback={fallback} 308 loadingIndicator={loadingIndicator} 309 /> 310 ); 311 }; 312 313 if (showParent) { 314 if (currentLoading || (parentLoading && !parentRecord)) { 315 return ( 316 <div style={threadContainerStyle}> 317 <div style={loadingStyle}>Loading thread</div> 318 </div> 319 ); 320 } 321 322 if (currentError) { 323 return ( 324 <div style={{ padding: 8, color: "crimson" }}> 325 Failed to load post. 326 </div> 327 ); 328 } 329 330 if (!parentDid || !parentRkey) { 331 return renderMainPost(record); 332 } 333 334 if (parentError) { 335 return ( 336 <div style={{ padding: 8, color: "crimson" }}> 337 Failed to load parent post. 338 </div> 339 ); 340 } 341 342 return ( 343 <div style={threadContainerStyle}> 344 <div style={parentPostStyle}> 345 {recursiveParent && parentRecord?.reply?.parent?.uri ? ( 346 <BlueskyPost 347 did={parentDid} 348 rkey={parentRkey} 349 record={parentRecord} 350 showParent={true} 351 recursiveParent={true} 352 showIcon={false} 353 iconPlacement="cardBottomRight" 354 /> 355 ) : ( 356 <BlueskyPost 357 did={parentDid} 358 rkey={parentRkey} 359 record={parentRecord} 360 showIcon={false} 361 iconPlacement="cardBottomRight" 362 /> 363 )} 364 </div> 365 366 <div style={replyPostStyle}> 367 {renderMainPost(record || currentRecord)} 368 </div> 369 </div> 370 ); 371 } 372 373 return renderMainPost(record); 374 }, 375); 376 377export default BlueskyPost;