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;