import React, { useMemo } from "react"; import { usePaginatedRecords, type AuthorFeedReason, type ReplyParentInfo, } from "../hooks/usePaginatedRecords"; import type { FeedPostRecord } from "../types/bluesky"; import { useDidResolution } from "../hooks/useDidResolution"; import { BlueskyIcon } from "./BlueskyIcon"; import { parseAtUri } from "../utils/at-uri"; import { useAtProto } from "../providers/AtProtoProvider"; /** * Options for rendering a paginated list of Bluesky posts. */ export interface BlueskyPostListProps { /** * DID whose feed posts should be fetched. */ did: string; /** * Maximum number of records to list per page. Defaults to `5`. */ limit?: number; /** * Enables pagination controls when `true`. Defaults to `true`. */ enablePagination?: boolean; } /** * Fetches a DID's feed posts and renders them with rich pagination and theming. * * @param did - DID whose posts should be displayed. * @param limit - Maximum number of posts per page. Default `5`. * @param enablePagination - Whether pagination controls should render. Default `true`. * @returns A card-like list element with loading, empty, and error handling. */ export const BlueskyPostList: React.FC = React.memo(({ did, limit = 5, enablePagination = true, }) => { const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did); const actorLabel = resolvedHandle ?? formatDid(did); const actorPath = resolvedHandle ?? resolvedDid ?? did; const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount, } = usePaginatedRecords({ did, collection: "app.bsky.feed.post", limit, preferAuthorFeed: true, authorFeedActor: actorPath, }); const pageLabel = useMemo(() => { const knownTotal = Math.max(pageIndex + 1, pagesCount); if (!enablePagination) return undefined; if (hasNext && knownTotal === pageIndex + 1) return `${pageIndex + 1}/…`; return `${pageIndex + 1}/${knownTotal}`; }, [enablePagination, hasNext, pageIndex, pagesCount]); if (error) return (
Failed to load posts.
); return (
Latest Posts @{actorLabel}
{pageLabel && ( {pageLabel} )}
{loading && records.length === 0 && (
Loading posts…
)} {records.map((record, idx) => ( ))} {!loading && records.length === 0 && (
No posts found.
)}
{enablePagination && (
{pageIndex + 1} {(hasNext || pagesCount > pageIndex + 1) && ( {pageIndex + 2} )}
)} {loading && records.length > 0 && (
Updating…
)}
); }); interface ListRowProps { record: FeedPostRecord; rkey: string; did: string; reason?: AuthorFeedReason; replyParent?: ReplyParentInfo; hasDivider: boolean; } const ListRow: React.FC = ({ record, rkey, did, reason, replyParent, hasDivider, }) => { const { blueskyAppBaseUrl } = useAtProto(); const text = record.text?.trim() ?? ""; const relative = record.createdAt ? formatRelativeTime(record.createdAt) : undefined; const absolute = record.createdAt ? new Date(record.createdAt).toLocaleString() : undefined; const href = `${blueskyAppBaseUrl}/profile/${did}/post/${rkey}`; const repostLabel = reason?.$type === "app.bsky.feed.defs#reasonRepost" ? `${formatActor(reason.by) ?? "Someone"} reposted` : undefined; const parentUri = replyParent?.uri ?? record.reply?.parent?.uri; const parentDid = replyParent?.author?.did ?? (parentUri ? parseAtUri(parentUri)?.did : undefined); const { handle: resolvedReplyHandle } = useDidResolution( replyParent?.author?.handle ? undefined : parentDid, ); const replyLabel = formatReplyTarget( parentUri, replyParent, resolvedReplyHandle, ); return ( {repostLabel && ( {repostLabel} )} {replyLabel && ( {replyLabel} )} {relative && ( {relative} )} {text && (

{text}

)} {!text && (

No text content.

)}
); }; function formatDid(did: string) { return did.replace(/^did:(plc:)?/, ""); } function formatRelativeTime(iso: string): string { const date = new Date(iso); const diffSeconds = (date.getTime() - Date.now()) / 1000; const absSeconds = Math.abs(diffSeconds); const thresholds: Array<{ limit: number; unit: Intl.RelativeTimeFormatUnit; divisor: number; }> = [ { limit: 60, unit: "second", divisor: 1 }, { limit: 3600, unit: "minute", divisor: 60 }, { limit: 86400, unit: "hour", divisor: 3600 }, { limit: 604800, unit: "day", divisor: 86400 }, { limit: 2629800, unit: "week", divisor: 604800 }, { limit: 31557600, unit: "month", divisor: 2629800 }, { limit: Infinity, unit: "year", divisor: 31557600 }, ]; const threshold = thresholds.find((t) => absSeconds < t.limit) ?? thresholds[thresholds.length - 1]; const value = diffSeconds / threshold.divisor; const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); return rtf.format(Math.round(value), threshold.unit); } const listStyles = { card: { borderRadius: 16, borderWidth: "1px", borderStyle: "solid", borderColor: "transparent", boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)", overflow: "hidden", display: "flex", flexDirection: "column", } satisfies React.CSSProperties, header: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 18px", fontSize: 14, fontWeight: 500, borderBottom: "1px solid transparent", } satisfies React.CSSProperties, headerInfo: { display: "flex", alignItems: "center", gap: 12, } satisfies React.CSSProperties, headerIcon: { width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center", //background: 'rgba(17, 133, 254, 0.14)', borderRadius: "50%", } satisfies React.CSSProperties, headerText: { display: "flex", flexDirection: "column", gap: 2, } satisfies React.CSSProperties, title: { fontSize: 15, fontWeight: 600, } satisfies React.CSSProperties, subtitle: { fontSize: 12, fontWeight: 500, } satisfies React.CSSProperties, pageMeta: { fontSize: 12, } satisfies React.CSSProperties, items: { display: "flex", flexDirection: "column", } satisfies React.CSSProperties, empty: { padding: "24px 18px", fontSize: 13, textAlign: "center", } satisfies React.CSSProperties, row: { padding: "18px", textDecoration: "none", display: "flex", flexDirection: "column", gap: 6, transition: "background-color 120ms ease", } satisfies React.CSSProperties, rowHeader: { display: "flex", gap: 6, alignItems: "baseline", fontSize: 13, } satisfies React.CSSProperties, rowTime: { fontSize: 12, fontWeight: 500, } satisfies React.CSSProperties, rowMeta: { fontSize: 12, fontWeight: 500, letterSpacing: "0.6px", } satisfies React.CSSProperties, rowBody: { margin: 0, whiteSpace: "pre-wrap", fontSize: 14, lineHeight: 1.45, } satisfies React.CSSProperties, footer: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderTop: "1px solid transparent", fontSize: 13, } satisfies React.CSSProperties, navButton: { border: "none", borderRadius: 999, padding: "6px 12px", fontSize: 13, fontWeight: 500, background: "transparent", display: "flex", alignItems: "center", gap: 4, transition: "background-color 120ms ease", } satisfies React.CSSProperties, pageChips: { display: "flex", gap: 6, alignItems: "center", } satisfies React.CSSProperties, pageChip: { padding: "4px 10px", borderRadius: 999, fontSize: 13, borderWidth: "1px", borderStyle: "solid", borderColor: "transparent", } satisfies React.CSSProperties, pageChipActive: { padding: "4px 10px", borderRadius: 999, fontSize: 13, fontWeight: 600, borderWidth: "1px", borderStyle: "solid", borderColor: "transparent", } satisfies React.CSSProperties, pageButton: { border: "none", borderRadius: 999, padding: "6px 12px", fontSize: 13, fontWeight: 500, background: "transparent", display: "flex", alignItems: "center", gap: 4, transition: "background-color 120ms ease", } satisfies React.CSSProperties, loadingBar: { padding: "4px 18px 14px", fontSize: 12, textAlign: "right", color: "#64748b", } satisfies React.CSSProperties, }; export default BlueskyPostList; function formatActor(actor?: { handle?: string; did?: string }) { if (!actor) return undefined; if (actor.handle) return `@${actor.handle}`; if (actor.did) return `@${formatDid(actor.did)}`; return undefined; } function formatReplyTarget( parentUri?: string, feedParent?: ReplyParentInfo, resolvedHandle?: string, ) { const directHandle = feedParent?.author?.handle; const handle = directHandle ?? resolvedHandle; if (handle) { return `Replying to @${handle}`; } const parentDid = feedParent?.author?.did; const targetUri = feedParent?.uri ?? parentUri; if (!targetUri) return undefined; const parsed = parseAtUri(targetUri); const did = parentDid ?? parsed?.did; if (!did) return undefined; return `Replying to @${formatDid(did)}`; }