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