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 120export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post"; 121 122const threadContainerStyle: React.CSSProperties = { 123 display: "flex", 124 flexDirection: "column", 125 maxWidth: "600px", 126 width: "100%", 127 background: "var(--atproto-color-bg)", 128 position: "relative", 129}; 130 131const parentPostStyle: React.CSSProperties = { 132 position: "relative", 133}; 134 135const replyPostStyle: React.CSSProperties = { 136 position: "relative", 137}; 138 139const loadingStyle: React.CSSProperties = { 140 padding: "24px 18px", 141 fontSize: "14px", 142 textAlign: "center", 143 color: "var(--atproto-color-text-secondary)", 144}; 145 146/** 147 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar, 148 * and renders it via a customizable renderer component. 149 * 150 * @param did - DID of the repository that stores the post. 151 * @param rkey - Record key for the post within the feed collection. 152 * @param record - Prefetched record for the post. 153 * @param renderer - Optional renderer component to override the default. 154 * @param fallback - Node rendered before the first fetch attempt resolves. 155 * @param loadingIndicator - Node rendered while the post is loading. 156 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`. 157 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`. 158 * @returns A component that renders loading/fallback states and the resolved post. 159 */ 160export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo( 161 ({ 162 did: handleOrDid, 163 rkey, 164 record, 165 renderer, 166 fallback, 167 loadingIndicator, 168 showIcon = true, 169 iconPlacement = "timestamp", 170 showParent = false, 171 recursiveParent = false, 172 }) => { 173 const { 174 did: resolvedDid, 175 handle, 176 loading: resolvingIdentity, 177 error: resolutionError, 178 } = useDidResolution(handleOrDid); 179 const repoIdentifier = resolvedDid ?? handleOrDid; 180 const { record: profile } = useAtProtoRecord<ProfileRecord>({ 181 did: repoIdentifier, 182 collection: BLUESKY_PROFILE_COLLECTION, 183 rkey: "self", 184 }); 185 const avatar = profile?.avatar; 186 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 187 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile); 188 189 const { 190 record: fetchedRecord, 191 loading: currentLoading, 192 error: currentError, 193 } = useAtProtoRecord<FeedPostRecord>({ 194 did: showParent && !record ? repoIdentifier : "", 195 collection: showParent && !record ? BLUESKY_POST_COLLECTION : "", 196 rkey: showParent && !record ? rkey : "", 197 }); 198 199 const currentRecord = record ?? fetchedRecord; 200 201 const parentUri = currentRecord?.reply?.parent?.uri; 202 const parsedParentUri = parentUri ? parseAtUri(parentUri) : null; 203 const parentDid = parsedParentUri?.did; 204 const parentRkey = parsedParentUri?.rkey; 205 206 const { 207 record: parentRecord, 208 loading: parentLoading, 209 error: parentError, 210 } = useAtProtoRecord<FeedPostRecord>({ 211 did: showParent && parentDid ? parentDid : "", 212 collection: showParent && parentDid ? BLUESKY_POST_COLLECTION : "", 213 rkey: showParent && parentRkey ? parentRkey : "", 214 }); 215 216 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = 217 useMemo( 218 () => 219 renderer ?? ((props) => <BlueskyPostRenderer {...props} />), 220 [renderer], 221 ); 222 223 const displayHandle = 224 handle ?? 225 (handleOrDid.startsWith("did:") ? undefined : handleOrDid); 226 const authorHandle = 227 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 228 const atUri = resolvedDid 229 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` 230 : undefined; 231 232 const Wrapped = useMemo(() => { 233 const WrappedComponent: React.FC<{ 234 record: FeedPostRecord; 235 loading: boolean; 236 error?: Error; 237 }> = (props) => { 238 const { url: avatarUrlFromBlob } = useBlob( 239 repoIdentifier, 240 avatarCid, 241 ); 242 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 243 return ( 244 <Comp 245 {...props} 246 authorHandle={authorHandle} 247 authorDid={repoIdentifier} 248 avatarUrl={avatarUrl} 249 iconPlacement={iconPlacement} 250 showIcon={showIcon} 251 atUri={atUri} 252 isInThread={true} // Always true for posts rendered in this component 253 threadDepth={showParent ? 1 : 0} 254 /> 255 ); 256 }; 257 WrappedComponent.displayName = "BlueskyPostWrappedRenderer"; 258 return WrappedComponent; 259 }, [ 260 Comp, 261 repoIdentifier, 262 avatarCid, 263 avatarCdnUrl, 264 authorHandle, 265 iconPlacement, 266 showIcon, 267 atUri, 268 showParent, 269 ]); 270 271 if (!displayHandle && resolvingIdentity) { 272 return <div style={{ padding: 8 }}>Resolving handle</div>; 273 } 274 if (!displayHandle && resolutionError) { 275 return ( 276 <div style={{ padding: 8, color: "crimson" }}> 277 Could not resolve handle. 278 </div> 279 ); 280 } 281 282 const renderMainPost = (mainRecord?: FeedPostRecord) => { 283 if (mainRecord !== undefined) { 284 return ( 285 <AtProtoRecord<FeedPostRecord> 286 record={mainRecord} 287 renderer={Wrapped} 288 fallback={fallback} 289 loadingIndicator={loadingIndicator} 290 /> 291 ); 292 } 293 294 return ( 295 <AtProtoRecord<FeedPostRecord> 296 did={repoIdentifier} 297 collection={BLUESKY_POST_COLLECTION} 298 rkey={rkey} 299 renderer={Wrapped} 300 fallback={fallback} 301 loadingIndicator={loadingIndicator} 302 /> 303 ); 304 }; 305 306 if (showParent) { 307 if (currentLoading || (parentLoading && !parentRecord)) { 308 return ( 309 <div style={threadContainerStyle}> 310 <div style={loadingStyle}>Loading thread</div> 311 </div> 312 ); 313 } 314 315 if (currentError) { 316 return ( 317 <div style={{ padding: 8, color: "crimson" }}> 318 Failed to load post. 319 </div> 320 ); 321 } 322 323 if (!parentDid || !parentRkey) { 324 return renderMainPost(record); 325 } 326 327 if (parentError) { 328 return ( 329 <div style={{ padding: 8, color: "crimson" }}> 330 Failed to load parent post. 331 </div> 332 ); 333 } 334 335 return ( 336 <div style={threadContainerStyle}> 337 <div style={parentPostStyle}> 338 {recursiveParent && parentRecord?.reply?.parent?.uri ? ( 339 <BlueskyPost 340 did={parentDid} 341 rkey={parentRkey} 342 record={parentRecord} 343 showParent={true} 344 recursiveParent={true} 345 showIcon={false} 346 iconPlacement="cardBottomRight" 347 /> 348 ) : ( 349 <BlueskyPost 350 did={parentDid} 351 rkey={parentRkey} 352 record={parentRecord} 353 showIcon={false} 354 iconPlacement="cardBottomRight" 355 /> 356 )} 357 </div> 358 359 <div style={replyPostStyle}> 360 {renderMainPost(record || currentRecord)} 361 </div> 362 </div> 363 ); 364 } 365 366 return renderMainPost(record); 367 }, 368); 369 370export default BlueskyPost;