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