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