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 }}>{primaryName}</strong> 170 {authorDisplayName && 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 <div style={baseStyles.timestampRow}> 258 <time 259 style={{ 260 ...baseStyles.time, 261 color: `var(--atproto-color-text-muted)`, 262 }} 263 dateTime={record.createdAt} 264 > 265 {created} 266 </time> 267 {postUrl && ( 268 <span style={baseStyles.linkWithIcon}> 269 <a 270 href={postUrl} 271 target="_blank" 272 rel="noopener noreferrer" 273 style={{ 274 ...baseStyles.postLink, 275 color: `var(--atproto-color-link)`, 276 }} 277 > 278 View on Bluesky 279 </a> 280 {iconPlacement === "linkInline" && showIcon && ( 281 <span style={baseStyles.inlineIcon} aria-hidden> 282 {makeIcon()} 283 </span> 284 )} 285 </span> 286 )} 287 </div> 288 {resolvedEmbed && ( 289 <div style={baseStyles.embedContainer}>{resolvedEmbed}</div> 290 )} 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 display: "flex", 423 flexDirection: "column", 424 gap: 8, 425 }, 426 timestampRow: { 427 display: "flex", 428 justifyContent: "flex-end", 429 alignItems: "center", 430 gap: 12, 431 marginTop: 12, 432 flexWrap: "wrap", 433 }, 434 linkWithIcon: { 435 display: "inline-flex", 436 alignItems: "center", 437 gap: 6, 438 }, 439 postLink: { 440 fontSize: 11, 441 textDecoration: "none", 442 fontWeight: 600, 443 }, 444 inlineIcon: { 445 display: "inline-flex", 446 alignItems: "center", 447 }, 448 facetTag: { 449 padding: "2px 6px", 450 borderRadius: 4, 451 fontSize: 11, 452 }, 453 replyLine: { 454 fontSize: 12, 455 }, 456 replyLink: { 457 textDecoration: "none", 458 fontWeight: 500, 459 }, 460 iconCorner: { 461 position: "absolute", 462 right: 12, 463 bottom: 12, 464 display: "flex", 465 alignItems: "center", 466 justifyContent: "flex-end", 467 }, 468}; 469 470function formatReplyLabel( 471 target: ParsedAtUri, 472 resolvedHandle?: string, 473 loading?: boolean, 474): string { 475 if (resolvedHandle) return `@${resolvedHandle}`; 476 if (loading) return "…"; 477 return `@${formatDidForLabel(target.did)}`; 478} 479 480function createAutoEmbed( 481 record: FeedPostRecord, 482 authorDid: string | undefined, 483): React.ReactNode { 484 const embed = record.embed as { $type?: string } | undefined; 485 if (!embed) return null; 486 if (embed.$type === "app.bsky.embed.images") { 487 return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} />; 488 } 489 if (embed.$type === "app.bsky.embed.recordWithMedia") { 490 const media = (embed as RecordWithMediaEmbed).media; 491 if (media?.$type === "app.bsky.embed.images") { 492 return ( 493 <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} /> 494 ); 495 } 496 } 497 return null; 498} 499 500type ImagesEmbedType = { 501 $type: "app.bsky.embed.images"; 502 images: Array<{ 503 alt?: string; 504 mime?: string; 505 size?: number; 506 image?: { 507 $type?: string; 508 ref?: { $link?: string }; 509 cid?: string; 510 }; 511 aspectRatio?: { 512 width: number; 513 height: number; 514 }; 515 }>; 516}; 517 518type RecordWithMediaEmbed = { 519 $type: "app.bsky.embed.recordWithMedia"; 520 record?: unknown; 521 media?: { $type?: string }; 522}; 523 524interface ImagesEmbedProps { 525 embed: ImagesEmbedType; 526 did?: string; 527} 528 529const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did }) => { 530 if (!embed.images || embed.images.length === 0) return null; 531 532 const columns = 533 embed.images.length > 1 534 ? "repeat(auto-fit, minmax(160px, 1fr))" 535 : "1fr"; 536 return ( 537 <div 538 style={{ 539 ...imagesBase.container, 540 background: `var(--atproto-color-bg-elevated)`, 541 gridTemplateColumns: columns, 542 }} 543 > 544 {embed.images.map((img, idx) => ( 545 <PostImage key={idx} image={img} did={did} /> 546 ))} 547 </div> 548 ); 549}; 550 551interface PostImageProps { 552 image: ImagesEmbedType["images"][number]; 553 did?: string; 554} 555 556const PostImage: React.FC<PostImageProps> = ({ image, did }) => { 557 const imageBlob = image.image; 558 const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined; 559 const cid = cdnUrl ? undefined : extractCidFromBlob(imageBlob); 560 const { url: urlFromBlob, loading, error } = useBlob(did, cid); 561 const url = cdnUrl || urlFromBlob; 562 const alt = image.alt?.trim() || "Bluesky attachment"; 563 564 const aspect = 565 image.aspectRatio && image.aspectRatio.height > 0 566 ? `${image.aspectRatio.width} / ${image.aspectRatio.height}` 567 : undefined; 568 569 return ( 570 <figure 571 style={{ 572 ...imagesBase.item, 573 background: `var(--atproto-color-bg-elevated)`, 574 }} 575 > 576 <div 577 style={{ 578 ...imagesBase.media, 579 background: `var(--atproto-color-image-bg)`, 580 aspectRatio: aspect, 581 }} 582 > 583 {url ? ( 584 <img src={url} alt={alt} style={imagesBase.img} /> 585 ) : ( 586 <div 587 style={{ 588 ...imagesBase.placeholder, 589 color: `var(--atproto-color-text-muted)`, 590 }} 591 > 592 {loading 593 ? "Loading image…" 594 : error 595 ? "Image failed to load" 596 : "Image unavailable"} 597 </div> 598 )} 599 </div> 600 {image.alt && image.alt.trim().length > 0 && ( 601 <figcaption 602 style={{ 603 ...imagesBase.caption, 604 color: `var(--atproto-color-text-secondary)`, 605 }} 606 > 607 {image.alt} 608 </figcaption> 609 )} 610 </figure> 611 ); 612}; 613 614const imagesBase = { 615 container: { 616 display: "grid", 617 gap: 8, 618 width: "100%", 619 } satisfies React.CSSProperties, 620 item: { 621 margin: 0, 622 display: "flex", 623 flexDirection: "column", 624 gap: 4, 625 } satisfies React.CSSProperties, 626 media: { 627 position: "relative", 628 width: "100%", 629 borderRadius: 12, 630 overflow: "hidden", 631 } satisfies React.CSSProperties, 632 img: { 633 width: "100%", 634 height: "100%", 635 objectFit: "cover", 636 } satisfies React.CSSProperties, 637 placeholder: { 638 display: "flex", 639 alignItems: "center", 640 justifyContent: "center", 641 width: "100%", 642 height: "100%", 643 } satisfies React.CSSProperties, 644 caption: { 645 fontSize: 12, 646 lineHeight: 1.3, 647 } satisfies React.CSSProperties, 648}; 649 650export default BlueskyPostRenderer;