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