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 } from "../types/bluesky";
8import { useDidResolution } from "../hooks/useDidResolution";
9import { BlueskyIcon } from "./BlueskyIcon";
10import { parseAtUri } from "../utils/at-uri";
11import { useAtProto } from "../providers/AtProtoProvider";
12
13/**
14 * Options for rendering a paginated list of Bluesky posts.
15 */
16export interface BlueskyPostListProps {
17 /**
18 * DID whose feed posts should be fetched.
19 */
20 did: string;
21 /**
22 * Maximum number of records to list per page. Defaults to `5`.
23 */
24 limit?: number;
25 /**
26 * Enables pagination controls when `true`. Defaults to `true`.
27 */
28 enablePagination?: boolean;
29}
30
31/**
32 * Fetches a DID's feed posts and renders them with rich pagination and theming.
33 *
34 * @param did - DID whose posts should be displayed.
35 * @param limit - Maximum number of posts per page. Default `5`.
36 * @param enablePagination - Whether pagination controls should render. Default `true`.
37 * @returns A card-like list element with loading, empty, and error handling.
38 */
39export const BlueskyPostList: React.FC<BlueskyPostListProps> = React.memo(({
40 did,
41 limit = 5,
42 enablePagination = true,
43}) => {
44 const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
45 const actorLabel = resolvedHandle ?? formatDid(did);
46 const actorPath = resolvedHandle ?? resolvedDid ?? did;
47
48 const {
49 records,
50 loading,
51 error,
52 hasNext,
53 hasPrev,
54 loadNext,
55 loadPrev,
56 pageIndex,
57 pagesCount,
58 } = usePaginatedRecords<FeedPostRecord>({
59 did,
60 collection: "app.bsky.feed.post",
61 limit,
62 preferAuthorFeed: true,
63 authorFeedActor: actorPath,
64 });
65
66 const pageLabel = useMemo(() => {
67 const knownTotal = Math.max(pageIndex + 1, pagesCount);
68 if (!enablePagination) return undefined;
69 if (hasNext && knownTotal === pageIndex + 1)
70 return `${pageIndex + 1}/…`;
71 return `${pageIndex + 1}/${knownTotal}`;
72 }, [enablePagination, hasNext, pageIndex, pagesCount]);
73
74 if (error)
75 return (
76 <div role="alert" style={{ padding: 8, color: "crimson" }}>
77 Failed to load posts.
78 </div>
79 );
80
81 return (
82 <div style={{ ...listStyles.card, background: `var(--atproto-color-bg)`, borderWidth: "1px", borderStyle: "solid", borderColor: `var(--atproto-color-border)` }}>
83 <div style={{ ...listStyles.header, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text)` }}>
84 <div style={listStyles.headerInfo}>
85 <div style={listStyles.headerIcon}>
86 <BlueskyIcon size={20} />
87 </div>
88 <div style={listStyles.headerText}>
89 <span style={listStyles.title}>Latest Posts</span>
90 <span
91 style={{
92 ...listStyles.subtitle,
93 color: `var(--atproto-color-text-secondary)`,
94 }}
95 >
96 @{actorLabel}
97 </span>
98 </div>
99 </div>
100 {pageLabel && (
101 <span
102 style={{ ...listStyles.pageMeta, color: `var(--atproto-color-text-secondary)` }}
103 >
104 {pageLabel}
105 </span>
106 )}
107 </div>
108 <div style={listStyles.items}>
109 {loading && records.length === 0 && (
110 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
111 Loading posts…
112 </div>
113 )}
114 {records.map((record, idx) => (
115 <ListRow
116 key={record.rkey}
117 record={record.value}
118 rkey={record.rkey}
119 did={actorPath}
120 uri={record.uri}
121 reason={record.reason}
122 replyParent={record.replyParent}
123 hasDivider={idx < records.length - 1}
124 />
125 ))}
126 {!loading && records.length === 0 && (
127 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
128 No posts found.
129 </div>
130 )}
131 </div>
132 {enablePagination && (
133 <div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
134 <button
135 type="button"
136 style={{
137 ...listStyles.pageButton,
138 background: `var(--atproto-color-button-bg)`,
139 color: `var(--atproto-color-button-text)`,
140 cursor: hasPrev ? "pointer" : "not-allowed",
141 opacity: hasPrev ? 1 : 0.5,
142 }}
143 onClick={loadPrev}
144 disabled={!hasPrev}
145 >
146 ‹ Prev
147 </button>
148 <div style={listStyles.pageChips}>
149 <span
150 style={{
151 ...listStyles.pageChipActive,
152 color: `var(--atproto-color-button-text)`,
153 background: `var(--atproto-color-button-bg)`,
154 borderWidth: "1px",
155 borderStyle: "solid",
156 borderColor: `var(--atproto-color-button-bg)`,
157 }}
158 >
159 {pageIndex + 1}
160 </span>
161 {(hasNext || pagesCount > pageIndex + 1) && (
162 <span
163 style={{
164 ...listStyles.pageChip,
165 color: `var(--atproto-color-text-secondary)`,
166 borderWidth: "1px",
167 borderStyle: "solid",
168 borderColor: `var(--atproto-color-border)`,
169 background: `var(--atproto-color-bg)`,
170 }}
171 >
172 {pageIndex + 2}
173 </span>
174 )}
175 </div>
176 <button
177 type="button"
178 style={{
179 ...listStyles.pageButton,
180 background: `var(--atproto-color-button-bg)`,
181 color: `var(--atproto-color-button-text)`,
182 cursor: hasNext ? "pointer" : "not-allowed",
183 opacity: hasNext ? 1 : 0.5,
184 }}
185 onClick={loadNext}
186 disabled={!hasNext}
187 >
188 Next ›
189 </button>
190 </div>
191 )}
192 {loading && records.length > 0 && (
193 <div
194 style={{ ...listStyles.loadingBar, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }}
195 >
196 Updating…
197 </div>
198 )}
199 </div>
200 );
201});
202
203interface ListRowProps {
204 record: FeedPostRecord;
205 rkey: string;
206 did: string;
207 uri?: string;
208 reason?: AuthorFeedReason;
209 replyParent?: ReplyParentInfo;
210 hasDivider: boolean;
211}
212
213const ListRow: React.FC<ListRowProps> = ({
214 record,
215 rkey,
216 did,
217 uri,
218 reason,
219 replyParent,
220 hasDivider,
221}) => {
222 const { blueskyAppBaseUrl } = useAtProto();
223 const text = record.text?.trim() ?? "";
224 const relative = record.createdAt
225 ? formatRelativeTime(record.createdAt)
226 : undefined;
227 const absolute = record.createdAt
228 ? new Date(record.createdAt).toLocaleString()
229 : undefined;
230
231 // Parse the URI to get the actual post's DID and rkey
232 // This handles reposts correctly by linking to the original post
233 const parsedUri = uri ? parseAtUri(uri) : undefined;
234 const postDid = parsedUri?.did ?? did;
235 const postRkey = parsedUri?.rkey ?? rkey;
236 const href = `${blueskyAppBaseUrl}/profile/${postDid}/post/${postRkey}`;
237
238 // Resolve the original post author's handle for reposts
239 const { handle: originalAuthorHandle } = useDidResolution(
240 reason?.$type === "app.bsky.feed.defs#reasonRepost" ? postDid : undefined,
241 );
242
243 const repostLabel =
244 reason?.$type === "app.bsky.feed.defs#reasonRepost"
245 ? `${formatActor(reason.by) ?? "Someone"} reposted @${originalAuthorHandle ?? formatDid(postDid)}`
246 : undefined;
247 const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
248 const parentDid =
249 replyParent?.author?.did ??
250 (parentUri ? parseAtUri(parentUri)?.did : undefined);
251 const { handle: resolvedReplyHandle } = useDidResolution(
252 replyParent?.author?.handle ? undefined : parentDid,
253 );
254 const replyTarget = formatReplyTarget(
255 parentUri,
256 replyParent,
257 resolvedReplyHandle,
258 );
259
260 const isReply = !!replyTarget;
261
262 const postPreview = text.slice(0, 100);
263 const ariaLabel = text
264 ? `Post by ${did}: ${postPreview}${text.length > 100 ? '...' : ''}`
265 : `Post by ${did}`;
266
267 return (
268 <div
269 style={{
270 ...listStyles.rowContainer,
271 borderBottom: hasDivider
272 ? `1px solid var(--atproto-color-border)`
273 : "none",
274 borderLeft: isReply
275 ? `3px solid #1185FE`
276 : "3px solid transparent",
277 }}
278 >
279 {repostLabel && (
280 <div style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}>
281 {repostLabel}
282 </div>
283 )}
284 {isReply && (
285 <div style={listStyles.replyHeader}>
286 <span style={{ ...listStyles.replyArrow, color: `#1185FE` }}>
287 ↩
288 </span>
289 <span style={{ ...listStyles.replyText, color: `var(--atproto-color-text-secondary)` }}>
290 replying to {replyTarget}
291 </span>
292 {relative && (
293 <span
294 style={{ ...listStyles.rowTime, color: `var(--atproto-color-text-secondary)`, marginLeft: "auto" }}
295 title={absolute}
296 >
297 {relative}
298 </span>
299 )}
300 </div>
301 )}
302 {!isReply && relative && (
303 <span
304 style={{ ...listStyles.rowTime, color: `var(--atproto-color-text-secondary)` }}
305 title={absolute}
306 >
307 {relative}
308 </span>
309 )}
310 <a
311 href={href}
312 target="_blank"
313 rel="noopener noreferrer"
314 aria-label={ariaLabel}
315 style={{
316 ...listStyles.rowLink,
317 color: `var(--atproto-color-text)`,
318 }}
319 >
320 {text && (
321 <p style={{ ...listStyles.rowBody, color: `var(--atproto-color-text)` }}>
322 {text}
323 </p>
324 )}
325 {!text && (
326 <p
327 style={{
328 ...listStyles.rowBody,
329 color: `var(--atproto-color-text)`,
330 fontStyle: "italic",
331 }}
332 >
333 No text content.
334 </p>
335 )}
336 </a>
337 </div>
338 );
339};
340
341function formatDid(did: string) {
342 return did.replace(/^did:(plc:)?/, "");
343}
344
345function formatRelativeTime(iso: string): string {
346 const date = new Date(iso);
347 const diffSeconds = (date.getTime() - Date.now()) / 1000;
348 const absSeconds = Math.abs(diffSeconds);
349 const thresholds: Array<{
350 limit: number;
351 unit: Intl.RelativeTimeFormatUnit;
352 divisor: number;
353 }> = [
354 { limit: 60, unit: "second", divisor: 1 },
355 { limit: 3600, unit: "minute", divisor: 60 },
356 { limit: 86400, unit: "hour", divisor: 3600 },
357 { limit: 604800, unit: "day", divisor: 86400 },
358 { limit: 2629800, unit: "week", divisor: 604800 },
359 { limit: 31557600, unit: "month", divisor: 2629800 },
360 { limit: Infinity, unit: "year", divisor: 31557600 },
361 ];
362 const threshold =
363 thresholds.find((t) => absSeconds < t.limit) ??
364 thresholds[thresholds.length - 1];
365 const value = diffSeconds / threshold.divisor;
366 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
367 return rtf.format(Math.round(value), threshold.unit);
368}
369
370
371const listStyles = {
372 card: {
373 borderRadius: 16,
374 borderWidth: "1px",
375 borderStyle: "solid",
376 borderColor: "transparent",
377 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
378 overflow: "hidden",
379 display: "flex",
380 flexDirection: "column",
381 } satisfies React.CSSProperties,
382 header: {
383 display: "flex",
384 alignItems: "center",
385 justifyContent: "space-between",
386 padding: "14px 18px",
387 fontSize: 14,
388 fontWeight: 500,
389 borderBottom: "1px solid transparent",
390 } satisfies React.CSSProperties,
391 headerInfo: {
392 display: "flex",
393 alignItems: "center",
394 gap: 12,
395 } satisfies React.CSSProperties,
396 headerIcon: {
397 width: 28,
398 height: 28,
399 display: "flex",
400 alignItems: "center",
401 justifyContent: "center",
402 //background: 'rgba(17, 133, 254, 0.14)',
403 borderRadius: "50%",
404 } satisfies React.CSSProperties,
405 headerText: {
406 display: "flex",
407 flexDirection: "column",
408 gap: 2,
409 } satisfies React.CSSProperties,
410 title: {
411 fontSize: 15,
412 fontWeight: 600,
413 } satisfies React.CSSProperties,
414 subtitle: {
415 fontSize: 12,
416 fontWeight: 500,
417 } satisfies React.CSSProperties,
418 pageMeta: {
419 fontSize: 12,
420 } satisfies React.CSSProperties,
421 items: {
422 display: "flex",
423 flexDirection: "column",
424 } satisfies React.CSSProperties,
425 empty: {
426 padding: "24px 18px",
427 fontSize: 13,
428 textAlign: "center",
429 } satisfies React.CSSProperties,
430 rowContainer: {
431 padding: "18px",
432 display: "flex",
433 flexDirection: "column",
434 gap: 6,
435 transition: "background-color 120ms ease",
436 } satisfies React.CSSProperties,
437 rowLink: {
438 textDecoration: "none",
439 display: "block",
440 } satisfies React.CSSProperties,
441 replyHeader: {
442 display: "flex",
443 alignItems: "center",
444 gap: 6,
445 fontSize: 12,
446 fontWeight: 500,
447 } satisfies React.CSSProperties,
448 replyArrow: {
449 fontSize: 14,
450 fontWeight: 600,
451 } satisfies React.CSSProperties,
452 replyText: {
453 fontSize: 12,
454 fontWeight: 500,
455 } satisfies React.CSSProperties,
456 rowHeader: {
457 display: "flex",
458 gap: 6,
459 alignItems: "baseline",
460 fontSize: 13,
461 } satisfies React.CSSProperties,
462 rowTime: {
463 fontSize: 12,
464 fontWeight: 500,
465 } satisfies React.CSSProperties,
466 rowMeta: {
467 fontSize: 12,
468 fontWeight: 500,
469 letterSpacing: "0.6px",
470 } satisfies React.CSSProperties,
471 rowBody: {
472 margin: 0,
473 whiteSpace: "pre-wrap",
474 fontSize: 14,
475 lineHeight: 1.45,
476 } satisfies React.CSSProperties,
477 footer: {
478 display: "flex",
479 alignItems: "center",
480 justifyContent: "space-between",
481 padding: "12px 18px",
482 borderTop: "1px solid transparent",
483 fontSize: 13,
484 } satisfies React.CSSProperties,
485 navButton: {
486 border: "none",
487 borderRadius: 999,
488 padding: "6px 12px",
489 fontSize: 13,
490 fontWeight: 500,
491 background: "transparent",
492 display: "flex",
493 alignItems: "center",
494 gap: 4,
495 transition: "background-color 120ms ease",
496 } satisfies React.CSSProperties,
497 pageChips: {
498 display: "flex",
499 gap: 6,
500 alignItems: "center",
501 } satisfies React.CSSProperties,
502 pageChip: {
503 padding: "4px 10px",
504 borderRadius: 999,
505 fontSize: 13,
506 borderWidth: "1px",
507 borderStyle: "solid",
508 borderColor: "transparent",
509 } satisfies React.CSSProperties,
510 pageChipActive: {
511 padding: "4px 10px",
512 borderRadius: 999,
513 fontSize: 13,
514 fontWeight: 600,
515 borderWidth: "1px",
516 borderStyle: "solid",
517 borderColor: "transparent",
518 } satisfies React.CSSProperties,
519 pageButton: {
520 border: "none",
521 borderRadius: 999,
522 padding: "6px 12px",
523 fontSize: 13,
524 fontWeight: 500,
525 background: "transparent",
526 display: "flex",
527 alignItems: "center",
528 gap: 4,
529 transition: "background-color 120ms ease",
530 } satisfies React.CSSProperties,
531 loadingBar: {
532 padding: "4px 18px 14px",
533 fontSize: 12,
534 textAlign: "right",
535 color: "#64748b",
536 } satisfies React.CSSProperties,
537};
538
539export default BlueskyPostList;
540
541function formatActor(actor?: { handle?: string; did?: string }) {
542 if (!actor) return undefined;
543 if (actor.handle) return `@${actor.handle}`;
544 if (actor.did) return `@${formatDid(actor.did)}`;
545 return undefined;
546}
547
548function formatReplyTarget(
549 parentUri?: string,
550 feedParent?: ReplyParentInfo,
551 resolvedHandle?: string,
552) {
553 const directHandle = feedParent?.author?.handle;
554 const handle = directHandle ?? resolvedHandle;
555 if (handle) {
556 return `@${handle}`;
557 }
558 const parentDid = feedParent?.author?.did;
559 const targetUri = feedParent?.uri ?? parentUri;
560 if (!targetUri) return undefined;
561 const parsed = parseAtUri(targetUri);
562 const did = parentDid ?? parsed?.did;
563 if (!did) return undefined;
564 return `@${formatDid(did)}`;
565}