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