diff --git a/src/components/Autocomplete/useAutocomplete/index.ts b/src/components/Autocomplete/useAutocomplete/index.ts index 3ec5a11e3..94b1d08ff 100644 --- a/src/components/Autocomplete/useAutocomplete/index.ts +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -2,6 +2,7 @@ import {useCallback, useMemo} from 'react' import {moderateProfile, type ModerationOpts} from '@bsky/sdk/moderation' import {keepPreviousData, useQuery} from '@tanstack/react-query' +import {searchActorsTypeahead} from '#/lib/api/search-actors-typeahead' import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' import {parseSearchLink} from '#/lib/routes/searchLink' import { @@ -12,15 +13,13 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences' import {useAppviewClient} from '#/state/session' -import {useAgent, useSession} from '#/state/session' -import {createPublicAgent} from '#/state/session/agent' import { type AutocompleteApi, type AutocompleteItem, type AutocompleteItemType, type AutocompleteProfile, } from '#/components/Autocomplete/types' -import {app} from '#/lexicons' +import {type app} from '#/lexicons' import {useEmojiSearch} from './useEmojiSearch' const DEFAULT_MOD_OPTS = { @@ -40,19 +39,11 @@ export function useAutocomplete({ showSearchFallback?: boolean }): AutocompleteApi { const client = useAppviewClient() - const {hasSession} = useSession() - const sessionAgent = useAgent() const [appViewDid] = useCustomAppViewDid() const [appViewUrl] = useCustomAppViewUrl() const moderationOpts = useModerationOpts() const emojiSearch = useEmojiSearch() - /* - * While logged out, the session agent is created once at startup and does not - * pick up App server changes from the login dialog. Rebuild a guest agent - * from persisted AppView settings whenever that selection changes. - */ - const query = useQuery({ staleTime: STALE.MINUTES.ONE, queryKey: [ @@ -72,7 +63,7 @@ export function useAutocomplete({ // Going from "foo" to "foo." should not clear matches. q = q.toLowerCase().trim().replace(/\.$/, '') - const data = await client.call(app.bsky.actor.searchActorsTypeahead, { + const data = await searchActorsTypeahead(client, { q, limit: limit || 8, }) diff --git a/src/lib/api/search-actors-typeahead.test.ts b/src/lib/api/search-actors-typeahead.test.ts new file mode 100644 index 000000000..ddfd7ce10 --- /dev/null +++ b/src/lib/api/search-actors-typeahead.test.ts @@ -0,0 +1,183 @@ +import {type Client} from '@atproto/lex' +import {PasswordSession} from '@atproto/lex-password-session' + +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' +import { + asFetch, + DID, + HANDLE, + json, + makeAccount, + makeMockFetch, +} from '#/state/session/__tests__/mock-fetch' +import {sessionAccountToSessionData} from '#/state/session/session-data' +import {app} from '#/lexicons' +import {searchActorsTypeahead} from './search-actors-typeahead' + +const BLACKSKY = 'did:web:api.blacksky.community#bsky_appview' +const BLUESKY = 'did:web:api.bsky.app#bsky_appview' +const params = {q: 'alice', limit: 8} +const result = {actors: [{did: DID, handle: HANDLE}]} + +function makeClient(fetchMock: ReturnType) { + const session = new PasswordSession( + sessionAccountToSessionData(makeAccount()), + {fetch: asFetch(fetchMock)}, + ) + return createLexClient(session, {service: BLACKSKY}) +} + +function headers(fetchMock: ReturnType) { + return fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers)) +} + +it.each([result, {actors: []}])( + 'keeps successful results on the selected AppView', + async body => { + const fetchMock = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': () => json(body), + }) + await expect( + searchActorsTypeahead(makeClient(fetchMock), params), + ).resolves.toEqual(body) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(headers(fetchMock)[0].get('atproto-proxy')).toBe(BLACKSKY) + }, +) + +it.each([ + [502, 'InternalServerError', 'Failed to perform upstream request'], + [404, 'NotFound', 'Not found'], + [501, 'MethodNotImplemented', 'Unavailable'], + [400, 'UnsupportedMethod', 'Unavailable'], +])( + 'retries an unavailable endpoint (%s) through the same authenticated client', + async (status, error, message) => { + const fetchMock = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': (_url, init) => + new Headers(init.headers).get('atproto-proxy') === BLACKSKY + ? json({error, message}, status) + : json(result), + 'app.bsky.actor.getProfile': () => json(result.actors[0]), + }) + const client = makeClient(fetchMock) + await expect(searchActorsTypeahead(client, params)).resolves.toEqual(result) + const requests = headers(fetchMock) + expect(requests.map(h => h.get('atproto-proxy'))).toEqual([ + BLACKSKY, + BLUESKY, + ]) + expect(requests.map(h => h.get('authorization'))).toEqual([ + 'Bearer access-jwt', + 'Bearer access-jwt', + ]) + await client.call(app.bsky.actor.getProfile, {actor: DID}) + expect(headers(fetchMock)[2].get('atproto-proxy')).toBe(BLACKSKY) + }, +) + +it.each([ + [400, 'InvalidRequest', 'Invalid query'], + [401, 'AuthRequired', 'Authentication Required'], + [403, 'Forbidden', 'Forbidden'], + [429, 'RateLimitExceeded', 'Slow down'], + [500, 'InternalServerError', 'Unexpected failure'], + [502, 'InternalServerError', 'Other upstream failure'], +])( + 'does not fall back for unrelated errors (%s)', + async (status, error, message) => { + const fetchMock = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': () => + json({error, message}, status), + }) + await expect( + searchActorsTypeahead(makeClient(fetchMock), params), + ).rejects.toMatchObject({status, error}) + const actorCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes('app.bsky.actor.searchActorsTypeahead'), + ) + expect(actorCalls.length).toBe(status === 401 ? 2 : 1) + for (const [, init] of actorCalls) { + expect(new Headers(init?.headers).get('atproto-proxy')).toBe(BLACKSKY) + } + }, +) + +it('does not retry Bluesky against itself', async () => { + const fetchMock = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': () => + json({error: 'NotFound'}, 404), + }) + const client = createLexClient(makeClient(fetchMock).agent, { + service: BLUESKY, + }) + await expect(searchActorsTypeahead(client, params)).rejects.toMatchObject({ + status: 404, + }) + expect(fetchMock).toHaveBeenCalledTimes(1) +}) + +it('propagates fallback failures', async () => { + const fetchMock = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': (_url, init) => + new Headers(init.headers).get('atproto-proxy') === BLACKSKY + ? json( + { + error: 'InternalServerError', + message: 'Failed to perform upstream request', + }, + 502, + ) + : json({error: 'RateLimitExceeded'}, 429), + }) + await expect( + searchActorsTypeahead(makeClient(fetchMock), params), + ).rejects.toMatchObject({status: 429}) + expect(fetchMock).toHaveBeenCalledTimes(2) +}) + +it('uses the public Bluesky endpoint for a guest fallback', async () => { + const originalFetch = global.fetch + const fetchMock: jest.MockedFunction = jest + .fn() + .mockResolvedValue(json(result)) + global.fetch = fetchMock + const primaryFetch = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': () => + json( + { + error: 'InternalServerError', + message: 'Failed to perform upstream request', + }, + 502, + ), + }) + const client: Client = createLexClient({ + service: 'https://api.blacksky.community', + fetch: asFetch(primaryFetch), + }) + try { + await expect(searchActorsTypeahead(client, params)).resolves.toEqual(result) + const [input, init] = fetchMock.mock.calls[0] + const url = new URL(input instanceof Request ? input.url : input) + expect(url.origin).toBe(new URL(PUBLIC_BSKY_SERVICE).origin) + expect(url.searchParams.get('q')).toBe(params.q) + expect(url.searchParams.get('limit')).toBe(String(params.limit)) + expect(new Headers(init?.headers).has('authorization')).toBe(false) + expect(new Headers(init?.headers).has('atproto-proxy')).toBe(false) + } finally { + global.fetch = originalFetch + } +}) + +it('does not fall back after a network failure', async () => { + const fetchMock = makeMockFetch({ + 'app.bsky.actor.searchActorsTypeahead': () => + Promise.reject(new Error('network failure')), + }) + await expect( + searchActorsTypeahead(makeClient(fetchMock), params), + ).rejects.toBeInstanceOf(Error) + expect(fetchMock).toHaveBeenCalledTimes(1) +}) diff --git a/src/lib/api/search-actors-typeahead.ts b/src/lib/api/search-actors-typeahead.ts new file mode 100644 index 000000000..77e520c32 --- /dev/null +++ b/src/lib/api/search-actors-typeahead.ts @@ -0,0 +1,41 @@ +import {type Client} from '@atproto/lex' + +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' +import {getErrorName, getErrorStatus} from '#/lib/xrpc-error' +import {isBlackskySearchUpstreamFailure} from '#/state/queries/search-fallback' +import {app} from '#/lexicons' + +const BLUESKY_APPVIEW = 'did:web:api.bsky.app#bsky_appview' +let publicBlueskyClient: Client | undefined + +/** Try the selected AppView before falling back for unavailable actor search. */ +export async function searchActorsTypeahead( + client: Client, + params: app.bsky.actor.searchActorsTypeahead.$Params, +): Promise { + try { + return await client.call(app.bsky.actor.searchActorsTypeahead, params) + } catch (error) { + const unavailable = + [404, 501].includes(getErrorStatus(error) ?? 0) || + ['MethodNotImplemented', 'NotImplemented', 'UnsupportedMethod'].includes( + getErrorName(error) ?? '', + ) || + isBlackskySearchUpstreamFailure(error) + if (!unavailable || client.xrpcDefaults.service === BLUESKY_APPVIEW) { + throw error + } + } + + if (client.did) { + /* Keep the account's auth and labelers, changing only this request's proxy. */ + return client.call(app.bsky.actor.searchActorsTypeahead, params, { + service: BLUESKY_APPVIEW, + }) + } + + /* A guest has no PDS to proxy through, so use Bluesky's public endpoint. */ + publicBlueskyClient ??= createLexClient({service: PUBLIC_BSKY_SERVICE}) + return publicBlueskyClient.call(app.bsky.actor.searchActorsTypeahead, params) +} diff --git a/src/state/queries/__tests__/actor-autocomplete.test.tsx b/src/state/queries/__tests__/actor-autocomplete.test.tsx new file mode 100644 index 000000000..3ec0b309e --- /dev/null +++ b/src/state/queries/__tests__/actor-autocomplete.test.tsx @@ -0,0 +1,94 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {renderHook, waitFor} from '@testing-library/react-native' + +import {searchActorsTypeahead} from '#/lib/api/search-actors-typeahead' +import { + useActorAutocompleteFn, + useActorAutocompleteQuery, +} from '#/state/queries/actor-autocomplete' +import {useAutocomplete} from '#/components/Autocomplete/useAutocomplete' + +jest.mock('#/lib/api/search-actors-typeahead', () => ({ + searchActorsTypeahead: jest.fn(), +})) +jest.mock('#/state/session', () => ({useAppviewClient: () => ({})})) +jest.mock('#/state/queries/preferences', () => ({ + DEFAULT_LOGGED_OUT_PREFERENCES: {moderationPrefs: {}}, +})) +jest.mock('#/state/preferences/moderation-opts', () => ({ + useModerationOpts: () => undefined, +})) +jest.mock('#/state/preferences/custom-appview-did', () => ({ + useCustomAppViewDid: () => ['did:web:api.blacksky.community'], + useCustomAppViewUrl: () => ['https://api.blacksky.community'], +})) +jest.mock('@bsky/sdk/moderation', () => ({ + moderateProfile: () => ({ui: () => ({filter: false})}), +})) +jest.mock('#/components/Autocomplete/useAutocomplete/useEmojiSearch', () => ({ + useEmojiSearch: () => () => [], +})) + +const profile = {did: 'did:plc:alice', handle: 'alice.test'} as const + +function setup() { + const queryClient = new QueryClient({ + defaultOptions: {queries: {retry: false}}, + }) + jest.mocked(searchActorsTypeahead).mockResolvedValue({actors: [profile]}) + function Wrapper({children}: {children: React.ReactNode}) { + return ( + {children} + ) + } + return {queryClient, Wrapper} +} + +beforeEach(() => { + jest.mocked(searchActorsTypeahead).mockClear() +}) + +it('uses the fallback helper for composer autocomplete and shares an array cache', async () => { + const {queryClient, Wrapper} = setup() + const {result, unmount} = renderHook( + () => ({ + query: useActorAutocompleteQuery('alice'), + autocomplete: useActorAutocompleteFn(), + }), + {wrapper: Wrapper}, + ) + await waitFor(() => expect(result.current.query.isSuccess).toBe(true)) + expect(result.current.query.data).toEqual([profile]) + await expect(result.current.autocomplete({query: 'alice'})).resolves.toEqual([ + profile, + ]) + expect(searchActorsTypeahead).toHaveBeenCalledTimes(1) + unmount() + queryClient.clear() +}) + +it('uses the same fallback helper for search and sign-in suggestions while typing', async () => { + const {queryClient, Wrapper} = setup() + const {result, rerender, unmount} = renderHook( + ({query}: {query: string}) => useAutocomplete({type: 'profile', query}), + {wrapper: Wrapper, initialProps: {query: ''}}, + ) + for (const query of ['a', 'al', 'ali', 'alice']) { + rerender({query}) + await waitFor(() => expect(result.current.isFetching).toBe(false)) + expect(result.current.items).toEqual([ + { + key: profile.did, + type: 'profile', + value: '@alice.test', + profile, + }, + ]) + expect(searchActorsTypeahead).toHaveBeenLastCalledWith(expect.anything(), { + q: query, + limit: 8, + }) + } + unmount() + queryClient.clear() +}) diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 982204d99..e65d21d40 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -2,11 +2,12 @@ import {useCallback} from 'react' import {moderateProfile, type ModerationOpts} from '@bsky/sdk/moderation' import {keepPreviousData, useQuery, useQueryClient} from '@tanstack/react-query' +import {searchActorsTypeahead} from '#/lib/api/search-actors-typeahead' import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {useAppviewClient} from '#/state/session' -import {app} from '#/lexicons' +import {type app} from '#/lexicons' import {useModerationOpts} from '../preferences/moderation-opts' import {DEFAULT_LOGGED_OUT_PREFERENCES} from './preferences' @@ -37,7 +38,7 @@ export function useActorAutocompleteQuery( queryKey: RQKEY(prefix || ''), async queryFn() { const data = prefix - ? await client.call(app.bsky.actor.searchActorsTypeahead, { + ? await searchActorsTypeahead(client, { q: prefix, limit: limit || 8, }) @@ -73,11 +74,13 @@ export function useActorAutocompleteFn() { res = await queryClient.fetchQuery({ staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(query || ''), - queryFn: () => - client.call(app.bsky.actor.searchActorsTypeahead, { + queryFn: async () => { + const data = await searchActorsTypeahead(client, { q: query, limit, - }), + }) + return data.actors + }, }) } catch (e) { logger.error('useActorSearch: searchActorsTypeahead failed', { @@ -88,7 +91,7 @@ export function useActorAutocompleteFn() { return computeSuggestions({ q: query, - searched: res?.actors, + searched: res, moderationOpts: moderationOpts || DEFAULT_MOD_OPTS, }) },