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