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 parentAvatar = parentProfile?.avatar;
291 const parentAvatarCdnUrl = isBlobWithCdn(parentAvatar) ? parentAvatar.cdnUrl : undefined;
292 const parentAvatarCid = parentAvatarCdnUrl ? undefined : getAvatarCid(parentProfile);
293 const { url: parentAvatarUrl } = useBlob(
294 parentDid,
295 parentAvatarCid,
296 );
297 const finalParentAvatarUrl = parentAvatarCdnUrl ?? parentAvatarUrl;
298
299 const isReply = !!parentUri;
300 const replyTargetHandle = replyParent?.author?.handle ?? parentHandle;
301
302 const postPreview = text.slice(0, 100);
303 const ariaLabel = text
304 ? `Post by ${authorDisplayName ?? authorHandle ?? did}: ${postPreview}${text.length > 100 ? "..." : ""}`
305 : `Post by ${authorDisplayName ?? authorHandle ?? did}`;
306
307 return (
308 <div
309 style={{
310 ...listStyles.rowContainer,
311 borderBottom: hasDivider ? `1px solid var(--atproto-color-border)` : "none",
312 }}
313 >
314 {isRepost && (
315 <div style={listStyles.repostIndicator}>
316 {finalReposterAvatarUrl && (
317 <img
318 src={finalReposterAvatarUrl}
319 alt=""
320 style={listStyles.repostAvatar}
321 />
322 )}
323 <svg
324 width="16"
325 height="16"
326 viewBox="0 0 16 16"
327 fill="none"
328 style={{ flexShrink: 0 }}
329 >
330 <path
331 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"
332 stroke="var(--atproto-color-text-secondary)"
333 strokeWidth="1.5"
334 strokeLinecap="round"
335 strokeLinejoin="round"
336 />
337 </svg>
338 <span style={{ ...listStyles.repostText, color: "var(--atproto-color-text-secondary)" }}>
339 {reposterDisplayName ?? reposterHandle ?? "Someone"} reposted
340 </span>
341 </div>
342 )}
343
344 {isReply && (
345 <div style={listStyles.replyIndicator}>
346 <svg
347 width="14"
348 height="14"
349 viewBox="0 0 14 14"
350 fill="none"
351 style={{ flexShrink: 0 }}
352 >
353 <path
354 d="M11 7H3M3 7L7 3M3 7L7 11"
355 stroke="#1185FE"
356 strokeWidth="1.5"
357 strokeLinecap="round"
358 strokeLinejoin="round"
359 />
360 </svg>
361 <span style={{ ...listStyles.replyText, color: "var(--atproto-color-text-secondary)" }}>
362 Replying to
363 </span>
364 {finalParentAvatarUrl && (
365 <img
366 src={finalParentAvatarUrl}
367 alt=""
368 style={listStyles.replyAvatar}
369 />
370 )}
371 <span style={{ color: "#1185FE", fontWeight: 600 }}>
372 @{replyTargetHandle ?? formatDid(parentDid ?? "")}
373 </span>
374 </div>
375 )}
376
377 <div style={listStyles.postContent}>
378 <div style={listStyles.avatarContainer}>
379 {finalAuthorAvatarUrl ? (
380 <img
381 src={finalAuthorAvatarUrl}
382 alt={authorDisplayName ?? authorHandle ?? "User avatar"}
383 style={listStyles.avatar}
384 />
385 ) : (
386 <div style={listStyles.avatarPlaceholder}>
387 {(authorDisplayName ?? authorHandle ?? "?")[0].toUpperCase()}
388 </div>
389 )}
390 </div>
391
392 <div style={listStyles.postMain}>
393 <div style={listStyles.postHeader}>
394 <a
395 href={`${blueskyAppBaseUrl}/profile/${postDid}`}
396 target="_blank"
397 rel="noopener noreferrer"
398 style={{ ...listStyles.authorName, color: "var(--atproto-color-text)" }}
399 onClick={(e) => e.stopPropagation()}
400 >
401 {authorDisplayName ?? authorHandle ?? formatDid(postDid)}
402 </a>
403 <span style={{ ...listStyles.authorHandle, color: "var(--atproto-color-text-secondary)" }}>
404 @{authorHandle ?? formatDid(postDid)}
405 </span>
406 <span style={{ ...listStyles.separator, color: "var(--atproto-color-text-secondary)" }}>·</span>
407 <span
408 style={{ ...listStyles.timestamp, color: "var(--atproto-color-text-secondary)" }}
409 title={absolute}
410 >
411 {relative}
412 </span>
413 </div>
414
415 <a
416 href={href}
417 target="_blank"
418 rel="noopener noreferrer"
419 aria-label={ariaLabel}
420 style={{ ...listStyles.postLink, color: "var(--atproto-color-text)" }}
421 >
422 {text && (
423 <p style={listStyles.postText}>
424 <BlueskyRichText text={text} facets={record.facets} />
425 </p>
426 )}
427 {!text && (
428 <p style={{ ...listStyles.postText, fontStyle: "italic", color: "var(--atproto-color-text-secondary)" }}>
429 No text content
430 </p>
431 )}
432 </a>
433 </div>
434 </div>
435 </div>
436 );
437};
438
439function formatDid(did: string) {
440 return did.replace(/^did:(plc:)?/, "");
441}
442
443function formatRelativeTime(iso: string): string {
444 const date = new Date(iso);
445 const diffSeconds = (date.getTime() - Date.now()) / 1000;
446 const absSeconds = Math.abs(diffSeconds);
447 const thresholds: Array<{
448 limit: number;
449 unit: Intl.RelativeTimeFormatUnit;
450 divisor: number;
451 }> = [
452 { limit: 60, unit: "second", divisor: 1 },
453 { limit: 3600, unit: "minute", divisor: 60 },
454 { limit: 86400, unit: "hour", divisor: 3600 },
455 { limit: 604800, unit: "day", divisor: 86400 },
456 { limit: 2629800, unit: "week", divisor: 604800 },
457 { limit: 31557600, unit: "month", divisor: 2629800 },
458 { limit: Infinity, unit: "year", divisor: 31557600 },
459 ];
460 const threshold =
461 thresholds.find((t) => absSeconds < t.limit) ??
462 thresholds[thresholds.length - 1];
463 const value = diffSeconds / threshold.divisor;
464 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
465 return rtf.format(Math.round(value), threshold.unit);
466}
467
468
469const listStyles = {
470 card: {
471 borderRadius: 16,
472 borderWidth: "1px",
473 borderStyle: "solid",
474 borderColor: "transparent",
475 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
476 overflow: "hidden",
477 display: "flex",
478 flexDirection: "column",
479 } satisfies React.CSSProperties,
480 header: {
481 display: "flex",
482 alignItems: "center",
483 justifyContent: "space-between",
484 padding: "14px 18px",
485 fontSize: 14,
486 fontWeight: 500,
487 borderBottom: "1px solid transparent",
488 } satisfies React.CSSProperties,
489 headerInfo: {
490 display: "flex",
491 alignItems: "center",
492 gap: 12,
493 } satisfies React.CSSProperties,
494 headerIcon: {
495 width: 28,
496 height: 28,
497 display: "flex",
498 alignItems: "center",
499 justifyContent: "center",
500 borderRadius: "50%",
501 } satisfies React.CSSProperties,
502 headerText: {
503 display: "flex",
504 flexDirection: "column",
505 gap: 2,
506 } satisfies React.CSSProperties,
507 title: {
508 fontSize: 15,
509 fontWeight: 600,
510 } satisfies React.CSSProperties,
511 subtitle: {
512 fontSize: 12,
513 fontWeight: 500,
514 } satisfies React.CSSProperties,
515 pageMeta: {
516 fontSize: 12,
517 } satisfies React.CSSProperties,
518 items: {
519 display: "flex",
520 flexDirection: "column",
521 } satisfies React.CSSProperties,
522 empty: {
523 padding: "24px 18px",
524 fontSize: 13,
525 textAlign: "center",
526 } satisfies React.CSSProperties,
527 rowContainer: {
528 padding: "16px",
529 display: "flex",
530 flexDirection: "column",
531 gap: 8,
532 transition: "background-color 120ms ease",
533 position: "relative",
534 } satisfies React.CSSProperties,
535 repostIndicator: {
536 display: "flex",
537 alignItems: "center",
538 gap: 8,
539 fontSize: 13,
540 fontWeight: 500,
541 paddingLeft: 8,
542 marginBottom: 4,
543 } satisfies React.CSSProperties,
544 repostAvatar: {
545 width: 16,
546 height: 16,
547 borderRadius: "50%",
548 objectFit: "cover",
549 } satisfies React.CSSProperties,
550 repostText: {
551 fontSize: 13,
552 fontWeight: 500,
553 } satisfies React.CSSProperties,
554 replyIndicator: {
555 display: "flex",
556 alignItems: "center",
557 gap: 8,
558 fontSize: 13,
559 fontWeight: 500,
560 paddingLeft: 8,
561 marginBottom: 4,
562 } satisfies React.CSSProperties,
563 replyAvatar: {
564 width: 16,
565 height: 16,
566 borderRadius: "50%",
567 objectFit: "cover",
568 } satisfies React.CSSProperties,
569 replyText: {
570 fontSize: 13,
571 fontWeight: 500,
572 } satisfies React.CSSProperties,
573 postContent: {
574 display: "flex",
575 gap: 12,
576 } satisfies React.CSSProperties,
577 avatarContainer: {
578 flexShrink: 0,
579 } satisfies React.CSSProperties,
580 avatar: {
581 width: 48,
582 height: 48,
583 borderRadius: "50%",
584 objectFit: "cover",
585 } satisfies React.CSSProperties,
586 avatarPlaceholder: {
587 width: 48,
588 height: 48,
589 borderRadius: "50%",
590 background: "var(--atproto-color-bg-elevated)",
591 color: "var(--atproto-color-text-secondary)",
592 display: "flex",
593 alignItems: "center",
594 justifyContent: "center",
595 fontSize: 18,
596 fontWeight: 600,
597 } satisfies React.CSSProperties,
598 postMain: {
599 flex: 1,
600 minWidth: 0,
601 display: "flex",
602 flexDirection: "column",
603 gap: 6,
604 } satisfies React.CSSProperties,
605 postHeader: {
606 display: "flex",
607 alignItems: "baseline",
608 gap: 6,
609 flexWrap: "wrap",
610 } satisfies React.CSSProperties,
611 authorName: {
612 fontWeight: 700,
613 fontSize: 15,
614 textDecoration: "none",
615 maxWidth: "200px",
616 overflow: "hidden",
617 textOverflow: "ellipsis",
618 whiteSpace: "nowrap",
619 } satisfies React.CSSProperties,
620 authorHandle: {
621 fontSize: 15,
622 fontWeight: 400,
623 maxWidth: "150px",
624 overflow: "hidden",
625 textOverflow: "ellipsis",
626 whiteSpace: "nowrap",
627 } satisfies React.CSSProperties,
628 separator: {
629 fontSize: 15,
630 fontWeight: 400,
631 } satisfies React.CSSProperties,
632 timestamp: {
633 fontSize: 15,
634 fontWeight: 400,
635 } satisfies React.CSSProperties,
636 postLink: {
637 textDecoration: "none",
638 display: "block",
639 } satisfies React.CSSProperties,
640 postText: {
641 margin: 0,
642 whiteSpace: "pre-wrap",
643 fontSize: 15,
644 lineHeight: 1.5,
645 wordBreak: "break-word",
646 } satisfies React.CSSProperties,
647 footer: {
648 display: "flex",
649 alignItems: "center",
650 justifyContent: "space-between",
651 padding: "12px 18px",
652 borderTop: "1px solid transparent",
653 fontSize: 13,
654 } satisfies React.CSSProperties,
655 pageChips: {
656 display: "flex",
657 gap: 6,
658 alignItems: "center",
659 } satisfies React.CSSProperties,
660 pageChip: {
661 padding: "4px 10px",
662 borderRadius: 999,
663 fontSize: 13,
664 borderWidth: "1px",
665 borderStyle: "solid",
666 borderColor: "transparent",
667 } satisfies React.CSSProperties,
668 pageChipActive: {
669 padding: "4px 10px",
670 borderRadius: 999,
671 fontSize: 13,
672 fontWeight: 600,
673 borderWidth: "1px",
674 borderStyle: "solid",
675 borderColor: "transparent",
676 } satisfies React.CSSProperties,
677 pageButton: {
678 border: "none",
679 borderRadius: 999,
680 padding: "6px 12px",
681 fontSize: 13,
682 fontWeight: 500,
683 background: "transparent",
684 display: "flex",
685 alignItems: "center",
686 gap: 4,
687 transition: "background-color 120ms ease",
688 } satisfies React.CSSProperties,
689 loadingBar: {
690 padding: "4px 18px 14px",
691 fontSize: 12,
692 textAlign: "right",
693 color: "#64748b",
694 } satisfies React.CSSProperties,
695};
696
697export default BlueskyPostList;