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