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;