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