From c52e730112eaf3917687e31e963ef65951a7f7db Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 13 Aug 2026 22:26:14 +0300 Subject: [PATCH] [SDK] Add pds and chat clients and the canonical client hooks (#11350) Co-authored-by: Claude Fable 5 --- src/lib/constants.ts | 16 ++ .../queries/trending/useGetTrendsQuery.ts | 4 +- src/state/session/__tests__/clients-test.ts | 196 ++++++++++++++++-- .../__tests__/provider-clients-test.tsx | 193 +++++++++++++++++ src/state/session/clients.ts | 122 +++++++++-- src/state/session/index.tsx | 74 ++++++- 6 files changed, 565 insertions(+), 40 deletions(-) create mode 100644 src/state/session/__tests__/provider-clients-test.tsx diff --git a/src/lib/constants.ts b/src/lib/constants.ts index bc079c7af5..1a89fa5b5e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,6 @@ import {type Insets, Platform} from 'react-native' import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api' +import {type Service} from '@atproto/lex' import {type ProxyHeaderValue} from '#/state/session/agent' import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env' @@ -247,6 +248,21 @@ export const BLUESKY_PROXY_HEADER = { }, } +/** + * The chat service's proxy target, in the `did#service_id` form a lex client's + * `service` option takes. A client constructed with it emits `atproto-proxy: + * ` on every request, which is what routes `chat.bsky.*` calls to + * the chat service. + * + * The DID comes from the env-configurable `CHAT_PROXY_DID` (via + * `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the + * target can be retargeted per environment. + * + * This is the client-level equivalent of {@link DM_SERVICE_HEADERS}, which + * carries the same value as a per-call header. + */ +export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat` + export const DM_SERVICE_HEADERS = { 'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`, } 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__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index f90841dbc8..452de11d9d 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -13,9 +13,16 @@ jest.mock('jwt-decode', () => ({ }, })) -import {app} from '#/lexicons' +import {CHAT_PROXY_SERVICE} from '#/lib/constants' +import {app, chat, com} from '#/lexicons' import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' -import {agentToLexClient} from '../clients' +import { + agentToAppviewClient, + agentToChatClient, + agentToPdsClient, + getUnauthenticatedThrowingClient, + NotAuthenticatedError, +} from '../clients' import {sessionAccountToSessionData} from '../session-data' import { asFetch, @@ -26,6 +33,7 @@ import { makeMockFetch, type MockFetch, SERVICE, + urlsOf, } from './mock-fetch' const PROFILE_BODY = { @@ -70,7 +78,7 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined { return call?.[1] } -describe('agentToLexClient', () => { +describe('agentToAppviewClient', () => { let fetchMock: MockFetch beforeEach(() => { @@ -81,9 +89,9 @@ describe('agentToLexClient', () => { const {agent: agentA} = setup(fetchMock) const {agent: agentB} = setup(fetchMock) - const clientA1 = agentToLexClient(agentA) - const clientA2 = agentToLexClient(agentA) - const clientB = agentToLexClient(agentB) + const clientA1 = agentToAppviewClient(agentA) + const clientA2 = agentToAppviewClient(agentA) + const clientB = agentToAppviewClient(agentB) expect(clientA1).toBeInstanceOf(Client) expect(clientA1).toBe(clientA2) @@ -92,20 +100,23 @@ describe('agentToLexClient', () => { it('passes through the agent did', () => { const {agent} = setup(fetchMock) - expect(agentToLexClient(agent).did).toBe(DID) + expect(agentToAppviewClient(agent).did).toBe(DID) }) it('reflects an undefined did on a logged-out agent', () => { const {agent} = setupPublic(fetchMock) - expect(agentToLexClient(agent).did).toBeUndefined() + expect(agentToAppviewClient(agent).did).toBeUndefined() }) it('routes client.call through the agent to the network', async () => { const {agent} = setup(fetchMock) - const body = await agentToLexClient(agent).call(app.bsky.actor.getProfile, { - actor: HANDLE, - }) + const body = await agentToAppviewClient(agent).call( + app.bsky.actor.getProfile, + { + actor: HANDLE, + }, + ) expect(body.handle).toBe(HANDLE) const call = fetchMock.mock.calls.find(c => { @@ -121,7 +132,7 @@ describe('agentToLexClient', () => { const {agent} = setup(fetchMock) agent.configureProxy('did:web:api.bsky.app#bsky_appview') - await agentToLexClient(agent).call(app.bsky.actor.getProfile, { + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { actor: HANDLE, }) @@ -135,7 +146,7 @@ describe('agentToLexClient', () => { const {agent} = setup(fetchMock) agent.configureLabelersHeader(['did:plc:labeler']) - await agentToLexClient(agent).call(app.bsky.actor.getProfile, { + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { actor: HANDLE, }) @@ -155,7 +166,7 @@ describe('agentToLexClient', () => { it('sends the session access token', async () => { const {agent} = setup(fetchMock) - await agentToLexClient(agent).call(app.bsky.actor.getProfile, { + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { actor: HANDLE, }) @@ -167,7 +178,7 @@ describe('agentToLexClient', () => { it('falls back to unauthenticated requests once the agent is disposed', async () => { const {agent} = setup(fetchMock) - const client = agentToLexClient(agent) + const client = agentToAppviewClient(agent) agent.dispose() await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) @@ -177,3 +188,158 @@ describe('agentToLexClient', () => { expect(client.did).toBeUndefined() }) }) + +describe('agentToPdsClient', () => { + let fetchMock: MockFetch + + beforeEach(() => { + fetchMock = makeProfileFetch() + }) + + it('memoizes one client per agent', () => { + const {agent: agentA} = setup(fetchMock) + const {agent: agentB} = setup(fetchMock) + + const clientA1 = agentToPdsClient(agentA) + const clientA2 = agentToPdsClient(agentA) + + expect(clientA1).toBeInstanceOf(Client) + expect(clientA1).toBe(clientA2) + expect(clientA1).not.toBe(agentToPdsClient(agentB)) + }) + + it('is a distinct client from the appview client for the same agent', () => { + const {agent} = setup(fetchMock) + expect(agentToPdsClient(agent)).not.toBe(agentToAppviewClient(agent)) + }) + + it('passes through the agent did', () => { + const {agent} = setup(fetchMock) + expect(agentToPdsClient(agent).did).toBe(DID) + }) + + it('sends the session access token', async () => { + const {agent} = setup(fetchMock) + + await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) + + const init = initFor(fetchMock, 'com.atproto.server.getSession') + expect(new Headers(init?.headers).get('authorization')).toBe( + 'Bearer access-jwt', + ) + }) + + it('emits neither the proxy nor the labeler header the agent is configured with', async () => { + /* + * The load-bearing difference from the appview client: this client wraps the + * session manager, below the agent layer that sets both headers, so a + * request reaches the account's PDS instead of being proxied onward. + */ + const {agent} = setup(fetchMock) + agent.configureProxy('did:web:api.bsky.app#bsky_appview') + agent.configureLabelersHeader(['did:plc:labeler']) + + await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) + + const headers = new Headers( + initFor(fetchMock, 'com.atproto.server.getSession')?.headers, + ) + expect(headers.get('atproto-proxy')).toBeNull() + expect(headers.get('atproto-accept-labelers')).toBeNull() + }) + + it('resolves the relative xrpc path against the account host', async () => { + /* + * lex-client hands its fetchHandler an origin-less `/xrpc/` path; the + * session manager absolutizes it against dispatchUrl. + */ + const {agent} = setup(fetchMock) + + await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) + + expect(urlsOf(fetchMock)).toContain( + `${SERVICE}/xrpc/com.atproto.server.getSession`, + ) + }) +}) + +describe('agentToChatClient', () => { + let fetchMock: MockFetch + + beforeEach(() => { + fetchMock = makeProfileFetch() + }) + + it('memoizes one client per agent, distinct from the pds client', () => { + const {agent} = setup(fetchMock) + + const client = agentToChatClient(agent) + + expect(client).toBeInstanceOf(Client) + expect(client).toBe(agentToChatClient(agent)) + expect(client).not.toBe(agentToPdsClient(agent)) + }) + + it('emits the chat proxy header exactly once, with the session token', async () => { + const {agent} = setup(fetchMock) + + /* the stub body fails listConvos output validation; headers are recorded pre-parse */ + await agentToChatClient(agent) + .call(chat.bsky.convo.listConvos, {}) + .catch(() => {}) + + const headers = new Headers( + initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, + ) + /* + * An exact match, not `toContain`: `Headers` comma-joins repeated entries + * for the same name, so a second contributor would show up here. + */ + expect(headers.get('atproto-proxy')).toBe(CHAT_PROXY_SERVICE) + expect(headers.get('authorization')).toBe('Bearer access-jwt') + }) + + it('does not emit the agent labeler header', async () => { + const {agent} = setup(fetchMock) + agent.configureLabelersHeader(['did:plc:labeler']) + + await agentToChatClient(agent) + .call(chat.bsky.convo.listConvos, {}) + .catch(() => {}) + + const headers = new Headers( + initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, + ) + expect(headers.get('atproto-accept-labelers')).toBeNull() + }) +}) + +describe('getUnauthenticatedThrowingClient', () => { + it('is a stable singleton with no did', () => { + const client = getUnauthenticatedThrowingClient() + + expect(client.did).toBeUndefined() + /* identity is stable so it is safe in React Query keys */ + expect(getUnauthenticatedThrowingClient()).toBe(client) + }) + + it('rejects any call with NotAuthenticatedError as the cause, with no fetch', async () => { + /* + * The throwing fetchHandler fires before any network I/O. lex-client wraps a + * fetchHandler throw in an internal error whose `cause` is the original, so + * the NotAuthenticatedError surfaces there. + */ + const fetchMock = makeProfileFetch() + const err = await getUnauthenticatedThrowingClient() + .call(com.atproto.server.getSession, {}) + .then(() => undefined) + .catch((e: unknown) => e) + + expect((err as Error).cause).toBeInstanceOf(NotAuthenticatedError) + expect(((err as Error).cause as Error).name).toBe('NotAuthenticatedError') + expect(((err as Error).cause as Error).message).toBe( + 'Not authenticated: this operation requires an active session', + ) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) 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/clients.ts b/src/state/session/clients.ts index ff6a597ac7..4daf5368b3 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -1,15 +1,15 @@ import {type Client} from '@atproto/lex' -import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {CHAT_PROXY_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {createLexClient} from '#/lib/lexClient' import {type BskyAppAgent} from './bridge-agent' import {networkAwareFetch} from './network' /* - * One client per agent, so that repeated reads for the same agent return the - * same instance. Client identity is observable: a lex `Client` is passed to - * React Query `queryFn`s and read from render paths, so a freshly allocated - * client on every read would break any dependency array or reference + * One client per agent, per surface, so that repeated reads for the same agent + * return the same instance. Client identity is observable: a lex `Client` is + * passed to React Query `queryFn`s and read from render paths, so a freshly + * allocated client on every read would break any dependency array or reference * comparison built on top of it. * * Keying on the agent also ties client lifetime to agent lifetime. A disposed @@ -17,10 +17,12 @@ import {networkAwareFetch} from './network' * rotation builds a new agent rather than mutating the old one, so a client * derived from a stale agent becomes unreachable exactly when its agent does. */ -const lexClients = new WeakMap() +const appviewClients = new WeakMap() +const pdsClients = new WeakMap() +const chatClients = new WeakMap() /** - * The lex {@link Client} for an agent, memoized per agent. + * The appview {@link Client} for an agent, memoized per agent. * * The wrapped handler is `agent.fetchHandler`, NOT * `agent.sessionManager.fetchHandler`. The agent-level handler is where @@ -30,8 +32,8 @@ const lexClients = new WeakMap() * deliberately built with neither a `service` option nor labelers - setting * either here would emit them a second time. */ -export function agentToLexClient(agent: BskyAppAgent): Client { - const existing = lexClients.get(agent) +export function agentToAppviewClient(agent: BskyAppAgent): Client { + const existing = appviewClients.get(agent) if (existing) { return existing } @@ -41,10 +43,101 @@ export function agentToLexClient(agent: BskyAppAgent): Client { }, fetchHandler: (path, init) => agent.fetchHandler(path, init), }) - lexClients.set(agent, client) + appviewClients.set(agent, client) return client } +/** + * The account-host {@link Client} for an agent, memoized per agent. + * + * This wraps `agent.sessionManager.fetchHandler`, one layer below + * {@link agentToAppviewClient}. That layer does authorization and refresh-on-401 + * and resolves the request against `dispatchUrl` (the account's PDS), but it + * does NOT set `atproto-proxy` or `atproto-accept-labelers`, so requests reach + * the PDS itself rather than being proxied onward. That is the right transport + * for `com.atproto.*` repo/server/identity calls. + * + * No `service` option for the same reason: adding one would reintroduce the + * proxy header this client exists to avoid. + * + * The handler is wrapped in a closure rather than passed by reference because + * `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are + * intentional: lex-client hands its handler an origin-less + * `/xrpc/[?query]` path, which the session manager absolutizes against + * `dispatchUrl`. + */ +export function agentToPdsClient(agent: BskyAppAgent): Client { + const existing = pdsClients.get(agent) + if (existing) { + return existing + } + const client = createLexClient({ + get did() { + return agent.did + }, + fetchHandler: (path, init) => agent.sessionManager.fetchHandler(path, init), + }) + pdsClients.set(agent, client) + return client +} + +/** + * The chat {@link Client} for an agent, memoized per agent. + * + * Same session-manager transport as {@link agentToPdsClient} - authorization + * and PDS routing, no agent-level proxy or labeler headers - but constructed + * with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits + * `atproto-proxy: ` on every request and `chat.bsky.*` + * calls are proxied to the chat service. + */ +export function agentToChatClient(agent: BskyAppAgent): Client { + const existing = chatClients.get(agent) + if (existing) { + return existing + } + const client = createLexClient( + { + get did() { + return agent.did + }, + fetchHandler: (path, init) => + agent.sessionManager.fetchHandler(path, init), + }, + {service: CHAT_PROXY_SERVICE}, + ) + chatClients.set(agent, client) + return client +} + +/** Thrown when a write/auth-only client is used with no active session. */ +export class NotAuthenticatedError extends Error { + constructor(op = 'this operation') { + super(`Not authenticated: ${op} requires an active session`) + this.name = 'NotAuthenticatedError' + } +} + +let unauthedClient: Client | undefined + +/** + * A {@link Client} that throws {@link NotAuthenticatedError} on any request, + * before any network I/O. It is the logged-out value of the write/auth-only + * hooks (`usePdsClient`/`useChatClient`) so an unauthenticated call fails + * immediately and legibly instead of silently hitting public infrastructure, + * which would answer with an opaque 4xx. + * + * A single module-level instance, so its identity is stable across renders - + * safe to use in React Query keys and as a hook return value. + */ +export function getUnauthenticatedThrowingClient(): Client { + return (unauthedClient ??= createLexClient({ + did: undefined, + fetchHandler: () => { + throw new NotAuthenticatedError() + }, + })) +} + let publicLexClient: Client | undefined /** @@ -52,9 +145,10 @@ let publicLexClient: Client | undefined * appview. * * A single module-level instance for the same identity-stability reason as - * {@link agentToLexClient}: there is no session to scope it to, so it lives for - * the lifetime of the process. Requests go through {@link networkAwareFetch} so - * public reads feed the app's reachability signal like authenticated ones do. + * {@link agentToAppviewClient}: there is no session to scope it to, so it lives + * for the lifetime of the process. Requests go through + * {@link networkAwareFetch} so public reads feed the app's reachability signal + * like authenticated ones do. * * Unlike the public agent it parallels, this client sends neither * `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures @@ -63,7 +157,7 @@ let publicLexClient: Client | undefined * consumer that needs moderation labels on public reads must configure labelers * itself before issuing the request. */ -export function getPublicLexClient(): Client { +export function getPublicAppviewClient(): Client { return (publicLexClient ??= createLexClient({ service: PUBLIC_BSKY_SERVICE, fetch: networkAwareFetch, 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() }