A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React from "react"; 2import type { FeedPostRecord } from "../types/bluesky"; 3import { 4 parseAtUri, 5 toBlueskyPostUrl, 6 formatDidForLabel, 7 type ParsedAtUri, 8} from "../utils/at-uri"; 9import { useDidResolution } from "../hooks/useDidResolution"; 10import { useBlob } from "../hooks/useBlob"; 11import { BlueskyIcon } from "../components/BlueskyIcon"; 12import { isBlobWithCdn, extractCidFromBlob } from "../utils/blob"; 13 14export interface BlueskyPostRendererProps { 15 record: FeedPostRecord; 16 loading: boolean; 17 error?: Error; 18 authorHandle?: string; 19 authorDisplayName?: string; 20 avatarUrl?: string; 21 authorDid?: string; 22 embed?: React.ReactNode; 23 iconPlacement?: "cardBottomRight" | "timestamp" | "linkInline"; 24 showIcon?: boolean; 25 atUri?: string; 26 isInThread?: boolean; 27 threadDepth?: number; 28 isQuotePost?: boolean; 29 showThreadBorder?: boolean; 30} 31 32export const BlueskyPostRenderer: React.FC<BlueskyPostRendererProps> = ({ 33 record, 34 loading, 35 error, 36 authorDisplayName, 37 authorHandle, 38 avatarUrl, 39 authorDid, 40 embed, 41 iconPlacement = "timestamp", 42 showIcon = true, 43 atUri, 44 isInThread = false, 45 threadDepth = 0, 46 isQuotePost = false, 47 showThreadBorder = false 48}) => { 49 void threadDepth; 50 51 const replyParentUri = record.reply?.parent?.uri; 52 const replyTarget = replyParentUri ? parseAtUri(replyParentUri) : undefined; 53 const { handle: parentHandle, loading: parentHandleLoading } = 54 useDidResolution(replyTarget?.did); 55 56 if (error) { 57 return ( 58 <div style={{ padding: 8, color: "crimson" }}> 59 Failed to load post. 60 </div> 61 ); 62 } 63 if (loading && !record) return <div style={{ padding: 8 }}>Loading</div>; 64 65 const text = record.text; 66 const createdDate = new Date(record.createdAt); 67 const created = createdDate.toLocaleString(undefined, { 68 dateStyle: "medium", 69 timeStyle: "short", 70 }); 71 const primaryName = authorDisplayName || authorHandle || "…"; 72 const replyHref = replyTarget ? toBlueskyPostUrl(replyTarget) : undefined; 73 const replyLabel = replyTarget 74 ? formatReplyLabel(replyTarget, parentHandle, parentHandleLoading) 75 : undefined; 76 77 const makeIcon = () => (showIcon ? <BlueskyIcon size={16} /> : null); 78 const resolvedEmbed = embed ?? createAutoEmbed(record, authorDid); 79 const parsedSelf = atUri ? parseAtUri(atUri) : undefined; 80 const postUrl = parsedSelf ? toBlueskyPostUrl(parsedSelf) : undefined; 81 const cardPadding = 82 typeof baseStyles.card.padding === "number" 83 ? baseStyles.card.padding 84 : 12; 85 86 const cardStyle: React.CSSProperties = { 87 ...baseStyles.card, 88 border: (isInThread && !isQuotePost && !showThreadBorder) ? "none" : `1px solid var(--atproto-color-border)`, 89 background: `var(--atproto-color-bg)`, 90 color: `var(--atproto-color-text)`, 91 borderRadius: (isInThread && !isQuotePost && !showThreadBorder) ? "0" : "12px", 92 ...(iconPlacement === "cardBottomRight" && showIcon && !isInThread 93 ? { paddingBottom: cardPadding + 16 } 94 : {}), 95 }; 96 97 return ( 98 <article style={cardStyle} aria-busy={loading}> 99 {isInThread ? ( 100 <ThreadLayout 101 avatarUrl={avatarUrl} 102 primaryName={primaryName} 103 authorDisplayName={authorDisplayName} 104 authorHandle={authorHandle} 105 iconPlacement={iconPlacement} 106 showIcon={showIcon} 107 makeIcon={makeIcon} 108 replyHref={replyHref} 109 replyLabel={replyLabel} 110 text={text} 111 record={record} 112 created={created} 113 postUrl={postUrl} 114 resolvedEmbed={resolvedEmbed} 115 /> 116 ) : ( 117 <DefaultLayout 118 avatarUrl={avatarUrl} 119 primaryName={primaryName} 120 authorDisplayName={authorDisplayName} 121 authorHandle={authorHandle} 122 iconPlacement={iconPlacement} 123 showIcon={showIcon} 124 makeIcon={makeIcon} 125 replyHref={replyHref} 126 replyLabel={replyLabel} 127 text={text} 128 record={record} 129 created={created} 130 postUrl={postUrl} 131 resolvedEmbed={resolvedEmbed} 132 /> 133 )} 134 </article> 135 ); 136}; 137 138interface LayoutProps { 139 avatarUrl?: string; 140 primaryName: string; 141 authorDisplayName?: string; 142 authorHandle?: string; 143 iconPlacement: "cardBottomRight" | "timestamp" | "linkInline"; 144 showIcon: boolean; 145 makeIcon: () => React.ReactNode; 146 replyHref?: string; 147 replyLabel?: string; 148 text: string; 149 record: FeedPostRecord; 150 created: string; 151 postUrl?: string; 152 resolvedEmbed: React.ReactNode; 153} 154 155const AuthorInfo: React.FC<{ 156 primaryName: string; 157 authorDisplayName?: string; 158 authorHandle?: string; 159 inline?: boolean; 160}> = ({ primaryName, authorDisplayName, authorHandle, inline = false }) => ( 161 <div 162 style={{ 163 display: "flex", 164 flexDirection: inline ? "row" : "column", 165 alignItems: inline ? "center" : "flex-start", 166 gap: inline ? 8 : 0, 167 }} 168 > 169 <strong style={{ fontSize: 14 }}>{authorDisplayName || primaryName}</strong> 170 {authorHandle && ( 171 <span 172 style={{ 173 ...baseStyles.handle, 174 color: `var(--atproto-color-text-secondary)`, 175 }} 176 > 177 @{authorHandle} 178 </span> 179 )} 180 </div> 181); 182 183const Avatar: React.FC<{ avatarUrl?: string }> = ({ avatarUrl }) => 184 avatarUrl ? ( 185 <img src={avatarUrl} alt="avatar" style={baseStyles.avatarImg} /> 186 ) : ( 187 <div style={baseStyles.avatarPlaceholder} aria-hidden /> 188 ); 189 190const ReplyInfo: React.FC<{ 191 replyHref?: string; 192 replyLabel?: string; 193 marginBottom?: number; 194}> = ({ replyHref, replyLabel, marginBottom = 0 }) => 195 replyHref && replyLabel ? ( 196 <div 197 style={{ 198 ...baseStyles.replyLine, 199 color: `var(--atproto-color-text-secondary)`, 200 marginBottom, 201 }} 202 > 203 Replying to{" "} 204 <a 205 href={replyHref} 206 target="_blank" 207 rel="noopener noreferrer" 208 style={{ 209 ...baseStyles.replyLink, 210 color: `var(--atproto-color-link)`, 211 }} 212 > 213 {replyLabel} 214 </a> 215 </div> 216 ) : null; 217 218const PostContent: React.FC<{ 219 text: string; 220 record: FeedPostRecord; 221 created: string; 222 postUrl?: string; 223 iconPlacement: "cardBottomRight" | "timestamp" | "linkInline"; 224 showIcon: boolean; 225 makeIcon: () => React.ReactNode; 226 resolvedEmbed: React.ReactNode; 227}> = ({ 228 text, 229 record, 230 created, 231 postUrl, 232 iconPlacement, 233 showIcon, 234 makeIcon, 235 resolvedEmbed, 236}) => ( 237 <div style={baseStyles.body}> 238 <p style={{ ...baseStyles.text, color: `var(--atproto-color-text)` }}> 239 {text} 240 </p> 241 {record.facets && record.facets.length > 0 && ( 242 <div style={baseStyles.facets}> 243 {record.facets.map((_, idx) => ( 244 <span 245 key={idx} 246 style={{ 247 ...baseStyles.facetTag, 248 background: `var(--atproto-color-bg-secondary)`, 249 color: `var(--atproto-color-text-secondary)`, 250 }} 251 > 252 facet 253 </span> 254 ))} 255 </div> 256 )} 257 {resolvedEmbed && ( 258 <div style={baseStyles.embedContainer}>{resolvedEmbed}</div> 259 )} 260 <div style={baseStyles.timestampRow}> 261 <time 262 style={{ 263 ...baseStyles.time, 264 color: `var(--atproto-color-text-muted)`, 265 }} 266 dateTime={record.createdAt} 267 > 268 {created} 269 </time> 270 {postUrl && ( 271 <span style={baseStyles.linkWithIcon}> 272 <a 273 href={postUrl} 274 target="_blank" 275 rel="noopener noreferrer" 276 style={{ 277 ...baseStyles.postLink, 278 color: `var(--atproto-color-link)`, 279 }} 280 > 281 View on Bluesky 282 </a> 283 {iconPlacement === "linkInline" && showIcon && ( 284 <span style={baseStyles.inlineIcon} aria-hidden> 285 {makeIcon()} 286 </span> 287 )} 288 </span> 289 )} 290 </div> 291 </div> 292); 293 294const ThreadLayout: React.FC<LayoutProps> = (props) => ( 295 <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}> 296 <Avatar avatarUrl={props.avatarUrl} /> 297 <div style={{ flex: 1, minWidth: 0 }}> 298 <div 299 style={{ 300 display: "flex", 301 alignItems: "center", 302 gap: 8, 303 marginBottom: 4, 304 }} 305 > 306 <AuthorInfo 307 primaryName={props.primaryName} 308 authorDisplayName={props.authorDisplayName} 309 authorHandle={props.authorHandle} 310 inline 311 /> 312 {props.iconPlacement === "timestamp" && props.showIcon && ( 313 <div style={{ marginLeft: "auto" }}>{props.makeIcon()}</div> 314 )} 315 </div> 316 <ReplyInfo 317 replyHref={props.replyHref} 318 replyLabel={props.replyLabel} 319 marginBottom={4} 320 /> 321 <PostContent {...props} /> 322 {props.iconPlacement === "cardBottomRight" && props.showIcon && ( 323 <div 324 style={{ 325 position: "relative", 326 right: 0, 327 bottom: 0, 328 justifyContent: "flex-start", 329 marginTop: 8, 330 display: "flex", 331 }} 332 aria-hidden 333 > 334 {props.makeIcon()} 335 </div> 336 )} 337 </div> 338 </div> 339); 340 341const DefaultLayout: React.FC<LayoutProps> = (props) => ( 342 <> 343 <header style={baseStyles.header}> 344 <Avatar avatarUrl={props.avatarUrl} /> 345 <AuthorInfo 346 primaryName={props.primaryName} 347 authorDisplayName={props.authorDisplayName} 348 authorHandle={props.authorHandle} 349 /> 350 {props.iconPlacement === "timestamp" && props.showIcon && ( 351 <div style={baseStyles.headerIcon}>{props.makeIcon()}</div> 352 )} 353 </header> 354 <ReplyInfo replyHref={props.replyHref} replyLabel={props.replyLabel} /> 355 <PostContent {...props} /> 356 {props.iconPlacement === "cardBottomRight" && props.showIcon && ( 357 <div style={baseStyles.iconCorner} aria-hidden> 358 {props.makeIcon()} 359 </div> 360 )} 361 </> 362); 363 364const baseStyles: Record<string, React.CSSProperties> = { 365 card: { 366 borderRadius: 12, 367 padding: 12, 368 fontFamily: "system-ui, sans-serif", 369 display: "flex", 370 flexDirection: "column", 371 gap: 8, 372 maxWidth: 600, 373 transition: 374 "background-color 180ms ease, border-color 180ms ease, color 180ms ease", 375 position: "relative", 376 }, 377 header: { 378 display: "flex", 379 alignItems: "center", 380 gap: 8, 381 }, 382 headerIcon: { 383 marginLeft: "auto", 384 display: "flex", 385 alignItems: "center", 386 }, 387 avatarPlaceholder: { 388 width: 40, 389 height: 40, 390 borderRadius: "50%", 391 }, 392 avatarImg: { 393 width: 40, 394 height: 40, 395 borderRadius: "50%", 396 objectFit: "cover", 397 }, 398 handle: { 399 fontSize: 12, 400 }, 401 time: { 402 fontSize: 11, 403 }, 404 body: { 405 fontSize: 14, 406 lineHeight: 1.4, 407 }, 408 text: { 409 margin: 0, 410 whiteSpace: "pre-wrap", 411 overflowWrap: "anywhere", 412 }, 413 facets: { 414 marginTop: 8, 415 display: "flex", 416 gap: 4, 417 }, 418 embedContainer: { 419 marginTop: 12, 420 padding: 8, 421 borderRadius: 12, 422 border: `1px solid var(--atproto-color-border)`, 423 background: `var(--atproto-color-bg-elevated)`, 424 display: "flex", 425 flexDirection: "column", 426 gap: 8, 427 }, 428 timestampRow: { 429 display: "flex", 430 justifyContent: "flex-end", 431 alignItems: "center", 432 gap: 12, 433 marginTop: 12, 434 flexWrap: "wrap", 435 }, 436 linkWithIcon: { 437 display: "inline-flex", 438 alignItems: "center", 439 gap: 6, 440 }, 441 postLink: { 442 fontSize: 11, 443 textDecoration: "none", 444 fontWeight: 600, 445 }, 446 inlineIcon: { 447 display: "inline-flex", 448 alignItems: "center", 449 }, 450 facetTag: { 451 padding: "2px 6px", 452 borderRadius: 4, 453 fontSize: 11, 454 }, 455 replyLine: { 456 fontSize: 12, 457 }, 458 replyLink: { 459 textDecoration: "none", 460 fontWeight: 500, 461 }, 462 iconCorner: { 463 position: "absolute", 464 right: 12, 465 bottom: 12, 466 display: "flex", 467 alignItems: "center", 468 justifyContent: "flex-end", 469 }, 470}; 471 472function formatReplyLabel( 473 target: ParsedAtUri, 474 resolvedHandle?: string, 475 loading?: boolean, 476): string { 477 if (resolvedHandle) return `@${resolvedHandle}`; 478 if (loading) return "…"; 479 return `@${formatDidForLabel(target.did)}`; 480} 481 482function createAutoEmbed( 483 record: FeedPostRecord, 484 authorDid: string | undefined, 485): React.ReactNode { 486 const embed = record.embed as { $type?: string } | undefined; 487 if (!embed) return null; 488 if (embed.$type === "app.bsky.embed.images") { 489 return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} />; 490 } 491 if (embed.$type === "app.bsky.embed.recordWithMedia") { 492 const media = (embed as RecordWithMediaEmbed).media; 493 if (media?.$type === "app.bsky.embed.images") { 494 return ( 495 <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} /> 496 ); 497 } 498 } 499 return null; 500} 501 502type ImagesEmbedType = { 503 $type: "app.bsky.embed.images"; 504 images: Array<{ 505 alt?: string; 506 mime?: string; 507 size?: number; 508 image?: { 509 $type?: string; 510 ref?: { $link?: string }; 511 cid?: string; 512 }; 513 aspectRatio?: { 514 width: number; 515 height: number; 516 }; 517 }>; 518}; 519 520type RecordWithMediaEmbed = { 521 $type: "app.bsky.embed.recordWithMedia"; 522 record?: unknown; 523 media?: { $type?: string }; 524}; 525 526interface ImagesEmbedProps { 527 embed: ImagesEmbedType; 528 did?: string; 529} 530 531const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did }) => { 532 if (!embed.images || embed.images.length === 0) return null; 533 534 const columns = 535 embed.images.length > 1 536 ? "repeat(auto-fit, minmax(160px, 1fr))" 537 : "1fr"; 538 return ( 539 <div 540 style={{ 541 ...imagesBase.container, 542 background: `var(--atproto-color-bg-elevated)`, 543 gridTemplateColumns: columns, 544 }} 545 > 546 {embed.images.map((img, idx) => ( 547 <PostImage key={idx} image={img} did={did} /> 548 ))} 549 </div> 550 ); 551}; 552 553interface PostImageProps { 554 image: ImagesEmbedType["images"][number]; 555 did?: string; 556} 557 558const PostImage: React.FC<PostImageProps> = ({ image, did }) => { 559 const imageBlob = image.image; 560 const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined; 561 const cid = cdnUrl ? undefined : extractCidFromBlob(imageBlob); 562 const { url: urlFromBlob, loading, error } = useBlob(did, cid); 563 const url = cdnUrl || urlFromBlob; 564 const alt = image.alt?.trim() || "Bluesky attachment"; 565 566 const aspect = 567 image.aspectRatio && image.aspectRatio.height > 0 568 ? `${image.aspectRatio.width} / ${image.aspectRatio.height}` 569 : undefined; 570 571 return ( 572 <figure 573 style={{ 574 ...imagesBase.item, 575 background: `var(--atproto-color-bg-elevated)`, 576 }} 577 > 578 <div 579 style={{ 580 ...imagesBase.media, 581 background: `var(--atproto-color-image-bg)`, 582 aspectRatio: aspect, 583 }} 584 > 585 {url ? ( 586 <img src={url} alt={alt} style={imagesBase.img} /> 587 ) : ( 588 <div 589 style={{ 590 ...imagesBase.placeholder, 591 color: `var(--atproto-color-text-muted)`, 592 }} 593 > 594 {loading 595 ? "Loading image…" 596 : error 597 ? "Image failed to load" 598 : "Image unavailable"} 599 </div> 600 )} 601 </div> 602 {image.alt && image.alt.trim().length > 0 && ( 603 <figcaption 604 style={{ 605 ...imagesBase.caption, 606 color: `var(--atproto-color-text-secondary)`, 607 }} 608 > 609 {image.alt} 610 </figcaption> 611 )} 612 </figure> 613 ); 614}; 615 616const imagesBase = { 617 container: { 618 display: "grid", 619 gap: 8, 620 width: "100%", 621 } satisfies React.CSSProperties, 622 item: { 623 margin: 0, 624 display: "flex", 625 flexDirection: "column", 626 gap: 4, 627 } satisfies React.CSSProperties, 628 media: { 629 position: "relative", 630 width: "100%", 631 borderRadius: 12, 632 overflow: "hidden", 633 } satisfies React.CSSProperties, 634 img: { 635 width: "100%", 636 height: "100%", 637 objectFit: "cover", 638 } satisfies React.CSSProperties, 639 placeholder: { 640 display: "flex", 641 alignItems: "center", 642 justifyContent: "center", 643 width: "100%", 644 height: "100%", 645 } satisfies React.CSSProperties, 646 caption: { 647 fontSize: 12, 648 lineHeight: 1.3, 649 } satisfies React.CSSProperties, 650}; 651 652export default BlueskyPostRenderer;