diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 5ffe12abbc..1d22db2747 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -848,9 +848,6 @@
},
"typescript/no-misused-promises": {
"count": 1
- },
- "typescript/no-unsafe-member-access": {
- "count": 1
}
},
"src/screens/E2E/SharedPreferencesTesterScreen.tsx": {
diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts
index 28a4591747..e6e79e348d 100644
--- a/src/ageAssurance/useBeginAgeAssurance.ts
+++ b/src/ageAssurance/useBeginAgeAssurance.ts
@@ -1,5 +1,4 @@
import {Platform} from 'react-native'
-import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
@@ -9,26 +8,28 @@ import {
PUBLIC_APPVIEW_DID,
} from '#/lib/constants'
import {isNetworkError} from '#/lib/hooks/useCleanError'
-import {useAgent} from '#/state/session'
+import {createLexClient} from '#/lib/lexClient'
+import {usePdsClient} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance/logger'
import {useAnalytics} from '#/analytics'
import {BLUESKY_PROXY_DID} from '#/env'
import {useGeolocation} from '#/geolocation'
+import {app, com} from '#/lexicons'
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
export function useBeginAgeAssurance() {
const ax = useAnalytics()
- const agent = useAgent()
+ const pdsClient = usePdsClient()
const geolocation = useGeolocation()
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
return useMutation({
async mutationFn(
props: Omit<
- AppBskyAgeassuranceBegin.InputSchema,
+ app.bsky.ageassurance.begin.$InputBody,
'countryCode' | 'regionCode'
>,
) {
@@ -38,17 +39,22 @@ export function useBeginAgeAssurance() {
throw new Error(`Geolocation not available, cannot init age assurance.`)
}
- const {
- data: {token},
- } = await agent.com.atproto.server.getServiceAuth({
+ const {token} = await pdsClient.call(com.atproto.server.getServiceAuth, {
aud: BLUESKY_PROXY_DID,
lxm: `app.bsky.ageassurance.begin`,
})
- const appView = new AtpAgent({service: APPVIEW})
- appView.sessionManager.session = {...agent.session!}
- appView.sessionManager.session.accessJwt = token
- appView.sessionManager.session.refreshJwt = ''
+ /*
+ * A single-use client scoped to the service-auth token: it has no session,
+ * so nothing can refresh it, and the request goes straight to the appview
+ * with the token as a static `authorization` header. A raw client is
+ * allowed to preset that header where a session-backed one is not, which
+ * also makes the old `refreshJwt = ''` clone unnecessary.
+ */
+ const scopedClient = createLexClient({
+ service: APPVIEW,
+ headers: {authorization: `Bearer ${token}`},
+ })
ax.metric('ageAssurance:api:begin', {
platform: Platform.OS,
@@ -60,9 +66,9 @@ export function useBeginAgeAssurance() {
* 2s wait is good actually. Email sending takes a hot sec and this helps
* ensure the email is ready for the user once they open their inbox.
*/
- const {data} = await wait(
+ const data = await wait(
2e3,
- appView.app.bsky.ageassurance.begin({
+ scopedClient.call(app.bsky.ageassurance.begin, {
...props,
countryCode,
regionCode,
diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
index 9d74b0c6dc..052c3a4731 100644
--- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
+++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx
@@ -1,6 +1,6 @@
import {useState} from 'react'
import {View} from 'react-native'
-import {XRPCError} from '@atproto/api'
+import {XrpcResponseError} from '@atproto/lex'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -14,6 +14,7 @@ import {
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTLDs} from '#/lib/hooks/useTLDs'
import {isEmailMaybeInvalid} from '#/lib/strings/email'
+import {matchXrpcError} from '#/lib/xrpc-error'
import {type AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
@@ -33,6 +34,7 @@ import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance'
import {useAnalytics} from '#/analytics'
+import {app} from '#/lexicons'
export {useDialogControl} from '#/components/Dialog/context'
@@ -139,30 +141,37 @@ function Inner() {
msg`Something went wrong, please try again`,
)
- if (e instanceof XRPCError) {
- if (e.error === 'InvalidEmail') {
- error = _(
- msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
- )
- ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
- } else if (e.error === 'DidTooLong') {
- error = (
- <>
-
- We're having issues initializing the age assurance process for
- your account. Please{' '}
-
- contact support
- {' '}
- for assistance.
-
- >
- )
- ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
- } else {
- ax.metric('ageAssurance:initDialogError', {code: 'other'})
+ if (e instanceof XrpcResponseError) {
+ switch (matchXrpcError(e, app.bsky.ageassurance.begin)) {
+ case 'InvalidEmail':
+ error = _(
+ msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`,
+ )
+ ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'})
+ break
+ case 'DidTooLong':
+ error = (
+ <>
+
+ We're having issues initializing the age assurance process for
+ your account. Please{' '}
+
+ contact support
+ {' '}
+ for assistance.
+
+ >
+ )
+ ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'})
+ break
+ default:
+ /*
+ * An undeclared code keeps the generic message rather than surfacing
+ * the server's text, as the old `e.error` fallthrough did.
+ */
+ ax.metric('ageAssurance:initDialogError', {code: 'other'})
}
} else {
const {clean, raw} = cleanError(e)
diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts
index 67466be926..00472be52f 100644
--- a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts
+++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts
@@ -1,13 +1,15 @@
import {useMutation} from '@tanstack/react-query'
-import {useAgent, useSession} from '#/state/session'
+import {usePdsClient, useSession, useSessionApi} from '#/state/session'
+import {com} from '#/lexicons'
export function useConfirmEmail({
onSuccess,
onError,
}: {onSuccess?: () => void; onError?: () => void} = {}) {
- const agent = useAgent()
+ const pdsClient = usePdsClient()
const {currentAccount} = useSession()
+ const {refreshSession} = useSessionApi()
return useMutation({
mutationFn: async ({token}: {token: string}) => {
@@ -15,12 +17,12 @@ export function useConfirmEmail({
throw new Error('No email found for the current account')
}
- await agent.com.atproto.server.confirmEmail({
+ await pdsClient.call(com.atproto.server.confirmEmail, {
email: currentAccount.email.trim(),
token: token.trim(),
})
// will update session state at root of app
- await agent.resumeSession(agent.session!)
+ await refreshSession()
},
onSuccess,
onError,
diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts
index 358bf86544..d00e487474 100644
--- a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts
+++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts
@@ -1,10 +1,12 @@
import {useMutation} from '@tanstack/react-query'
-import {useAgent, useSession} from '#/state/session'
+import {usePdsClient, useSession, useSessionApi} from '#/state/session'
+import {com} from '#/lexicons'
export function useManageEmail2FA() {
- const agent = useAgent()
+ const pdsClient = usePdsClient()
const {currentAccount} = useSession()
+ const {refreshSession} = useSessionApi()
return useMutation({
mutationFn: async ({
@@ -17,13 +19,13 @@ export function useManageEmail2FA() {
throw new Error('No email found for the current account')
}
- await agent.com.atproto.server.updateEmail({
+ await pdsClient.call(com.atproto.server.updateEmail, {
email: currentAccount.email,
emailAuthFactor: enabled,
token,
})
// will update session state at root of app
- await agent.resumeSession(agent.session!)
+ await refreshSession()
},
})
}
diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts
index 2ec1eb6dc2..7293227f59 100644
--- a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts
+++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts
@@ -1,19 +1,26 @@
+import {type Client} from '@atproto/lex'
import {useMutation} from '@tanstack/react-query'
-import {useAgent} from '#/state/session'
+import {usePdsClient, useSessionApi} from '#/state/session'
import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate'
+import {com} from '#/lexicons'
async function updateEmailAndRefreshSession(
- agent: ReturnType,
+ pdsClient: Client,
+ refreshSession: () => Promise,
email: string,
token?: string,
) {
- await agent.com.atproto.server.updateEmail({email: email.trim(), token})
- await agent.resumeSession(agent.session!)
+ await pdsClient.call(com.atproto.server.updateEmail, {
+ email: email.trim(),
+ token,
+ })
+ await refreshSession()
}
export function useUpdateEmail() {
- const agent = useAgent()
+ const pdsClient = usePdsClient()
+ const {refreshSession} = useSessionApi()
const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate()
return useMutation<
@@ -23,7 +30,12 @@ export function useUpdateEmail() {
>({
mutationFn: async ({email, token}: {email: string; token?: string}) => {
if (token) {
- await updateEmailAndRefreshSession(agent, email, token)
+ await updateEmailAndRefreshSession(
+ pdsClient,
+ refreshSession,
+ email,
+ token,
+ )
return {
status: 'success',
}
@@ -34,7 +46,12 @@ export function useUpdateEmail() {
status: 'tokenRequired',
}
} else {
- await updateEmailAndRefreshSession(agent, email, token)
+ await updateEmailAndRefreshSession(
+ pdsClient,
+ refreshSession,
+ email,
+ token,
+ )
return {
status: 'success',
}
diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx
index 525e189ace..a9a14fc2d3 100644
--- a/src/features/liveNow/index.tsx
+++ b/src/features/liveNow/index.tsx
@@ -23,7 +23,7 @@ import {
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useAgent, usePdsClient, useSession} from '#/state/session'
+import {usePdsClient, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import {useDialogContext} from '#/components/Dialog'
import * as Toast from '#/components/Toast'
@@ -194,7 +194,6 @@ export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig()
const {_} = useLingui()
- const agent = useAgent()
return useQuery({
enabled: !!url,
queryKey: ['link-meta', url],
@@ -212,7 +211,7 @@ export function useLiveLinkMetaQuery(url: string | null) {
)
}
- return await getLinkMeta(agent, url)
+ return await getLinkMeta(url)
},
})
}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index e52d4ec4fc..f13c227857 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -32,7 +32,6 @@ import {app, com} from '#/lexicons'
import * as bsky from '#/types/bsky'
import {createGIFDescription} from '../gif-alt-text'
import {computeCid} from './computeCid'
-import {fromLegacyBlobRef} from './legacy-blob'
import {uploadBlob} from './upload-blob'
export {uploadBlob}
@@ -410,10 +409,11 @@ async function resolveMedia(
return {
$type: 'app.bsky.embed.video',
/*
- * The video pipeline still reads its blob off the legacy agent, so
- * normalize it to the lex shape before it reaches the lex write.
+ * The video blob is a plain lex blob from the video pipeline
+ * (getJobStatus, in composer state/video). Its structural shape matches
+ * the lexicon blob field and the CID hasher (see computeCid).
*/
- video: fromLegacyBlobRef(videoDraft.pendingPublish.blobRef),
+ video: videoDraft.pendingPublish.blobRef,
alt: videoDraft.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio,
diff --git a/src/lib/api/legacy-blob.ts b/src/lib/api/legacy-blob.ts
index a98e375d20..b60acadfe4 100644
--- a/src/lib/api/legacy-blob.ts
+++ b/src/lib/api/legacy-blob.ts
@@ -14,18 +14,3 @@ import {type BlobRef as LexBlobRef} from '@atproto/lex'
export function toLegacyBlobRef(blob: LexBlobRef): BlobRef {
return BlobRef.fromJsonRef(blob as Parameters[0])
}
-
-/**
- * Normalize a legacy `BlobRef` class instance to the plain-JSON lex blob shape.
- *
- * Required for any blob that reaches a lex write: the lex serializer walks
- * plain objects, so a class instance goes on the wire with its internal
- * `original` field and no `$type`. `ipld()` yields exactly the lex shape, and
- * hashes identically (see `computeCid.test.ts` case 2b).
- *
- * Only the video pipeline still needs this - it reads its blob off the legacy
- * agent (`app.bsky.video.getJobStatus`). Drop it when the video client moves.
- */
-export function fromLegacyBlobRef(blob: BlobRef): LexBlobRef {
- return blob.ipld()
-}
diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts
index 476c0f1a20..67bc46284a 100644
--- a/src/lib/api/resolve.ts
+++ b/src/lib/api/resolve.ts
@@ -184,7 +184,7 @@ export async function resolveLink(
view: res.data.starterPack,
}
}
- return resolveExternal(agent, uri)
+ return resolveExternal(uri)
// Forked from useGetPost. TODO: move into RQ.
async function getPost({uri}: {uri: string}) {
@@ -258,11 +258,8 @@ function getFileSlug(url: string | undefined): string | undefined {
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
}
-async function resolveExternal(
- agent: AtpAgent,
- uri: string,
-): Promise {
- const result = await getLinkMeta(agent, uri)
+async function resolveExternal(uri: string): Promise {
+ const result = await getLinkMeta(uri)
return {
type: 'external',
uri: result.url,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 156712c809..9fd7d12b47 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -279,9 +279,14 @@ export const DM_SERVICE_HEADERS = {
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
}
-export const BLUESKY_NOTIF_SERVICE_HEADERS = {
- 'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`,
-}
+/**
+ * The notification service's proxy target, in the `did#service_id` form a lex
+ * client's per-call `service` option takes. Passing it emits `atproto-proxy:
+ * ` on that one request, which is what routes push registration to
+ * the notification service (replaces the old
+ * `BLUESKY_NOTIF_SERVICE_HEADERS`).
+ */
+export const NOTIF_SERVICE: Service = `${BLUESKY_PROXY_DID}#bsky_notif`
export const webLinks = {
tos: `https://bsky.social/about/support/tos`,
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index c282a0c4c3..ee96d6db43 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -1,4 +1,4 @@
-import {type AppBskyEmbedExternal, type AtpAgent} from '@atproto/api'
+import {type AppBskyEmbedExternal} from '@atproto/api'
import {LINK_META_PROXY} from '#/lib/constants'
import {getGiphyMetaUri} from '#/lib/strings/embed-player'
@@ -31,7 +31,6 @@ export interface LinkMeta {
}
export async function getLinkMeta(
- agent: AtpAgent,
url: string,
timeout = 15e3,
): Promise {
@@ -80,9 +79,7 @@ export async function getLinkMeta(
try {
const response = await fetch(
- `${LINK_META_PROXY(agent.serviceUrl.toString() || '')}${encodeURIComponent(
- url,
- )}`,
+ `${LINK_META_PROXY('')}${encodeURIComponent(url)}`,
{signal: controller.signal},
)
diff --git a/src/lib/media/video/multipart/types.ts b/src/lib/media/video/multipart/types.ts
index 16817778c3..a0cfd216e3 100644
--- a/src/lib/media/video/multipart/types.ts
+++ b/src/lib/media/video/multipart/types.ts
@@ -1,3 +1,5 @@
+import {type app} from '#/lexicons'
+
/**
* One part of a multipart upload. `partNumber` is 1-indexed to match the S3
* convention the backend uses.
@@ -57,13 +59,13 @@ export type UploadStatusResponse = {
expiresAt: string
state: UploadState
completedJobId?: string
- jobStatus?: import('@atproto/api').AppBskyVideoDefs.JobStatus
+ jobStatus?: app.bsky.video.defs.JobStatus
failureReason?: string
}
export type FinishUploadResponse = {
completedJobId: string
- jobStatus: import('@atproto/api').AppBskyVideoDefs.JobStatus
+ jobStatus: app.bsky.video.defs.JobStatus
}
export type AbortUploadResponse = Pick<
diff --git a/src/lib/media/video/multipart/upload.ts b/src/lib/media/video/multipart/upload.ts
index 2b45e6d2c6..d3e5210873 100644
--- a/src/lib/media/video/multipart/upload.ts
+++ b/src/lib/media/video/multipart/upload.ts
@@ -1,9 +1,10 @@
-import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
+import {type Client} from '@atproto/lex'
import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {type CompressedVideo} from '#/lib/media/video/types'
import {shouldRetryError} from '#/lib/strings/errors'
+import {type app} from '#/lexicons'
import {getServiceAuthToken} from '../upload.shared'
import {mimeToExt} from '../util'
import {
@@ -29,19 +30,22 @@ export class MultipartFallbackError extends Error {}
export async function uploadVideoMultipart({
video,
- agent,
+ client,
+ dispatchUrl,
setProgress,
signal,
onStarted,
}: {
video: CompressedVideo
- agent: AtpAgent
+ client: Client
+ /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
+ dispatchUrl: string | URL
setProgress: (progress: number) => void
signal: AbortSignal
onStarted?: () => void
-}): Promise {
+}): Promise {
throwIfAborted(signal)
- const tokenProvider = createTokenProvider(agent, signal)
+ const tokenProvider = createTokenProvider(client, dispatchUrl, signal)
const token = await tokenProvider.get()
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
let session
@@ -134,7 +138,7 @@ async function finishAndRecover({
getToken: (forceRefresh?: boolean) => Promise
signal: AbortSignal
resendMissingParts: (receivedPartNumbers: number[]) => Promise
-}): Promise {
+}): Promise {
let createdFailures = 0
let forceTokenRefresh = true
while (true) {
@@ -224,7 +228,7 @@ async function abortThenFallbackOrResolve(
jobId: string,
token: string,
cause: unknown,
-): Promise {
+): Promise {
const result = await abortUploadWithRetry(jobId, token)
if (result.state === 'aborted') {
throw new MultipartFallbackError(
@@ -264,7 +268,11 @@ async function abortUploadWithRetry(jobId: string, token: string) {
throw lastError
}
-function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
+function createTokenProvider(
+ client: Client,
+ dispatchUrl: string | URL,
+ signal: AbortSignal,
+) {
let token: string | undefined
let expiresAt = 0
let refresh: Promise | undefined
@@ -273,7 +281,7 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
if (!refresh) {
const exp = Math.floor(Date.now() / 1000) + 60 * 30
- refresh = getServiceAuthTokenWithRetry(agent, exp, signal)
+ refresh = getServiceAuthTokenWithRetry(client, dispatchUrl, exp, signal)
.then(nextToken => {
token = nextToken
expiresAt = exp * 1000
@@ -290,7 +298,8 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
}
async function getServiceAuthTokenWithRetry(
- agent: AtpAgent,
+ client: Client,
+ dispatchUrl: string | URL,
exp: number,
signal: AbortSignal,
) {
@@ -299,7 +308,8 @@ async function getServiceAuthTokenWithRetry(
throwIfAborted(signal)
try {
return await getServiceAuthToken({
- agent,
+ client,
+ dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp,
})
diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts
index f8aaa1249b..9e8d99a185 100644
--- a/src/lib/media/video/upload.shared.ts
+++ b/src/lib/media/video/upload.shared.ts
@@ -1,44 +1,62 @@
-import {type AtpAgent} from '@atproto/api'
+import {type Client} from '@atproto/lex'
+import {type DidString, type NsidString} from '@atproto/syntax'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
-import {createVideoAgent} from './util'
+import {app, com} from '#/lexicons'
+import {createVideoServiceClient} from './util'
export async function getServiceAuthToken({
- agent,
+ client,
+ dispatchUrl,
aud,
lxm,
exp,
}: {
- agent: AtpAgent
+ client: Client
+ /**
+ * The account's dispatch URL (the old `agent.dispatchUrl`: its PDS, falling
+ * back to the account service). Only needed when `aud` is omitted, so the
+ * default audience can be derived from the PDS host. A lex {@link Client} does
+ * not expose this - it resolves the PDS per request internally - so the caller,
+ * which holds the session, passes it in.
+ */
+ dispatchUrl?: string | URL
aud?: string
- lxm: string
+ lxm: NsidString
exp?: number
}) {
- const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
- if (!pdsAud) {
- throw new Error('Agent does not have a PDS URL')
+ let resolvedAud = aud
+ if (!resolvedAud) {
+ if (!dispatchUrl) {
+ throw new Error('Missing service auth audience: no aud or dispatchUrl')
+ }
+ const pdsAud = getServiceAuthAudFromUrl(dispatchUrl)
+ if (!pdsAud) {
+ throw new Error('Agent does not have a PDS URL')
+ }
+ resolvedAud = pdsAud
}
- const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
- aud: aud ?? pdsAud,
+ const {token} = await client.call(com.atproto.server.getServiceAuth, {
+ aud: resolvedAud as DidString,
lxm,
exp,
})
- return serviceAuth.token
+ return token
}
-export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) {
+export async function getVideoUploadLimits(client: Client, i18n: I18n) {
const token = await getServiceAuthToken({
- agent,
+ client,
lxm: 'app.bsky.video.getUploadLimits',
aud: VIDEO_SERVICE_DID,
})
- const videoAgent = createVideoAgent()
- const {data: limits} = await videoAgent.app.bsky.video
- .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}})
+ const videoClient = createVideoServiceClient(token)
+ const limits = await videoClient
+ .call(app.bsky.video.getUploadLimits)
.catch(err => {
if (err instanceof Error) {
throw new UploadLimitError(err.message)
diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts
index b91ad7a153..287c8f09c6 100644
--- a/src/lib/media/video/upload.ts
+++ b/src/lib/media/video/upload.ts
@@ -1,5 +1,5 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
-import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
+import {type Client} from '@atproto/lex'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
@@ -11,13 +11,15 @@ import {
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
+import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
- agent,
+ client,
+ dispatchUrl,
did,
setProgress,
signal,
@@ -25,7 +27,9 @@ export async function uploadVideo({
onTransport,
}: {
video: CompressedVideo
- agent: AtpAgent
+ client: Client
+ /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
+ dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void
signal: AbortSignal
@@ -35,13 +39,14 @@ export async function uploadVideo({
if (signal.aborted) {
throw new AbortError()
}
- await getVideoUploadLimits(agent, i18n)
+ await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
- agent,
+ client,
+ dispatchUrl,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
@@ -64,7 +69,8 @@ export async function uploadVideo({
throw new AbortError()
}
const token = await getServiceAuthToken({
- agent,
+ client,
+ dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
@@ -91,7 +97,7 @@ export async function uploadVideo({
throw new Error('No response')
}
- const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
+ const responseBody = JSON.parse(res.body) as app.bsky.video.defs.JobStatus
if (!responseBody.jobId) {
throw new ServerError(
diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts
index cfefbd797d..90c10a0684 100644
--- a/src/lib/media/video/upload.web.ts
+++ b/src/lib/media/video/upload.web.ts
@@ -1,4 +1,4 @@
-import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
+import {type Client} from '@atproto/lex'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
@@ -10,13 +10,15 @@ import {
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {Features, features} from '#/analytics/features'
+import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
- agent,
+ client,
+ dispatchUrl,
did,
setProgress,
signal,
@@ -24,7 +26,9 @@ export async function uploadVideo({
onTransport,
}: {
video: CompressedVideo
- agent: AtpAgent
+ client: Client
+ /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */
+ dispatchUrl: string | URL
did: string
setProgress: (progress: number) => void
signal: AbortSignal
@@ -34,13 +38,14 @@ export async function uploadVideo({
if (signal.aborted) {
throw new AbortError()
}
- await getVideoUploadLimits(agent, i18n)
+ await getVideoUploadLimits(client, i18n)
if (features.isOn(Features.VideoMultipartUploadEnable)) {
try {
return await uploadVideoMultipart({
video,
- agent,
+ client,
+ dispatchUrl,
setProgress,
signal,
onStarted: () => onTransport?.('multipart'),
@@ -71,7 +76,8 @@ export async function uploadVideo({
throw new AbortError()
}
const token = await getServiceAuthToken({
- agent,
+ client,
+ dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
})
@@ -80,7 +86,7 @@ export async function uploadVideo({
throw new AbortError()
}
const xhr = new XMLHttpRequest()
- const res = await new Promise(
+ const res = await new Promise(
(resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
const progress = e.loaded / e.total
@@ -92,7 +98,7 @@ export async function uploadVideo({
} else if (xhr.readyState === 4) {
const uploadRes = JSON.parse(
xhr.responseText,
- ) as AppBskyVideoDefs.JobStatus
+ ) as app.bsky.video.defs.JobStatus
resolve(uploadRes)
} else {
reject(new ServerError(i18n._(msg`Failed to upload video`)))
diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts
index 236f0cff3e..7c9686a7f4 100644
--- a/src/lib/media/video/util.ts
+++ b/src/lib/media/video/util.ts
@@ -1,6 +1,5 @@
-import {AtpAgent} from '@atproto/api'
-
import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
+import {createLexClient} from '#/lib/lexClient'
export const createVideoEndpointUrl = (
route: string,
@@ -16,12 +15,29 @@ export const createVideoEndpointUrl = (
return url.href
}
-export function createVideoAgent() {
- return new AtpAgent({
+/**
+ * A non-refreshing single-use lex {@link Client} scoped to the video service and
+ * authenticated by a per-call service-auth token. It has no session, so nothing
+ * can refresh it: requests go straight to the video service with the token as a
+ * static `authorization` header, which a raw client - unlike a session-backed
+ * one - is allowed to preset. Mirrors the scoped client in
+ * `#/ageAssurance/useBeginAgeAssurance`.
+ */
+export function createVideoServiceClient(token: string) {
+ return createLexClient({
service: VIDEO_SERVICE,
+ headers: {authorization: `Bearer ${token}`},
})
}
+/**
+ * An unauthenticated lex {@link Client} scoped to the video service, for public
+ * reads like `getJobStatus` polling.
+ */
+export function createTokenlessVideoServiceClient() {
+ return createLexClient({service: VIDEO_SERVICE})
+}
+
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
switch (mimeType) {
case 'video/mp4':
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 6351a8d8e1..5acc6ab06d 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -2,33 +2,47 @@ import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native'
import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
-import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
+import {type Client} from '@atproto/lex'
import debounce from 'lodash.debounce'
import {
- BLUESKY_NOTIF_SERVICE_HEADERS,
+ NOTIF_SERVICE,
PUBLIC_APPVIEW_DID,
PUBLIC_STAGING_APPVIEW_DID,
} from '#/lib/constants'
import {logger as notyLogger} from '#/lib/notifications/util'
import {isNetworkError} from '#/lib/strings/errors'
-import {type SessionAccount, useAgent, useSession} from '#/state/session'
+import {type SessionAccount, usePdsClient, useSession} from '#/state/session'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_DEV, IS_NATIVE} from '#/env'
+import {app} from '#/lexicons'
+
+/**
+ * A resumed single-use account client paired with the account's service origin
+ * and handle. Produced by `createTemporaryClientsAndResume` (session util) and
+ * consumed by {@link unregisterPushToken}, which needs the service host to pick
+ * the appview DID and the handle for a debug log line without reaching into the
+ * session internals.
+ */
+export type TemporaryPushClient = {
+ client: Client
+ service: string
+ handle: string
+}
/**
* @private
* Registers the device's push notification token with the Bluesky server.
*/
async function _registerPushToken({
- agent,
+ client,
currentAccount,
token,
extra = {},
}: {
- agent: AtpAgent
+ client: Client
currentAccount: SessionAccount
token: Notifications.DevicePushToken
extra?: {
@@ -36,7 +50,7 @@ async function _registerPushToken({
}
}) {
try {
- const payload: AppBskyNotificationRegisterPush.InputSchema = {
+ const payload: app.bsky.notification.registerPush.$InputBody = {
serviceDid: currentAccount.service?.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID,
@@ -48,8 +62,8 @@ async function _registerPushToken({
notyLogger.debug(`registerPushToken: registering`, {...payload})
- await agent.app.bsky.notification.registerPush(payload, {
- headers: BLUESKY_NOTIF_SERVICE_HEADERS,
+ await client.call(app.bsky.notification.registerPush, payload, {
+ service: NOTIF_SERVICE,
})
notyLogger.debug(`registerPushToken: success`)
@@ -74,7 +88,7 @@ const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
* `_registerPushTokenDebounced` directly.
*/
export function useRegisterPushToken() {
- const agent = useAgent()
+ const client = usePdsClient()
const {currentAccount} = useSession()
return useCallback(
@@ -87,7 +101,7 @@ export function useRegisterPushToken() {
}) => {
if (!currentAccount) return
return _registerPushTokenDebounced({
- agent,
+ client,
currentAccount,
token,
extra: {
@@ -95,7 +109,7 @@ export function useRegisterPushToken() {
},
})
},
- [agent, currentAccount],
+ [client, currentAccount],
)
}
@@ -326,16 +340,17 @@ export async function resetBadgeCount() {
await setBadgeCountAsync(0)
}
-export async function unregisterPushToken(agents: AtpAgent[]) {
+export async function unregisterPushToken(clients: TemporaryPushClient[]) {
if (!IS_NATIVE) return
try {
const token = await getPushToken()
if (token) {
- for (const agent of agents) {
- await agent.app.bsky.notification.unregisterPush(
+ for (const {client, service, handle} of clients) {
+ await client.call(
+ app.bsky.notification.unregisterPush,
{
- serviceDid: agent.serviceUrl.hostname.includes('staging')
+ serviceDid: service.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID,
platform: Platform.OS,
@@ -343,10 +358,10 @@ export async function unregisterPushToken(agents: AtpAgent[]) {
appId: 'xyz.blueskyweb.app',
},
{
- headers: BLUESKY_NOTIF_SERVICE_HEADERS,
+ service: NOTIF_SERVICE,
},
)
- notyLogger.debug(`Push token unregistered for ${agent.session?.handle}`)
+ notyLogger.debug(`Push token unregistered for ${handle}`)
}
} else {
notyLogger.debug('Tried to unregister push token, but could not find one')
diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx
index 2782c5ef0d..3be42add78 100644
--- a/src/screens/Deactivated.tsx
+++ b/src/screens/Deactivated.tsx
@@ -7,10 +7,11 @@ import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
+import {isErrorMaybeAppPasswordPermissions} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {
type SessionAccount,
- useAgent,
+ usePdsClient,
useSession,
useSessionApi,
} from '#/state/session'
@@ -25,6 +26,7 @@ import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
+import {com} from '#/lexicons'
const COL_WIDTH = 400
@@ -36,8 +38,8 @@ export function Deactivated() {
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1
- const {logoutCurrentAccount} = useSessionApi()
- const agent = useAgent()
+ const {logoutCurrentAccount, refreshSession} = useSessionApi()
+ const pdsClient = usePdsClient()
const [pending, setPending] = useState(false)
const [error, setError] = useState()
const queryClient = useQueryClient()
@@ -70,21 +72,24 @@ export function Deactivated() {
const handleActivate = useCallback(async () => {
try {
setPending(true)
- await agent.com.atproto.server.activateAccount()
+ await pdsClient.call(com.atproto.server.activateAccount)
await queryClient.resetQueries()
- await agent.resumeSession(agent.session!)
+ await refreshSession()
} catch (e: any) {
- switch (e.message) {
- case 'Bad token scope':
- setError(
- _(
- msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`,
- ),
- )
- break
- default:
- setError(_(msg`Something went wrong, please try again`))
- break
+ /*
+ * `activateAccount` declares no lexicon errors, so the app-password case
+ * arrives as an undeclared code plus a message. The shared helper matches
+ * both that and the plain-string form the old exact `e.message` switch
+ * relied on.
+ */
+ if (isErrorMaybeAppPasswordPermissions(e)) {
+ setError(
+ _(
+ msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`,
+ ),
+ )
+ } else {
+ setError(_(msg`Something went wrong, please try again`))
}
logger.error(e, {
@@ -93,7 +98,7 @@ export function Deactivated() {
} finally {
setPending(false)
}
- }, [_, agent, setPending, setError, queryClient])
+ }, [_, pdsClient, refreshSession, setPending, setError, queryClient])
return (
diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx
index 1b31a7de2a..a8c9474410 100644
--- a/src/screens/Settings/components/ChangeHandleDialog.tsx
+++ b/src/screens/Settings/components/ChangeHandleDialog.tsx
@@ -27,7 +27,7 @@ import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useServiceQuery} from '#/state/queries/service'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
-import {useAgent, useSession} from '#/state/session'
+import {useAgent, useSession, useSessionApi} from '#/state/session'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
@@ -152,7 +152,7 @@ function ProvidedHandlePage({
}) {
const {_} = useLingui()
const [subdomain, setSubdomain] = useState('')
- const agent = useAgent()
+ const {refreshSession} = useSessionApi()
const control = Dialog.useDialogContext()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
@@ -173,7 +173,7 @@ function ProvidedHandlePage({
queryKey: RQKEY_PROFILE(currentAccount.did),
})
}
- agent.resumeSession(agent.session!).then(() => control.close())
+ refreshSession().then(() => control.close())
},
})
@@ -311,7 +311,7 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
const {currentAccount} = useSession()
const [dnsPanel, setDNSPanel] = useState(true)
const [domain, setDomain] = useState('')
- const agent = useAgent()
+ const {refreshSession} = useSessionApi()
const control = Dialog.useDialogContext()
const fetchDid = useFetchDid()
const queryClient = useQueryClient()
@@ -328,7 +328,7 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
queryKey: RQKEY_PROFILE(currentAccount.did),
})
}
- agent.resumeSession(agent.session!).then(() => control.close())
+ refreshSession().then(() => control.close())
},
})
diff --git a/src/screens/Settings/components/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx
index 8b774b54f7..c633c82029 100644
--- a/src/screens/Settings/components/DisableEmail2FADialog.tsx
+++ b/src/screens/Settings/components/DisableEmail2FADialog.tsx
@@ -5,7 +5,8 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {cleanError} from '#/lib/strings/errors'
-import {useAgent, useSession} from '#/state/session'
+import {matchXrpcError} from '#/lib/xrpc-error'
+import {usePdsClient, useSession, useSessionApi} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -16,6 +17,7 @@ import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {P, Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
+import {com} from '#/lexicons'
enum Stages {
Email,
@@ -31,7 +33,8 @@ export function DisableEmail2FADialog({
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
- const agent = useAgent()
+ const pdsClient = usePdsClient()
+ const {refreshSession} = useSessionApi()
const [stage, setStage] = useState(Stages.Email)
const [confirmationCode, setConfirmationCode] = useState('')
@@ -42,7 +45,7 @@ export function DisableEmail2FADialog({
setError('')
setIsProcessing(true)
try {
- await agent.com.atproto.server.requestEmailUpdate()
+ await pdsClient.call(com.atproto.server.requestEmailUpdate)
setStage(Stages.ConfirmCode)
} catch (e) {
setError(cleanError(String(e)))
@@ -56,21 +59,26 @@ export function DisableEmail2FADialog({
setIsProcessing(true)
try {
if (currentAccount?.email) {
- await agent.com.atproto.server.updateEmail({
+ await pdsClient.call(com.atproto.server.updateEmail, {
email: currentAccount.email,
token: confirmationCode.trim(),
emailAuthFactor: false,
})
- await agent.resumeSession(agent.session!)
+ await refreshSession()
Toast.show(_(msg({message: 'Email 2FA disabled', context: 'toast'})))
}
control.close()
} catch (e) {
- const errMsg = String(e)
- if (errMsg.includes('Token is invalid')) {
+ /*
+ * The old check matched the PDS message "Token is invalid"; the lexicon
+ * declares that case as `InvalidToken`, so match the code instead.
+ */
+ if (
+ matchXrpcError(e, com.atproto.server.updateEmail) === 'InvalidToken'
+ ) {
setError(_(msg`Invalid 2FA confirmation code.`))
} else {
- setError(cleanError(errMsg))
+ setError(cleanError(e))
}
} finally {
setIsProcessing(false)
diff --git a/src/screens/Settings/components/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx
index 13b60dfdaa..6f28aa340f 100644
--- a/src/screens/Settings/components/ExportCarDialog.tsx
+++ b/src/screens/Settings/components/ExportCarDialog.tsx
@@ -1,11 +1,11 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
+import {type DidString} from '@atproto/syntax'
import {Trans, useLingui} from '@lingui/react/macro'
-import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {saveBytesToDisk} from '#/lib/media/manip'
import {logger} from '#/logger'
-import {useAgent} from '#/state/session'
+import {useChatClient, usePdsClient, useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -14,6 +14,7 @@ import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
+import {chat, com} from '#/lexicons'
export function ExportCarDialog({
control,
@@ -22,21 +23,29 @@ export function ExportCarDialog({
}) {
const {t: l} = useLingui()
const t = useTheme()
- const agent = useAgent()
+ const {currentAccount} = useSession()
+ const pdsClient = usePdsClient()
+ const chatClient = useChatClient()
const [loading, setLoading] = useState<'repo' | 'chat' | false>(false)
const download = useCallback(async () => {
- if (!agent.session) {
+ if (!currentAccount) {
return // shouldn't ever happen
}
try {
setLoading('repo')
- const did = agent.session.did
- const downloadRes = await agent.com.atproto.sync.getRepo({did})
+ const did = currentAccount.did as DidString
+ const data = await pdsClient.call(com.atproto.sync.getRepo, {did})
+ /*
+ * getRepo declares `application/vnd.ipld.car`, so lex-client hands back
+ * the raw bytes unparsed and does not surface the response content-type.
+ * The old code already fell back to this same constant when the header was
+ * absent, and the endpoint always returns CAR.
+ */
const saveRes = await saveBytesToDisk(
'repo.car',
- downloadRes.data,
- downloadRes.headers['content-type'] || 'application/vnd.ipld.car',
+ data,
+ 'application/vnd.ipld.car',
)
if (saveRes) {
@@ -48,28 +57,26 @@ export function ExportCarDialog({
} finally {
setLoading(false)
}
- }, [l, agent])
+ }, [l, currentAccount, pdsClient])
const downloadChatData = useCallback(async () => {
- if (!agent.session) {
+ if (!currentAccount) {
return
}
try {
setLoading('chat')
- // Using raw fetch because the XRPC client incorrectly tries to JSON-parse
- // application/jsonl responses (substring match on application/json).
- const res = await agent.sessionManager.fetchHandler(
- '/xrpc/chat.bsky.actor.exportAccountData',
- {headers: DM_SERVICE_HEADERS},
- )
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`)
- }
- const data = new Uint8Array(await res.arrayBuffer())
+ /*
+ * lex-client only JSON-parses a response when the declared output encoding
+ * is `application/json`; this endpoint declares `application/jsonl`, so it
+ * returns the raw bytes. That removes the reason for the old low-level
+ * fetchHandler workaround, and the chat client emits the proxy header
+ * itself, so the per-call DM headers go away too.
+ */
+ const data = await chatClient.call(chat.bsky.actor.exportAccountData)
const saveRes = await saveBytesToDisk(
'chat.jsonl',
data,
- res.headers.get('content-type') || 'application/jsonl',
+ 'application/jsonl',
)
if (saveRes) {
@@ -81,7 +88,7 @@ export function ExportCarDialog({
} finally {
setLoading(false)
}
- }, [l, agent])
+ }, [l, currentAccount, chatClient])
return (
diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx
index b83e627404..e0bf7f2edd 100644
--- a/src/screens/SignupQueued.tsx
+++ b/src/screens/SignupQueued.tsx
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {logger} from '#/logger'
-import {isSignupQueued, useAgent, useSessionApi} from '#/state/session'
+import {isSignupQueued, usePdsClient, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
@@ -15,6 +15,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
import {IS_IOS, IS_LIQUID_GLASS, IS_WEB} from '#/env'
+import {com} from '#/lexicons'
const COL_WIDTH = 400
@@ -24,8 +25,8 @@ export function SignupQueued() {
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
- const {logoutCurrentAccount} = useSessionApi()
- const agent = useAgent()
+ const {logoutCurrentAccount, refreshSession} = useSessionApi()
+ const pdsClient = usePdsClient()
const [isProcessing, setProcessing] = useState(false)
const [estimatedTime, setEstimatedTime] = useState(
@@ -38,18 +39,23 @@ export function SignupQueued() {
const checkStatus = useCallback(async () => {
setProcessing(true)
try {
- const res = await agent.com.atproto.temp.checkSignupQueue()
- if (res.data.activated) {
- // ready to go, exchange the access token for a usable one and kick off onboarding
- await agent.sessionManager.refreshSession()
- if (!isSignupQueued(agent.session?.accessJwt)) {
+ const res = await pdsClient.call(com.atproto.temp.checkSignupQueue)
+ if (res.activated) {
+ /*
+ * Ready to go, exchange the access token for a usable one and kick off
+ * onboarding. The refreshed snapshot carries the new scope; reading
+ * `currentAccount` here would still see the pre-refresh token, since the
+ * session's update hook dispatches a render away.
+ */
+ const refreshed = await refreshSession()
+ if (!isSignupQueued(refreshed?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
- setEstimatedTime(msToString(res.data.estimatedTimeMs))
- if (typeof res.data.placeInQueue !== 'undefined') {
- setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
+ setEstimatedTime(msToString(res.estimatedTimeMs))
+ if (typeof res.placeInQueue !== 'undefined') {
+ setPlaceInQueue(Math.max(res.placeInQueue, 1))
}
}
} catch (e: any) {
@@ -62,7 +68,8 @@ export function SignupQueued() {
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
- agent,
+ pdsClient,
+ refreshSession,
])
useEffect(() => {
diff --git a/src/state/session/__tests__/provider-refresh-session-test.tsx b/src/state/session/__tests__/provider-refresh-session-test.tsx
new file mode 100644
index 0000000000..6b1e10bdec
--- /dev/null
+++ b/src/state/session/__tests__/provider-refresh-session-test.tsx
@@ -0,0 +1,204 @@
+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-clients-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