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