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;