+7
-1
lib/components/BlueskyPostList.tsx
+7
-1
lib/components/BlueskyPostList.tsx
···
44
44
const actorLabel = resolvedHandle ?? formatDid(did);
45
45
const actorPath = resolvedHandle ?? resolvedDid ?? did;
46
46
47
-
const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({ did, collection: 'app.bsky.feed.post', limit });
47
+
const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({
48
+
did,
49
+
collection: 'app.bsky.feed.post',
50
+
limit,
51
+
preferAuthorFeed: true,
52
+
authorFeedActor: actorPath
53
+
});
48
54
49
55
const pageLabel = useMemo(() => {
50
56
const knownTotal = Math.max(pageIndex + 1, pagesCount);
+1
-1
lib/components/LeafletDocument.tsx
+1
-1
lib/components/LeafletDocument.tsx
···
111
111
const href = parsed ? toBlueskyPostUrl(parsed) : undefined;
112
112
if (href) return href;
113
113
}
114
-
return `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
114
+
return `at://${encodeURIComponent(did)}/${LEAFLET_DOCUMENT_COLLECTION}/${encodeURIComponent(rkey)}`;
115
115
}
116
116
117
117
export default LeafletDocument;
+33
-9
lib/core/AtProtoRecord.tsx
+33
-9
lib/core/AtProtoRecord.tsx
···
1
1
import React from 'react';
2
2
import { useAtProtoRecord } from '../hooks/useAtProtoRecord';
3
3
4
-
export interface AtProtoRecordProps<T = unknown> {
5
-
did: string;
6
-
collection: string;
7
-
rkey: string;
4
+
interface AtProtoRecordRenderProps<T> {
8
5
renderer?: React.ComponentType<{ record: T; loading: boolean; error?: Error }>;
9
6
fallback?: React.ReactNode;
10
7
loadingIndicator?: React.ReactNode;
11
8
}
12
9
13
-
export function AtProtoRecord<T = unknown>({ did, collection, rkey, renderer: Renderer, fallback = null, loadingIndicator = 'Loading…' }: AtProtoRecordProps<T>) {
14
-
const { record, error, loading } = useAtProtoRecord<T>({ did, collection, rkey });
10
+
type AtProtoRecordFetchProps<T> = AtProtoRecordRenderProps<T> & {
11
+
did: string;
12
+
collection: string;
13
+
rkey: string;
14
+
record?: undefined;
15
+
};
15
16
16
-
if (error) return <>{fallback}</>;
17
-
if (!record) return <>{loading ? loadingIndicator : fallback}</>;
18
-
if (Renderer) return <Renderer record={record} loading={loading} error={error} />;
17
+
type AtProtoRecordProvidedRecordProps<T> = AtProtoRecordRenderProps<T> & {
18
+
record: T;
19
+
did?: string;
20
+
collection?: string;
21
+
rkey?: string;
22
+
};
23
+
24
+
export type AtProtoRecordProps<T = unknown> = AtProtoRecordFetchProps<T> | AtProtoRecordProvidedRecordProps<T>;
25
+
26
+
export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) {
27
+
const { renderer: Renderer, fallback = null, loadingIndicator = 'Loading…' } = props;
28
+
const hasProvidedRecord = 'record' in props;
29
+
const providedRecord = hasProvidedRecord ? props.record : undefined;
30
+
31
+
const { record: fetchedRecord, error, loading } = useAtProtoRecord<T>({
32
+
did: hasProvidedRecord ? undefined : props.did,
33
+
collection: hasProvidedRecord ? undefined : props.collection,
34
+
rkey: hasProvidedRecord ? undefined : props.rkey,
35
+
});
36
+
37
+
const record = providedRecord ?? fetchedRecord;
38
+
const isLoading = loading && !providedRecord;
39
+
40
+
if (error && !record) return <>{fallback}</>;
41
+
if (!record) return <>{isLoading ? loadingIndicator : fallback}</>;
42
+
if (Renderer) return <Renderer record={record} loading={isLoading} error={error} />;
19
43
return <pre style={{ fontSize: 12, padding: 8, background: '#f5f5f5', overflow: 'auto' }}>{JSON.stringify(record, null, 2)}</pre>;
20
44
}
+5
-4
lib/hooks/useAtProtoRecord.ts
+5
-4
lib/hooks/useAtProtoRecord.ts
···
10
10
/** Repository DID (or handle prior to resolution) containing the record. */
11
11
did?: string;
12
12
/** NSID collection in which the record resides. */
13
-
collection: string;
13
+
collection?: string;
14
14
/** Record key string uniquely identifying the record within the collection. */
15
-
rkey: string;
15
+
rkey?: string;
16
16
}
17
17
18
18
/**
···
48
48
setState(prev => ({ ...prev, ...next }));
49
49
};
50
50
51
-
if (!handleOrDid) {
51
+
if (!handleOrDid || !collection || !rkey) {
52
52
assignState({ loading: false, record: undefined, error: undefined });
53
53
return () => { cancelled = true; };
54
54
}
···
85
85
const record = (res.data as { value: T }).value;
86
86
assignState({ record, loading: false });
87
87
} catch (e) {
88
-
assignState({ error: e as Error, loading: false });
88
+
const err = e instanceof Error ? e : new Error(String(e));
89
+
assignState({ error: err, loading: false });
89
90
}
90
91
})();
91
92
+161
-38
lib/hooks/usePaginatedRecords.ts
+161
-38
lib/hooks/usePaginatedRecords.ts
···
30
30
collection: string;
31
31
/** Maximum page size to request; defaults to `5`. */
32
32
limit?: number;
33
+
/** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
34
+
preferAuthorFeed?: boolean;
35
+
/** Optional filter applied when fetching from the appview author feed. */
36
+
authorFeedFilter?: AuthorFeedFilter;
37
+
/** Whether to include pinned posts when fetching from the author feed. */
38
+
authorFeedIncludePins?: boolean;
39
+
/** Override for the appview service base URL used to query the author feed. */
40
+
authorFeedService?: string;
41
+
/** Optional explicit actor identifier for the author feed request. */
42
+
authorFeedActor?: string;
33
43
}
34
44
35
45
/**
···
56
66
pagesCount: number;
57
67
}
58
68
69
+
const DEFAULT_APPVIEW_SERVICE = 'https://public.api.bsky.app';
70
+
71
+
export type AuthorFeedFilter =
72
+
| 'posts_with_replies'
73
+
| 'posts_no_replies'
74
+
| 'posts_with_media'
75
+
| 'posts_and_author_threads'
76
+
| 'posts_with_video';
77
+
59
78
/**
60
79
* React hook that fetches a repository collection with cursor-based pagination and prefetching.
61
80
*
···
64
83
* @param limit - Maximum number of records to request per page. Defaults to `5`.
65
84
* @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
66
85
*/
67
-
export function usePaginatedRecords<T>({ did: handleOrDid, collection, limit = 5 }: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
68
-
const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
86
+
export function usePaginatedRecords<T>({
87
+
did: handleOrDid,
88
+
collection,
89
+
limit = 5,
90
+
preferAuthorFeed = false,
91
+
authorFeedFilter,
92
+
authorFeedIncludePins,
93
+
authorFeedService,
94
+
authorFeedActor
95
+
}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
96
+
const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
69
97
const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
70
98
const [pages, setPages] = useState<PageData<T>[]>([]);
71
99
const [pageIndex, setPageIndex] = useState(0);
···
73
101
const [error, setError] = useState<Error | undefined>(undefined);
74
102
const inFlight = useRef<Set<string>>(new Set());
75
103
const requestSeq = useRef(0);
104
+
const identityRef = useRef<string | undefined>(undefined);
105
+
const feedDisabledRef = useRef(false);
106
+
107
+
const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
108
+
const normalizedInput = useMemo(() => {
109
+
if (!handleOrDid) return undefined;
110
+
const trimmed = handleOrDid.trim();
111
+
return trimmed || undefined;
112
+
}, [handleOrDid]);
113
+
114
+
const actorIdentifier = useMemo(() => {
115
+
const explicit = authorFeedActor?.trim();
116
+
if (explicit) return explicit;
117
+
if (handle) return handle;
118
+
if (normalizedInput) return normalizedInput;
119
+
if (did) return did;
120
+
return undefined;
121
+
}, [authorFeedActor, handle, normalizedInput, did]);
76
122
77
123
const resetState = useCallback(() => {
78
124
setPages([]);
···
80
126
setError(undefined);
81
127
inFlight.current.clear();
82
128
requestSeq.current += 1;
129
+
feedDisabledRef.current = false;
83
130
}, []);
84
131
85
-
const fetchPage = useCallback(async (cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
132
+
const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
86
133
if (!did || !endpoint) return;
134
+
const currentIdentity = `${did}::${endpoint}`;
135
+
if (identityKey !== currentIdentity) return;
87
136
const token = requestSeq.current;
88
-
const key = `${targetIndex}:${cursor ?? 'start'}`;
137
+
const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`;
89
138
if (inFlight.current.has(key)) return;
90
139
inFlight.current.add(key);
91
140
if (mode === 'active') {
···
93
142
setError(undefined);
94
143
}
95
144
try {
96
-
const { rpc } = await createAtprotoClient({ service: endpoint });
97
-
const res = await (rpc as unknown as {
98
-
get: (
99
-
nsid: string,
100
-
opts: { params: Record<string, string | number | boolean | undefined> }
101
-
) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
102
-
}).get('com.atproto.repo.listRecords', {
103
-
params: {
104
-
repo: did,
105
-
collection,
106
-
limit,
107
-
cursor,
108
-
reverse: false
145
+
let nextCursor: string | undefined;
146
+
let mapped: PaginatedRecord<T>[] | undefined;
147
+
148
+
const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier;
149
+
if (shouldUseAuthorFeed) {
150
+
try {
151
+
const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
152
+
const res = await (rpc as unknown as {
153
+
get: (
154
+
nsid: string,
155
+
opts: { params: Record<string, string | number | boolean | undefined> }
156
+
) => Promise<{ ok: boolean; data: { feed?: Array<{ post?: { uri?: string; record?: T } }>; cursor?: string } }>;
157
+
}).get('app.bsky.feed.getAuthorFeed', {
158
+
params: {
159
+
actor: actorIdentifier,
160
+
limit,
161
+
cursor,
162
+
filter: authorFeedFilter,
163
+
includePins: authorFeedIncludePins
164
+
}
165
+
});
166
+
if (!res.ok) throw new Error('Failed to fetch author feed');
167
+
const { feed, cursor: feedCursor } = res.data;
168
+
mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => {
169
+
const post = item?.post;
170
+
if (!post || typeof post.uri !== 'string' || !post.record) return acc;
171
+
acc.push({
172
+
uri: post.uri,
173
+
rkey: extractRkey(post.uri),
174
+
value: post.record as T
175
+
});
176
+
return acc;
177
+
}, []);
178
+
nextCursor = feedCursor;
179
+
} catch (err) {
180
+
feedDisabledRef.current = true;
181
+
if (process.env.NODE_ENV !== 'production') {
182
+
console.warn('[usePaginatedRecords] Author feed unavailable, falling back to PDS', err);
183
+
}
109
184
}
110
-
});
111
-
if (!res.ok) throw new Error('Failed to list records');
112
-
const { records, cursor: nextCursor } = res.data;
113
-
const mapped: PaginatedRecord<T>[] = records.map((item) => ({
114
-
uri: item.uri,
115
-
rkey: item.rkey ?? extractRkey(item.uri),
116
-
value: item.value
117
-
}));
118
-
if (token !== requestSeq.current) {
185
+
}
186
+
187
+
if (!mapped) {
188
+
const { rpc } = await createAtprotoClient({ service: endpoint });
189
+
const res = await (rpc as unknown as {
190
+
get: (
191
+
nsid: string,
192
+
opts: { params: Record<string, string | number | boolean | undefined> }
193
+
) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
194
+
}).get('com.atproto.repo.listRecords', {
195
+
params: {
196
+
repo: did,
197
+
collection,
198
+
limit,
199
+
cursor,
200
+
reverse: false
201
+
}
202
+
});
203
+
if (!res.ok) throw new Error('Failed to list records');
204
+
const { records, cursor: repoCursor } = res.data;
205
+
mapped = records.map((item) => ({
206
+
uri: item.uri,
207
+
rkey: item.rkey ?? extractRkey(item.uri),
208
+
value: item.value
209
+
}));
210
+
nextCursor = repoCursor;
211
+
}
212
+
213
+
if (token !== requestSeq.current || identityKey !== identityRef.current) {
119
214
return nextCursor;
120
215
}
121
216
if (mode === 'active') setPageIndex(targetIndex);
122
217
setPages(prev => {
123
218
const next = [...prev];
124
-
next[targetIndex] = { records: mapped, cursor: nextCursor };
219
+
next[targetIndex] = { records: mapped!, cursor: nextCursor };
125
220
return next;
126
221
});
127
222
return nextCursor;
128
223
} catch (e) {
129
-
if (mode === 'active') setError(e as Error);
224
+
if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
225
+
setError(e as Error);
226
+
}
130
227
} finally {
131
-
if (mode === 'active') setLoading(false);
228
+
if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
229
+
setLoading(false);
230
+
}
132
231
inFlight.current.delete(key);
133
232
}
134
233
return undefined;
135
-
}, [did, endpoint, collection, limit]);
234
+
}, [
235
+
did,
236
+
endpoint,
237
+
collection,
238
+
limit,
239
+
preferAuthorFeed,
240
+
actorIdentifier,
241
+
authorFeedService,
242
+
authorFeedFilter,
243
+
authorFeedIncludePins
244
+
]);
136
245
137
246
useEffect(() => {
138
247
if (!handleOrDid) {
248
+
identityRef.current = undefined;
139
249
resetState();
140
250
setLoading(false);
141
251
setError(undefined);
···
143
253
}
144
254
145
255
if (didError) {
256
+
identityRef.current = undefined;
146
257
resetState();
147
258
setLoading(false);
148
259
setError(didError);
···
150
261
}
151
262
152
263
if (endpointError) {
264
+
identityRef.current = undefined;
153
265
resetState();
154
266
setLoading(false);
155
267
setError(endpointError);
156
268
return;
157
269
}
158
270
159
-
if (resolvingDid || resolvingEndpoint || !did || !endpoint) {
160
-
resetState();
161
-
setLoading(true);
271
+
if (resolvingDid || resolvingEndpoint || !identity) {
272
+
if (identityRef.current !== identity) {
273
+
identityRef.current = identity;
274
+
resetState();
275
+
}
276
+
setLoading(!!handleOrDid);
162
277
setError(undefined);
163
278
return;
164
279
}
165
280
166
-
resetState();
167
-
fetchPage(undefined, 0, 'active').catch(() => {
281
+
if (identityRef.current !== identity) {
282
+
identityRef.current = identity;
283
+
resetState();
284
+
}
285
+
286
+
fetchPage(identity, undefined, 0, 'active').catch(() => {
168
287
/* error handled in state */
169
288
});
170
-
}, [handleOrDid, did, endpoint, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
289
+
}, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
171
290
172
291
const currentPage = pages[pageIndex];
173
292
const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
174
293
const hasPrev = pageIndex > 0;
175
294
176
295
const loadNext = useCallback(() => {
296
+
const identityKey = identityRef.current;
297
+
if (!identityKey) return;
177
298
const page = pages[pageIndex];
178
299
if (!page?.cursor && !pages[pageIndex + 1]) return;
179
300
if (pages[pageIndex + 1]) {
180
301
setPageIndex(pageIndex + 1);
181
302
return;
182
303
}
183
-
fetchPage(page.cursor, pageIndex + 1, 'active').catch(() => {
304
+
fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => {
184
305
/* handled via error state */
185
306
});
186
307
}, [fetchPage, pageIndex, pages]);
···
198
319
const cursor = pages[pageIndex]?.cursor;
199
320
if (!cursor) return;
200
321
if (pages[pageIndex + 1]) return;
201
-
fetchPage(cursor, pageIndex + 1, 'prefetch').catch(() => {
322
+
const identityKey = identityRef.current;
323
+
if (!identityKey) return;
324
+
fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => {
202
325
/* ignore prefetch errors */
203
326
});
204
327
}, [fetchPage, pageIndex, pages]);
+2
-1
lib/renderers/BlueskyPostRenderer.tsx
+2
-1
lib/renderers/BlueskyPostRenderer.tsx
+104
-16
lib/utils/atproto-client.ts
+104
-16
lib/utils/atproto-client.ts
···
1
-
import { Client, simpleFetchHandler } from '@atcute/client';
1
+
import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client';
2
2
import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver';
3
3
import type { DidDocument } from '@atcute/identity';
4
4
import type { Did, Handle } from '@atcute/lexicons/syntax';
···
17
17
const SUPPORTED_DID_METHODS = ['plc', 'web'] as const;
18
18
type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number];
19
19
type SupportedDid = Did<SupportedDidMethod>;
20
+
21
+
export const SLINGSHOT_BASE_URL = 'https://slingshot.microcosm.blue';
20
22
21
23
export const normalizeBaseUrl = (input: string): string => {
22
24
const trimmed = input.trim();
···
31
33
private plc: string;
32
34
private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
33
35
private handleResolver: XrpcHandleResolver;
36
+
private fetchImpl: typeof fetch;
34
37
constructor(opts: ServiceResolverOptions = {}) {
35
38
const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC;
36
39
const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE;
37
40
this.plc = normalizeBaseUrl(plcSource);
38
41
const identityBase = normalizeBaseUrl(identitySource);
39
-
const fetchImpl = opts.fetch ?? fetch;
40
-
const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: fetchImpl });
41
-
const webResolver = new WebDidDocumentResolver({ fetch: fetchImpl });
42
+
this.fetchImpl = bindFetch(opts.fetch);
43
+
const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: this.fetchImpl });
44
+
const webResolver = new WebDidDocumentResolver({ fetch: this.fetchImpl });
42
45
this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } });
43
-
this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: fetchImpl });
46
+
this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: this.fetchImpl });
44
47
}
45
48
46
49
async resolveDidDoc(did: string): Promise<DidDocument> {
···
66
69
async resolveHandle(handle: string): Promise<string> {
67
70
const normalized = handle.trim().toLowerCase();
68
71
if (!normalized) throw new Error('Handle cannot be empty');
69
-
return this.handleResolver.resolve(normalized as Handle);
72
+
let slingshotError: Error | undefined;
73
+
try {
74
+
const url = new URL('/xrpc/com.atproto.identity.resolveHandle', SLINGSHOT_BASE_URL);
75
+
url.searchParams.set('handle', normalized);
76
+
const response = await this.fetchImpl(url);
77
+
if (response.ok) {
78
+
const payload = await response.json() as { did?: string } | null;
79
+
if (payload?.did) {
80
+
console.info('[slingshot] resolveHandle cache hit', { handle: normalized });
81
+
return payload.did;
82
+
}
83
+
slingshotError = new Error('Slingshot resolveHandle response missing DID');
84
+
console.warn('[slingshot] resolveHandle payload missing DID; falling back', { handle: normalized });
85
+
} else {
86
+
slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`);
87
+
const body = response.body;
88
+
if (body) {
89
+
body.cancel().catch(() => {});
90
+
}
91
+
console.info('[slingshot] resolveHandle cache miss', { handle: normalized, status: response.status });
92
+
}
93
+
} catch (err) {
94
+
if (err instanceof DOMException && err.name === 'AbortError') throw err;
95
+
slingshotError = err instanceof Error ? err : new Error(String(err));
96
+
console.warn('[slingshot] resolveHandle error; falling back to identity service', { handle: normalized, error: slingshotError });
97
+
}
98
+
99
+
try {
100
+
const did = await this.handleResolver.resolve(normalized as Handle);
101
+
if (slingshotError) {
102
+
console.info('[slingshot] resolveHandle fallback succeeded', { handle: normalized });
103
+
}
104
+
return did;
105
+
} catch (err) {
106
+
if (slingshotError && err instanceof Error) {
107
+
const prior = err.message;
108
+
err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
109
+
if (slingshotError) {
110
+
console.warn('[slingshot] resolveHandle fallback failed', { handle: normalized, error: slingshotError });
111
+
}
112
+
}
113
+
throw err;
114
+
}
70
115
}
71
116
}
72
117
73
118
export interface CreateClientOptions extends ServiceResolverOptions {
74
-
did?: string; // optional to create a DID-scoped client
75
-
service?: string; // override service base url
119
+
did?: string; // optional to create a DID-scoped client
120
+
service?: string; // override service base url
76
121
}
77
122
78
123
export async function createAtprotoClient(opts: CreateClientOptions = {}) {
79
-
let service = opts.service;
80
-
const resolver = new ServiceResolver(opts);
81
-
if (!service && opts.did) {
82
-
service = await resolver.pdsEndpointForDid(opts.did);
83
-
}
124
+
const fetchImpl = bindFetch(opts.fetch);
125
+
let service = opts.service;
126
+
const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
127
+
if (!service && opts.did) {
128
+
service = await resolver.pdsEndpointForDid(opts.did);
129
+
}
84
130
if (!service) throw new Error('service or did required');
85
-
const handler = simpleFetchHandler({ service: normalizeBaseUrl(service) });
86
-
const rpc = new Client({ handler });
87
-
return { rpc, service, resolver };
131
+
const normalizedService = normalizeBaseUrl(service);
132
+
const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
133
+
const rpc = new Client({ handler });
134
+
return { rpc, service: normalizedService, resolver };
88
135
}
89
136
90
137
export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc'];
138
+
139
+
const SLINGSHOT_RETRY_PATHS = [
140
+
'/xrpc/com.atproto.repo.getRecord',
141
+
'/xrpc/com.atproto.identity.resolveHandle',
142
+
];
143
+
144
+
function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler {
145
+
const primary = simpleFetchHandler({ service, fetch: fetchImpl });
146
+
const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl });
147
+
return async (pathname, init) => {
148
+
const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`));
149
+
if (matched) {
150
+
try {
151
+
const slingshotResponse = await slingshot(pathname, init);
152
+
if (slingshotResponse.ok) {
153
+
console.info(`[slingshot] cache hit for ${matched}`);
154
+
return slingshotResponse;
155
+
}
156
+
const body = slingshotResponse.body;
157
+
if (body) {
158
+
body.cancel().catch(() => {});
159
+
}
160
+
console.info(`[slingshot] cache miss ${slingshotResponse.status} for ${matched}, falling back to ${service}`);
161
+
} catch (err) {
162
+
if (err instanceof DOMException && err.name === 'AbortError') {
163
+
throw err;
164
+
}
165
+
console.warn(`[slingshot] fetch error for ${matched}, falling back to ${service}`, err);
166
+
}
167
+
}
168
+
return primary(pathname, init);
169
+
};
170
+
}
171
+
172
+
function bindFetch(fetchImpl?: typeof fetch): typeof fetch {
173
+
const impl = fetchImpl ?? globalThis.fetch;
174
+
if (typeof impl !== 'function') {
175
+
throw new Error('fetch implementation not available');
176
+
}
177
+
return impl.bind(globalThis);
178
+
}
+1
-1
package.json
+1
-1
package.json
+9
-7
src/App.tsx
+9
-7
src/App.tsx
···
261
261
};
262
262
263
263
const LatestPostSummary: React.FC<{ did: string; handle?: string; colorScheme: ColorSchemePreference }> = ({ did, colorScheme }) => {
264
-
const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION);
264
+
const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION);
265
265
const scheme = useColorScheme(colorScheme);
266
266
const palette = scheme === 'dark' ? latestSummaryPalette.dark : latestSummaryPalette.light;
267
267
···
269
269
if (error) return <div style={palette.error}>Failed to load the latest post.</div>;
270
270
if (!rkey) return <div style={palette.muted}>No posts published yet.</div>;
271
271
272
+
const atProtoProps = record
273
+
? { record }
274
+
: { did, collection: 'app.bsky.feed.post', rkey };
275
+
272
276
return (
273
-
<AtProtoRecord<FeedPostRecord>
274
-
did={did}
275
-
collection="app.bsky.feed.post"
276
-
rkey={rkey}
277
-
renderer={({ record }) => (
277
+
<AtProtoRecord<FeedPostRecord>
278
+
{...atProtoProps}
279
+
renderer={({ record: resolvedRecord }) => (
278
280
<article data-color-scheme={scheme}>
279
-
<strong>{record?.text ?? 'Empty post'}</strong>
281
+
<strong>{resolvedRecord?.text ?? 'Empty post'}</strong>
280
282
</article>
281
283
)}
282
284
/>