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 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 return (
246 <a
247 href={href}
248 target="_blank"
249 rel="noopener noreferrer"
250 style={{
251 ...listStyles.row,
252 color: `var(--atproto-color-text)`,
253 borderBottom: hasDivider
254 ? `1px solid var(--atproto-color-border)`
255 : "none",
256 }}
257 >
258 {repostLabel && (
259 <span style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}>
260 {repostLabel}
261 </span>
262 )}
263 {replyLabel && (
264 <span style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}>
265 {replyLabel}
266 </span>
267 )}
268 {relative && (
269 <span
270 style={{ ...listStyles.rowTime, color: `var(--atproto-color-text-secondary)` }}
271 title={absolute}
272 >
273 {relative}
274 </span>
275 )}
276 {text && (
277 <p style={{ ...listStyles.rowBody, color: `var(--atproto-color-text)` }}>
278 {text}
279 </p>
280 )}
281 {!text && (
282 <p
283 style={{
284 ...listStyles.rowBody,
285 color: `var(--atproto-color-text)`,
286 fontStyle: "italic",
287 }}
288 >
289 No text content.
290 </p>
291 )}
292 </a>
293 );
294};
295
296function formatDid(did: string) {
297 return did.replace(/^did:(plc:)?/, "");
298}
299
300function formatRelativeTime(iso: string): string {
301 const date = new Date(iso);
302 const diffSeconds = (date.getTime() - Date.now()) / 1000;
303 const absSeconds = Math.abs(diffSeconds);
304 const thresholds: Array<{
305 limit: number;
306 unit: Intl.RelativeTimeFormatUnit;
307 divisor: number;
308 }> = [
309 { limit: 60, unit: "second", divisor: 1 },
310 { limit: 3600, unit: "minute", divisor: 60 },
311 { limit: 86400, unit: "hour", divisor: 3600 },
312 { limit: 604800, unit: "day", divisor: 86400 },
313 { limit: 2629800, unit: "week", divisor: 604800 },
314 { limit: 31557600, unit: "month", divisor: 2629800 },
315 { limit: Infinity, unit: "year", divisor: 31557600 },
316 ];
317 const threshold =
318 thresholds.find((t) => absSeconds < t.limit) ??
319 thresholds[thresholds.length - 1];
320 const value = diffSeconds / threshold.divisor;
321 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
322 return rtf.format(Math.round(value), threshold.unit);
323}
324
325
326const listStyles = {
327 card: {
328 borderRadius: 16,
329 borderWidth: "1px",
330 borderStyle: "solid",
331 borderColor: "transparent",
332 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)",
333 overflow: "hidden",
334 display: "flex",
335 flexDirection: "column",
336 } satisfies React.CSSProperties,
337 header: {
338 display: "flex",
339 alignItems: "center",
340 justifyContent: "space-between",
341 padding: "14px 18px",
342 fontSize: 14,
343 fontWeight: 500,
344 borderBottom: "1px solid transparent",
345 } satisfies React.CSSProperties,
346 headerInfo: {
347 display: "flex",
348 alignItems: "center",
349 gap: 12,
350 } satisfies React.CSSProperties,
351 headerIcon: {
352 width: 28,
353 height: 28,
354 display: "flex",
355 alignItems: "center",
356 justifyContent: "center",
357 //background: 'rgba(17, 133, 254, 0.14)',
358 borderRadius: "50%",
359 } satisfies React.CSSProperties,
360 headerText: {
361 display: "flex",
362 flexDirection: "column",
363 gap: 2,
364 } satisfies React.CSSProperties,
365 title: {
366 fontSize: 15,
367 fontWeight: 600,
368 } satisfies React.CSSProperties,
369 subtitle: {
370 fontSize: 12,
371 fontWeight: 500,
372 } satisfies React.CSSProperties,
373 pageMeta: {
374 fontSize: 12,
375 } satisfies React.CSSProperties,
376 items: {
377 display: "flex",
378 flexDirection: "column",
379 } satisfies React.CSSProperties,
380 empty: {
381 padding: "24px 18px",
382 fontSize: 13,
383 textAlign: "center",
384 } satisfies React.CSSProperties,
385 row: {
386 padding: "18px",
387 textDecoration: "none",
388 display: "flex",
389 flexDirection: "column",
390 gap: 6,
391 transition: "background-color 120ms ease",
392 } satisfies React.CSSProperties,
393 rowHeader: {
394 display: "flex",
395 gap: 6,
396 alignItems: "baseline",
397 fontSize: 13,
398 } satisfies React.CSSProperties,
399 rowTime: {
400 fontSize: 12,
401 fontWeight: 500,
402 } satisfies React.CSSProperties,
403 rowMeta: {
404 fontSize: 12,
405 fontWeight: 500,
406 letterSpacing: "0.6px",
407 } satisfies React.CSSProperties,
408 rowBody: {
409 margin: 0,
410 whiteSpace: "pre-wrap",
411 fontSize: 14,
412 lineHeight: 1.45,
413 } satisfies React.CSSProperties,
414 footer: {
415 display: "flex",
416 alignItems: "center",
417 justifyContent: "space-between",
418 padding: "12px 18px",
419 borderTop: "1px solid transparent",
420 fontSize: 13,
421 } satisfies React.CSSProperties,
422 navButton: {
423 border: "none",
424 borderRadius: 999,
425 padding: "6px 12px",
426 fontSize: 13,
427 fontWeight: 500,
428 background: "transparent",
429 display: "flex",
430 alignItems: "center",
431 gap: 4,
432 transition: "background-color 120ms ease",
433 } satisfies React.CSSProperties,
434 pageChips: {
435 display: "flex",
436 gap: 6,
437 alignItems: "center",
438 } satisfies React.CSSProperties,
439 pageChip: {
440 padding: "4px 10px",
441 borderRadius: 999,
442 fontSize: 13,
443 borderWidth: "1px",
444 borderStyle: "solid",
445 borderColor: "transparent",
446 } satisfies React.CSSProperties,
447 pageChipActive: {
448 padding: "4px 10px",
449 borderRadius: 999,
450 fontSize: 13,
451 fontWeight: 600,
452 borderWidth: "1px",
453 borderStyle: "solid",
454 borderColor: "transparent",
455 } satisfies React.CSSProperties,
456 pageButton: {
457 border: "none",
458 borderRadius: 999,
459 padding: "6px 12px",
460 fontSize: 13,
461 fontWeight: 500,
462 background: "transparent",
463 display: "flex",
464 alignItems: "center",
465 gap: 4,
466 transition: "background-color 120ms ease",
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
476export default BlueskyPostList;
477
478function formatActor(actor?: { handle?: string; did?: string }) {
479 if (!actor) return undefined;
480 if (actor.handle) return `@${actor.handle}`;
481 if (actor.did) return `@${formatDid(actor.did)}`;
482 return undefined;
483}
484
485function formatReplyTarget(
486 parentUri?: string,
487 feedParent?: ReplyParentInfo,
488 resolvedHandle?: string,
489) {
490 const directHandle = feedParent?.author?.handle;
491 const handle = directHandle ?? resolvedHandle;
492 if (handle) {
493 return `Replying to @${handle}`;
494 }
495 const parentDid = feedParent?.author?.did;
496 const targetUri = feedParent?.uri ?? parentUri;
497 if (!targetUri) return undefined;
498 const parsed = parseAtUri(targetUri);
499 const did = parentDid ?? parsed?.did;
500 if (!did) return undefined;
501 return `Replying to @${formatDid(did)}`;
502}