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