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