+52
-2
lib/components/BlueskyPostList.tsx
+52
-2
lib/components/BlueskyPostList.tsx
···
1
1
import React, { useMemo } from 'react';
2
-
import { usePaginatedRecords } from '../hooks/usePaginatedRecords';
2
+
import { usePaginatedRecords, type AuthorFeedReason, type ReplyParentInfo } from '../hooks/usePaginatedRecords';
3
3
import { useColorScheme } from '../hooks/useColorScheme';
4
4
import type { FeedPostRecord } from '../types/bluesky';
5
5
import { useDidResolution } from '../hooks/useDidResolution';
6
6
import { BlueskyIcon } from './BlueskyIcon';
7
+
import { parseAtUri } from '../utils/at-uri';
7
8
8
9
/**
9
10
* Options for rendering a paginated list of Bluesky posts.
···
83
84
record={record.value}
84
85
rkey={record.rkey}
85
86
did={actorPath}
87
+
reason={record.reason}
88
+
replyParent={record.replyParent}
86
89
palette={palette}
87
90
hasDivider={idx < records.length - 1}
88
91
/>
···
134
137
record: FeedPostRecord;
135
138
rkey: string;
136
139
did: string;
140
+
reason?: AuthorFeedReason;
141
+
replyParent?: ReplyParentInfo;
137
142
palette: ListPalette;
138
143
hasDivider: boolean;
139
144
}
140
145
141
-
const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, palette, hasDivider }) => {
146
+
const ListRow: React.FC<ListRowProps> = ({ record, rkey, did, reason, replyParent, palette, hasDivider }) => {
142
147
const text = record.text?.trim() ?? '';
143
148
const relative = record.createdAt ? formatRelativeTime(record.createdAt) : undefined;
144
149
const absolute = record.createdAt ? new Date(record.createdAt).toLocaleString() : undefined;
145
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);
146
160
147
161
return (
148
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>}
149
165
{relative && (
150
166
<span style={{ ...listStyles.rowTime, ...palette.rowTime }} title={absolute}>
151
167
{relative}
···
189
205
row: { color: string };
190
206
rowTime: { color: string };
191
207
rowBody: { color: string };
208
+
rowMeta: { color: string };
192
209
divider: string;
193
210
footer: { borderTopColor: string; color: string };
194
211
navButton: { color: string; background: string };
···
272
289
fontSize: 12,
273
290
fontWeight: 500
274
291
} satisfies React.CSSProperties,
292
+
rowMeta: {
293
+
fontSize: 12,
294
+
fontWeight: 500,
295
+
letterSpacing: '0.6px'
296
+
} satisfies React.CSSProperties,
275
297
rowBody: {
276
298
margin: 0,
277
299
whiteSpace: 'pre-wrap',
···
351
373
rowBody: {
352
374
color: '#0f172a'
353
375
},
376
+
rowMeta: {
377
+
color: '#64748b'
378
+
},
354
379
divider: '#e2e8f0',
355
380
footer: {
356
381
borderTopColor: '#e2e8f0',
···
402
427
rowBody: {
403
428
color: '#e2e8f0'
404
429
},
430
+
rowMeta: {
431
+
color: '#94a3b8'
432
+
},
405
433
divider: '#1e293b',
406
434
footer: {
407
435
borderTopColor: '#1e293b',
···
427
455
};
428
456
429
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
13
rkey: string;
14
14
/** Raw record value. */
15
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;
16
20
}
17
21
18
22
interface PageData<T> {
···
83
87
| 'posts_and_author_threads'
84
88
| 'posts_with_video';
85
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
+
86
107
/**
87
108
* React hook that fetches a repository collection with cursor-based pagination and prefetching.
88
109
*
···
104
125
const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
105
126
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
106
127
const [pages, setPages] = useState<PageData<T>[]>([]);
107
-
const [pageIndex, setPageIndex] = useState(0);
128
+
const [pageIndex, setPageIndex] = useState(0);
108
129
const [loading, setLoading] = useState(false);
109
130
const [error, setError] = useState<Error | undefined>(undefined);
110
131
const inFlight = useRef<Set<string>>(new Set());
···
157
178
if (shouldUseAuthorFeed) {
158
179
try {
159
180
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 } }>;
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
+
}>;
165
204
}).get('app.bsky.feed.getAuthorFeed', {
166
205
params: {
167
206
actor: actorIdentifier,
···
179
218
acc.push({
180
219
uri: post.uri,
181
220
rkey: extractRkey(post.uri),
182
-
value: post.record as T
221
+
value: post.record as T,
222
+
reason: item?.reason,
223
+
replyParent: post.reply?.parent
183
224
});
184
225
return acc;
185
226
}, []);