A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo } from "react";
2import {
3 usePaginatedRecords,
4 type AuthorFeedReason,
5 type ReplyParentInfo,
6} from "../hooks/usePaginatedRecords";
7import type { FeedPostRecord, ProfileRecord } from "../types/bluesky";
8import { useDidResolution } from "../hooks/useDidResolution";
9import { BlueskyIcon } from "./BlueskyIcon";
10import { parseAtUri } from "../utils/at-uri";
11import { useAtProto } from "../providers/AtProtoProvider";
12import { useAtProtoRecord } from "../hooks/useAtProtoRecord";
13import { useBlob } from "../hooks/useBlob";
14import { getAvatarCid } from "../utils/profile";
15import { isBlobWithCdn } from "../utils/blob";
16import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile";
17import { RichText as BlueskyRichText } from "./RichText";
18
19/**
20 * Options for rendering a paginated list of Bluesky posts.
21 */
22export interface BlueskyPostListProps {
23 /**
24 * DID whose feed posts should be fetched.
25 */
26 did: string;
27 /**
28 * Maximum number of records to list per page. Defaults to `5`.
29 */
30 limit?: number;
31 /**
32 * Enables pagination controls when `true`. Defaults to `true`.
33 */
34 enablePagination?: boolean;
35}
36
37/**
38 * Fetches a DID's feed posts and renders them with rich pagination and theming.
39 *
40 * @param did - DID whose posts should be displayed.
41 * @param limit - Maximum number of posts per page. Default `5`.
42 * @param enablePagination - Whether pagination controls should render. Default `true`.
43 * @returns A card-like list element with loading, empty, and error handling.
44 */
45export const BlueskyPostList: React.FC<BlueskyPostListProps> = React.memo(({
46 did,
47 limit = 5,
48 enablePagination = true,
49}) => {
50 const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
51 const actorLabel = resolvedHandle ?? formatDid(did);
52 const actorPath = resolvedHandle ?? resolvedDid ?? did;
53
54 const {
55 records,
56 loading,
57 error,
58 hasNext,
59 hasPrev,
60 loadNext,
61 loadPrev,
62 pageIndex,
63 pagesCount,
64 } = usePaginatedRecords<FeedPostRecord>({
65 did,
66 collection: "app.bsky.feed.post",
67 limit,
68 preferAuthorFeed: true,
69 authorFeedActor: actorPath,
70 });
71
72 const pageLabel = useMemo(() => {
73 const knownTotal = Math.max(pageIndex + 1, pagesCount);
74 if (!enablePagination) return undefined;
75 if (hasNext && knownTotal === pageIndex + 1)
76 return `${pageIndex + 1}/…`;
77 return `${pageIndex + 1}/${knownTotal}`;
78 }, [enablePagination, hasNext, pageIndex, pagesCount]);
79
80 if (error)
81 return (
82 <div role="alert" style={{ padding: 8, color: "crimson" }}>
83 Failed to load posts.
84 </div>
85 );
86
87 return (
88 <div style={{ ...listStyles.card, background: `var(--atproto-color-bg)`, borderWidth: "1px", borderStyle: "solid", borderColor: `var(--atproto-color-border)` }}>
89 <div style={{ ...listStyles.header, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text)` }}>
90 <div style={listStyles.headerInfo}>
91 <div style={listStyles.headerIcon}>
92 <BlueskyIcon size={20} />
93 </div>
94 <div style={listStyles.headerText}>
95 <span style={listStyles.title}>Latest Posts</span>
96 <span
97 style={{
98 ...listStyles.subtitle,
99 color: `var(--atproto-color-text-secondary)`,
100 }}
101 >
102 @{actorLabel}
103 </span>
104 </div>
105 </div>
106 {pageLabel && (
107 <span
108 style={{ ...listStyles.pageMeta, color: `var(--atproto-color-text-secondary)` }}
109 >
110 {pageLabel}
111 </span>
112 )}
113 </div>
114 <div style={listStyles.items}>
115 {loading && records.length === 0 && (
116 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
117 Loading posts…
118 </div>
119 )}
120 {records.map((record, idx) => (
121 <ListRow
122 key={record.rkey}
123 record={record.value}
124 rkey={record.rkey}
125 did={actorPath}
126 uri={record.uri}
127 reason={record.reason}
128 replyParent={record.replyParent}
129 hasDivider={idx < records.length - 1}
130 />
131 ))}
132 {!loading && records.length === 0 && (
133 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
134 No posts found.
135 </div>
136 )}
137 </div>
138 {enablePagination && (
139 <div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
140 <button
141 type="button"
142 style={{
143 ...listStyles.pageButton,
144 background: `var(--atproto-color-button-bg)`,
145 color: `var(--atproto-color-button-text)`,
146 cursor: hasPrev ? "pointer" : "not-allowed",
147 opacity: hasPrev ? 1 : 0.5,
148 }}
149 onClick={loadPrev}
150 disabled={!hasPrev}
151 >
152 ‹ Prev
153 </button>
154 <div style={listStyles.pageChips}>
155 <span
156 style={{
157 ...listStyles.pageChipActive,
158 color: `var(--atproto-color-button-text)`,
159 background: `var(--atproto-color-button-bg)`,
160 borderWidth: "1px",
161 borderStyle: "solid",
162 borderColor: `var(--atproto-color-button-bg)`,
163 }}
164 >
165 {pageIndex + 1}
166 </span>
167 {(hasNext || pagesCount > pageIndex + 1) && (
168 <span
169 style={{
170 ...listStyles.pageChip,
171 color: `var(--atproto-color-text-secondary)`,
172 borderWidth: "1px",
173 borderStyle: "solid",
174 borderColor: `var(--atproto-color-border)`,
175 background: `var(--atproto-color-bg)`,
176 }}
177 >
178 {pageIndex + 2}
179 </span>
180 )}
181 </div>
182 <button
183 type="button"
184 style={{
185 ...listStyles.pageButton,
186 background: `var(--atproto-color-button-bg)`,
187 color: `var(--atproto-color-button-text)`,
188 cursor: hasNext ? "pointer" : "not-allowed",
189 opacity: hasNext ? 1 : 0.5,
190 }}
191 onClick={loadNext}
192 disabled={!hasNext}
193 >
194 Next ›
195 </button>
196 </div>
197 )}
198 {loading && records.length > 0 && (
199 <div
200 style={{ ...listStyles.loadingBar, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }}
201 >
202 Updating…
203 </div>
204 )}
205 </div>
206 );
207});
208
209interface ListRowProps {
210 record: FeedPostRecord;
211 rkey: string;
212 did: string;
213 uri?: string;
214 reason?: AuthorFeedReason;
215 replyParent?: ReplyParentInfo;
216 hasDivider: boolean;
217}
218
219const ListRow: React.FC<ListRowProps> = ({
220 record,
221 rkey,
222 did,
223 uri,
224 reason,
225 replyParent,
226 hasDivider,
227}) => {
228 const { blueskyAppBaseUrl } = useAtProto();
229 const text = record.text?.trim() ?? "";
230 const relative = record.createdAt
231 ? formatRelativeTime(record.createdAt)
232 : undefined;
233 const absolute = record.createdAt
234 ? new Date(record.createdAt).toLocaleString()
235 : undefined;
236
237 // Parse the URI to get the actual post's DID and rkey
238 const parsedUri = uri ? parseAtUri(uri) : undefined;
239 const postDid = parsedUri?.did ?? did;
240 const postRkey = parsedUri?.rkey ?? rkey;
241 const href = `${blueskyAppBaseUrl}/profile/${postDid}/post/${postRkey}`;
242
243 // Author profile and avatar
244 const { handle: authorHandle } = useDidResolution(postDid);
245 const { record: authorProfile } = useAtProtoRecord<ProfileRecord>({
246 did: postDid,
247 collection: BLUESKY_PROFILE_COLLECTION,
248 rkey: "self",
249 });
250 const authorDisplayName = authorProfile?.displayName;
251 const authorAvatar = authorProfile?.avatar;
252 const authorAvatarCdnUrl = isBlobWithCdn(authorAvatar) ? authorAvatar.cdnUrl : undefined;
253 const authorAvatarCid = authorAvatarCdnUrl ? undefined : getAvatarCid(authorProfile);
254 const { url: authorAvatarUrl } = useBlob(
255 postDid,
256 authorAvatarCid,
257 );
258 const finalAuthorAvatarUrl = authorAvatarCdnUrl ?? authorAvatarUrl;
259
260 // Repost metadata
261 const isRepost = reason?.$type === "app.bsky.feed.defs#reasonRepost";
262 const reposterDid = reason?.by?.did;
263 const { handle: reposterHandle } = useDidResolution(reposterDid);
264 const { record: reposterProfile } = useAtProtoRecord<ProfileRecord>({
265 did: reposterDid,
266 collection: BLUESKY_PROFILE_COLLECTION,
267 rkey: "self",
268 });
269 const reposterDisplayName = reposterProfile?.displayName;
270 const reposterAvatar = reposterProfile?.avatar;
271 const reposterAvatarCdnUrl = isBlobWithCdn(reposterAvatar) ? reposterAvatar.cdnUrl : undefined;
272 const reposterAvatarCid = reposterAvatarCdnUrl ? undefined : getAvatarCid(reposterProfile);
273 const { url: reposterAvatarUrl } = useBlob(
274 reposterDid,
275 reposterAvatarCid,
276 );
277 const finalReposterAvatarUrl = reposterAvatarCdnUrl ?? reposterAvatarUrl;
278
279 // Reply metadata
280 const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
281 const parentDid = replyParent?.author?.did ?? (parentUri ? parseAtUri(parentUri)?.did : undefined);
282 const { handle: parentHandle } = useDidResolution(
283 replyParent?.author?.handle ? undefined : parentDid,
284 );
285 const { record: parentProfile } = useAtProtoRecord<ProfileRecord>({
286 did: parentDid,
287 collection: BLUESKY_PROFILE_COLLECTION,
288 rkey: "self",
289 });
290 const parentDisplayName = parentProfile?.displayName;
291 const parentAvatar = parentProfile?.avatar;
292 const parentAvatarCdnUrl = isBlobWithCdn(parentAvatar) ? parentAvatar.cdnUrl : undefined;
293 const parentAvatarCid = parentAvatarCdnUrl ? undefined : getAvatarCid(parentProfile);
294 const { url: parentAvatarUrl } = useBlob(
295 parentDid,
296 parentAvatarCid,
297 );
298 const finalParentAvatarUrl = parentAvatarCdnUrl ?? parentAvatarUrl;
299
300 const isReply = !!parentUri;
301 const replyTargetHandle = replyParent?.author?.handle ?? parentHandle;
302
303 const postPreview = text.slice(0, 100);
304 const ariaLabel = text
305 ? `Post by ${authorDisplayName ?? authorHandle ?? did}: ${postPreview}${text.length > 100 ? "..." : ""}`
306 : `Post by ${authorDisplayName ?? authorHandle ?? did}`;
307
308 return (
309 <div
310 style={{
311 ...listStyles.rowContainer,
312 borderBottom: hasDivider ? `1px solid var(--atproto-color-border)` : "none",
313 }}
314 >
315 {isRepost && (
316 <div style={listStyles.repostIndicator}>
317 {finalReposterAvatarUrl && (
318 <img
319 src={finalReposterAvatarUrl}
320 alt=""
321 style={listStyles.repostAvatar}
322 />
323 )}
324 <svg
325 width="16"
326 height="16"
327 viewBox="0 0 16 16"
328 fill="none"
329 style={{ flexShrink: 0 }}
330 >
331 <path
332 d="M5.5 3.5L3 6L5.5 8.5M3 6H10C11.1046 6 12 6.89543 12 8V8.5M10.5 12.5L13 10L10.5 7.5M13 10H6C4.89543 10 4 9.10457 4 8V7.5"
333 stroke="var(--atproto-color-text-secondary)"
334 strokeWidth="1.5"
335 strokeLinecap="round"
336 strokeLinejoin="round"
337 />
338 </svg>
339 <span style={{ ...listStyles.repostText, color: "var(--atproto-color-text-secondary)" }}>
340 {reposterDisplayName ?? reposterHandle ?? "Someone"} reposted
341 </span>
342 </div>
343 )}
344
345 {isReply && (
346 <div style={listStyles.replyIndicator}>
347 <svg
348 width="14"
349 height="14"
350 viewBox="0 0 14 14"
351 fill="none"
352 style={{ flexShrink: 0 }}
353 >
354 <path
355 d="M11 7H3M3 7L7 3M3 7L7 11"
356 stroke="#1185FE"
357 strokeWidth="1.5"
358 strokeLinecap="round"
359 strokeLinejoin="round"
360 />
361 </svg>
362 <span style={{ ...listStyles.replyText, color: "var(--atproto-color-text-secondary)" }}>
363 Replying to
364 </span>
365 {finalParentAvatarUrl && (
366 <img
367 src={finalParentAvatarUrl}
368 alt=""
369 style={listStyles.replyAvatar}
370 />
371 )}
372 <span style={{ color: "#1185FE", fontWeight: 600 }}>
373 @{replyTargetHandle ?? formatDid(parentDid ?? "")}
374 </span>
375 </div>
376 )}
377
378 <div style={listStyles.postContent}>
379 <div style={listStyles.avatarContainer}>
380 {finalAuthorAvatarUrl ? (
381 <img
382 src={finalAuthorAvatarUrl}
383 alt={authorDisplayName ?? authorHandle ?? "User avatar"}
384 style={listStyles.avatar}
385 />
386 ) : (
387 <div style={listStyles.avatarPlaceholder}>
388 {(authorDisplayName ?? authorHandle ?? "?")[0].toUpperCase()}
389 </div>
390 )}
391 </div>
392
393 <div style={listStyles.postMain}>
394 <div style={listStyles.postHeader}>
395 <a
396 href={`${blueskyAppBaseUrl}/profile/${postDid}`}
397 target="_blank"
398 rel="noopener noreferrer"
399 style={{ ...listStyles.authorName, color: "var(--atproto-color-text)" }}
400 onClick={(e) => e.stopPropagation()}
401 >
402 {authorDisplayName ?? authorHandle ?? formatDid(postDid)}
403 </a>
404 <span style={{ ...listStyles.authorHandle, color: "var(--atproto-color-text-secondary)" }}>
405 @{authorHandle ?? formatDid(postDid)}
406 </span>
407 <span style={{ ...listStyles.separator, color: "var(--atproto-color-text-secondary)" }}>·</span>
408 <span
409 style={{ ...listStyles.timestamp, color: "var(--atproto-color-text-secondary)" }}
410 title={absolute}
411 >
412 {relative}
413 </span>
414 </div>
415
416 <a
417 href={href}
418 target="_blank"
419 rel="noopener noreferrer"
420 aria-label={ariaLabel}
421 style={{ ...listStyles.postLink, color: "var(--atproto-color-text)" }}
422 >
423 {text && (
424 <p style={listStyles.postText}>
425 <BlueskyRichText text={text} facets={record.facets} />
426 </p>
427 )}
428 {!text && (
429 <p style={{ ...listStyles.postText, fontStyle: "italic", color: "var(--atproto-color-text-secondary)" }}>
430 No text content
431 </p>
432 )}
433 </a>
434 </div>
435 </div>
436 </div>
437 );
438};
439
440function formatDid(did: string) {
441 return did.replace(/^did:(plc:)?/, "");
442}
443
444function formatRelativeTime(iso: string): string {
445 const date = new Date(iso);
446 const diffSeconds = (date.getTime() - Date.now()) / 1000;
447 const absSeconds = Math.abs(diffSeconds);
448 const thresholds: Array<{
449 limit: number;
450 unit: Intl.RelativeTimeFormatUnit;
451 divisor: number;
452 }> = [
453 { limit: 60, unit: "second", divisor: 1 },
454 { limit: 3600, unit: "minute", divisor: 60 },
455 { limit: 86400, unit: "hour", divisor: 3600 },
456 { limit: 604800, unit: "day", divisor: 86400 },
457 { limit: 2629800, unit: "week", divisor: 604800 },
458 { limit: 31557600, unit: "month", divisor: 2629800 },
459 { limit: Infinity, unit: "year", divisor: 31557600 },
460 ];
461 const threshold =
462 thresholds.find((t) => absSeconds < t.limit) ??
463 thresholds[thresholds.length - 1];
464 const value = diffSeconds / threshold.divisor;
465 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
466 return rtf.format(Math.round(value), threshold.unit);
467}
468
469
470const listStyles = {
471 card: {
472 borderRadius: 16,
473 borderWidth: "1px",
474 borderStyle: "solid",
475 borderColor: "transparent",
476 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
477 overflow: "hidden",
478 display: "flex",
479 flexDirection: "column",
480 } satisfies React.CSSProperties,
481 header: {
482 display: "flex",
483 alignItems: "center",
484 justifyContent: "space-between",
485 padding: "14px 18px",
486 fontSize: 14,
487 fontWeight: 500,
488 borderBottom: "1px solid transparent",
489 } satisfies React.CSSProperties,
490 headerInfo: {
491 display: "flex",
492 alignItems: "center",
493 gap: 12,
494 } satisfies React.CSSProperties,
495 headerIcon: {
496 width: 28,
497 height: 28,
498 display: "flex",
499 alignItems: "center",
500 justifyContent: "center",
501 borderRadius: "50%",
502 } satisfies React.CSSProperties,
503 headerText: {
504 display: "flex",
505 flexDirection: "column",
506 gap: 2,
507 } satisfies React.CSSProperties,
508 title: {
509 fontSize: 15,
510 fontWeight: 600,
511 } satisfies React.CSSProperties,
512 subtitle: {
513 fontSize: 12,
514 fontWeight: 500,
515 } satisfies React.CSSProperties,
516 pageMeta: {
517 fontSize: 12,
518 } satisfies React.CSSProperties,
519 items: {
520 display: "flex",
521 flexDirection: "column",
522 } satisfies React.CSSProperties,
523 empty: {
524 padding: "24px 18px",
525 fontSize: 13,
526 textAlign: "center",
527 } satisfies React.CSSProperties,
528 rowContainer: {
529 padding: "16px",
530 display: "flex",
531 flexDirection: "column",
532 gap: 8,
533 transition: "background-color 120ms ease",
534 position: "relative",
535 } satisfies React.CSSProperties,
536 repostIndicator: {
537 display: "flex",
538 alignItems: "center",
539 gap: 8,
540 fontSize: 13,
541 fontWeight: 500,
542 paddingLeft: 8,
543 marginBottom: 4,
544 } satisfies React.CSSProperties,
545 repostAvatar: {
546 width: 16,
547 height: 16,
548 borderRadius: "50%",
549 objectFit: "cover",
550 } satisfies React.CSSProperties,
551 repostText: {
552 fontSize: 13,
553 fontWeight: 500,
554 } satisfies React.CSSProperties,
555 replyIndicator: {
556 display: "flex",
557 alignItems: "center",
558 gap: 8,
559 fontSize: 13,
560 fontWeight: 500,
561 paddingLeft: 8,
562 marginBottom: 4,
563 } satisfies React.CSSProperties,
564 replyAvatar: {
565 width: 16,
566 height: 16,
567 borderRadius: "50%",
568 objectFit: "cover",
569 } satisfies React.CSSProperties,
570 replyText: {
571 fontSize: 13,
572 fontWeight: 500,
573 } satisfies React.CSSProperties,
574 postContent: {
575 display: "flex",
576 gap: 12,
577 } satisfies React.CSSProperties,
578 avatarContainer: {
579 flexShrink: 0,
580 } satisfies React.CSSProperties,
581 avatar: {
582 width: 48,
583 height: 48,
584 borderRadius: "50%",
585 objectFit: "cover",
586 } satisfies React.CSSProperties,
587 avatarPlaceholder: {
588 width: 48,
589 height: 48,
590 borderRadius: "50%",
591 background: "var(--atproto-color-bg-elevated)",
592 color: "var(--atproto-color-text-secondary)",
593 display: "flex",
594 alignItems: "center",
595 justifyContent: "center",
596 fontSize: 18,
597 fontWeight: 600,
598 } satisfies React.CSSProperties,
599 postMain: {
600 flex: 1,
601 minWidth: 0,
602 display: "flex",
603 flexDirection: "column",
604 gap: 6,
605 } satisfies React.CSSProperties,
606 postHeader: {
607 display: "flex",
608 alignItems: "baseline",
609 gap: 6,
610 flexWrap: "wrap",
611 } satisfies React.CSSProperties,
612 authorName: {
613 fontWeight: 700,
614 fontSize: 15,
615 textDecoration: "none",
616 maxWidth: "200px",
617 overflow: "hidden",
618 textOverflow: "ellipsis",
619 whiteSpace: "nowrap",
620 } satisfies React.CSSProperties,
621 authorHandle: {
622 fontSize: 15,
623 fontWeight: 400,
624 maxWidth: "150px",
625 overflow: "hidden",
626 textOverflow: "ellipsis",
627 whiteSpace: "nowrap",
628 } satisfies React.CSSProperties,
629 separator: {
630 fontSize: 15,
631 fontWeight: 400,
632 } satisfies React.CSSProperties,
633 timestamp: {
634 fontSize: 15,
635 fontWeight: 400,
636 } satisfies React.CSSProperties,
637 postLink: {
638 textDecoration: "none",
639 display: "block",
640 } satisfies React.CSSProperties,
641 postText: {
642 margin: 0,
643 whiteSpace: "pre-wrap",
644 fontSize: 15,
645 lineHeight: 1.5,
646 wordBreak: "break-word",
647 } satisfies React.CSSProperties,
648 footer: {
649 display: "flex",
650 alignItems: "center",
651 justifyContent: "space-between",
652 padding: "12px 18px",
653 borderTop: "1px solid transparent",
654 fontSize: 13,
655 } satisfies React.CSSProperties,
656 pageChips: {
657 display: "flex",
658 gap: 6,
659 alignItems: "center",
660 } satisfies React.CSSProperties,
661 pageChip: {
662 padding: "4px 10px",
663 borderRadius: 999,
664 fontSize: 13,
665 borderWidth: "1px",
666 borderStyle: "solid",
667 borderColor: "transparent",
668 } satisfies React.CSSProperties,
669 pageChipActive: {
670 padding: "4px 10px",
671 borderRadius: 999,
672 fontSize: 13,
673 fontWeight: 600,
674 borderWidth: "1px",
675 borderStyle: "solid",
676 borderColor: "transparent",
677 } satisfies React.CSSProperties,
678 pageButton: {
679 border: "none",
680 borderRadius: 999,
681 padding: "6px 12px",
682 fontSize: 13,
683 fontWeight: 500,
684 background: "transparent",
685 display: "flex",
686 alignItems: "center",
687 gap: 4,
688 transition: "background-color 120ms ease",
689 } satisfies React.CSSProperties,
690 loadingBar: {
691 padding: "4px 18px 14px",
692 fontSize: 12,
693 textAlign: "right",
694 color: "#64748b",
695 } satisfies React.CSSProperties,
696};
697
698export default BlueskyPostList;