+52
-2
lib/components/BlueskyPostList.tsx
+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
+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
}, []);