A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 12 kB view raw
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 author's display name from their profile. 87 */ 88 authorDisplayName?: string; 89 /** 90 * The DID that owns the post record. 91 */ 92 authorDid: string; 93 /** 94 * Resolved URL for the author's avatar blob, if available. 95 */ 96 avatarUrl?: string; 97 98 /** 99 * Placement strategy for the Bluesky icon. 100 */ 101 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; 102 /** 103 * Controls whether the icon should render at all. 104 */ 105 showIcon?: boolean; 106 /** 107 * Fully qualified AT URI of the post, when resolvable. 108 */ 109 atUri?: string; 110 /** 111 * Optional override for the rendered embed contents. 112 */ 113 embed?: React.ReactNode; 114 /** 115 * Whether this post is part of a thread. 116 */ 117 isInThread?: boolean; 118 /** 119 * Depth of this post in a thread (0 = root, 1 = first reply, etc.). 120 */ 121 threadDepth?: number; 122 /** 123 * Whether to show border even when in thread context. 124 */ 125 showThreadBorder?: boolean; 126}; 127 128export const BLUESKY_POST_COLLECTION = "app.bsky.feed.post"; 129 130const threadContainerStyle: React.CSSProperties = { 131 display: "flex", 132 flexDirection: "column", 133 maxWidth: "600px", 134 width: "100%", 135 background: "var(--atproto-color-bg)", 136 position: "relative", 137 borderRadius: "12px", 138 overflow: "hidden" 139}; 140 141const parentPostStyle: React.CSSProperties = { 142 position: "relative", 143}; 144 145const replyPostStyle: React.CSSProperties = { 146 position: "relative", 147}; 148 149const loadingStyle: React.CSSProperties = { 150 padding: "24px 18px", 151 fontSize: "14px", 152 textAlign: "center", 153 color: "var(--atproto-color-text-secondary)", 154}; 155 156/** 157 * Fetches a Bluesky feed post, resolves metadata such as author handle and avatar, 158 * and renders it via a customizable renderer component. 159 * 160 * @param did - DID of the repository that stores the post. 161 * @param rkey - Record key for the post within the feed collection. 162 * @param record - Prefetched record for the post. 163 * @param renderer - Optional renderer component to override the default. 164 * @param fallback - Node rendered before the first fetch attempt resolves. 165 * @param loadingIndicator - Node rendered while the post is loading. 166 * @param showIcon - Controls whether the Bluesky icon should render alongside the post. Defaults to `true`. 167 * @param iconPlacement - Determines where the icon is positioned in the rendered post. Defaults to `'timestamp'`. 168 * @returns A component that renders loading/fallback states and the resolved post. 169 */ 170export const BlueskyPost: React.FC<BlueskyPostProps> = React.memo( 171 ({ 172 did: handleOrDid, 173 rkey, 174 record, 175 renderer, 176 fallback, 177 loadingIndicator, 178 showIcon = true, 179 iconPlacement = "timestamp", 180 showParent = false, 181 recursiveParent = false, 182 }) => { 183 const { 184 did: resolvedDid, 185 handle, 186 loading: resolvingIdentity, 187 error: resolutionError, 188 } = useDidResolution(handleOrDid); 189 const repoIdentifier = resolvedDid ?? handleOrDid; 190 const { record: profile } = useAtProtoRecord<ProfileRecord>({ 191 did: repoIdentifier, 192 collection: BLUESKY_PROFILE_COLLECTION, 193 rkey: "self", 194 }); 195 const avatar = profile?.avatar; 196 const avatarCdnUrl = isBlobWithCdn(avatar) ? avatar.cdnUrl : undefined; 197 const avatarCid = avatarCdnUrl ? undefined : getAvatarCid(profile); 198 const authorDisplayName = profile?.displayName; 199 200 const { 201 record: fetchedRecord, 202 loading: currentLoading, 203 error: currentError, 204 } = useAtProtoRecord<FeedPostRecord>({ 205 did: showParent && !record ? repoIdentifier : "", 206 collection: showParent && !record ? BLUESKY_POST_COLLECTION : "", 207 rkey: showParent && !record ? rkey : "", 208 }); 209 210 const currentRecord = record ?? fetchedRecord; 211 212 const parentUri = currentRecord?.reply?.parent?.uri; 213 const parsedParentUri = parentUri ? parseAtUri(parentUri) : null; 214 const parentDid = parsedParentUri?.did; 215 const parentRkey = parsedParentUri?.rkey; 216 217 const { 218 record: parentRecord, 219 loading: parentLoading, 220 error: parentError, 221 } = useAtProtoRecord<FeedPostRecord>({ 222 did: showParent && parentDid ? parentDid : "", 223 collection: showParent && parentDid ? BLUESKY_POST_COLLECTION : "", 224 rkey: showParent && parentRkey ? parentRkey : "", 225 }); 226 227 const Comp: React.ComponentType<BlueskyPostRendererInjectedProps> = 228 useMemo( 229 () => 230 renderer ?? ((props) => <BlueskyPostRenderer {...props} />), 231 [renderer], 232 ); 233 234 const displayHandle = 235 handle ?? 236 (handleOrDid.startsWith("did:") ? undefined : handleOrDid); 237 const authorHandle = 238 displayHandle ?? formatDidForLabel(resolvedDid ?? handleOrDid); 239 const atUri = resolvedDid 240 ? `at://${resolvedDid}/${BLUESKY_POST_COLLECTION}/${rkey}` 241 : undefined; 242 243 const Wrapped = useMemo(() => { 244 const WrappedComponent: React.FC<{ 245 record: FeedPostRecord; 246 loading: boolean; 247 error?: Error; 248 }> = (props) => { 249 const { url: avatarUrlFromBlob } = useBlob( 250 repoIdentifier, 251 avatarCid, 252 ); 253 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 254 return ( 255 <Comp 256 {...props} 257 authorHandle={authorHandle} 258 authorDisplayName={authorDisplayName} 259 authorDid={repoIdentifier} 260 avatarUrl={avatarUrl} 261 iconPlacement={iconPlacement} 262 showIcon={showIcon} 263 atUri={atUri} 264 isInThread 265 threadDepth={showParent ? 1 : 0} 266 showThreadBorder={!showParent && !!props.record?.reply?.parent} 267 /> 268 ); 269 }; 270 WrappedComponent.displayName = "BlueskyPostWrappedRenderer"; 271 return WrappedComponent; 272 }, [ 273 Comp, 274 repoIdentifier, 275 avatarCid, 276 avatarCdnUrl, 277 authorHandle, 278 authorDisplayName, 279 iconPlacement, 280 showIcon, 281 atUri, 282 showParent, 283 ]); 284 285 const WrappedWithoutIcon = useMemo(() => { 286 const WrappedComponent: React.FC<{ 287 record: FeedPostRecord; 288 loading: boolean; 289 error?: Error; 290 }> = (props) => { 291 const { url: avatarUrlFromBlob } = useBlob( 292 repoIdentifier, 293 avatarCid, 294 ); 295 const avatarUrl = avatarCdnUrl || avatarUrlFromBlob; 296 return ( 297 <Comp 298 {...props} 299 authorHandle={authorHandle} 300 authorDisplayName={authorDisplayName} 301 authorDid={repoIdentifier} 302 avatarUrl={avatarUrl} 303 iconPlacement={iconPlacement} 304 showIcon={false} 305 atUri={atUri} 306 isInThread 307 threadDepth={showParent ? 1 : 0} 308 showThreadBorder={!showParent && !!props.record?.reply?.parent} 309 /> 310 ); 311 }; 312 WrappedComponent.displayName = "BlueskyPostWrappedRendererWithoutIcon"; 313 return WrappedComponent; 314 }, [ 315 Comp, 316 repoIdentifier, 317 avatarCid, 318 avatarCdnUrl, 319 authorHandle, 320 authorDisplayName, 321 iconPlacement, 322 atUri, 323 showParent, 324 ]); 325 326 if (!displayHandle && resolvingIdentity) { 327 return <div style={{ padding: 8 }}>Resolving handle</div>; 328 } 329 if (!displayHandle && resolutionError) { 330 return ( 331 <div style={{ padding: 8, color: "crimson" }}> 332 Could not resolve handle. 333 </div> 334 ); 335 } 336 337 const renderMainPost = (mainRecord?: FeedPostRecord) => { 338 if (mainRecord !== undefined) { 339 return ( 340 <AtProtoRecord<FeedPostRecord> 341 record={mainRecord} 342 renderer={Wrapped} 343 fallback={fallback} 344 loadingIndicator={loadingIndicator} 345 /> 346 ); 347 } 348 349 return ( 350 <AtProtoRecord<FeedPostRecord> 351 did={repoIdentifier} 352 collection={BLUESKY_POST_COLLECTION} 353 rkey={rkey} 354 renderer={Wrapped} 355 fallback={fallback} 356 loadingIndicator={loadingIndicator} 357 /> 358 ); 359 }; 360 361 const renderMainPostWithoutIcon = (mainRecord?: FeedPostRecord) => { 362 if (mainRecord !== undefined) { 363 return ( 364 <AtProtoRecord<FeedPostRecord> 365 record={mainRecord} 366 renderer={WrappedWithoutIcon} 367 fallback={fallback} 368 loadingIndicator={loadingIndicator} 369 /> 370 ); 371 } 372 373 return ( 374 <AtProtoRecord<FeedPostRecord> 375 did={repoIdentifier} 376 collection={BLUESKY_POST_COLLECTION} 377 rkey={rkey} 378 renderer={WrappedWithoutIcon} 379 fallback={fallback} 380 loadingIndicator={loadingIndicator} 381 /> 382 ); 383 }; 384 385 if (showParent) { 386 if (currentLoading || (parentLoading && !parentRecord)) { 387 return ( 388 <div style={threadContainerStyle}> 389 <div style={loadingStyle}>Loading thread</div> 390 </div> 391 ); 392 } 393 394 if (currentError) { 395 return ( 396 <div style={{ padding: 8, color: "crimson" }}> 397 Failed to load post. 398 </div> 399 ); 400 } 401 402 if (!parentDid || !parentRkey) { 403 return renderMainPost(record); 404 } 405 406 if (parentError) { 407 return ( 408 <div style={{ padding: 8, color: "crimson" }}> 409 Failed to load parent post. 410 </div> 411 ); 412 } 413 414 return ( 415 <div style={threadContainerStyle}> 416 <div style={parentPostStyle}> 417 {recursiveParent && parentRecord?.reply?.parent?.uri ? ( 418 <BlueskyPost 419 did={parentDid} 420 rkey={parentRkey} 421 record={parentRecord} 422 showParent={true} 423 recursiveParent={true} 424 showIcon={showIcon} 425 iconPlacement={iconPlacement} 426 /> 427 ) : ( 428 <BlueskyPost 429 did={parentDid} 430 rkey={parentRkey} 431 record={parentRecord} 432 showIcon={showIcon} 433 iconPlacement={iconPlacement} 434 /> 435 )} 436 </div> 437 438 <div style={replyPostStyle}> 439 {renderMainPostWithoutIcon(record || currentRecord)} 440 </div> 441 </div> 442 ); 443 } 444 445 return renderMainPost(record); 446 }, 447); 448 449export default BlueskyPost;