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 reason={record.reason} 121 replyParent={record.replyParent} 122 hasDivider={idx < records.length - 1} 123 /> 124 ))} 125 {!loading && records.length === 0 && ( 126 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}> 127 No posts found. 128 </div> 129 )} 130 </div> 131 {enablePagination && ( 132 <div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}> 133 <button 134 type="button" 135 style={{ 136 ...listStyles.pageButton, 137 background: `var(--atproto-color-button-bg)`, 138 color: `var(--atproto-color-button-text)`, 139 cursor: hasPrev ? "pointer" : "not-allowed", 140 opacity: hasPrev ? 1 : 0.5, 141 }} 142 onClick={loadPrev} 143 disabled={!hasPrev} 144 > 145 Prev 146 </button> 147 <div style={listStyles.pageChips}> 148 <span 149 style={{ 150 ...listStyles.pageChipActive, 151 color: `var(--atproto-color-button-text)`, 152 background: `var(--atproto-color-button-bg)`, 153 borderWidth: "1px", 154 borderStyle: "solid", 155 borderColor: `var(--atproto-color-button-bg)`, 156 }} 157 > 158 {pageIndex + 1} 159 </span> 160 {(hasNext || pagesCount > pageIndex + 1) && ( 161 <span 162 style={{ 163 ...listStyles.pageChip, 164 color: `var(--atproto-color-text-secondary)`, 165 borderWidth: "1px", 166 borderStyle: "solid", 167 borderColor: `var(--atproto-color-border)`, 168 background: `var(--atproto-color-bg)`, 169 }} 170 > 171 {pageIndex + 2} 172 </span> 173 )} 174 </div> 175 <button 176 type="button" 177 style={{ 178 ...listStyles.pageButton, 179 background: `var(--atproto-color-button-bg)`, 180 color: `var(--atproto-color-button-text)`, 181 cursor: hasNext ? "pointer" : "not-allowed", 182 opacity: hasNext ? 1 : 0.5, 183 }} 184 onClick={loadNext} 185 disabled={!hasNext} 186 > 187 Next 188 </button> 189 </div> 190 )} 191 {loading && records.length > 0 && ( 192 <div 193 style={{ ...listStyles.loadingBar, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }} 194 > 195 Updating 196 </div> 197 )} 198 </div> 199 ); 200}); 201 202interface ListRowProps { 203 record: FeedPostRecord; 204 rkey: string; 205 did: string; 206 reason?: AuthorFeedReason; 207 replyParent?: ReplyParentInfo; 208 hasDivider: boolean; 209} 210 211const ListRow: React.FC<ListRowProps> = ({ 212 record, 213 rkey, 214 did, 215 reason, 216 replyParent, 217 hasDivider, 218}) => { 219 const { blueskyAppBaseUrl } = useAtProto(); 220 const text = record.text?.trim() ?? ""; 221 const relative = record.createdAt 222 ? formatRelativeTime(record.createdAt) 223 : undefined; 224 const absolute = record.createdAt 225 ? new Date(record.createdAt).toLocaleString() 226 : undefined; 227 const href = `${blueskyAppBaseUrl}/profile/${did}/post/${rkey}`; 228 const repostLabel = 229 reason?.$type === "app.bsky.feed.defs#reasonRepost" 230 ? `${formatActor(reason.by) ?? "Someone"} reposted` 231 : undefined; 232 const parentUri = replyParent?.uri ?? record.reply?.parent?.uri; 233 const parentDid = 234 replyParent?.author?.did ?? 235 (parentUri ? parseAtUri(parentUri)?.did : undefined); 236 const { handle: resolvedReplyHandle } = useDidResolution( 237 replyParent?.author?.handle ? undefined : parentDid, 238 ); 239 const replyLabel = formatReplyTarget( 240 parentUri, 241 replyParent, 242 resolvedReplyHandle, 243 ); 244 245 const postPreview = text.slice(0, 100); 246 const ariaLabel = text 247 ? `Post by ${did}: ${postPreview}${text.length > 100 ? '...' : ''}` 248 : `Post by ${did}`; 249 250 return ( 251 <a 252 href={href} 253 target="_blank" 254 rel="noopener noreferrer" 255 aria-label={ariaLabel} 256 style={{ 257 ...listStyles.row, 258 color: `var(--atproto-color-text)`, 259 borderBottom: hasDivider 260 ? `1px solid var(--atproto-color-border)` 261 : "none", 262 }} 263 > 264 {repostLabel && ( 265 <span style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}> 266 {repostLabel} 267 </span> 268 )} 269 {replyLabel && ( 270 <span style={{ ...listStyles.rowMeta, color: `var(--atproto-color-text-secondary)` }}> 271 {replyLabel} 272 </span> 273 )} 274 {relative && ( 275 <span 276 style={{ ...listStyles.rowTime, color: `var(--atproto-color-text-secondary)` }} 277 title={absolute} 278 > 279 {relative} 280 </span> 281 )} 282 {text && ( 283 <p style={{ ...listStyles.rowBody, color: `var(--atproto-color-text)` }}> 284 {text} 285 </p> 286 )} 287 {!text && ( 288 <p 289 style={{ 290 ...listStyles.rowBody, 291 color: `var(--atproto-color-text)`, 292 fontStyle: "italic", 293 }} 294 > 295 No text content. 296 </p> 297 )} 298 </a> 299 ); 300}; 301 302function formatDid(did: string) { 303 return did.replace(/^did:(plc:)?/, ""); 304} 305 306function formatRelativeTime(iso: string): string { 307 const date = new Date(iso); 308 const diffSeconds = (date.getTime() - Date.now()) / 1000; 309 const absSeconds = Math.abs(diffSeconds); 310 const thresholds: Array<{ 311 limit: number; 312 unit: Intl.RelativeTimeFormatUnit; 313 divisor: number; 314 }> = [ 315 { limit: 60, unit: "second", divisor: 1 }, 316 { limit: 3600, unit: "minute", divisor: 60 }, 317 { limit: 86400, unit: "hour", divisor: 3600 }, 318 { limit: 604800, unit: "day", divisor: 86400 }, 319 { limit: 2629800, unit: "week", divisor: 604800 }, 320 { limit: 31557600, unit: "month", divisor: 2629800 }, 321 { limit: Infinity, unit: "year", divisor: 31557600 }, 322 ]; 323 const threshold = 324 thresholds.find((t) => absSeconds < t.limit) ?? 325 thresholds[thresholds.length - 1]; 326 const value = diffSeconds / threshold.divisor; 327 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 328 return rtf.format(Math.round(value), threshold.unit); 329} 330 331 332const listStyles = { 333 card: { 334 borderRadius: 16, 335 borderWidth: "1px", 336 borderStyle: "solid", 337 borderColor: "transparent", 338 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)", 339 overflow: "hidden", 340 display: "flex", 341 flexDirection: "column", 342 } satisfies React.CSSProperties, 343 header: { 344 display: "flex", 345 alignItems: "center", 346 justifyContent: "space-between", 347 padding: "14px 18px", 348 fontSize: 14, 349 fontWeight: 500, 350 borderBottom: "1px solid transparent", 351 } satisfies React.CSSProperties, 352 headerInfo: { 353 display: "flex", 354 alignItems: "center", 355 gap: 12, 356 } satisfies React.CSSProperties, 357 headerIcon: { 358 width: 28, 359 height: 28, 360 display: "flex", 361 alignItems: "center", 362 justifyContent: "center", 363 //background: 'rgba(17, 133, 254, 0.14)', 364 borderRadius: "50%", 365 } satisfies React.CSSProperties, 366 headerText: { 367 display: "flex", 368 flexDirection: "column", 369 gap: 2, 370 } satisfies React.CSSProperties, 371 title: { 372 fontSize: 15, 373 fontWeight: 600, 374 } satisfies React.CSSProperties, 375 subtitle: { 376 fontSize: 12, 377 fontWeight: 500, 378 } satisfies React.CSSProperties, 379 pageMeta: { 380 fontSize: 12, 381 } satisfies React.CSSProperties, 382 items: { 383 display: "flex", 384 flexDirection: "column", 385 } satisfies React.CSSProperties, 386 empty: { 387 padding: "24px 18px", 388 fontSize: 13, 389 textAlign: "center", 390 } satisfies React.CSSProperties, 391 row: { 392 padding: "18px", 393 textDecoration: "none", 394 display: "flex", 395 flexDirection: "column", 396 gap: 6, 397 transition: "background-color 120ms ease", 398 } satisfies React.CSSProperties, 399 rowHeader: { 400 display: "flex", 401 gap: 6, 402 alignItems: "baseline", 403 fontSize: 13, 404 } satisfies React.CSSProperties, 405 rowTime: { 406 fontSize: 12, 407 fontWeight: 500, 408 } satisfies React.CSSProperties, 409 rowMeta: { 410 fontSize: 12, 411 fontWeight: 500, 412 letterSpacing: "0.6px", 413 } satisfies React.CSSProperties, 414 rowBody: { 415 margin: 0, 416 whiteSpace: "pre-wrap", 417 fontSize: 14, 418 lineHeight: 1.45, 419 } satisfies React.CSSProperties, 420 footer: { 421 display: "flex", 422 alignItems: "center", 423 justifyContent: "space-between", 424 padding: "12px 18px", 425 borderTop: "1px solid transparent", 426 fontSize: 13, 427 } satisfies React.CSSProperties, 428 navButton: { 429 border: "none", 430 borderRadius: 999, 431 padding: "6px 12px", 432 fontSize: 13, 433 fontWeight: 500, 434 background: "transparent", 435 display: "flex", 436 alignItems: "center", 437 gap: 4, 438 transition: "background-color 120ms ease", 439 } satisfies React.CSSProperties, 440 pageChips: { 441 display: "flex", 442 gap: 6, 443 alignItems: "center", 444 } satisfies React.CSSProperties, 445 pageChip: { 446 padding: "4px 10px", 447 borderRadius: 999, 448 fontSize: 13, 449 borderWidth: "1px", 450 borderStyle: "solid", 451 borderColor: "transparent", 452 } satisfies React.CSSProperties, 453 pageChipActive: { 454 padding: "4px 10px", 455 borderRadius: 999, 456 fontSize: 13, 457 fontWeight: 600, 458 borderWidth: "1px", 459 borderStyle: "solid", 460 borderColor: "transparent", 461 } satisfies React.CSSProperties, 462 pageButton: { 463 border: "none", 464 borderRadius: 999, 465 padding: "6px 12px", 466 fontSize: 13, 467 fontWeight: 500, 468 background: "transparent", 469 display: "flex", 470 alignItems: "center", 471 gap: 4, 472 transition: "background-color 120ms ease", 473 } satisfies React.CSSProperties, 474 loadingBar: { 475 padding: "4px 18px 14px", 476 fontSize: 12, 477 textAlign: "right", 478 color: "#64748b", 479 } satisfies React.CSSProperties, 480}; 481 482export default BlueskyPostList; 483 484function formatActor(actor?: { handle?: string; did?: string }) { 485 if (!actor) return undefined; 486 if (actor.handle) return `@${actor.handle}`; 487 if (actor.did) return `@${formatDid(actor.did)}`; 488 return undefined; 489} 490 491function formatReplyTarget( 492 parentUri?: string, 493 feedParent?: ReplyParentInfo, 494 resolvedHandle?: string, 495) { 496 const directHandle = feedParent?.author?.handle; 497 const handle = directHandle ?? resolvedHandle; 498 if (handle) { 499 return `Replying to @${handle}`; 500 } 501 const parentDid = feedParent?.author?.did; 502 const targetUri = feedParent?.uri ?? parentUri; 503 if (!targetUri) return undefined; 504 const parsed = parseAtUri(targetUri); 505 const did = parentDid ?? parsed?.did; 506 if (!did) return undefined; 507 return `Replying to @${formatDid(did)}`; 508}