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