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