A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

add replying to/reposted on postlist

Changed files
+100 -9
lib
+52 -2
lib/components/BlueskyPostList.tsx
··· 1 import React, { useMemo } from 'react'; 2 - import { usePaginatedRecords } from '../hooks/usePaginatedRecords'; 3 import { useColorScheme } from '../hooks/useColorScheme'; 4 import type { FeedPostRecord } from '../types/bluesky'; 5 import { useDidResolution } from '../hooks/useDidResolution'; 6 import { BlueskyIcon } from './BlueskyIcon'; 7 8 /** 9 * Options for rendering a paginated list of Bluesky posts. ··· 83 record={record.value} 84 rkey={record.rkey} 85 did={actorPath} 86 palette={palette} 87 hasDivider={idx < records.length - 1} 88 /> ··· 134 record: FeedPostRecord; 135 rkey: string; 136 did: string; 137 palette: ListPalette; 138 hasDivider: boolean; 139 } 140 141 - const 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} ··· 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 }; ··· 272 fontSize: 12, 273 fontWeight: 500 274 } satisfies React.CSSProperties, 275 rowBody: { 276 margin: 0, 277 whiteSpace: 'pre-wrap', ··· 351 rowBody: { 352 color: '#0f172a' 353 }, 354 divider: '#e2e8f0', 355 footer: { 356 borderTopColor: '#e2e8f0', ··· 402 rowBody: { 403 color: '#e2e8f0' 404 }, 405 divider: '#1e293b', 406 footer: { 407 borderTopColor: '#1e293b', ··· 427 }; 428 429 export default BlueskyPostList;
··· 1 import React, { useMemo } from 'react'; 2 + import { usePaginatedRecords, type AuthorFeedReason, type ReplyParentInfo } from '../hooks/usePaginatedRecords'; 3 import { useColorScheme } from '../hooks/useColorScheme'; 4 import type { FeedPostRecord } from '../types/bluesky'; 5 import { useDidResolution } from '../hooks/useDidResolution'; 6 import { BlueskyIcon } from './BlueskyIcon'; 7 + import { parseAtUri } from '../utils/at-uri'; 8 9 /** 10 * Options for rendering a paginated list of Bluesky posts. ··· 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 /> ··· 137 record: FeedPostRecord; 138 rkey: string; 139 did: string; 140 + reason?: AuthorFeedReason; 141 + replyParent?: ReplyParentInfo; 142 palette: ListPalette; 143 hasDivider: boolean; 144 } 145 146 + const 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} ··· 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 }; ··· 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', ··· 373 rowBody: { 374 color: '#0f172a' 375 }, 376 + rowMeta: { 377 + color: '#64748b' 378 + }, 379 divider: '#e2e8f0', 380 footer: { 381 borderTopColor: '#e2e8f0', ··· 427 rowBody: { 428 color: '#e2e8f0' 429 }, 430 + rowMeta: { 431 + color: '#94a3b8' 432 + }, 433 divider: '#1e293b', 434 footer: { 435 borderTopColor: '#1e293b', ··· 455 }; 456 457 export default BlueskyPostList; 458 + 459 + function 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 + 466 + function 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 + }
+48 -7
lib/hooks/usePaginatedRecords.ts
··· 13 rkey: string; 14 /** Raw record value. */ 15 value: T; 16 } 17 18 interface PageData<T> { ··· 83 | 'posts_and_author_threads' 84 | 'posts_with_video'; 85 86 /** 87 * React hook that fetches a repository collection with cursor-based pagination and prefetching. 88 * ··· 104 const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 105 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 106 const [pages, setPages] = useState<PageData<T>[]>([]); 107 - const [pageIndex, setPageIndex] = useState(0); 108 const [loading, setLoading] = useState(false); 109 const [error, setError] = useState<Error | undefined>(undefined); 110 const inFlight = useRef<Set<string>>(new Set()); ··· 157 if (shouldUseAuthorFeed) { 158 try { 159 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE }); 160 - const res = await (rpc as unknown as { 161 - get: ( 162 - nsid: string, 163 - opts: { params: Record<string, string | number | boolean | undefined> } 164 - ) => Promise<{ ok: boolean; data: { feed?: Array<{ post?: { uri?: string; record?: T } }>; cursor?: string } }>; 165 }).get('app.bsky.feed.getAuthorFeed', { 166 params: { 167 actor: actorIdentifier, ··· 179 acc.push({ 180 uri: post.uri, 181 rkey: extractRkey(post.uri), 182 - value: post.record as T 183 }); 184 return acc; 185 }, []);
··· 13 rkey: string; 14 /** Raw record value. */ 15 value: T; 16 + /** Optional feed metadata (for example, repost context). */ 17 + reason?: AuthorFeedReason; 18 + /** Optional reply context derived from feed metadata. */ 19 + replyParent?: ReplyParentInfo; 20 } 21 22 interface PageData<T> { ··· 87 | 'posts_and_author_threads' 88 | 'posts_with_video'; 89 90 + export interface AuthorFeedReason { 91 + $type?: string; 92 + by?: { 93 + handle?: string; 94 + did?: string; 95 + }; 96 + indexedAt?: string; 97 + } 98 + 99 + export interface ReplyParentInfo { 100 + uri?: string; 101 + author?: { 102 + handle?: string; 103 + did?: string; 104 + }; 105 + } 106 + 107 /** 108 * React hook that fetches a repository collection with cursor-based pagination and prefetching. 109 * ··· 125 const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 126 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 127 const [pages, setPages] = useState<PageData<T>[]>([]); 128 + const [pageIndex, setPageIndex] = useState(0); 129 const [loading, setLoading] = useState(false); 130 const [error, setError] = useState<Error | undefined>(undefined); 131 const inFlight = useRef<Set<string>>(new Set()); ··· 178 if (shouldUseAuthorFeed) { 179 try { 180 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE }); 181 + const res = await (rpc as unknown as { 182 + get: ( 183 + nsid: string, 184 + opts: { params: Record<string, string | number | boolean | undefined> } 185 + ) => Promise<{ 186 + ok: boolean; 187 + data: { 188 + feed?: Array<{ 189 + post?: { 190 + uri?: string; 191 + record?: T; 192 + reply?: { 193 + parent?: { 194 + uri?: string; 195 + author?: { handle?: string; did?: string }; 196 + }; 197 + }; 198 + }; 199 + reason?: AuthorFeedReason; 200 + }>; 201 + cursor?: string; 202 + }; 203 + }>; 204 }).get('app.bsky.feed.getAuthorFeed', { 205 params: { 206 actor: actorIdentifier, ··· 218 acc.push({ 219 uri: post.uri, 220 rkey: extractRkey(post.uri), 221 + value: post.record as T, 222 + reason: item?.reason, 223 + replyParent: post.reply?.parent 224 }); 225 return acc; 226 }, []);