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 }}>{primaryName}</strong>
170 {authorDisplayName && 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 <div style={baseStyles.timestampRow}>
258 <time
259 style={{
260 ...baseStyles.time,
261 color: `var(--atproto-color-text-muted)`,
262 }}
263 dateTime={record.createdAt}
264 >
265 {created}
266 </time>
267 {postUrl && (
268 <span style={baseStyles.linkWithIcon}>
269 <a
270 href={postUrl}
271 target="_blank"
272 rel="noopener noreferrer"
273 style={{
274 ...baseStyles.postLink,
275 color: `var(--atproto-color-link)`,
276 }}
277 >
278 View on Bluesky
279 </a>
280 {iconPlacement === "linkInline" && showIcon && (
281 <span style={baseStyles.inlineIcon} aria-hidden>
282 {makeIcon()}
283 </span>
284 )}
285 </span>
286 )}
287 </div>
288 {resolvedEmbed && (
289 <div style={baseStyles.embedContainer}>{resolvedEmbed}</div>
290 )}
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 display: "flex",
423 flexDirection: "column",
424 gap: 8,
425 },
426 timestampRow: {
427 display: "flex",
428 justifyContent: "flex-end",
429 alignItems: "center",
430 gap: 12,
431 marginTop: 12,
432 flexWrap: "wrap",
433 },
434 linkWithIcon: {
435 display: "inline-flex",
436 alignItems: "center",
437 gap: 6,
438 },
439 postLink: {
440 fontSize: 11,
441 textDecoration: "none",
442 fontWeight: 600,
443 },
444 inlineIcon: {
445 display: "inline-flex",
446 alignItems: "center",
447 },
448 facetTag: {
449 padding: "2px 6px",
450 borderRadius: 4,
451 fontSize: 11,
452 },
453 replyLine: {
454 fontSize: 12,
455 },
456 replyLink: {
457 textDecoration: "none",
458 fontWeight: 500,
459 },
460 iconCorner: {
461 position: "absolute",
462 right: 12,
463 bottom: 12,
464 display: "flex",
465 alignItems: "center",
466 justifyContent: "flex-end",
467 },
468};
469
470function formatReplyLabel(
471 target: ParsedAtUri,
472 resolvedHandle?: string,
473 loading?: boolean,
474): string {
475 if (resolvedHandle) return `@${resolvedHandle}`;
476 if (loading) return "…";
477 return `@${formatDidForLabel(target.did)}`;
478}
479
480function createAutoEmbed(
481 record: FeedPostRecord,
482 authorDid: string | undefined,
483): React.ReactNode {
484 const embed = record.embed as { $type?: string } | undefined;
485 if (!embed) return null;
486 if (embed.$type === "app.bsky.embed.images") {
487 return <ImagesEmbed embed={embed as ImagesEmbedType} did={authorDid} />;
488 }
489 if (embed.$type === "app.bsky.embed.recordWithMedia") {
490 const media = (embed as RecordWithMediaEmbed).media;
491 if (media?.$type === "app.bsky.embed.images") {
492 return (
493 <ImagesEmbed embed={media as ImagesEmbedType} did={authorDid} />
494 );
495 }
496 }
497 return null;
498}
499
500type ImagesEmbedType = {
501 $type: "app.bsky.embed.images";
502 images: Array<{
503 alt?: string;
504 mime?: string;
505 size?: number;
506 image?: {
507 $type?: string;
508 ref?: { $link?: string };
509 cid?: string;
510 };
511 aspectRatio?: {
512 width: number;
513 height: number;
514 };
515 }>;
516};
517
518type RecordWithMediaEmbed = {
519 $type: "app.bsky.embed.recordWithMedia";
520 record?: unknown;
521 media?: { $type?: string };
522};
523
524interface ImagesEmbedProps {
525 embed: ImagesEmbedType;
526 did?: string;
527}
528
529const ImagesEmbed: React.FC<ImagesEmbedProps> = ({ embed, did }) => {
530 if (!embed.images || embed.images.length === 0) return null;
531
532 const columns =
533 embed.images.length > 1
534 ? "repeat(auto-fit, minmax(160px, 1fr))"
535 : "1fr";
536 return (
537 <div
538 style={{
539 ...imagesBase.container,
540 background: `var(--atproto-color-bg-elevated)`,
541 gridTemplateColumns: columns,
542 }}
543 >
544 {embed.images.map((img, idx) => (
545 <PostImage key={idx} image={img} did={did} />
546 ))}
547 </div>
548 );
549};
550
551interface PostImageProps {
552 image: ImagesEmbedType["images"][number];
553 did?: string;
554}
555
556const PostImage: React.FC<PostImageProps> = ({ image, did }) => {
557 const imageBlob = image.image;
558 const cdnUrl = isBlobWithCdn(imageBlob) ? imageBlob.cdnUrl : undefined;
559 const cid = cdnUrl ? undefined : extractCidFromBlob(imageBlob);
560 const { url: urlFromBlob, loading, error } = useBlob(did, cid);
561 const url = cdnUrl || urlFromBlob;
562 const alt = image.alt?.trim() || "Bluesky attachment";
563
564 const aspect =
565 image.aspectRatio && image.aspectRatio.height > 0
566 ? `${image.aspectRatio.width} / ${image.aspectRatio.height}`
567 : undefined;
568
569 return (
570 <figure
571 style={{
572 ...imagesBase.item,
573 background: `var(--atproto-color-bg-elevated)`,
574 }}
575 >
576 <div
577 style={{
578 ...imagesBase.media,
579 background: `var(--atproto-color-image-bg)`,
580 aspectRatio: aspect,
581 }}
582 >
583 {url ? (
584 <img src={url} alt={alt} style={imagesBase.img} />
585 ) : (
586 <div
587 style={{
588 ...imagesBase.placeholder,
589 color: `var(--atproto-color-text-muted)`,
590 }}
591 >
592 {loading
593 ? "Loading image…"
594 : error
595 ? "Image failed to load"
596 : "Image unavailable"}
597 </div>
598 )}
599 </div>
600 {image.alt && image.alt.trim().length > 0 && (
601 <figcaption
602 style={{
603 ...imagesBase.caption,
604 color: `var(--atproto-color-text-secondary)`,
605 }}
606 >
607 {image.alt}
608 </figcaption>
609 )}
610 </figure>
611 );
612};
613
614const imagesBase = {
615 container: {
616 display: "grid",
617 gap: 8,
618 width: "100%",
619 } satisfies React.CSSProperties,
620 item: {
621 margin: 0,
622 display: "flex",
623 flexDirection: "column",
624 gap: 4,
625 } satisfies React.CSSProperties,
626 media: {
627 position: "relative",
628 width: "100%",
629 borderRadius: 12,
630 overflow: "hidden",
631 } satisfies React.CSSProperties,
632 img: {
633 width: "100%",
634 height: "100%",
635 objectFit: "cover",
636 } satisfies React.CSSProperties,
637 placeholder: {
638 display: "flex",
639 alignItems: "center",
640 justifyContent: "center",
641 width: "100%",
642 height: "100%",
643 } satisfies React.CSSProperties,
644 caption: {
645 fontSize: 12,
646 lineHeight: 1.3,
647 } satisfies React.CSSProperties,
648};
649
650export default BlueskyPostRenderer;