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}