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