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 reason={record.reason}
121 replyParent={record.replyParent}
122 hasDivider={idx < records.length - 1}
123 />
124 ))}
125 {!loading && records.length === 0 && (
126 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}>
127 No posts found.
128 </div>
129 )}
130 </div>
131 {enablePagination && (
132 <div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}>
133 <button
134 type="button"
135 style={{
136 ...listStyles.pageButton,
137 background: `var(--atproto-color-button-bg)`,
138 color: `var(--atproto-color-button-text)`,
139 cursor: hasPrev ? "pointer" : "not-allowed",
140 opacity: hasPrev ? 1 : 0.5,
141 }}
142 onClick={loadPrev}
143 disabled={!hasPrev}
144 >
145 ‹ Prev
146 </button>
147 <div style={listStyles.pageChips}>
148 <span
149 style={{
150 ...listStyles.pageChipActive,
151 color: `var(--atproto-color-button-text)`,
152 background: `var(--atproto-color-button-bg)`,
153 borderWidth: "1px",
154 borderStyle: "solid",
155 borderColor: `var(--atproto-color-button-bg)`,
156 }}
157 >
158 {pageIndex + 1}
159 </span>
160 {(hasNext || pagesCount > pageIndex + 1) && (
161 <span
162 style={{
163 ...listStyles.pageChip,
164 color: `var(--atproto-color-text-secondary)`,
165 borderWidth: "1px",
166 borderStyle: "solid",
167 borderColor: `var(--atproto-color-border)`,
168 background: `var(--atproto-color-bg)`,
169 }}
170 >
171 {pageIndex + 2}
172 </span>
173 )}
174 </div>
175 <button
176 type="button"
177 style={{
178 ...listStyles.pageButton,
179 background: `var(--atproto-color-button-bg)`,
180 color: `var(--atproto-color-button-text)`,
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, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }}
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 hasDivider: boolean;
209}
210
211const ListRow: React.FC<ListRowProps> = ({
212 record,
213 rkey,
214 did,
215 reason,
216 replyParent,
217 hasDivider,
218}) => {
219 const { blueskyAppBaseUrl } = useAtProto();
220 const text = record.text?.trim() ?? "";
221 const relative = record.createdAt
222 ? formatRelativeTime(record.createdAt)
223 : undefined;
224 const absolute = record.createdAt
225 ? new Date(record.createdAt).toLocaleString()
226 : undefined;
227 const href = `${blueskyAppBaseUrl}/profile/${did}/post/${rkey}`;
228 const repostLabel =
229 reason?.$type === "app.bsky.feed.defs#reasonRepost"
230 ? `${formatActor(reason.by) ?? "Someone"} reposted`
231 : undefined;
232 const parentUri = replyParent?.uri ?? record.reply?.parent?.uri;
233 const parentDid =
234 replyParent?.author?.did ??
235 (parentUri ? parseAtUri(parentUri)?.did : undefined);
236 const { handle: resolvedReplyHandle } = useDidResolution(
237 replyParent?.author?.handle ? undefined : parentDid,
238 );
239 const replyLabel = formatReplyTarget(
240 parentUri,
241 replyParent,
242 resolvedReplyHandle,
243 );
244
245 const postPreview = text.slice(0, 100);
246 const ariaLabel = text
247 ? `Post by ${did}: ${postPreview}${text.length > 100 ? '...' : ''}`
248 : `Post by ${did}`;
249
250 return (
251 <a
252 href={href}
253 target="_blank"
254 rel="noopener noreferrer"
255 aria-label={ariaLabel}
256 style={{
257 ...listStyles.row,
258 color: `var(--atproto-color-text)`,
259 borderBottom: hasDivider
260 ? `1px solid var(--atproto-color-border)`
261 : "none",
262 }}
263 >
264 {repostLabel && (
265 <span style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}>
266 {repostLabel}
267 </span>
268 )}
269 {replyLabel && (
270 <span style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}>
271 {replyLabel}
272 </span>
273 )}
274 {relative && (
275 <span
276 style={{ ...listStyles.rowTime, color: `var(--atproto-color-text-secondary)` }}
277 title={absolute}
278 >
279 {relative}
280 </span>
281 )}
282 {text && (
283 <p style={{ ...listStyles.rowBody, color: `var(--atproto-color-text)` }}>
284 {text}
285 </p>
286 )}
287 {!text && (
288 <p
289 style={{
290 ...listStyles.rowBody,
291 color: `var(--atproto-color-text)`,
292 fontStyle: "italic",
293 }}
294 >
295 No text content.
296 </p>
297 )}
298 </a>
299 );
300};
301
302function formatDid(did: string) {
303 return did.replace(/^did:(plc:)?/, "");
304}
305
306function formatRelativeTime(iso: string): string {
307 const date = new Date(iso);
308 const diffSeconds = (date.getTime() - Date.now()) / 1000;
309 const absSeconds = Math.abs(diffSeconds);
310 const thresholds: Array<{
311 limit: number;
312 unit: Intl.RelativeTimeFormatUnit;
313 divisor: number;
314 }> = [
315 { limit: 60, unit: "second", divisor: 1 },
316 { limit: 3600, unit: "minute", divisor: 60 },
317 { limit: 86400, unit: "hour", divisor: 3600 },
318 { limit: 604800, unit: "day", divisor: 86400 },
319 { limit: 2629800, unit: "week", divisor: 604800 },
320 { limit: 31557600, unit: "month", divisor: 2629800 },
321 { limit: Infinity, unit: "year", divisor: 31557600 },
322 ];
323 const threshold =
324 thresholds.find((t) => absSeconds < t.limit) ??
325 thresholds[thresholds.length - 1];
326 const value = diffSeconds / threshold.divisor;
327 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
328 return rtf.format(Math.round(value), threshold.unit);
329}
330
331
332const listStyles = {
333 card: {
334 borderRadius: 16,
335 borderWidth: "1px",
336 borderStyle: "solid",
337 borderColor: "transparent",
338 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
339 overflow: "hidden",
340 display: "flex",
341 flexDirection: "column",
342 } satisfies React.CSSProperties,
343 header: {
344 display: "flex",
345 alignItems: "center",
346 justifyContent: "space-between",
347 padding: "14px 18px",
348 fontSize: 14,
349 fontWeight: 500,
350 borderBottom: "1px solid transparent",
351 } satisfies React.CSSProperties,
352 headerInfo: {
353 display: "flex",
354 alignItems: "center",
355 gap: 12,
356 } satisfies React.CSSProperties,
357 headerIcon: {
358 width: 28,
359 height: 28,
360 display: "flex",
361 alignItems: "center",
362 justifyContent: "center",
363 //background: 'rgba(17, 133, 254, 0.14)',
364 borderRadius: "50%",
365 } satisfies React.CSSProperties,
366 headerText: {
367 display: "flex",
368 flexDirection: "column",
369 gap: 2,
370 } satisfies React.CSSProperties,
371 title: {
372 fontSize: 15,
373 fontWeight: 600,
374 } satisfies React.CSSProperties,
375 subtitle: {
376 fontSize: 12,
377 fontWeight: 500,
378 } satisfies React.CSSProperties,
379 pageMeta: {
380 fontSize: 12,
381 } satisfies React.CSSProperties,
382 items: {
383 display: "flex",
384 flexDirection: "column",
385 } satisfies React.CSSProperties,
386 empty: {
387 padding: "24px 18px",
388 fontSize: 13,
389 textAlign: "center",
390 } satisfies React.CSSProperties,
391 row: {
392 padding: "18px",
393 textDecoration: "none",
394 display: "flex",
395 flexDirection: "column",
396 gap: 6,
397 transition: "background-color 120ms ease",
398 } satisfies React.CSSProperties,
399 rowHeader: {
400 display: "flex",
401 gap: 6,
402 alignItems: "baseline",
403 fontSize: 13,
404 } satisfies React.CSSProperties,
405 rowTime: {
406 fontSize: 12,
407 fontWeight: 500,
408 } satisfies React.CSSProperties,
409 rowMeta: {
410 fontSize: 12,
411 fontWeight: 500,
412 letterSpacing: "0.6px",
413 } satisfies React.CSSProperties,
414 rowBody: {
415 margin: 0,
416 whiteSpace: "pre-wrap",
417 fontSize: 14,
418 lineHeight: 1.45,
419 } satisfies React.CSSProperties,
420 footer: {
421 display: "flex",
422 alignItems: "center",
423 justifyContent: "space-between",
424 padding: "12px 18px",
425 borderTop: "1px solid transparent",
426 fontSize: 13,
427 } satisfies React.CSSProperties,
428 navButton: {
429 border: "none",
430 borderRadius: 999,
431 padding: "6px 12px",
432 fontSize: 13,
433 fontWeight: 500,
434 background: "transparent",
435 display: "flex",
436 alignItems: "center",
437 gap: 4,
438 transition: "background-color 120ms ease",
439 } satisfies React.CSSProperties,
440 pageChips: {
441 display: "flex",
442 gap: 6,
443 alignItems: "center",
444 } satisfies React.CSSProperties,
445 pageChip: {
446 padding: "4px 10px",
447 borderRadius: 999,
448 fontSize: 13,
449 borderWidth: "1px",
450 borderStyle: "solid",
451 borderColor: "transparent",
452 } satisfies React.CSSProperties,
453 pageChipActive: {
454 padding: "4px 10px",
455 borderRadius: 999,
456 fontSize: 13,
457 fontWeight: 600,
458 borderWidth: "1px",
459 borderStyle: "solid",
460 borderColor: "transparent",
461 } satisfies React.CSSProperties,
462 pageButton: {
463 border: "none",
464 borderRadius: 999,
465 padding: "6px 12px",
466 fontSize: 13,
467 fontWeight: 500,
468 background: "transparent",
469 display: "flex",
470 alignItems: "center",
471 gap: 4,
472 transition: "background-color 120ms ease",
473 } satisfies React.CSSProperties,
474 loadingBar: {
475 padding: "4px 18px 14px",
476 fontSize: 12,
477 textAlign: "right",
478 color: "#64748b",
479 } satisfies React.CSSProperties,
480};
481
482export default BlueskyPostList;
483
484function formatActor(actor?: { handle?: string; did?: string }) {
485 if (!actor) return undefined;
486 if (actor.handle) return `@${actor.handle}`;
487 if (actor.did) return `@${formatDid(actor.did)}`;
488 return undefined;
489}
490
491function formatReplyTarget(
492 parentUri?: string,
493 feedParent?: ReplyParentInfo,
494 resolvedHandle?: string,
495) {
496 const directHandle = feedParent?.author?.handle;
497 const handle = directHandle ?? resolvedHandle;
498 if (handle) {
499 return `Replying to @${handle}`;
500 }
501 const parentDid = feedParent?.author?.did;
502 const targetUri = feedParent?.uri ?? parentUri;
503 if (!targetUri) return undefined;
504 const parsed = parseAtUri(targetUri);
505 const did = parentDid ?? parsed?.did;
506 if (!did) return undefined;
507 return `Replying to @${formatDid(did)}`;
508}