Skip to content

Commit 6837841

Browse files
authored
Merge pull request #20 from afbase/fallback-implementation
Fallback implementation
2 parents 433509f + 0ea57b0 commit 6837841

File tree

9 files changed

+905
-22
lines changed

9 files changed

+905
-22
lines changed

src/state/queries/actor-autocomplete.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react'
22
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
3-
import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query'
3+
import {fetchQueryWithFallback, keepPreviousData, useQuery, useQueryClient} from './useQueryWithFallback'
44

55
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
66
import {logger} from '#/logger'
@@ -69,14 +69,16 @@ export function useActorAutocompleteFn() {
6969
let res
7070
if (query) {
7171
try {
72-
res = await queryClient.fetchQuery({
73-
staleTime: STALE.MINUTES.ONE,
72+
res = await fetchQueryWithFallback(queryClient, {
7473
queryKey: RQKEY(query || ''),
7574
queryFn: () =>
7675
agent.searchActorsTypeahead({
7776
q: query,
7877
limit,
7978
}),
79+
enableFallback: true,
80+
fallbackType: 'profile',
81+
fallbackIdentifier: query,
8082
})
8183
} catch (e) {
8284
logger.error('useActorSearch: searchActorsTypeahead failed', {

src/state/queries/handle.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import {useMutation, useQueryClient} from '@tanstack/react-query'
2+
import {fetchQueryWithFallback, useMutation, useQueryClient} from './useQueryWithFallback'
33

44
import {STALE} from '#/state/queries'
55
import {useAgent} from '#/state/session'
@@ -19,10 +19,12 @@ export function useFetchHandle() {
1919
return React.useCallback(
2020
async (handleOrDid: string) => {
2121
if (handleOrDid.startsWith('did:')) {
22-
const res = await queryClient.fetchQuery({
23-
staleTime: STALE.MINUTES.FIVE,
22+
const res = await fetchQueryWithFallback(queryClient, {
2423
queryKey: fetchHandleQueryKey(handleOrDid),
2524
queryFn: () => agent.getProfile({actor: handleOrDid}),
25+
enableFallback: true,
26+
fallbackType: 'profile',
27+
fallbackIdentifier: handleOrDid,
2628
})
2729
return res.data.handle
2830
}
@@ -57,8 +59,7 @@ export function useFetchDid() {
5759

5860
return React.useCallback(
5961
async (handleOrDid: string) => {
60-
return queryClient.fetchQuery({
61-
staleTime: STALE.INFINITY,
62+
return fetchQueryWithFallback(queryClient, {
6263
queryKey: fetchDidQueryKey(handleOrDid),
6364
queryFn: async () => {
6465
let identifier = handleOrDid
@@ -68,6 +69,9 @@ export function useFetchDid() {
6869
}
6970
return identifier
7071
},
72+
enableFallback: true,
73+
fallbackType: 'profile',
74+
fallbackIdentifier: handleOrDid,
7175
})
7276
},
7377
[queryClient, agent],
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
const SLINGSHOT_URL = 'https://slingshot.microcosm.blue'
2+
const CONSTELLATION_URL = 'https://constellation.microcosm.blue'
3+
4+
export interface MicrocosmRecord {
5+
uri: string
6+
cid: string
7+
value: any
8+
}
9+
10+
export interface ConstellationCounts {
11+
likeCount: number
12+
repostCount: number
13+
replyCount: number
14+
// might need a saves/bookmark counter
15+
// bookmarkCount: number
16+
}
17+
18+
/**
19+
* Generic helper to fetch from Slingshot proxy
20+
*/
21+
async function fetchFromSlingshot<T>(
22+
endpoint: string,
23+
params: Record<string, string>,
24+
): Promise<T | null> {
25+
try {
26+
// URLSearchParams handles encoding automatically, no need for manual encodeURIComponent
27+
const queryString = new URLSearchParams(params).toString()
28+
29+
const res = await fetch(`${SLINGSHOT_URL}/xrpc/${endpoint}?${queryString}`)
30+
if (!res.ok) return null
31+
return await res.json()
32+
} catch (e) {
33+
console.error(`Slingshot fetch failed (${endpoint}):`, e)
34+
return null
35+
}
36+
}
37+
38+
/**
39+
* Fetch a record directly from PDS via Slingshot proxy
40+
*
41+
* Uses the ergonomic `com.bad-example.repo.getUriRecord` endpoint which accepts
42+
* a full at-uri instead of separate repo/collection/rkey parameters.
43+
*
44+
* @see https://slingshot.microcosm.blue/ for full API documentation
45+
*/
46+
export async function fetchRecordViaSlingshot(
47+
atUri: string,
48+
): Promise<MicrocosmRecord | null> {
49+
return fetchFromSlingshot<MicrocosmRecord>(
50+
'com.bad-example.repo.getUriRecord',
51+
{at_uri: atUri},
52+
)
53+
}
54+
55+
/**
56+
* Resolve identity (DID/handle) via Slingshot
57+
*
58+
* Uses `com.bad-example.identity.resolveMiniDoc` which returns a compact identity
59+
* document with bi-directionally verified DID/handle and the user's PDS URL.
60+
* This is more convenient than the standard resolveHandle + describeRepo flow.
61+
*
62+
* @returns {did, handle, pds} or null if resolution fails
63+
* @see https://slingshot.microcosm.blue/ for full API documentation
64+
*/
65+
export async function resolveIdentityViaSlingshot(
66+
identifier: string,
67+
): Promise<{did: string; handle: string; pds: string} | null> {
68+
return fetchFromSlingshot('com.bad-example.identity.resolveMiniDoc', {
69+
identifier,
70+
})
71+
}
72+
73+
/**
74+
* Fetch engagement counts from Constellation backlink indexer
75+
*
76+
* Constellation indexes all social interactions (likes, reposts, replies) as backlinks
77+
* to posts. This provides real engagement counts even for AppView-suspended users.
78+
*
79+
* Note: Constellation only provides per-post engagement counts, not profile-level
80+
* aggregates (total followers, following, posts).
81+
*
82+
* @see https://constellation.microcosm.blue/ for more about Constellation
83+
*/
84+
export async function fetchConstellationCounts(
85+
atUri: string,
86+
): Promise<ConstellationCounts> {
87+
try {
88+
const res = await fetch(
89+
`${CONSTELLATION_URL}/links/all?target=${encodeURIComponent(atUri)}`,
90+
)
91+
if (!res.ok) throw new Error('Constellation fetch failed')
92+
93+
const data = await res.json()
94+
const links = data.links || {}
95+
96+
return {
97+
likeCount:
98+
links?.['app.bsky.feed.like']?.['.subject.uri']?.distinct_dids ?? 0,
99+
repostCount:
100+
links?.['app.bsky.feed.repost']?.['.subject.uri']?.distinct_dids ?? 0,
101+
replyCount:
102+
links?.['app.bsky.feed.post']?.['.reply.parent.uri']?.records ?? 0,
103+
}
104+
} catch (e) {
105+
console.error('Constellation fetch failed:', e)
106+
return {likeCount: 0, repostCount: 0, replyCount: 0}
107+
}
108+
}
109+
110+
/**
111+
* Detect if error is AppView-related (suspended user, not found, etc.)
112+
*/
113+
export function isAppViewError(error: any): boolean {
114+
if (!error) return false
115+
116+
// Check HTTP status codes
117+
if (error.status === 400 || error.status === 404) return true
118+
119+
// Check error messages
120+
// TODO: see if there is an easy way to source error messages from the appview
121+
const msg = error.message?.toLowerCase() || ''
122+
if (msg.includes('not found')) return true
123+
if (msg.includes('suspended')) return true
124+
if (msg.includes('could not locate')) return true
125+
126+
return false
127+
}
128+
129+
/**
130+
* Build synthetic ProfileViewDetailed from PDS data
131+
*
132+
* Fetches the user's profile record from their PDS via Slingshot and constructs
133+
* an AppView-compatible ProfileViewDetailed object.
134+
*
135+
* LIMITATION: Profile-level aggregate counts (followers, following, posts) are not
136+
* available from Slingshot or Constellation and are set to undefined. These would
137+
* require AppView-style indexing infrastructure.
138+
*/
139+
export async function buildSyntheticProfileView(
140+
did: string,
141+
handle: string,
142+
): Promise<any> {
143+
const profileUri = `at://${did}/app.bsky.actor.profile/self`
144+
const record = await fetchRecordViaSlingshot(profileUri)
145+
146+
return {
147+
$type: 'app.bsky.actor.defs#profileViewDetailed',
148+
did,
149+
handle,
150+
displayName: record?.value?.displayName || handle,
151+
description: record?.value?.description || '',
152+
avatar: record?.value?.avatar
153+
? `https://cdn.bsky.app/img/avatar/plain/${did}/${record.value.avatar.ref.$link}@jpeg`
154+
: undefined,
155+
banner: record?.value?.banner
156+
? `https://cdn.bsky.app/img/banner/plain/${did}/${record.value.banner.ref.$link}@jpeg`
157+
: undefined,
158+
followersCount: undefined, // Not available from PDS or Constellation
159+
followsCount: undefined, // Not available from PDS or Constellation
160+
postsCount: undefined, // Not available from PDS or Constellation
161+
indexedAt: new Date().toISOString(),
162+
viewer: {},
163+
labels: [],
164+
__fallbackMode: true, // Mark as fallback data
165+
}
166+
}
167+
168+
/**
169+
* Build synthetic PostView from PDS + Constellation data
170+
*/
171+
export async function buildSyntheticPostView(
172+
atUri: string,
173+
authorDid: string,
174+
authorHandle: string,
175+
): Promise<any> {
176+
const record = await fetchRecordViaSlingshot(atUri)
177+
if (!record) return null
178+
179+
const counts = await fetchConstellationCounts(atUri)
180+
const profileView = await buildSyntheticProfileView(authorDid, authorHandle)
181+
182+
return {
183+
$type: 'app.bsky.feed.defs#postView',
184+
uri: atUri,
185+
cid: record.cid,
186+
author: profileView,
187+
record: record.value,
188+
indexedAt: new Date().toISOString(),
189+
likeCount: counts.likeCount,
190+
repostCount: counts.repostCount,
191+
replyCount: counts.replyCount,
192+
viewer: {},
193+
labels: [],
194+
__fallbackMode: true, // Mark as fallback data
195+
}
196+
}
197+
198+
/**
199+
* Build synthetic feed page from PDS data
200+
* This is used for infinite queries that need paginated results
201+
*
202+
* IMPORTANT: This function bypasses Slingshot and fetches directly from the user's PDS
203+
* because Slingshot does not support the `com.atproto.repo.listRecords` endpoint needed
204+
* for bulk record fetching.
205+
*
206+
* Trade-off: No caching benefit from Slingshot, but we can still provide author feed
207+
* functionality for AppView-suspended users.
208+
*
209+
* Each post in the feed will trigger:
210+
* - 1 record fetch via Slingshot (for the full post data, cached)
211+
* - 1 Constellation request (for engagement counts)
212+
* - Profile fetch (cached after first request)
213+
*/
214+
export async function buildSyntheticFeedPage(
215+
did: string,
216+
pdsUrl: string,
217+
cursor?: string,
218+
): Promise<any> {
219+
try {
220+
const limit = 25
221+
const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
222+
223+
// Fetch posts directly from PDS using com.atproto.repo.listRecords
224+
// NOTE: This bypasses Slingshot because listRecords is not available there
225+
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`
226+
const res = await fetch(url)
227+
228+
if (!res.ok) {
229+
console.error(
230+
'[Fallback] Failed to fetch author feed from PDS:',
231+
res.statusText,
232+
)
233+
return null
234+
}
235+
236+
const data = await res.json()
237+
238+
// Build FeedViewPost array from records
239+
const feed = await Promise.all(
240+
data.records.map(async (record: any) => {
241+
const postView = await buildSyntheticPostView(
242+
record.uri,
243+
did,
244+
'', // Handle will be resolved in buildSyntheticPostView
245+
)
246+
247+
if (!postView) return null
248+
249+
// Wrap in FeedViewPost format
250+
return {
251+
$type: 'app.bsky.feed.defs#feedViewPost',
252+
post: postView,
253+
feedContext: undefined,
254+
}
255+
}),
256+
)
257+
258+
// Filter out null results
259+
const validFeed = feed.filter(item => item !== null)
260+
261+
return {
262+
feed: validFeed,
263+
cursor: data.cursor,
264+
__fallbackMode: true, // Mark as fallback data
265+
}
266+
} catch (e) {
267+
console.error('[Fallback] Failed to build synthetic feed page:', e)
268+
return null
269+
}
270+
}

src/state/queries/post-feed.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
type QueryClient,
1616
type QueryKey,
1717
useInfiniteQuery,
18-
} from '@tanstack/react-query'
18+
} from './useQueryWithFallback'
1919

2020
import {AuthorFeedAPI} from '#/lib/api/feed/author'
2121
import {CustomFeedAPI} from '#/lib/api/feed/custom'
@@ -381,6 +381,11 @@ export function usePostFeedQuery(
381381
},
382382
[selectArgs /* Don't change. Everything needs to go into selectArgs. */],
383383
),
384+
enableFallback: feedDesc.startsWith('author'),
385+
fallbackType: 'feed',
386+
fallbackIdentifier: feedDesc.startsWith('author|')
387+
? feedDesc.split('|')[1]
388+
: undefined,
384389
})
385390

386391
// The server may end up returning an empty page, a page with too few items,

0 commit comments

Comments
 (0)