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;