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