A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo } from 'react';
2import { usePaginatedRecords } from '../hooks/usePaginatedRecords';
3import { useColorScheme } from '../hooks/useColorScheme';
4import type { FeedPostRecord } from '../types/bluesky';
5import { useDidResolution } from '../hooks/useDidResolution';
6import { BlueskyIcon } from './BlueskyIcon';
7
8/**
9 * Options for rendering a paginated list of Bluesky posts.
10 */
11export interface BlueskyPostListProps {
12 /**
13 * DID whose feed posts should be fetched.
14 */
15 did: string;
16 /**
17 * Maximum number of records to list per page. Defaults to `5`.
18 */
19 limit?: number;
20 /**
21 * Enables pagination controls when `true`. Defaults to `true`.
22 */
23 enablePagination?: boolean;
24 /**
25 * Preferred color scheme passed through to styling helpers.
26 * Defaults to `'system'` which follows the OS preference.
27 */
28 colorScheme?: 'light' | 'dark' | 'system';
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 * @param colorScheme - Preferred color scheme used for styling. Default `'system'`.
38 * @returns A card-like list element with loading, empty, and error handling.
39 */
40export const BlueskyPostList: React.FC<BlueskyPostListProps> = ({ did, limit = 5, enablePagination = true, colorScheme = 'system' }) => {
41 const scheme = useColorScheme(colorScheme);
42 const palette: ListPalette = scheme === 'dark' ? darkPalette : lightPalette;
43 const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did);
44 const actorLabel = resolvedHandle ?? formatDid(did);
45 const actorPath = resolvedHandle ?? resolvedDid ?? did;
46
47 const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({
48 did,
49 collection: 'app.bsky.feed.post',
50 limit,
51 preferAuthorFeed: true,
52 authorFeedActor: actorPath
53 });
54
55 const pageLabel = useMemo(() => {
56 const knownTotal = Math.max(pageIndex + 1, pagesCount);
57 if (!enablePagination) return undefined;
58 if (hasNext && knownTotal === pageIndex + 1) return `${pageIndex + 1}/…`;
59 return `${pageIndex + 1}/${knownTotal}`;
60 }, [enablePagination, hasNext, pageIndex, pagesCount]);
61
62 if (error) return <div style={{ padding: 8, color: 'crimson' }}>Failed to load posts.</div>;
63
64 return (
65 <div style={{ ...listStyles.card, ...palette.card }}>
66 <div style={{ ...listStyles.header, ...palette.header }}>
67 <div style={listStyles.headerInfo}>
68 <div style={listStyles.headerIcon}>
69 <BlueskyIcon size={20} />
70 </div>
71 <div style={listStyles.headerText}>
72 <span style={listStyles.title}>Latest Posts</span>
73 <span style={{ ...listStyles.subtitle, ...palette.subtitle }}>@{actorLabel}</span>
74 </div>
75 </div>
76 {pageLabel && <span style={{ ...listStyles.pageMeta, ...palette.pageMeta }}>{pageLabel}</span>}
77 </div>
78 <div style={listStyles.items}>
79 {loading && records.length === 0 && <div style={{ ...listStyles.empty, ...palette.empty }}>Loading posts…</div>}
80 {records.map((record, idx) => (
81 <ListRow
82 key={record.rkey}
83 record={record.value}
84 rkey={record.rkey}
85 did={actorPath}
86 palette={palette}
87 hasDivider={idx < records.length - 1}
88 />
89 ))}
90 {!loading && records.length === 0 && <div style={{ ...listStyles.empty, ...palette.empty }}>No posts found.</div>}
91 </div>
92 {enablePagination && (
93 <div style={{ ...listStyles.footer, ...palette.footer }}>
94 <button
95 type="button"
96 style={{
97 ...listStyles.navButton,
98 ...palette.navButton,
99 cursor: hasPrev ? 'pointer' : 'not-allowed',
100 opacity: hasPrev ? 1 : 0.5
101 }}
102 onClick={loadPrev}
103 disabled={!hasPrev}
104 >
105 ‹ Prev
106 </button>
107 <div style={listStyles.pageChips}>
108 <span style={{ ...listStyles.pageChipActive, ...palette.pageChipActive }}>{pageIndex + 1}</span>
109 {(hasNext || pagesCount > pageIndex + 1) && (
110 <span style={{ ...listStyles.pageChip, ...palette.pageChip }}>{pageIndex + 2}</span>
111 )}
112 </div>
113 <button
114 type="button"
115 style={{
116 ...listStyles.navButton,
117 ...palette.navButton,
118 cursor: hasNext ? 'pointer' : 'not-allowed',
119 opacity: hasNext ? 1 : 0.5
120 }}
121 onClick={loadNext}
122 disabled={!hasNext}
123 >
124 Next ›
125 </button>
126 </div>
127 )}
128 {loading && records.length > 0 && <div style={{ ...listStyles.loadingBar, ...palette.loadingBar }}>Updating…</div>}
129 </div>
130 );
131};
132
133interface ListRowProps {
134 record: FeedPostRecord;
135 rkey: string;
136 did: string;
137 palette: ListPalette;
138 hasDivider: boolean;
139}
140
141const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, palette, hasDivider }) => {
142 const text = record.text?.trim() ?? '';
143 const relative = record.createdAt ? formatRelativeTime(record.createdAt) : undefined;
144 const absolute = record.createdAt ? new Date(record.createdAt).toLocaleString() : undefined;
145 const href = `https://bsky.app/profile/${did}/post/${rkey}`;
146
147 return (
148 <a href={href} target="_blank" rel="noopener noreferrer" style={{ ...listStyles.row, ...palette.row, borderBottom: hasDivider ? `1px solid ${palette.divider}` : 'none' }}>
149 {relative && (
150 <span style={{ ...listStyles.rowTime, ...palette.rowTime }} title={absolute}>
151 {relative}
152 </span>
153 )}
154 {text && <p style={{ ...listStyles.rowBody, ...palette.rowBody }}>{text}</p>}
155 {!text && <p style={{ ...listStyles.rowBody, ...palette.rowBody, fontStyle: 'italic' }}>No text content.</p>}
156 </a>
157 );
158};
159
160function formatDid(did: string) {
161 return did.replace(/^did:(plc:)?/, '');
162}
163
164function formatRelativeTime(iso: string): string {
165 const date = new Date(iso);
166 const diffSeconds = (date.getTime() - Date.now()) / 1000;
167 const absSeconds = Math.abs(diffSeconds);
168 const thresholds: Array<{ limit: number; unit: Intl.RelativeTimeFormatUnit; divisor: number }> = [
169 { limit: 60, unit: 'second', divisor: 1 },
170 { limit: 3600, unit: 'minute', divisor: 60 },
171 { limit: 86400, unit: 'hour', divisor: 3600 },
172 { limit: 604800, unit: 'day', divisor: 86400 },
173 { limit: 2629800, unit: 'week', divisor: 604800 },
174 { limit: 31557600, unit: 'month', divisor: 2629800 },
175 { limit: Infinity, unit: 'year', divisor: 31557600 }
176 ];
177 const threshold = thresholds.find(t => absSeconds < t.limit) ?? thresholds[thresholds.length - 1];
178 const value = diffSeconds / threshold.divisor;
179 const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
180 return rtf.format(Math.round(value), threshold.unit);
181}
182
183interface ListPalette {
184 card: { background: string; borderColor: string };
185 header: { borderBottomColor: string; color: string };
186 pageMeta: { color: string };
187 subtitle: { color: string };
188 empty: { color: string };
189 row: { color: string };
190 rowTime: { color: string };
191 rowBody: { color: string };
192 divider: string;
193 footer: { borderTopColor: string; color: string };
194 navButton: { color: string; background: string };
195 pageChip: { color: string; borderColor: string; background: string };
196 pageChipActive: { color: string; background: string; borderColor: string };
197 loadingBar: { color: string };
198}
199
200const listStyles = {
201 card: {
202 borderRadius: 16,
203 border: '1px solid transparent',
204 boxShadow: '0 8px 18px -12px rgba(15, 23, 42, 0.25)',
205 overflow: 'hidden',
206 display: 'flex',
207 flexDirection: 'column'
208 } satisfies React.CSSProperties,
209 header: {
210 display: 'flex',
211 alignItems: 'center',
212 justifyContent: 'space-between',
213 padding: '14px 18px',
214 fontSize: 14,
215 fontWeight: 500,
216 borderBottom: '1px solid transparent'
217 } satisfies React.CSSProperties,
218 headerInfo: {
219 display: 'flex',
220 alignItems: 'center',
221 gap: 12
222 } satisfies React.CSSProperties,
223 headerIcon: {
224 width: 28,
225 height: 28,
226 display: 'flex',
227 alignItems: 'center',
228 justifyContent: 'center',
229 //background: 'rgba(17, 133, 254, 0.14)',
230 borderRadius: '50%'
231 } satisfies React.CSSProperties,
232 headerText: {
233 display: 'flex',
234 flexDirection: 'column',
235 gap: 2
236 } satisfies React.CSSProperties,
237 title: {
238 fontSize: 15,
239 fontWeight: 600
240 } satisfies React.CSSProperties,
241 subtitle: {
242 fontSize: 12,
243 fontWeight: 500
244 } satisfies React.CSSProperties,
245 pageMeta: {
246 fontSize: 12
247 } satisfies React.CSSProperties,
248 items: {
249 display: 'flex',
250 flexDirection: 'column'
251 } satisfies React.CSSProperties,
252 empty: {
253 padding: '24px 18px',
254 fontSize: 13,
255 textAlign: 'center'
256 } satisfies React.CSSProperties,
257 row: {
258 padding: '18px',
259 textDecoration: 'none',
260 display: 'flex',
261 flexDirection: 'column',
262 gap: 6,
263 transition: 'background-color 120ms ease'
264 } satisfies React.CSSProperties,
265 rowHeader: {
266 display: 'flex',
267 gap: 6,
268 alignItems: 'baseline',
269 fontSize: 13
270 } satisfies React.CSSProperties,
271 rowTime: {
272 fontSize: 12,
273 fontWeight: 500
274 } satisfies React.CSSProperties,
275 rowBody: {
276 margin: 0,
277 whiteSpace: 'pre-wrap',
278 fontSize: 14,
279 lineHeight: 1.45
280 } satisfies React.CSSProperties,
281 footer: {
282 display: 'flex',
283 alignItems: 'center',
284 justifyContent: 'space-between',
285 padding: '12px 18px',
286 borderTop: '1px solid transparent',
287 fontSize: 13
288 } satisfies React.CSSProperties,
289 navButton: {
290 border: 'none',
291 borderRadius: 999,
292 padding: '6px 12px',
293 fontSize: 13,
294 fontWeight: 500,
295 background: 'transparent',
296 display: 'flex',
297 alignItems: 'center',
298 gap: 4,
299 transition: 'background-color 120ms ease'
300 } satisfies React.CSSProperties,
301 pageChips: {
302 display: 'flex',
303 gap: 6,
304 alignItems: 'center'
305 } satisfies React.CSSProperties,
306 pageChip: {
307 padding: '4px 10px',
308 borderRadius: 999,
309 fontSize: 13,
310 border: '1px solid transparent'
311 } satisfies React.CSSProperties,
312 pageChipActive: {
313 padding: '4px 10px',
314 borderRadius: 999,
315 fontSize: 13,
316 fontWeight: 600,
317 border: '1px solid transparent'
318 } satisfies React.CSSProperties,
319 loadingBar: {
320 padding: '4px 18px 14px',
321 fontSize: 12,
322 textAlign: 'right',
323 color: '#64748b'
324 } satisfies React.CSSProperties
325};
326
327const lightPalette: ListPalette = {
328 card: {
329 background: '#ffffff',
330 borderColor: '#e2e8f0'
331 },
332 header: {
333 borderBottomColor: '#e2e8f0',
334 color: '#0f172a'
335 },
336 pageMeta: {
337 color: '#64748b'
338 },
339 subtitle: {
340 color: '#475569'
341 },
342 empty: {
343 color: '#64748b'
344 },
345 row: {
346 color: '#0f172a'
347 },
348 rowTime: {
349 color: '#94a3b8'
350 },
351 rowBody: {
352 color: '#0f172a'
353 },
354 divider: '#e2e8f0',
355 footer: {
356 borderTopColor: '#e2e8f0',
357 color: '#0f172a'
358 },
359 navButton: {
360 color: '#0f172a',
361 background: '#f1f5f9'
362 },
363 pageChip: {
364 color: '#475569',
365 borderColor: '#e2e8f0',
366 background: '#ffffff'
367 },
368 pageChipActive: {
369 color: '#ffffff',
370 background: '#0f172a',
371 borderColor: '#0f172a'
372 },
373 loadingBar: {
374 color: '#64748b'
375 }
376};
377
378const darkPalette: ListPalette = {
379 card: {
380 background: '#0f172a',
381 borderColor: '#1e293b'
382 },
383 header: {
384 borderBottomColor: '#1e293b',
385 color: '#e2e8f0'
386 },
387 pageMeta: {
388 color: '#94a3b8'
389 },
390 subtitle: {
391 color: '#94a3b8'
392 },
393 empty: {
394 color: '#94a3b8'
395 },
396 row: {
397 color: '#e2e8f0'
398 },
399 rowTime: {
400 color: '#94a3b8'
401 },
402 rowBody: {
403 color: '#e2e8f0'
404 },
405 divider: '#1e293b',
406 footer: {
407 borderTopColor: '#1e293b',
408 color: '#e2e8f0'
409 },
410 navButton: {
411 color: '#e2e8f0',
412 background: '#111c31'
413 },
414 pageChip: {
415 color: '#cbd5f5',
416 borderColor: '#1e293b',
417 background: '#0f172a'
418 },
419 pageChipActive: {
420 color: '#0f172a',
421 background: '#38bdf8',
422 borderColor: '#38bdf8'
423 },
424 loadingBar: {
425 color: '#94a3b8'
426 }
427};
428
429export default BlueskyPostList;