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