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
···
import React, { useMemo } from 'react';
-
import { usePaginatedRecords } from '../hooks/usePaginatedRecords';
+
import { usePaginatedRecords, type AuthorFeedReason, type ReplyParentInfo } from '../hooks/usePaginatedRecords';
import { useColorScheme } from '../hooks/useColorScheme';
import type { FeedPostRecord } from '../types/bluesky';
import { useDidResolution } from '../hooks/useDidResolution';
import { BlueskyIcon } from './BlueskyIcon';
+
import { parseAtUri } from '../utils/at-uri';
/**
* Options for rendering a paginated list of Bluesky posts.
···
record={record.value}
rkey={record.rkey}
did={actorPath}
+
reason={record.reason}
+
replyParent={record.replyParent}
palette={palette}
hasDivider={idx < records.length - 1}
/>
···
record: FeedPostRecord;
rkey: string;
did: string;
+
reason?: AuthorFeedReason;
+
replyParent?: ReplyParentInfo;
palette: ListPalette;
hasDivider: boolean;
}
-
const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, palette, hasDivider }) => {
+
const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, reason, replyParent, palette, hasDivider }) => {
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 = `https://bsky.app/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 (
<a href={href} target="_blank" rel="noopener noreferrer" style={{ ...listStyles.row, ...palette.row, borderBottom: hasDivider ? `1px solid ${palette.divider}` : 'none' }}>
+
{repostLabel && <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>{repostLabel}</span>}
+
{replyLabel && <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}>{replyLabel}</span>}
{relative && (
<span style={{ ...listStyles.rowTime, ...palette.rowTime }} title={absolute}>
{relative}
···
row: { color: string };
rowTime: { color: string };
rowBody: { color: string };
+
rowMeta: { color: string };
divider: string;
footer: { borderTopColor: string; color: string };
navButton: { color: string; background: string };
···
fontSize: 12,
fontWeight: 500
} satisfies React.CSSProperties,
+
rowMeta: {
+
fontSize: 12,
+
fontWeight: 500,
+
letterSpacing: '0.6px'
+
} satisfies React.CSSProperties,
rowBody: {
margin: 0,
whiteSpace: 'pre-wrap',
···
rowBody: {
color: '#0f172a'
},
+
rowMeta: {
+
color: '#64748b'
+
},
divider: '#e2e8f0',
footer: {
borderTopColor: '#e2e8f0',
···
rowBody: {
color: '#e2e8f0'
},
+
rowMeta: {
+
color: '#94a3b8'
+
},
divider: '#1e293b',
footer: {
borderTopColor: '#1e293b',
···
};
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)}`;
+
}
+48 -7
lib/hooks/usePaginatedRecords.ts
···
rkey: string;
/** Raw record value. */
value: T;
+
/** Optional feed metadata (for example, repost context). */
+
reason?: AuthorFeedReason;
+
/** Optional reply context derived from feed metadata. */
+
replyParent?: ReplyParentInfo;
}
interface PageData<T> {
···
| 'posts_and_author_threads'
| 'posts_with_video';
+
export interface AuthorFeedReason {
+
$type?: string;
+
by?: {
+
handle?: string;
+
did?: string;
+
};
+
indexedAt?: string;
+
}
+
+
export interface ReplyParentInfo {
+
uri?: string;
+
author?: {
+
handle?: string;
+
did?: string;
+
};
+
}
+
/**
* React hook that fetches a repository collection with cursor-based pagination and prefetching.
*
···
const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
const [pages, setPages] = useState<PageData<T>[]>([]);
-
const [pageIndex, setPageIndex] = useState(0);
+
const [pageIndex, setPageIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const inFlight = useRef<Set<string>>(new Set());
···
if (shouldUseAuthorFeed) {
try {
const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
-
const res = await (rpc as unknown as {
-
get: (
-
nsid: string,
-
opts: { params: Record<string, string | number | boolean | undefined> }
-
) => Promise<{ ok: boolean; data: { feed?: Array<{ post?: { uri?: string; record?: T } }>; cursor?: string } }>;
+
const res = await (rpc as unknown as {
+
get: (
+
nsid: string,
+
opts: { params: Record<string, string | number | boolean | undefined> }
+
) => Promise<{
+
ok: boolean;
+
data: {
+
feed?: Array<{
+
post?: {
+
uri?: string;
+
record?: T;
+
reply?: {
+
parent?: {
+
uri?: string;
+
author?: { handle?: string; did?: string };
+
};
+
};
+
};
+
reason?: AuthorFeedReason;
+
}>;
+
cursor?: string;
+
};
+
}>;
}).get('app.bsky.feed.getAuthorFeed', {
params: {
actor: actorIdentifier,
···
acc.push({
uri: post.uri,
rkey: extractRkey(post.uri),
-
value: post.record as T
+
value: post.record as T,
+
reason: item?.reason,
+
replyParent: post.reply?.parent
});
return acc;
}, []);