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 }, 172 facets: { 173 marginTop: 8, 174 display: 'flex', 175 gap: 4 176 }, 177 embedContainer: { 178 marginTop: 12, 179 padding: 8, 180 borderRadius: 12, 181 display: 'flex', 182 flexDirection: 'column', 183 gap: 8 184 }, 185 timestampRow: { 186 display: 'flex', 187 justifyContent: 'flex-end', 188 alignItems: 'center', 189 gap: 12, 190 marginTop: 12, 191 flexWrap: 'wrap' 192 }, 193 linkWithIcon: { 194 display: 'inline-flex', 195 alignItems: 'center', 196 gap: 6 197 }, 198 postLink: { 199 fontSize: 11, 200 textDecoration: 'none', 201 fontWeight: 600 202 }, 203 inlineIcon: { 204 display: 'inline-flex', 205 alignItems: 'center' 206 }, 207 facetTag: { 208 padding: '2px 6px', 209 borderRadius: 4, 210 fontSize: 11 211 }, 212 replyLine: { 213 fontSize: 12 214 }, 215 replyLink: { 216 textDecoration: 'none', 217 fontWeight: 500 218 }, 219 iconCorner: { 220 position: 'absolute', 221 right: 12, 222 bottom: 12, 223 display: 'flex', 224 alignItems: 'center', 225 justifyContent: 'flex-end' 226 } 227}; 228 229const themeStyles = { 230 light: { 231 card: { 232 border: '1px solid #e2e8f0', 233 background: '#ffffff', 234 color: '#0f172a' 235 }, 236 avatarPlaceholder: { 237 background: '#cbd5e1' 238 }, 239 handle: { 240 color: '#64748b' 241 }, 242 time: { 243 color: '#94a3b8' 244 }, 245 text: { 246 color: '#0f172a' 247 }, 248 facetTag: { 249 background: '#f1f5f9', 250 color: '#475569' 251 }, 252 replyLine: { 253 color: '#475569' 254 }, 255 replyLink: { 256 color: '#2563eb' 257 }, 258 embedContainer: { 259 border: '1px solid #e2e8f0', 260 borderRadius: 12, 261 background: '#f8fafc' 262 }, 263 postLink: { 264 color: '#2563eb' 265 } 266 }, 267 dark: { 268 card: { 269 border: '1px solid #1e293b', 270 background: '#0f172a', 271 color: '#e2e8f0' 272 }, 273 avatarPlaceholder: { 274 background: '#1e293b' 275 }, 276 handle: { 277 color: '#cbd5f5' 278 }, 279 time: { 280 color: '#94a3ff' 281 }, 282 text: { 283 color: '#e2e8f0' 284 }, 285 facetTag: { 286 background: '#1e293b', 287 color: '#e0f2fe' 288 }, 289 replyLine: { 290 color: '#cbd5f5' 291 }, 292 replyLink: { 293 color: '#38bdf8' 294 }, 295 embedContainer: { 296 border: '1px solid #1e293b', 297 borderRadius: 12, 298 background: '#0b1120' 299 }, 300 postLink: { 301 color: '#38bdf8' 302 } 303 } 304} satisfies Record<'light' | 'dark', Record<string, React.CSSProperties>>; 305 306function formatReplyLabel(target: ParsedAtUri, resolvedHandle?: string, loading?: boolean): string { 307 if (resolvedHandle) return `@${resolvedHandle}`; 308 if (loading) return '…'; 309 return `@${formatDidForLabel(target.did)}`; 310} 311 312function createAutoEmbed(record: FeedPostRecord, authorDid: string | undefined, scheme: 'light' | 'dark'): React.ReactNode { 313 const embed = record.embed as { $type?: string } | undefined; 314 if (!embed) return null; 315 if (embed.$type === 'app.bsky.embed.images') { 316 return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} scheme={scheme} />; 317 } 318 if (embed.$type === 'app.bsky.embed.recordWithMedia') { 319 const media = (embed as RecordWithMediaEmbed).media; 320 if (media?.$type === 'app.bsky.embed.images') { 321 return <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} scheme={scheme} />; 322 } 323 } 324 return null; 325} 326 327type ImagesEmbedType = { 328 $type: 'app.bsky.embed.images'; 329 images: Array<{ 330 alt?: string; 331 mime?: string; 332 size?: number; 333 image?: { 334 $type?: string; 335 ref?: { $link?: string }; 336 cid?: string; 337 }; 338 aspectRatio?: { 339 width: number; 340 height: number; 341 }; 342 }>; 343}; 344 345type RecordWithMediaEmbed = { 346 $type: 'app.bsky.embed.recordWithMedia'; 347 record?: unknown; 348 media?: { $type?: string }; 349}; 350 351interface ImagesEmbedProps { 352 embed: ImagesEmbedType; 353 did?: string; 354 scheme: 'light' | 'dark'; 355} 356 357const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did, scheme }) => { 358 if (!embed.images || embed.images.length === 0) return null; 359 const palette = scheme === 'dark' ? imagesPalette.dark : imagesPalette.light; 360 const columns = embed.images.length > 1 ? 'repeat(auto-fit, minmax(160px, 1fr))' : '1fr'; 361 return ( 362 <div style={{ ...imagesBase.container, ...palette.container, gridTemplateColumns: columns }}> 363 {embed.images.map((image, idx) => ( 364 <PostImage key={idx} image={image} did={did} scheme={scheme} /> 365 ))} 366 </div> 367 ); 368}; 369 370interface PostImageProps { 371 image: ImagesEmbedType['images'][number]; 372 did?: string; 373 scheme: 'light' | 'dark'; 374} 375 376const PostImage: React.FC<PostImageProps> = ({ image, did, scheme }) => { 377 const cid = image.image?.ref?.$link ?? image.image?.cid; 378 const { url, loading, error } = useBlob(did, cid); 379 const alt = image.alt?.trim() || 'Bluesky attachment'; 380 const palette = scheme === 'dark' ? imagesPalette.dark : imagesPalette.light; 381 const aspect = image.aspectRatio && image.aspectRatio.height > 0 382 ? `${image.aspectRatio.width} / ${image.aspectRatio.height}` 383 : undefined; 384 385 return ( 386 <figure style={{ ...imagesBase.item, ...palette.item }}> 387 <div style={{ ...imagesBase.media, ...palette.media, aspectRatio: aspect }}> 388 {url ? ( 389 <img src={url} alt={alt} style={imagesBase.img} /> 390 ) : ( 391 <div style={{ ...imagesBase.placeholder, ...palette.placeholder }}> 392 {loading ? 'Loading image…' : error ? 'Image failed to load' : 'Image unavailable'} 393 </div> 394 )} 395 </div> 396 {image.alt && image.alt.trim().length > 0 && ( 397 <figcaption style={{ ...imagesBase.caption, ...palette.caption }}>{image.alt}</figcaption> 398 )} 399 </figure> 400 ); 401}; 402 403const imagesBase = { 404 container: { 405 display: 'grid', 406 gap: 8, 407 width: '100%' 408 } satisfies React.CSSProperties, 409 item: { 410 margin: 0, 411 display: 'flex', 412 flexDirection: 'column', 413 gap: 4 414 } satisfies React.CSSProperties, 415 media: { 416 position: 'relative', 417 width: '100%', 418 borderRadius: 12, 419 overflow: 'hidden' 420 } satisfies React.CSSProperties, 421 img: { 422 width: '100%', 423 height: '100%', 424 objectFit: 'cover' 425 } satisfies React.CSSProperties, 426 placeholder: { 427 display: 'flex', 428 alignItems: 'center', 429 justifyContent: 'center', 430 width: '100%', 431 height: '100%' 432 } satisfies React.CSSProperties, 433 caption: { 434 fontSize: 12, 435 lineHeight: 1.3 436 } satisfies React.CSSProperties 437}; 438 439const imagesPalette = { 440 light: { 441 container: { 442 padding: 0 443 } satisfies React.CSSProperties, 444 item: {}, 445 media: { 446 background: '#e2e8f0' 447 } satisfies React.CSSProperties, 448 placeholder: { 449 color: '#475569' 450 } satisfies React.CSSProperties, 451 caption: { 452 color: '#475569' 453 } satisfies React.CSSProperties 454 }, 455 dark: { 456 container: { 457 padding: 0 458 } satisfies React.CSSProperties, 459 item: {}, 460 media: { 461 background: '#1e293b' 462 } satisfies React.CSSProperties, 463 placeholder: { 464 color: '#cbd5f5' 465 } satisfies React.CSSProperties, 466 caption: { 467 color: '#94a3b8' 468 } satisfies React.CSSProperties 469 } 470} as const; 471 472export default BlueskyPostRenderer;