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