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