diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts index e7d6806b3e..8fe0ad845e 100644 --- a/src/state/queries/trending/useGetTrendsQuery.ts +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -10,7 +10,7 @@ import {logger} from '#/logger' import {getContentLanguages} from '#/state/preferences/languages' import {STALE} from '#/state/queries' import {usePreferencesQuery} from '#/state/queries/preferences' -import {useLexClient} from '#/state/session' +import {useAppviewClient} from '#/state/session' import {app} from '#/lexicons' export const DEFAULT_LIMIT = 5 @@ -33,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) => limit === undefined ? ['trends'] : ['trends', {limit}] export function useGetTrendsQuery(props: QueryProps = {}) { - const client = useLexClient() + const client = useAppviewClient() const {data: preferences} = usePreferencesQuery() const limit = props.limit ?? DEFAULT_LIMIT const mutedWords = useMemo(() => { diff --git a/src/state/session/__tests__/provider-clients-test.tsx b/src/state/session/__tests__/provider-clients-test.tsx new file mode 100644 index 0000000000..58ea9ad6fe --- /dev/null +++ b/src/state/session/__tests__/provider-clients-test.tsx @@ -0,0 +1,193 @@ +import {type Client} from '@atproto/lex' +import {PasswordSession} from '@atproto/lex-password-session' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {act, render} from '@testing-library/react-native' + +import {type SessionAccount} from '../types' + +/* + * The provider pulls the whole app shell in through `#/state/util` and the + * account factories. These mocks cut the tree back to the session lifecycle + * itself, mirroring provider-abort-test.tsx. + */ +jest.mock('#/state/persisted', () => { + const { + defaults, + }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') + return { + defaults, + get: (key: keyof typeof defaults) => defaults[key], + write: () => Promise.resolve(), + readLatest: (key: keyof typeof defaults) => defaults[key], + onUpdate: () => () => {}, + } +}) +jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}})) +jest.mock('#/components/dialogs/Context', () => ({ + useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}), +})) +jest.mock('#/analytics', () => ({ + AnalyticsContext: ({children}: {children: React.ReactNode}) => children, + useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}), + utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined}, +})) +jest.mock('#/state/shell/onboarding', () => ({ + useOnboardingDispatch: () => () => {}, +})) +jest.mock('#/ageAssurance/data', () => ({ + clearAgeAssuranceServerDataForAll: () => {}, + clearAgeAssuranceServerDataForDid: () => {}, +})) +jest.mock('#/lib/persisted-query-storage', () => ({ + clearPersistedQueryStorage: () => Promise.resolve(), +})) +jest.mock('#/lib/notifications/notifications', () => ({ + unregisterPushToken: () => Promise.resolve(), +})) +jest.mock('jwt-decode', () => ({ + jwtDecode: () => ({scope: 'com.atproto.access'}), +})) +jest.mock('#/state/events', () => ({ + emitSessionDropped: () => {}, + emitNetworkConfirmed: () => {}, + emitNetworkLost: () => {}, +})) + +const mockLogin = jest.fn<(...args: unknown[]) => Promise>() +jest.mock('../session-core', () => ({ + ...jest.requireActual('../session-core'), + createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args), +})) +jest.mock('../create-account', () => ({ + createSessionBundleAndCreateAccount: () => new Promise(() => {}), +})) + +import { + Provider, + useAppviewClient, + useChatClient, + useMaybeChatClient, + useMaybePdsClient, + usePdsClient, + useSessionApi, +} from '#/state/session' +import {type SessionApiContext} from '#/state/session/types' +import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' +import { + agentToAppviewClient, + agentToChatClient, + agentToPdsClient, + getUnauthenticatedThrowingClient, +} from '../clients' +import {type SessionBundle} from '../session-core' +import {sessionAccountToSessionData} from '../session-data' +import {asFetch, makeAccount, makeMockFetch} from './mock-fetch' + +type Clients = { + appview: Client + pds: Client + chat: Client + maybePds: Client | null + maybeChat: Client | null +} + +/** + * Build a bundle whose agent is a real `BskyAppAgent` over a real + * `PasswordSession`, since the client builders derive from the agent. Only the + * fields the provider reads are populated. + */ +function makeBundle(account: SessionAccount): SessionBundle { + const fetchMock = makeMockFetch() + const session = new PasswordSession(sessionAccountToSessionData(account), { + fetch: asFetch(fetchMock), + }) + const manager = new PasswordSessionManager(session, { + service: account.service, + }) + manager.setFetch(asFetch(fetchMock)) + return { + session, + agent: new BskyAppAgent(manager), + service: new URL(account.service), + } +} + +/** Render the provider and hand back the client hooks' current values. */ +function renderClients(): {api: SessionApiContext; clients: () => Clients} { + let api!: SessionApiContext + let clients!: Clients + function Probe() { + api = useSessionApi() + clients = { + appview: useAppviewClient(), + pds: usePdsClient(), + chat: useChatClient(), + maybePds: useMaybePdsClient(), + maybeChat: useMaybeChatClient(), + } + return null + } + render( + + + , + ) + return {api, clients: () => clients} +} + +beforeEach(() => { + mockLogin.mockReset() +}) + +describe('client hooks while logged out', () => { + it('serves the public agent for appview reads', () => { + const {clients} = renderClients() + expect(clients().appview).toBeDefined() + /* the logged-out bundle's agent IS the public agent, so no separate branch */ + expect(clients().appview.did).toBeUndefined() + }) + + it('serves the throwing client for the write surfaces', () => { + const {clients} = renderClients() + const throwing = getUnauthenticatedThrowingClient() + expect(clients().pds).toBe(throwing) + expect(clients().chat).toBe(throwing) + }) + + it('serves null from the maybe variants', () => { + const {clients} = renderClients() + expect(clients().maybePds).toBeNull() + expect(clients().maybeChat).toBeNull() + }) +}) + +describe('client hooks with a session', () => { + it('derives every surface from the session bundle agent', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {api, clients} = renderClients() + + mockLogin.mockResolvedValueOnce({bundle, account}) + await act(async () => { + await api.login({} as never, 'LoginForm') + }) + + expect(clients().appview).toBe(agentToAppviewClient(bundle.agent)) + expect(clients().pds).toBe(agentToPdsClient(bundle.agent)) + expect(clients().chat).toBe(agentToChatClient(bundle.agent)) + }) + + it('serves the same clients from the maybe variants', async () => { + const account = makeAccount() + const bundle = makeBundle(account) + const {api, clients} = renderClients() + + mockLogin.mockResolvedValueOnce({bundle, account}) + await act(async () => { + await api.login({} as never, 'LoginForm') + }) + + expect(clients().maybePds).toBe(clients().pds) + expect(clients().maybeChat).toBe(clients().chat) + }) +}) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 618804ee7b..01bca93c36 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -19,7 +19,13 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {IS_WEB} from '#/env' import {emitSessionDropped} from '../events' -import {agentToLexClient, getPublicLexClient} from './clients' +import { + agentToAppviewClient, + agentToChatClient, + agentToPdsClient, + getPublicAppviewClient, + getUnauthenticatedThrowingClient, +} from './clients' import {createSessionBundleAndCreateAccount} from './create-account' import {pickExpiryRescueCandidate} from './expiry-rescue' import {type Action, getInitialState, reducer, type State} from './reducer' @@ -711,20 +717,70 @@ export function useAgent(): AtpAgent { } /** - * The lex client for the active session, or for the public agent when logged - * out. + * Client for appview reads. + * + * When logged out the bundle's agent is the public agent built by + * `createPublicAgent`, which is configured with the appview proxy and dispatches + * unauthenticated, so the logged-out fallback is the agent itself - there is no + * separate public branch here. */ -export function useLexClient(): Client { +export function useAppviewClient(): Client { const bundle = useContext(BundleContext) if (!bundle) { - throw Error('useLexClient() must be below .') + throw Error('useAppviewClient() must be below .') } - return agentToLexClient(bundle.agent) + return agentToAppviewClient(bundle.agent) } /** - * The unauthenticated lex client for public reads. + * Client for account-host requests. It shares the active session's auth + * lifecycle but sets no proxy or labeler headers, so calls target the PDS. + * Logged out, calls throw `NotAuthenticatedError` before network I/O. Use + * {@link useMaybePdsClient} when the caller must branch on authentication. */ -export function usePublicLexClient(): Client { - return getPublicLexClient() +export function usePdsClient(): Client { + const bundle = useContext(BundleContext) + if (!bundle) { + throw Error('usePdsClient() must be below .') + } + return bundle.session + ? agentToPdsClient(bundle.agent) + : getUnauthenticatedThrowingClient() +} + +/** + * Client for `chat.bsky.*` calls. Logged-out calls throw + * `NotAuthenticatedError`; use {@link useMaybeChatClient} to branch on auth. + */ +export function useChatClient(): Client { + const bundle = useContext(BundleContext) + if (!bundle) { + throw Error('useChatClient() must be below .') + } + return bundle.session + ? agentToChatClient(bundle.agent) + : getUnauthenticatedThrowingClient() +} + +/** + * Account-host client for the active session, or `null` when logged out. + */ +export function useMaybePdsClient(): Client | null { + const bundle = useContext(BundleContext) + return bundle?.session ? agentToPdsClient(bundle.agent) : null +} + +/** + * Chat client for the active session, or `null` when logged out. + */ +export function useMaybeChatClient(): Client | null { + const bundle = useContext(BundleContext) + return bundle?.session ? agentToChatClient(bundle.agent) : null +} + +/** + * The unauthenticated client for public appview reads. + */ +export function usePublicAppviewClient(): Client { + return getPublicAppviewClient() }