[SDK] Add refreshSession and migrate the session-pinned infra (#11381)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -848,9 +848,6 @@
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/E2E/SharedPreferencesTesterScreen.tsx": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<Trans>
|
||||
We're having issues initializing the age assurance process for
|
||||
your account. Please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_DID, email})}
|
||||
label={_(msg`Contact support`)}>
|
||||
contact support
|
||||
</SimpleInlineLinkText>{' '}
|
||||
for assistance.
|
||||
</Trans>
|
||||
</>
|
||||
)
|
||||
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 = (
|
||||
<>
|
||||
<Trans>
|
||||
We're having issues initializing the age assurance process for
|
||||
your account. Please{' '}
|
||||
<SimpleInlineLinkText
|
||||
to={createSupportLink({code: SupportCode.AA_DID, email})}
|
||||
label={_(msg`Contact support`)}>
|
||||
contact support
|
||||
</SimpleInlineLinkText>{' '}
|
||||
for assistance.
|
||||
</Trans>
|
||||
</>
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<typeof useAgent>,
|
||||
pdsClient: Client,
|
||||
refreshSession: () => Promise<unknown>,
|
||||
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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,18 +14,3 @@ import {type BlobRef as LexBlobRef} from '@atproto/lex'
|
||||
export function toLegacyBlobRef(blob: LexBlobRef): BlobRef {
|
||||
return BlobRef.fromJsonRef(blob as Parameters<typeof BlobRef.fromJsonRef>[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()
|
||||
}
|
||||
|
||||
@@ -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<ResolvedExternalLink> {
|
||||
const result = await getLinkMeta(agent, uri)
|
||||
async function resolveExternal(uri: string): Promise<ResolvedExternalLink> {
|
||||
const result = await getLinkMeta(uri)
|
||||
return {
|
||||
type: 'external',
|
||||
uri: result.url,
|
||||
|
||||
@@ -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:
|
||||
* <this value>` 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`,
|
||||
|
||||
@@ -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<LinkMeta> {
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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<AppBskyVideoDefs.JobStatus> {
|
||||
}): Promise<app.bsky.video.defs.JobStatus> {
|
||||
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<string>
|
||||
signal: AbortSignal
|
||||
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
|
||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
||||
}): Promise<app.bsky.video.defs.JobStatus> {
|
||||
let createdFailures = 0
|
||||
let forceTokenRefresh = true
|
||||
while (true) {
|
||||
@@ -224,7 +228,7 @@ async function abortThenFallbackOrResolve(
|
||||
jobId: string,
|
||||
token: string,
|
||||
cause: unknown,
|
||||
): Promise<AppBskyVideoDefs.JobStatus> {
|
||||
): Promise<app.bsky.video.defs.JobStatus> {
|
||||
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<string> | 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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AppBskyVideoDefs.JobStatus>(
|
||||
const res = await new Promise<app.bsky.video.defs.JobStatus>(
|
||||
(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`)))
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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')
|
||||
|
||||
+22
-17
@@ -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<string | undefined>()
|
||||
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 (
|
||||
<View style={[a.util_screen_outer, a.flex_1]}>
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>(Stages.Email)
|
||||
const [confirmationCode, setConfirmationCode] = useState<string>('')
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
|
||||
@@ -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<string | undefined>(
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<unknown>>()
|
||||
jest.mock('../session-core', () => ({
|
||||
...jest.requireActual<object>('../session-core'),
|
||||
createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args),
|
||||
}))
|
||||
jest.mock('../create-account', () => ({
|
||||
createSessionBundleAndCreateAccount: () => new Promise(() => {}),
|
||||
}))
|
||||
|
||||
import {Provider, useSession, useSessionApi} from '#/state/session'
|
||||
import {type SessionApiContext} from '#/state/session/types'
|
||||
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
|
||||
import {type SessionBundle} from '../session-core'
|
||||
import {sessionAccountToSessionData} from '../session-data'
|
||||
import {
|
||||
asFetch,
|
||||
DID,
|
||||
HANDLE,
|
||||
json,
|
||||
makeAccount,
|
||||
makeMockFetch,
|
||||
type MockFetch,
|
||||
} from './mock-fetch'
|
||||
|
||||
/**
|
||||
* Build a bundle whose session is a real `PasswordSession` over the stubbed
|
||||
* network, since `refreshSession` drives the session's own refresh machinery.
|
||||
*/
|
||||
function makeBundle(
|
||||
account: SessionAccount,
|
||||
fetchMock: MockFetch,
|
||||
): SessionBundle {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
api: SessionApiContext
|
||||
currentAccount: () => SessionAccount | undefined
|
||||
}
|
||||
|
||||
function renderProvider(): Harness {
|
||||
let api!: SessionApiContext
|
||||
let currentAccount: SessionAccount | undefined
|
||||
function Probe() {
|
||||
api = useSessionApi()
|
||||
currentAccount = useSession().currentAccount
|
||||
return null
|
||||
}
|
||||
render(
|
||||
<Provider>
|
||||
<Probe />
|
||||
</Provider>,
|
||||
)
|
||||
return {api, currentAccount: () => currentAccount}
|
||||
}
|
||||
|
||||
/** Render the provider and log `account` in through the stubbed login factory. */
|
||||
async function renderLoggedIn(
|
||||
account: SessionAccount,
|
||||
fetchMock: MockFetch,
|
||||
): Promise<Harness> {
|
||||
const bundle = makeBundle(account, fetchMock)
|
||||
const harness = renderProvider()
|
||||
mockLogin.mockResolvedValueOnce({bundle, account})
|
||||
await act(async () => {
|
||||
await harness.api.login({} as never, 'LoginForm')
|
||||
})
|
||||
return harness
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogin.mockReset()
|
||||
})
|
||||
|
||||
describe('refreshSession', () => {
|
||||
it('resolves with the rotated account snapshot', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
const {api} = await renderLoggedIn(makeAccount(), fetchMock)
|
||||
|
||||
let refreshed: SessionAccount | undefined
|
||||
await act(async () => {
|
||||
refreshed = await api.refreshSession()
|
||||
})
|
||||
|
||||
/* the mock's refresh response rotates both tokens */
|
||||
expect(refreshed?.accessJwt).toBe('access-jwt-2')
|
||||
expect(refreshed?.refreshJwt).toBe('refresh-jwt-2')
|
||||
expect(refreshed?.did).toBe(DID)
|
||||
expect(refreshed?.handle).toBe(HANDLE)
|
||||
})
|
||||
|
||||
it('exposes the fresh tokens before the store has caught up', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
const {api, currentAccount} = await renderLoggedIn(makeAccount(), fetchMock)
|
||||
|
||||
/*
|
||||
* The point of the return value: `SignupQueued` branches on the fresh
|
||||
* accessJwt synchronously, without waiting for `onUpdated` -> dispatch ->
|
||||
* re-render.
|
||||
*/
|
||||
let refreshed: SessionAccount | undefined
|
||||
const before = currentAccount()?.accessJwt
|
||||
await act(async () => {
|
||||
refreshed = await api.refreshSession()
|
||||
})
|
||||
expect(before).toBe('access-jwt')
|
||||
expect(refreshed?.accessJwt).toBe('access-jwt-2')
|
||||
})
|
||||
|
||||
it('resolves with undefined when logged out', async () => {
|
||||
const {api} = renderProvider()
|
||||
|
||||
let refreshed: SessionAccount | undefined = makeAccount()
|
||||
await act(async () => {
|
||||
refreshed = await api.refreshSession()
|
||||
})
|
||||
|
||||
expect(refreshed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects when the refresh rotated nothing', async () => {
|
||||
/*
|
||||
* A transient failure: `PasswordSession.refresh()` reports through
|
||||
* `onUpdateFailure` and resolves with the SAME data object. Callers read
|
||||
* resolution as "tokens rotated", so this must reject.
|
||||
*/
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
json({error: 'InternalServerError'}, 500),
|
||||
})
|
||||
const {api} = await renderLoggedIn(makeAccount(), fetchMock)
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await api.refreshSession()
|
||||
}),
|
||||
).rejects.toThrow('Failed to refresh session')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
import {type SessionData} from '@atproto/lex-password-session'
|
||||
import {describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
@@ -18,21 +17,21 @@ jest.mock('../../../ageAssurance/state', () => ({
|
||||
unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
|
||||
}))
|
||||
jest.mock('#/lib/notifications/notifications', () => ({
|
||||
unregisterPushToken(_agents: AtpAgent[]) {
|
||||
unregisterPushToken(_clients: unknown[]) {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}))
|
||||
/*
|
||||
* The logout and account-removal reducer cases fire a push-token side effect
|
||||
* whose first step, `createTemporaryAgentsAndResume`, builds real `AtpAgent`s
|
||||
* and resumes them over the real network. Under jest that request outlives the
|
||||
* whose first step, `createTemporaryClientsAndResume`, resumes real
|
||||
* `PasswordSession`s over the real network. Under jest that request outlives the
|
||||
* suite: it rejects after teardown, and the resulting `logger.error` reaches
|
||||
* for `nanoid` in an environment that no longer has it, failing whichever suite
|
||||
* happens to be running at that moment. Stubbing the module keeps the side
|
||||
* effect synchronous and offline.
|
||||
*/
|
||||
jest.mock('../util', () => ({
|
||||
createTemporaryAgentsAndResume: () => Promise.resolve([]),
|
||||
createTemporaryClientsAndResume: () => Promise.resolve([]),
|
||||
}))
|
||||
|
||||
// Reuse a bundle within each test: session events are scoped by bundle identity.
|
||||
|
||||
@@ -82,6 +82,7 @@ const ApiContext = createContext<SessionApiContext>({
|
||||
resumeSession: async () => {},
|
||||
removeAccount: () => {},
|
||||
partialRefreshSession: async () => {},
|
||||
refreshSession: () => Promise.resolve(undefined),
|
||||
})
|
||||
ApiContext.displayName = 'SessionApiContext'
|
||||
|
||||
@@ -473,6 +474,50 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
})
|
||||
}, [store, cancelPendingTask])
|
||||
|
||||
/**
|
||||
* Rotate the session's tokens and hand back the resulting account snapshot.
|
||||
*
|
||||
* Rejects when the rotation was a no-op, restoring the contract the
|
||||
* `agent.resumeSession(agent.session!)` call sites were written against (the
|
||||
* bridge agent's `refreshSession` override does the same, for the same
|
||||
* reason). `PasswordSession.refresh()` resolves with the
|
||||
* unchanged `SessionData` on a transient failure - a 500 or a network error
|
||||
* reported through `onUpdateFailure` - and reserves rejection for a
|
||||
* definitively dead session. Callers here all read resolution as "tokens
|
||||
* rotated": the verification dialogs close, `Deactivated` clears its error
|
||||
* state, and `SignupQueued` re-checks the token scope, so a resolved no-op
|
||||
* would report success or loop silently. Identity, not a field comparison, is
|
||||
* the signal: `PasswordSession` allocates a new object per successful
|
||||
* rotation and returns the existing one untouched otherwise. Capturing the
|
||||
* data immediately before the call also handles concurrent refreshes, since a
|
||||
* rotation another caller's queued refresh performed still differs from what
|
||||
* we captured.
|
||||
*
|
||||
* Like {@link partialRefreshSession}, the bundle comes from
|
||||
* `store.getState()` rather than the render's `state`: a dispatch landing
|
||||
* before the next render would otherwise leave this holding a disposed
|
||||
* bundle, and reading live also keeps the callback's identity stable across
|
||||
* unrelated state updates.
|
||||
*/
|
||||
const refreshSession = useCallback<
|
||||
SessionApiContext['refreshSession']
|
||||
>(async () => {
|
||||
const bundle = store.getState().currentBundleState.bundle as unknown as
|
||||
| SessionBundle
|
||||
| PublicSessionBundle
|
||||
if (!bundle.session) return undefined // logged out: nothing to refresh
|
||||
const before = bundle.session.session
|
||||
const after = await bundle.session.refresh()
|
||||
if (after === before) {
|
||||
throw new Error('Failed to refresh session')
|
||||
}
|
||||
/*
|
||||
* The session's `onUpdated` hook dispatches the new tokens into the store,
|
||||
* but that lands a render away; this snapshot exposes them immediately.
|
||||
*/
|
||||
return sessionDataToSessionAccount(after, after.service)
|
||||
}, [store])
|
||||
|
||||
const removeAccount = useCallback<SessionApiContext['removeAccount']>(
|
||||
account => {
|
||||
addSessionDebugLog({
|
||||
@@ -607,6 +652,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
partialRefreshSession,
|
||||
refreshSession,
|
||||
}),
|
||||
[
|
||||
createAccount,
|
||||
@@ -616,6 +662,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
partialRefreshSession,
|
||||
refreshSession,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {logger} from '#/lib/notifications/util'
|
||||
import {wrapSessionReducerForLogging} from './logging'
|
||||
import {createPublicSessionBundle} from './session-core'
|
||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||
import {createTemporaryAgentsAndResume} from './util'
|
||||
import {createTemporaryClientsAndResume} from './util'
|
||||
|
||||
// Keep session internals outside the reducer's static view of a bundle.
|
||||
type OpaqueSessionBundle = {
|
||||
@@ -155,8 +155,8 @@ let reducer = (state: State, action: Action): State => {
|
||||
// side effect
|
||||
const account = state.accounts.find(a => a.did === accountDid)
|
||||
if (account) {
|
||||
createTemporaryAgentsAndResume([account])
|
||||
.then(agents => unregisterPushToken(agents))
|
||||
createTemporaryClientsAndResume([account])
|
||||
.then(clients => unregisterPushToken(clients))
|
||||
.then(() =>
|
||||
logger.debug('Push token unregistered', {did: accountDid}),
|
||||
)
|
||||
@@ -183,8 +183,8 @@ let reducer = (state: State, action: Action): State => {
|
||||
// side effect
|
||||
const account = state.accounts.find(a => a.did === accountDid)
|
||||
if (account && accountDid) {
|
||||
createTemporaryAgentsAndResume([account])
|
||||
.then(agents => unregisterPushToken(agents))
|
||||
createTemporaryClientsAndResume([account])
|
||||
.then(clients => unregisterPushToken(clients))
|
||||
.then(() =>
|
||||
logger.debug('Push token unregistered', {did: accountDid}),
|
||||
)
|
||||
@@ -211,8 +211,8 @@ let reducer = (state: State, action: Action): State => {
|
||||
}
|
||||
}
|
||||
case 'logged-out-every-account': {
|
||||
createTemporaryAgentsAndResume(state.accounts)
|
||||
.then(agents => unregisterPushToken(agents))
|
||||
createTemporaryClientsAndResume(state.accounts)
|
||||
.then(clients => unregisterPushToken(clients))
|
||||
.then(() => logger.debug('Push token unregistered'))
|
||||
.catch(err => {
|
||||
logger.error('Failed to unregister push token', {
|
||||
|
||||
@@ -52,4 +52,17 @@ export type SessionApiContext = {
|
||||
* so it produces no session-change side effects.
|
||||
*/
|
||||
partialRefreshSession: () => Promise<void>
|
||||
/**
|
||||
* Rotates the session's tokens and resolves with the resulting account
|
||||
* snapshot, or `undefined` when logged out.
|
||||
*
|
||||
* Rejects when nothing was rotated, so a resolved promise means "tokens
|
||||
* rotated". Every caller relies on that: the verification dialogs close on
|
||||
* resolution, and `SignupQueued` re-checks the token scope.
|
||||
*
|
||||
* The snapshot is returned rather than read off `currentAccount`, because the
|
||||
* session's `onUpdated` hook -> `store.dispatch` path is a render cycle away
|
||||
* and `SignupQueued` branches synchronously on the fresh `accessJwt`.
|
||||
*/
|
||||
refreshSession: () => Promise<SessionAccount | undefined>
|
||||
}
|
||||
|
||||
+34
-20
@@ -1,7 +1,10 @@
|
||||
import AtpAgent from '@atproto/api'
|
||||
import {PasswordSession} from '@atproto/lex-password-session'
|
||||
|
||||
import {createLexClient} from '#/lib/lexClient'
|
||||
import {type TemporaryPushClient} from '#/lib/notifications/notifications'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {sessionAccountToSession} from './session-data'
|
||||
import {networkAwareFetch} from './network'
|
||||
import {sessionAccountToSessionData} from './session-data'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
export {isSessionExpired, isSignupQueued} from './session-data'
|
||||
@@ -12,30 +15,41 @@ export function readLastActiveAccount() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and attempted to resumeSession for every stored session.
|
||||
* Intended to be used to send push token revokations just before logout.
|
||||
* Resume a single-use session per stored account, for the push-token revocation
|
||||
* sent just before logout.
|
||||
*
|
||||
* The sessions carry no lifecycle hooks - no `onUpdated`, no `onDeleted` - so a
|
||||
* rotation one of them performs can neither persist over nor race the live
|
||||
* session's tokens. That isolation is load-bearing: each exists only long enough
|
||||
* to authenticate one `unregisterPush` call.
|
||||
*
|
||||
* PDS routing is left to the session rather than pinned from the stored
|
||||
* `pdsUrl`, because `resume` refreshes (and fills in a missing didDoc from
|
||||
* `getSession`) before the client issues anything, so the request already goes
|
||||
* to the didDoc PDS.
|
||||
*
|
||||
* `resume` rejects only when a session is definitively dead; a transient network
|
||||
* failure resolves with the stored tokens, which are the same ones the old agent
|
||||
* path would have sent. Definitively dead sessions drop out of the settled list.
|
||||
*/
|
||||
export async function createTemporaryAgentsAndResume(
|
||||
export async function createTemporaryClientsAndResume(
|
||||
accounts: SessionAccount[],
|
||||
) {
|
||||
const agents = await Promise.allSettled(
|
||||
): Promise<TemporaryPushClient[]> {
|
||||
const settled = await Promise.allSettled(
|
||||
accounts.map(async account => {
|
||||
const agent: AtpAgent = new AtpAgent({service: account.service})
|
||||
if (account.pdsUrl) {
|
||||
agent.sessionManager.pdsUrl = new URL(account.pdsUrl)
|
||||
}
|
||||
|
||||
const session = sessionAccountToSession(account)
|
||||
const res = await agent.resumeSession(session)
|
||||
if (!res.success) throw new Error('Failed to resume session')
|
||||
|
||||
agent.assertAuthenticated() // confirm auth success
|
||||
|
||||
return agent
|
||||
const session = await PasswordSession.resume(
|
||||
sessionAccountToSessionData(account),
|
||||
{fetch: networkAwareFetch},
|
||||
)
|
||||
return {
|
||||
client: createLexClient(session),
|
||||
service: session.session.service,
|
||||
handle: session.session.handle,
|
||||
} satisfies TemporaryPushClient
|
||||
}),
|
||||
)
|
||||
|
||||
return agents
|
||||
return settled
|
||||
.filter(x => x.status === 'fulfilled')
|
||||
.map(promise => promise.value)
|
||||
}
|
||||
|
||||
@@ -285,6 +285,12 @@ export const ComposePost = ({
|
||||
const pdsClient = usePdsClient()
|
||||
const queryClient = useQueryClient()
|
||||
const currentDid = currentAccount!.did
|
||||
/*
|
||||
* The host the video service-auth token is minted for. This is the same value
|
||||
* that seeds the session's PDS routing, so the audience always matches the host
|
||||
* the upload actually reaches; a mismatch would 401 the upload.
|
||||
*/
|
||||
const currentDispatchUrl = currentAccount!.pdsUrl ?? currentAccount!.service
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const requireAltTextEnabled = useRequireAltTextEnabled()
|
||||
@@ -479,7 +485,8 @@ export const ComposePost = ({
|
||||
},
|
||||
})
|
||||
},
|
||||
agent,
|
||||
pdsClient,
|
||||
currentDispatchUrl,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
@@ -489,7 +496,8 @@ export const ComposePost = ({
|
||||
[
|
||||
l,
|
||||
i18n,
|
||||
agent,
|
||||
pdsClient,
|
||||
currentDispatchUrl,
|
||||
currentDid,
|
||||
composerDispatch,
|
||||
ax.metric,
|
||||
@@ -661,7 +669,8 @@ export const ComposePost = ({
|
||||
},
|
||||
})
|
||||
},
|
||||
agent,
|
||||
pdsClient,
|
||||
currentDispatchUrl,
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
@@ -677,7 +686,8 @@ export const ComposePost = ({
|
||||
[
|
||||
l,
|
||||
i18n,
|
||||
agent,
|
||||
pdsClient,
|
||||
currentDispatchUrl,
|
||||
currentDid,
|
||||
composerDispatch,
|
||||
ax.metric,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api'
|
||||
import {type BlobRef, type Client} from '@atproto/lex'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {uploadVideo} from '#/lib/media/video/upload'
|
||||
import {createVideoAgent} from '#/lib/media/video/util'
|
||||
import {createTokenlessVideoServiceClient} from '#/lib/media/video/util'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {app} from '#/lexicons'
|
||||
import {
|
||||
advanceVideoProgress,
|
||||
didSkipVideoCompression,
|
||||
@@ -56,7 +57,7 @@ export type VideoAction =
|
||||
}
|
||||
| {
|
||||
type: 'update_job_status'
|
||||
jobStatus: AppBskyVideoDefs.JobStatus
|
||||
jobStatus: app.bsky.video.defs.JobStatus
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ type ProcessingState = {
|
||||
asset: ImagePickerAsset
|
||||
video: CompressedVideo
|
||||
jobId: string
|
||||
jobStatus: AppBskyVideoDefs.JobStatus | null
|
||||
jobStatus: app.bsky.video.defs.JobStatus | null
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
@@ -295,7 +296,8 @@ function trunc2dp(num: number) {
|
||||
export async function processVideo(
|
||||
asset: ImagePickerAsset,
|
||||
dispatch: (action: VideoAction) => void,
|
||||
agent: AtpAgent,
|
||||
client: Client,
|
||||
dispatchUrl: string | URL,
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
i18n: I18n,
|
||||
@@ -339,12 +341,13 @@ export async function processVideo(
|
||||
signal,
|
||||
})
|
||||
|
||||
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
||||
let uploadResponse: app.bsky.video.defs.JobStatus | undefined
|
||||
try {
|
||||
telemetry.uploadStarted(video.size)
|
||||
uploadResponse = await uploadVideo({
|
||||
video,
|
||||
agent,
|
||||
client,
|
||||
dispatchUrl,
|
||||
did,
|
||||
signal,
|
||||
i18n,
|
||||
@@ -381,12 +384,14 @@ export async function processVideo(
|
||||
return // Exit async loop
|
||||
}
|
||||
|
||||
const videoAgent = createVideoAgent()
|
||||
let status: AppBskyVideoDefs.JobStatus | undefined
|
||||
const videoClient = createTokenlessVideoServiceClient()
|
||||
let status: app.bsky.video.defs.JobStatus | undefined
|
||||
let blob: BlobRef | undefined
|
||||
try {
|
||||
const response = await videoAgent.app.bsky.video.getJobStatus({jobId})
|
||||
status = response.data.jobStatus
|
||||
const response = await videoClient.call(app.bsky.video.getJobStatus, {
|
||||
jobId,
|
||||
})
|
||||
status = response.jobStatus
|
||||
pollFailures = 0
|
||||
|
||||
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||
|
||||
Reference in New Issue
Block a user