rework session layer on PasswordSession + three-client pattern
- PasswordSession (@atproto/lex-password-session) is now the single auth
core; SessionAgent bridges it to the base Agent from @atproto/api so
the ~148 useAgent() consumer files keep working unchanged
- SessionBundle carries {session, agent, accountClient, appviewClient};
useLexClient() now returns the authed appview client, new
useAppviewClient()/usePdsClient() hooks
- reducer: OpaqueSessionBundle, new replaced-current-bundle action for
network-free cross-tab same-did sync (PasswordSession is immutable, so
the bundle is rebuilt instead of mutated in place)
- moderation: AtpAgent.configure -> Client.configure + Agent.configure,
per-account labelers on both the bridge and the appview client
- push-token unregistration uses temporary PasswordSessions
- hard-tail migrations: SignupQueued refresh via resumeSession shim,
useBeginAgeAssurance scoped-token raw Client, useAccountEmailState
reads currentAccount
- PDS routing preserved on the no-network resume fast path by
synthesizing a minimal didDoc from the persisted pdsUrl
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1483,11 +1483,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/session/agent.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/shell/color-mode.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
snoozeBirthdateUpdateAllowedForDid,
|
||||
} from '#/state/birthdate'
|
||||
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {type SessionAgent, useAgent, useSession} from '#/state/session'
|
||||
import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
|
||||
import * as debug from '#/ageAssurance/debug'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
@@ -63,7 +63,7 @@ const [, cacheHydrationPromise] = persistQueryClient({
|
||||
persister,
|
||||
})
|
||||
|
||||
export function getDidFromAgentSession(agent: AtpAgent) {
|
||||
export function getDidFromAgentSession(agent: SessionAgent) {
|
||||
const sessionManager = agent.sessionManager
|
||||
if (!sessionManager || !sessionManager.did) return
|
||||
return sessionManager.did
|
||||
@@ -187,7 +187,7 @@ export function useConfigQuery() {
|
||||
export function createServerStateQueryKey({did}: {did: string}) {
|
||||
return ['serverState', did]
|
||||
}
|
||||
export async function getServerState({agent}: {agent: AtpAgent}) {
|
||||
export async function getServerState({agent}: {agent: SessionAgent}) {
|
||||
if (debug.enabled && debug.serverState)
|
||||
return debug.resolve(debug.serverState)
|
||||
const geolocation = device.get(['mergedGeolocation'])
|
||||
@@ -218,7 +218,7 @@ export function getServerStateFromCache({
|
||||
createServerStateQueryKey({did}),
|
||||
)
|
||||
}
|
||||
export async function prefetchServerState({agent}: {agent: AtpAgent}) {
|
||||
export async function prefetchServerState({agent}: {agent: SessionAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
|
||||
if (!did) return
|
||||
@@ -245,7 +245,7 @@ export async function prefetchServerState({agent}: {agent: AtpAgent}) {
|
||||
})
|
||||
}
|
||||
}
|
||||
export async function refetchServerState({agent}: {agent: AtpAgent}) {
|
||||
export async function refetchServerState({agent}: {agent: SessionAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
logger.debug(`refetchServerState: fetching...`)
|
||||
@@ -344,7 +344,7 @@ export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||
async function getOtherRequiredData({
|
||||
agent,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
}): Promise<OtherRequiredData> {
|
||||
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
|
||||
const did = getDidFromAgentSession(agent)
|
||||
@@ -426,7 +426,11 @@ export function setOtherRequiredDataActorDeclarationCache({
|
||||
next,
|
||||
)
|
||||
}
|
||||
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
|
||||
export async function prefetchOtherRequiredData({
|
||||
agent,
|
||||
}: {
|
||||
agent: SessionAgent
|
||||
}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
|
||||
if (!did) return
|
||||
@@ -577,7 +581,7 @@ export function setDeviceSignalsForRegion({
|
||||
prev => ({...prev, [regionKey]: signals}),
|
||||
)
|
||||
}
|
||||
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
|
||||
export async function prefetchDeviceSignals({agent}: {agent: SessionAgent}) {
|
||||
const did = getDidFromAgentSession(agent)
|
||||
if (!did) return
|
||||
|
||||
@@ -659,7 +663,7 @@ export function useDeviceSignalsQuery() {
|
||||
/**
|
||||
* Helper to prefetch all age assurance data from the server.
|
||||
*/
|
||||
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
|
||||
export function prefetchAgeAssuranceServerData({agent}: {agent: SessionAgent}) {
|
||||
return Promise.allSettled([
|
||||
// config fetch initiated at the top of the App.platform.tsx files, awaited here
|
||||
configPrefetchPromise,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex-client'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {wait} from '#/lib/async/wait'
|
||||
@@ -15,6 +15,7 @@ import {logger} from '#/ageAssurance/logger'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {BLUESKY_PROXY_DID} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
import {app} from '#/lexicons'
|
||||
|
||||
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
|
||||
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
|
||||
@@ -28,7 +29,7 @@ export function useBeginAgeAssurance() {
|
||||
return useMutation({
|
||||
async mutationFn(
|
||||
props: Omit<
|
||||
AppBskyAgeassuranceBegin.InputSchema,
|
||||
app.bsky.ageassurance.begin.$InputBody,
|
||||
'countryCode' | 'regionCode'
|
||||
>,
|
||||
) {
|
||||
@@ -45,10 +46,16 @@ export function useBeginAgeAssurance() {
|
||||
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 non-refreshing throwaway client scoped to the service-auth token: it
|
||||
* has no session, so nothing can refresh it. Requests go straight to the
|
||||
* appview with the token as a static Authorization header (a raw client,
|
||||
* unlike a session, is allowed to preset that header).
|
||||
*/
|
||||
const scopedClient = new Client({
|
||||
service: APPVIEW,
|
||||
headers: {authorization: `Bearer ${token}`},
|
||||
})
|
||||
|
||||
ax.metric('ageAssurance:api:begin', {
|
||||
platform: Platform.OS,
|
||||
@@ -60,9 +67,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,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {useContext} from 'react'
|
||||
import {Alert, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import * as Contacts from 'expo-contacts'
|
||||
import type AtpAgent from '@atproto/api'
|
||||
import {
|
||||
type AppBskyActorProfile,
|
||||
AppBskyContactImportContacts,
|
||||
@@ -17,7 +16,7 @@ import {uploadBlob} from '#/lib/api'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import {
|
||||
Context as OnboardingContext,
|
||||
type OnboardingAction,
|
||||
@@ -325,7 +324,7 @@ function showPermissionDeniedAlert() {
|
||||
* Copied from `#/screens/Onboarding/StepFinished/index.tsx`
|
||||
*/
|
||||
async function createProfileRecord(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
onboardingContext: {
|
||||
state: OnboardingState
|
||||
dispatch: React.Dispatch<OnboardingAction>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useAgent, useSessionApi} from '#/state/session'
|
||||
import {useSession, useSessionApi} from '#/state/session'
|
||||
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
|
||||
|
||||
export type AccountEmailState = {
|
||||
@@ -12,24 +12,24 @@ export type AccountEmailState = {
|
||||
export const accountEmailStateQueryKey = ['accountEmailState'] as const
|
||||
|
||||
export function useAccountEmailState() {
|
||||
const agent = useAgent()
|
||||
const {currentAccount} = useSession()
|
||||
const {partialRefreshSession} = useSessionApi()
|
||||
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
|
||||
!!agent.session?.emailConfirmed,
|
||||
!!currentAccount?.emailConfirmed,
|
||||
)
|
||||
const state: AccountEmailState = useMemo(
|
||||
() => ({
|
||||
isEmailVerified: !!agent.session?.emailConfirmed,
|
||||
email2FAEnabled: !!agent.session?.emailAuthFactor,
|
||||
isEmailVerified: !!currentAccount?.emailConfirmed,
|
||||
email2FAEnabled: !!currentAccount?.emailAuthFactor,
|
||||
}),
|
||||
[agent.session],
|
||||
[currentAccount],
|
||||
)
|
||||
|
||||
/**
|
||||
* Only here to refetch on focus, when necessary
|
||||
*/
|
||||
useQuery({
|
||||
enabled: !!agent.session,
|
||||
enabled: !!currentAccount,
|
||||
/**
|
||||
* Only refetch if the email verification s incomplete.
|
||||
*/
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
type AtpAgent,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type FeedAPI, type FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
_params: GetAuthorFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
agent,
|
||||
feedParams,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedParams: GetAuthorFeed.QueryParams
|
||||
}) {
|
||||
this.agent = agent
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
getAppLanguageAsContentLanguage,
|
||||
getContentLanguages,
|
||||
} from '#/state/preferences/languages'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type FeedAPI, type FeedAPIResponse} from './types'
|
||||
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
params: GetCustomFeed.QueryParams
|
||||
userInterests?: string
|
||||
|
||||
@@ -22,7 +23,7 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
feedParams,
|
||||
userInterests,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedParams: GetCustomFeed.QueryParams
|
||||
userInterests?: string
|
||||
}) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
import {DEMO_FEED} from '#/lib/demo'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type FeedAPI, type FeedAPIResponse} from './types'
|
||||
|
||||
export class DemoFeedAPI implements FeedAPI {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
|
||||
constructor({agent}: {agent: AtpAgent}) {
|
||||
constructor({agent}: {agent: SessionAgent}) {
|
||||
this.agent = agent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type FeedAPI, type FeedAPIResponse} from './types'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
|
||||
constructor({agent}: {agent: AtpAgent}) {
|
||||
constructor({agent}: {agent: SessionAgent}) {
|
||||
this.agent = agent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {CustomFeedAPI} from './custom'
|
||||
import {FollowingFeedAPI} from './following'
|
||||
import {type FeedAPI, type FeedAPIResponse} from './types'
|
||||
@@ -27,7 +28,7 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
|
||||
}
|
||||
|
||||
export class HomeFeedAPI implements FeedAPI {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
following: FollowingFeedAPI
|
||||
discover: CustomFeedAPI
|
||||
usingDiscover = false
|
||||
@@ -39,7 +40,7 @@ export class HomeFeedAPI implements FeedAPI {
|
||||
agent,
|
||||
}: {
|
||||
userInterests?: string
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
}) {
|
||||
this.agent = agent
|
||||
this.following = new FollowingFeedAPI({agent})
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
type AtpAgent,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type FeedAPI, type FeedAPIResponse} from './types'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
params: GetActorLikes.QueryParams
|
||||
|
||||
constructor({
|
||||
agent,
|
||||
feedParams,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedParams: GetActorLikes.QueryParams
|
||||
}) {
|
||||
this.agent = agent
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedGetTimeline,
|
||||
type AtpAgent,
|
||||
} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs, type AppBskyFeedGetTimeline} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
|
||||
import {bundleAsync} from '#/lib/async/bundle'
|
||||
@@ -10,6 +6,7 @@ import {timeout} from '#/lib/async/timeout'
|
||||
import {feedUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {getContentLanguages} from '#/state/preferences/languages'
|
||||
import {type FeedParams} from '#/state/queries/post-feed'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {FeedTuner} from '../feed-manip'
|
||||
import {type FeedTunerFn} from '../feed-manip'
|
||||
import {
|
||||
@@ -24,7 +21,7 @@ const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
|
||||
|
||||
export class MergeFeedAPI implements FeedAPI {
|
||||
userInterests?: string
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
params: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
following: MergeFeedSource_Following
|
||||
@@ -39,7 +36,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
feedTuners,
|
||||
userInterests,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedParams: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
userInterests?: string
|
||||
@@ -175,7 +172,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedTuners: FeedTunerFn[]
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
cursor: string | undefined = undefined
|
||||
@@ -186,7 +183,7 @@ class MergeFeedSource {
|
||||
agent,
|
||||
feedTuners,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedTuners: FeedTunerFn[]
|
||||
}) {
|
||||
this.agent = agent
|
||||
@@ -253,7 +250,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
}
|
||||
|
||||
class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
minDate: Date
|
||||
feedUri: string
|
||||
userInterests?: string
|
||||
@@ -264,7 +261,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
feedTuners,
|
||||
userInterests,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
feedUri: string
|
||||
feedTuners: FeedTunerFn[]
|
||||
userInterests?: string
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type AppBskyEmbedRecordWithMedia,
|
||||
type AppBskyEmbedVideo,
|
||||
AppBskyFeedPost,
|
||||
type AtpAgent,
|
||||
BlobRef,
|
||||
ChatBskyGroupDefs,
|
||||
type ComAtprotoLabelDefs,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
createThreadgateRecord,
|
||||
threadgateAllowUISettingToAllowRecordValue,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {
|
||||
type EmbedDraft,
|
||||
type PostDraft,
|
||||
@@ -54,7 +54,7 @@ interface PostOpts {
|
||||
}
|
||||
|
||||
export async function post(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
queryClient: QueryClient,
|
||||
opts: PostOpts,
|
||||
) {
|
||||
@@ -196,7 +196,7 @@ export async function post(
|
||||
return {uris}
|
||||
}
|
||||
|
||||
async function resolveRT(agent: AtpAgent, richtext: RichText) {
|
||||
async function resolveRT(agent: SessionAgent, richtext: RichText) {
|
||||
const trimmedText = richtext.text
|
||||
// Trim leading whitespace-only lines (but don't break ASCII art).
|
||||
.replace(/^(\s*\n)+/, '')
|
||||
@@ -216,7 +216,7 @@ export class ReplyDeletedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveReply(agent: AtpAgent, replyTo: string) {
|
||||
async function resolveReply(agent: SessionAgent, replyTo: string) {
|
||||
const {data} = await agent.app.bsky.feed.getPosts({
|
||||
uris: [replyTo],
|
||||
})
|
||||
@@ -249,7 +249,7 @@ async function resolveReply(agent: AtpAgent, replyTo: string) {
|
||||
}
|
||||
|
||||
async function resolveEmbed(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
queryClient: QueryClient,
|
||||
draft: PostDraft,
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
@@ -308,7 +308,7 @@ async function resolveEmbed(
|
||||
}
|
||||
|
||||
async function resolveMedia(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
queryClient: QueryClient,
|
||||
embedDraft: EmbedDraft,
|
||||
onStateChange: ((state: string) => void) | undefined,
|
||||
@@ -481,7 +481,7 @@ async function resolveMedia(
|
||||
}
|
||||
|
||||
async function resolveRecord(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Promise<ComAtprotoRepoStrongRef.Main> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
type AtpAgent,
|
||||
type ComAtprotoRepoStrongRef,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import {createComposerImage} from '#/state/gallery'
|
||||
import {type ChatInvitePreview} from '#/state/queries/join-links'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type Gif} from '#/features/gifPicker/types'
|
||||
import {createGIFDescription} from '../gif-alt-text'
|
||||
|
||||
@@ -95,7 +95,7 @@ export class EmbeddingDisabledError extends Error {
|
||||
}
|
||||
|
||||
export async function resolveLink(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
uri: string,
|
||||
): Promise<ResolvedLink> {
|
||||
if (isShortLink(uri)) {
|
||||
@@ -217,7 +217,7 @@ export async function resolveLink(
|
||||
}
|
||||
|
||||
export async function resolveGif(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
gif: Gif,
|
||||
): Promise<ResolvedExternalLink> {
|
||||
const gifUrl = gif.media_formats.gif.url
|
||||
@@ -259,7 +259,7 @@ function getFileSlug(url: string | undefined): string | undefined {
|
||||
}
|
||||
|
||||
async function resolveExternal(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
uri: string,
|
||||
): Promise<ResolvedExternalLink> {
|
||||
const result = await getLinkMeta(agent, uri)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {copyAsync} from 'expo-file-system/legacy'
|
||||
import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
import {type Agent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
|
||||
import {safeDeleteAsync} from '#/lib/media/manip'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {safeDeleteAsync} from '#/lib/media/manip'
|
||||
* @param encoding Allows overriding the blob's type
|
||||
*/
|
||||
export async function uploadBlob(
|
||||
agent: AtpAgent,
|
||||
agent: Agent,
|
||||
input: string | Blob,
|
||||
encoding?: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
import {type Agent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* @note It is recommended, on web, to use the `file` instance of the file
|
||||
@@ -7,7 +7,7 @@ import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
* be passed directly to this function.
|
||||
*/
|
||||
export async function uploadBlob(
|
||||
agent: AtpAgent,
|
||||
agent: Agent,
|
||||
input: string | Blob,
|
||||
encoding?: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type $Typed,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyGraphGetStarterPack,
|
||||
type AtpAgent,
|
||||
type ComAtprotoRepoApplyWrites,
|
||||
type Facet,
|
||||
} from '@atproto/api'
|
||||
@@ -14,7 +13,7 @@ import {until} from '#/lib/async/until'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export const createStarterPackList = async ({
|
||||
@@ -28,7 +27,7 @@ export const createStarterPackList = async ({
|
||||
description?: string
|
||||
descriptionFacets?: Facet[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
}): Promise<{uri: string; cid: string}> => {
|
||||
if (profiles.length === 0) throw new Error('No profiles given')
|
||||
|
||||
@@ -152,7 +151,7 @@ function createListItem({
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
uri: string,
|
||||
fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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'
|
||||
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {isBskyAppUrl} from '../strings/url-helpers'
|
||||
|
||||
export enum LikelyType {
|
||||
@@ -31,7 +32,7 @@ export interface LinkMeta {
|
||||
}
|
||||
|
||||
export async function getLinkMeta(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
url: string,
|
||||
timeout = 15e3,
|
||||
): Promise<LinkMeta> {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
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 {type SessionAgent} from '#/state/session'
|
||||
import {createVideoAgent} from './util'
|
||||
|
||||
export async function getServiceAuthToken({
|
||||
@@ -13,7 +13,7 @@ export async function getServiceAuthToken({
|
||||
lxm,
|
||||
exp,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
aud?: string
|
||||
lxm: string
|
||||
exp?: number
|
||||
@@ -30,7 +30,7 @@ export async function getServiceAuthToken({
|
||||
return serviceAuth.token
|
||||
}
|
||||
|
||||
export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) {
|
||||
export async function getVideoUploadLimits(agent: SessionAgent, i18n: I18n) {
|
||||
const token = await getServiceAuthToken({
|
||||
agent,
|
||||
lxm: 'app.bsky.video.getUploadLimits',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
|
||||
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
|
||||
import {type AppBskyVideoDefs} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type CompressedVideo,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||
@@ -25,7 +26,7 @@ export async function uploadVideo({
|
||||
onTransport,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
did: string
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
|
||||
import {type AppBskyVideoDefs} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type CompressedVideo,
|
||||
type VideoUploadTransport,
|
||||
} from '#/lib/media/video/types'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
|
||||
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
|
||||
@@ -24,7 +25,7 @@ export async function uploadVideo({
|
||||
onTransport,
|
||||
}: {
|
||||
video: CompressedVideo
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
did: string
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 AppBskyNotificationRegisterPush} from '@atproto/api'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {logger as notyLogger} from '#/lib/notifications/util'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {type SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {type SessionAgent} from '#/state/session/session-core'
|
||||
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -28,7 +29,7 @@ async function _registerPushToken({
|
||||
token,
|
||||
extra = {},
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
currentAccount: SessionAccount
|
||||
token: Notifications.DevicePushToken
|
||||
extra?: {
|
||||
@@ -326,7 +327,7 @@ export async function resetBadgeCount() {
|
||||
await setBadgeCountAsync(0)
|
||||
}
|
||||
|
||||
export async function unregisterPushToken(agents: AtpAgent[]) {
|
||||
export async function unregisterPushToken(agents: SessionAgent[]) {
|
||||
if (!IS_NATIVE) return
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type $Typed,
|
||||
type AppBskyGraphFollow,
|
||||
type AppBskyGraphGetFollows,
|
||||
type AtpAgent,
|
||||
type ComAtprotoRepoApplyWrites,
|
||||
type ComAtprotoRepoStrongRef,
|
||||
} from '@atproto/api'
|
||||
@@ -10,9 +9,10 @@ import {TID} from '@atproto/common-web'
|
||||
import chunk from 'lodash.chunk'
|
||||
|
||||
import {until} from '#/lib/async/until'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
|
||||
export async function bulkWriteFollows(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
dids: string[],
|
||||
via?: ComAtprotoRepoStrongRef.Main,
|
||||
) {
|
||||
@@ -59,7 +59,7 @@ export async function bulkWriteFollows(
|
||||
}
|
||||
|
||||
async function whenFollowsIndexed(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
actor: string,
|
||||
fn: (res: AppBskyGraphGetFollows.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {Platform, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyActorGetProfile,
|
||||
type AtpAgent,
|
||||
} from '@atproto/api'
|
||||
import {type AppBskyActorDefs, type AppBskyActorGetProfile} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
@@ -13,7 +9,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {RQKEY} from '#/state/queries/profile'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {type SessionAgent, useAgent, 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'
|
||||
@@ -323,7 +319,7 @@ function platform() {
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
actor: string,
|
||||
fn: (res: AppBskyActorGetProfile.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -41,7 +41,7 @@ export function SignupQueued() {
|
||||
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()
|
||||
await agent.resumeSession()
|
||||
if (!isSignupQueued(agent.session?.accessJwt)) {
|
||||
onboardingDispatch({type: 'start'})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyEmbedRecord,
|
||||
type AtpAgent,
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyConvoGetLog,
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
} from '#/state/messages/convo/types'
|
||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||
import {type MessagesEventBusError} from '#/state/messages/events/types'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {
|
||||
type ConvoWithDetails,
|
||||
type GroupConvoMember,
|
||||
@@ -105,7 +105,7 @@ function toDeletedMessageView(
|
||||
export class Convo {
|
||||
private id: string
|
||||
|
||||
private agent: AtpAgent
|
||||
private agent: SessionAgent
|
||||
private events: MessagesEventBus
|
||||
private senderUserDid: string
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyEmbedRecord,
|
||||
type AtpAgent,
|
||||
type ChatBskyActorDefs,
|
||||
type ChatBskyConvoDefs,
|
||||
type ChatBskyConvoSendMessage,
|
||||
@@ -9,11 +8,12 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {type ConvoWithDetails} from '#/components/dms/util'
|
||||
|
||||
export type ConvoParams = {
|
||||
convoId: string
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
events: MessagesEventBus
|
||||
placeholderData?: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api'
|
||||
import {type ChatBskyConvoGetLog} from '@atproto/api'
|
||||
import {EventEmitter} from 'eventemitter3'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
@@ -21,13 +21,14 @@ import {
|
||||
type MessagesEventBusParams,
|
||||
MessagesEventBusStatus,
|
||||
} from '#/state/messages/events/types'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
|
||||
const logger = Logger.create(Logger.Context.DMsAgent)
|
||||
|
||||
export class MessagesEventBus {
|
||||
private id: string
|
||||
|
||||
private agent: AtpAgent
|
||||
private agent: SessionAgent
|
||||
private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>()
|
||||
|
||||
private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api'
|
||||
import {type ChatBskyConvoGetLog} from '@atproto/api'
|
||||
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
|
||||
export type MessagesEventBusParams = {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
}
|
||||
|
||||
export enum MessagesEventBusStatus {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries/index'
|
||||
import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
|
||||
/**
|
||||
* The three preview shapes we currently support. Excludes the `{$type: string}`
|
||||
@@ -159,7 +159,7 @@ async function fetchJoinLinkPreviews({
|
||||
codes,
|
||||
hasSession,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
codes: string[]
|
||||
hasSession: boolean
|
||||
}) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyGraphDefs,
|
||||
type AppBskyGraphGetList,
|
||||
type AtpAgent,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
type InfiniteData,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
@@ -60,7 +59,7 @@ export function useAllListMembersQuery(uri?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAllListMembers(agent: AtpAgent, uri: string) {
|
||||
export async function getAllListMembers(agent: SessionAgent, uri: string) {
|
||||
let hasMore = true
|
||||
let cursor: string | undefined
|
||||
const listItems: AppBskyGraphDefs.ListItemView[] = []
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type AppBskyGraphDefs,
|
||||
type AppBskyGraphGetList,
|
||||
type AppBskyGraphList,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
type ComAtprotoRepoApplyWrites,
|
||||
type Facet,
|
||||
@@ -16,7 +15,7 @@ import {uploadBlob} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {type ImageMeta} from '#/state/gallery'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {type SessionAgent, useAgent, useSession} from '#/state/session'
|
||||
import {FEED_INFO_RQKEY_ROOT} from './feed'
|
||||
import {invalidate as invalidateMyLists} from './my-lists'
|
||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||
@@ -305,7 +304,7 @@ export function useListBlockMutation() {
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
uri: string,
|
||||
fn: (res: AppBskyGraphGetList.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type AtpAgent from '@atproto/api'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type ChatBskyActorDeclaration,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {resolveAllowGroupInvites} from '#/components/dms/util'
|
||||
import {RQKEY as PROFILE_RKEY} from '../profile'
|
||||
@@ -120,7 +120,7 @@ export async function fetchActorDeclarationRecord({
|
||||
agent,
|
||||
did,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
did?: string
|
||||
}) {
|
||||
if (!did) return
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type AtpAgent from '@atproto/api'
|
||||
import {type ChatBskyActorDeclaration} from '@atproto/api'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {logger} from '#/logger'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import {
|
||||
getDidFromAgentSession,
|
||||
getOtherRequiredDataFromCache,
|
||||
@@ -28,7 +28,7 @@ export async function restrictChatSettings({
|
||||
restrictIncoming = false,
|
||||
restrictGroupInvites = false,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
restrictIncoming?: boolean
|
||||
restrictGroupInvites?: boolean
|
||||
}): Promise<void> {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type AppBskyGraphDefs,
|
||||
AppBskyGraphStarterpack,
|
||||
type AppBskyNotificationListNotifications,
|
||||
type AtpAgent,
|
||||
hasMutedWord,
|
||||
moderateNotification,
|
||||
type ModerationOpts,
|
||||
@@ -15,6 +14,7 @@ import {type QueryClient} from '@tanstack/react-query'
|
||||
import chunk from 'lodash.chunk'
|
||||
|
||||
import {labelIsHideableOffense} from '#/lib/moderation'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {precacheProfile} from '../profile'
|
||||
import {
|
||||
@@ -46,7 +46,7 @@ export async function fetchPage({
|
||||
fetchAdditionalData,
|
||||
reasons,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
queryClient: QueryClient
|
||||
@@ -207,7 +207,7 @@ export function groupNotifications(
|
||||
}
|
||||
|
||||
async function fetchSubjects(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
groupedNotifs: FeedNotification[],
|
||||
): Promise<{
|
||||
posts: Map<string, AppBskyFeedDefs.PostView>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
type ModerationDecision,
|
||||
@@ -33,7 +32,7 @@ import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {KnownError} from '#/view/com/posts/PostFeedErrorMessage'
|
||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||
@@ -450,7 +449,7 @@ function createApi({
|
||||
feedParams: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
userInterests?: string
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
enableFollowingToDiscoverFallback: boolean
|
||||
}) {
|
||||
if (feedDesc === 'following') {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedPostgate,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
@@ -20,14 +19,14 @@ import {
|
||||
mergePostgateRecords,
|
||||
POSTGATE_COLLECTION,
|
||||
} from '#/state/queries/postgate/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export async function getPostgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
}): Promise<AppBskyFeedPostgate.Record | undefined> {
|
||||
const urip = new AtUri(postUri)
|
||||
@@ -89,7 +88,7 @@ export async function writePostgateRecord({
|
||||
postUri,
|
||||
postgate,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
postgate: AppBskyFeedPostgate.Record
|
||||
}) {
|
||||
@@ -110,7 +109,7 @@ export async function upsertPostgate(
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
},
|
||||
callback: (
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AppBskyActorGetProfiles,
|
||||
type AppBskyActorProfile,
|
||||
type AppBskyGraphGetFollows,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
type ComAtprotoRepoUploadBlob,
|
||||
type Un$Typed,
|
||||
@@ -33,7 +32,7 @@ import {
|
||||
useUnstableProfileViewCache,
|
||||
} from '#/state/queries/unstable-profile-cache'
|
||||
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {type SessionAgent, useAgent, useSession} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type Metrics, toClout} from '#/analytics/metrics'
|
||||
@@ -598,7 +597,7 @@ function useProfileUnblockMutation() {
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
actor: string,
|
||||
fn: (res: AppBskyActorGetProfile.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
|
||||
import {STALE} from '#/state/queries/index'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import {type Gif} from '#/features/gifPicker/types'
|
||||
|
||||
export const RQKEY_LINK_ROOT = 'resolve-link'
|
||||
@@ -12,7 +11,7 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
||||
export const RQKEY_GIF_ROOT = 'resolve-gif'
|
||||
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
|
||||
|
||||
export function resolveLinkQueryOptions(agent: AtpAgent, url: string) {
|
||||
export function resolveLinkQueryOptions(agent: SessionAgent, url: string) {
|
||||
return queryOptions({
|
||||
staleTime: STALE.HOURS.ONE,
|
||||
queryKey: RQKEY_LINK(url),
|
||||
@@ -26,7 +25,7 @@ export function useResolveLinkQuery(url: string) {
|
||||
}
|
||||
export function fetchResolveLinkQuery(
|
||||
queryClient: QueryClient,
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
url: string,
|
||||
) {
|
||||
return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url))
|
||||
@@ -51,7 +50,7 @@ export function useResolveGifQuery(gif: Gif) {
|
||||
}
|
||||
export function fetchResolveGifQuery(
|
||||
queryClient: QueryClient,
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
gif: Gif,
|
||||
) {
|
||||
return queryClient.fetchQuery({
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {type AtpAgent, AtUri} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import {useUnstableProfileViewCache} from './profile'
|
||||
|
||||
const RQKEY_ROOT = 'resolved-did'
|
||||
export const RQKEY = (didOrHandle: string) => [RQKEY_ROOT, didOrHandle]
|
||||
|
||||
const resolvedDidQueryOptions = (
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
getUnstableProfile: (did: string) => {did: string} | undefined,
|
||||
didOrHandle: string | undefined,
|
||||
) =>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type AppBskyGraphGetStarterPack,
|
||||
AppBskyGraphStarterpack,
|
||||
type AppBskyRichtextFacet,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
@@ -26,7 +25,7 @@ import {
|
||||
import {invalidateActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
|
||||
import {STALE} from '#/state/queries/index'
|
||||
import {invalidateListMembersQuery} from '#/state/queries/list-members'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
const RQKEY_ROOT = 'starter-pack'
|
||||
@@ -340,7 +339,7 @@ export function useDeleteStarterPackMutation({
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
uri: string,
|
||||
fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedThreadgate,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs, AppBskyFeedThreadgate, AtUri} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {networkRetry, retry} from '#/lib/async/retry'
|
||||
@@ -17,7 +12,7 @@ import {
|
||||
threadgateViewToAllowUISetting,
|
||||
} from '#/state/queries/threadgate/util'
|
||||
import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {type SessionAgent, useAgent} from '#/state/session'
|
||||
import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -88,7 +83,7 @@ export async function getThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
}): Promise<AppBskyFeedThreadgate.Record | null> {
|
||||
const urip = new AtUri(postUri)
|
||||
@@ -150,7 +145,7 @@ export async function writeThreadgateRecord({
|
||||
postUri,
|
||||
threadgate,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
threadgate: AppBskyFeedThreadgate.Record
|
||||
}) {
|
||||
@@ -176,7 +171,7 @@ export async function upsertThreadgate(
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
},
|
||||
callback: (
|
||||
@@ -205,7 +200,7 @@ export async function updateThreadgateAllow({
|
||||
postUri,
|
||||
allow,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
agent: SessionAgent
|
||||
postUri: string
|
||||
allow: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import {type AtpSessionEvent} from '@atproto/api'
|
||||
import {
|
||||
PasswordSession,
|
||||
type PasswordSessionOptions,
|
||||
type SessionData,
|
||||
} from '@atproto/lex-password-session'
|
||||
import {describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('#/state/events', () => ({
|
||||
emitNetworkConfirmed: jest.fn(),
|
||||
emitNetworkLost: jest.fn(),
|
||||
}))
|
||||
|
||||
/*
|
||||
* session-core imports the factory dependency graph (birthdate,
|
||||
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so
|
||||
* these tests do not pull in the native module chain (same approach as
|
||||
* session-test.ts / session-core-test.ts).
|
||||
*/
|
||||
jest.mock('#/state/birthdate')
|
||||
jest.mock('#/ageAssurance/data')
|
||||
jest.mock('#/ageAssurance/state', () => ({
|
||||
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
|
||||
}))
|
||||
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
|
||||
restrictChatSettings: () => Promise.resolve(),
|
||||
}))
|
||||
jest.mock('jwt-decode', () => ({
|
||||
jwtDecode() {
|
||||
return {scope: 'com.atproto.access'}
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
makeSessionHooks,
|
||||
sessionAccountToSessionData,
|
||||
SessionAgent,
|
||||
} from '../session-core'
|
||||
import {type SessionAccount} from '../types'
|
||||
|
||||
const DID = 'did:plc:example123'
|
||||
const HANDLE = 'alice.test'
|
||||
const SERVICE = 'https://bsky.social'
|
||||
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
|
||||
|
||||
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
|
||||
return {
|
||||
service: SERVICE,
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
|
||||
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
|
||||
* echoes the account; anything else returns an empty 200.
|
||||
*/
|
||||
function makeMockFetch(
|
||||
overrides: Record<
|
||||
string,
|
||||
(url: string, init: RequestInit) => Response | Promise<Response>
|
||||
> = {},
|
||||
) {
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {'content-type': 'application/json'},
|
||||
})
|
||||
const fetchMock = jest.fn(
|
||||
/*
|
||||
* PasswordSession calls fetch with a URL object (new URL(path, service));
|
||||
* asFetch() below widens the mock to the full fetch signature it expects.
|
||||
*/
|
||||
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
|
||||
const url = input instanceof URL ? input.href : input
|
||||
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
|
||||
const handler = nsid ? overrides[nsid] : undefined
|
||||
if (handler) {
|
||||
return handler(url, init)
|
||||
}
|
||||
if (nsid === 'com.atproto.server.refreshSession') {
|
||||
return json({
|
||||
accessJwt: 'access-jwt-2',
|
||||
refreshJwt: 'refresh-jwt-2',
|
||||
handle: HANDLE,
|
||||
did: DID,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
if (nsid === 'com.atproto.server.getSession') {
|
||||
return json({
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return json({})
|
||||
},
|
||||
)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
|
||||
function asFetch(mock: ReturnType<typeof makeMockFetch>): typeof fetch {
|
||||
return mock as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('SessionAgent getters', () => {
|
||||
it('reads live SessionData through .session', () => {
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(makeMockFetch())},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
expect(agent.session?.did).toBe(DID)
|
||||
expect(agent.session?.handle).toBe(HANDLE)
|
||||
expect(agent.did).toBe(DID)
|
||||
})
|
||||
|
||||
it('derives serviceUrl from the session service', () => {
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(makeMockFetch())},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
expect(agent.serviceUrl.toString()).toBe('https://bsky.social/')
|
||||
})
|
||||
|
||||
it('derives pdsUrl/dispatchUrl from a synthetic didDoc', () => {
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
|
||||
{fetch: asFetch(makeMockFetch())},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
expect(agent.pdsUrl?.toString()).toBe(`${PDS_URL}/`)
|
||||
expect(agent.dispatchUrl.toString()).toBe(`${PDS_URL}/`)
|
||||
})
|
||||
|
||||
it('dispatchUrl falls back to serviceUrl when there is no PDS', () => {
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(makeMockFetch())},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
expect(agent.pdsUrl).toBe(undefined)
|
||||
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
|
||||
})
|
||||
|
||||
it('public agent exposes undefined session and public serviceUrl', () => {
|
||||
const agent = new SessionAgent(null)
|
||||
expect(agent.session).toBe(undefined)
|
||||
expect(agent.did).toBe(undefined)
|
||||
expect(agent.serviceUrl.toString()).toBe('https://public.api.bsky.app/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionAgent.resumeSession', () => {
|
||||
it('calls session.refresh() and returns a success result', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(fetchMock)},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
const res = await agent.resumeSession()
|
||||
expect(res.success).toBe(true)
|
||||
expect(res.data.accessJwt).toBe('access-jwt-2')
|
||||
/* one refreshSession call */
|
||||
const calls = fetchMock.mock.calls.map(c =>
|
||||
c[0] instanceof URL ? c[0].href : c[0],
|
||||
)
|
||||
expect(
|
||||
calls.some(u => u.includes('com.atproto.server.refreshSession')),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionAgent destroyed session', () => {
|
||||
it('did/session getters do not throw after logout', async () => {
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(makeMockFetch())},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
await session.logout()
|
||||
expect(session.destroyed).toBe(true)
|
||||
/* PasswordSession.did throws when destroyed; SessionAgent must not */
|
||||
expect(() => agent.did).not.toThrow()
|
||||
expect(agent.did).toBe(undefined)
|
||||
expect(agent.session).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionAgent namespace routing', () => {
|
||||
it('routes a call through the session fetchHandler with proxy + labeler headers', async () => {
|
||||
const seen: {url: string; headers: Headers}[] = []
|
||||
const fetchMock = makeMockFetch({
|
||||
'app.bsky.actor.getProfile': (url, init) => {
|
||||
seen.push({url, headers: new Headers(init.headers)})
|
||||
return new Response(JSON.stringify({did: DID, handle: HANDLE}), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
})
|
||||
},
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
|
||||
{fetch: asFetch(fetchMock)},
|
||||
)
|
||||
const agent = new SessionAgent(session)
|
||||
/* base Agent's configureProxy is what buildBundle applies in production */
|
||||
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
|
||||
agent.configureLabelers(['did:plc:custom-labeler'])
|
||||
|
||||
/*
|
||||
* The request headers (what we assert) are captured by the fetch mock
|
||||
* before the base Agent parses the response body. Response-body lexicon
|
||||
* validation can throw in the jest environment (a multiformats CID mock
|
||||
* quirk unrelated to the header composition under test), so we ignore any
|
||||
* parse error here.
|
||||
*/
|
||||
await agent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
|
||||
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].headers.get('atproto-proxy')).toBe(
|
||||
'did:web:api.bsky.app#bsky_appview',
|
||||
)
|
||||
const labelers = seen[0].headers.get('atproto-accept-labelers')
|
||||
expect(labelers).toContain('did:plc:custom-labeler')
|
||||
/* the session attaches the bearer token */
|
||||
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeSessionHooks arm-latch + event mapping', () => {
|
||||
/*
|
||||
* The hooks read neither `this` (the PasswordSession) nor their data
|
||||
* argument, so we invoke them with empty stand-ins cast to the declared
|
||||
* parameter types. This keeps the test focused on the arm-latch + event
|
||||
* mapping.
|
||||
*/
|
||||
const fakeSession = {} as PasswordSession
|
||||
const fakeData = {} as SessionData
|
||||
|
||||
function setup() {
|
||||
const onSessionChange =
|
||||
jest.fn<
|
||||
(agent: SessionAgent, did: string, event: AtpSessionEvent) => void
|
||||
>()
|
||||
const agent = new SessionAgent(null)
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => agent,
|
||||
() => DID,
|
||||
)
|
||||
return {onSessionChange, agent, hooks}
|
||||
}
|
||||
|
||||
it('swallows events before arm()', () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
void hooks.onUpdated?.call(fakeSession, fakeData)
|
||||
expect(onSessionChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("maps onUpdated -> 'update' after arm()", () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
hooks.arm()
|
||||
void hooks.onUpdated?.call(fakeSession, fakeData)
|
||||
expect(onSessionChange).toHaveBeenCalledTimes(1)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('update')
|
||||
})
|
||||
|
||||
it("maps onDeleted -> 'expired' after arm()", () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
hooks.arm()
|
||||
void hooks.onDeleted?.call(fakeSession, fakeData)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
|
||||
})
|
||||
|
||||
it("maps onUpdateFailure -> 'network-error' after arm()", () => {
|
||||
const {onSessionChange, hooks} = setup()
|
||||
hooks.arm()
|
||||
void hooks.onUpdateFailure?.call(
|
||||
fakeSession,
|
||||
fakeData,
|
||||
{} as Parameters<NonNullable<typeof hooks.onUpdateFailure>>[1],
|
||||
)
|
||||
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PasswordSession lifecycle over mocked fetch', () => {
|
||||
it('resume fast path: constructing does not hit the network', () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
/* not expired -> new PasswordSession(...) with no refresh */
|
||||
void new PasswordSession(sessionAccountToSessionData(makeAccount()), {
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('resume network path fires onUpdated with fresh tokens', async () => {
|
||||
const fetchMock = makeMockFetch()
|
||||
const onUpdated =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
|
||||
const session = await PasswordSession.resume(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(fetchMock), onUpdated},
|
||||
)
|
||||
expect(onUpdated).toHaveBeenCalledTimes(1)
|
||||
expect(session.session.accessJwt).toBe('access-jwt-2')
|
||||
})
|
||||
|
||||
it('onDeleted fires when refresh returns a declared invalid-token error', async () => {
|
||||
const onDeleted =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
|
||||
const onUpdated =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
new Response(
|
||||
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
|
||||
{status: 400, headers: {'content-type': 'application/json'}},
|
||||
),
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{fetch: asFetch(fetchMock), onDeleted, onUpdated},
|
||||
)
|
||||
await expect(session.refresh()).rejects.toBeDefined()
|
||||
expect(onDeleted).toHaveBeenCalledTimes(1)
|
||||
expect(onUpdated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => {
|
||||
const onDeleted =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
|
||||
const onUpdateFailure =
|
||||
jest.fn<NonNullable<PasswordSessionOptions['onUpdateFailure']>>()
|
||||
const fetchMock = makeMockFetch({
|
||||
'com.atproto.server.refreshSession': () =>
|
||||
new Response(JSON.stringify({error: 'InternalServerError'}), {
|
||||
status: 500,
|
||||
headers: {'content-type': 'application/json'},
|
||||
}),
|
||||
})
|
||||
const session = new PasswordSession(
|
||||
sessionAccountToSessionData(makeAccount()),
|
||||
{
|
||||
fetch: asFetch(fetchMock),
|
||||
onDeleted,
|
||||
onUpdateFailure,
|
||||
},
|
||||
)
|
||||
await session.refresh()
|
||||
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
|
||||
expect(onDeleted).not.toHaveBeenCalled()
|
||||
/* session data is preserved (still the original tokens) */
|
||||
expect(session.session.accessJwt).toBe('access-jwt')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex-client'
|
||||
import {PasswordSession} from '@atproto/lex-password-session'
|
||||
import {api} from '@bsky.app/sdk'
|
||||
import {describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
/*
|
||||
* clients.ts imports session-core (for networkAwareFetch), which pulls the
|
||||
* factory dependency graph. Mock the heavy leaves so this test does not load
|
||||
* the native module chain (same approach as bridge-agent-test.ts).
|
||||
*/
|
||||
jest.mock('#/state/events', () => ({
|
||||
emitNetworkConfirmed: jest.fn(),
|
||||
emitNetworkLost: jest.fn(),
|
||||
}))
|
||||
jest.mock('#/state/birthdate')
|
||||
jest.mock('#/ageAssurance/data')
|
||||
jest.mock('#/ageAssurance/state', () => ({
|
||||
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
|
||||
}))
|
||||
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
|
||||
restrictChatSettings: () => Promise.resolve(),
|
||||
}))
|
||||
jest.mock('jwt-decode', () => ({
|
||||
jwtDecode() {
|
||||
return {scope: 'com.atproto.access'}
|
||||
},
|
||||
}))
|
||||
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {app} from '#/lexicons'
|
||||
import {
|
||||
buildAccountClient,
|
||||
buildAppviewClient,
|
||||
getPublicLexClient,
|
||||
} from '../clients'
|
||||
import {sessionAccountToSessionData} from '../session-core'
|
||||
import {type SessionAccount} from '../types'
|
||||
|
||||
const DID = 'did:plc:example123'
|
||||
const HANDLE = 'alice.test'
|
||||
const SERVICE = 'https://bsky.social'
|
||||
const APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
|
||||
const CUSTOM_LABELER = 'did:plc:custom-labeler'
|
||||
|
||||
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
|
||||
return {
|
||||
service: SERVICE,
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the request that a client emits. Records the last-seen URL + headers
|
||||
* and returns an empty 200 JSON body (the assertions here are about request
|
||||
* headers, not response parsing).
|
||||
*/
|
||||
function isRequest(input: URL | string | Request): input is Request {
|
||||
return (
|
||||
typeof input === 'object' &&
|
||||
'headers' in input &&
|
||||
input.headers instanceof Headers
|
||||
)
|
||||
}
|
||||
|
||||
function makeCapturingFetch() {
|
||||
const seen: {url: string; headers: Headers}[] = []
|
||||
const fetchMock = jest.fn(
|
||||
(
|
||||
input: URL | string | Request,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> => {
|
||||
/*
|
||||
* The lex Client calls fetch as (url, {headers}); the old AtpAgent
|
||||
* (XrpcClient) calls it with a single Request object carrying the
|
||||
* headers. Read headers from whichever the caller used.
|
||||
*/
|
||||
const url = isRequest(input)
|
||||
? input.url
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input
|
||||
const headers = isRequest(input)
|
||||
? input.headers
|
||||
: new Headers(init.headers)
|
||||
seen.push({url, headers})
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({did: DID, handle: HANDLE}), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
return {seen, fetchMock}
|
||||
}
|
||||
|
||||
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
|
||||
function asFetch(mock: ReturnType<typeof makeCapturingFetch>['fetchMock']) {
|
||||
return mock as unknown as typeof fetch
|
||||
}
|
||||
|
||||
function makeSession(
|
||||
fetchMock: ReturnType<typeof makeCapturingFetch>['fetchMock'],
|
||||
) {
|
||||
return new PasswordSession(sessionAccountToSessionData(makeAccount()), {
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildAppviewClient', () => {
|
||||
it('sets the appview atproto-proxy header and includes the moderation DID in labelers', async () => {
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildAppviewClient(session, [CUSTOM_LABELER])
|
||||
|
||||
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
|
||||
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].headers.get('atproto-proxy')).toBe(APPVIEW_PROXY)
|
||||
const labelers = seen[0].headers.get('atproto-accept-labelers') ?? ''
|
||||
expect(labelers).toContain(api.moderation.did)
|
||||
expect(labelers).toContain(CUSTOM_LABELER)
|
||||
})
|
||||
|
||||
it('routes through the session fetchHandler with the bearer token', async () => {
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildAppviewClient(session, [])
|
||||
|
||||
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
|
||||
/* the moderation DID is always re-asserted even with no custom labelers */
|
||||
expect(seen[0].headers.get('atproto-accept-labelers')).toContain(
|
||||
api.moderation.did,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAccountClient', () => {
|
||||
it('has no atproto-proxy header (requests hit the PDS directly)', async () => {
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildAccountClient(session)
|
||||
|
||||
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
|
||||
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes through the session fetchHandler with the bearer token', async () => {
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const session = makeSession(fetchMock)
|
||||
const client = buildAccountClient(session)
|
||||
|
||||
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublicLexClient', () => {
|
||||
it('is an unauthenticated singleton (no session did)', () => {
|
||||
const client = getPublicLexClient()
|
||||
expect(client.did).toBeUndefined()
|
||||
/* process-wide singleton: identity is stable across calls */
|
||||
expect(getPublicLexClient()).toBe(client)
|
||||
})
|
||||
|
||||
it('routes to public.api.bsky.app with no proxy or auth header', async () => {
|
||||
/*
|
||||
* getPublicLexClient builds `new Client({service: PUBLIC_BSKY_SERVICE,
|
||||
* fetch: networkAwareFetch})`. networkAwareFetch captures the global fetch
|
||||
* at import time, which is hard to intercept here, so we reconstruct the
|
||||
* same Client shape with an observable fetch to assert the routing +
|
||||
* header contract the source relies on. The response body fails getProfile
|
||||
* validation (it is a stub), but request headers are recorded pre-parse.
|
||||
*/
|
||||
const {seen, fetchMock} = makeCapturingFetch()
|
||||
const client = new Client({
|
||||
service: PUBLIC_BSKY_SERVICE,
|
||||
fetch: asFetch(fetchMock),
|
||||
})
|
||||
await client
|
||||
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
|
||||
.catch(() => {})
|
||||
|
||||
expect(seen.length).toBe(1)
|
||||
expect(seen[0].url).toContain('public.api.bsky.app')
|
||||
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
|
||||
expect(seen[0].headers.get('authorization')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Regression guard: the emitted `atproto-accept-labelers` header from a
|
||||
* fully-configured appview client must match the header the old AtpAgent
|
||||
* produced for the same labeler set - global appLabelers carry the `;redact`
|
||||
* suffix, per-instance labelers are plain. Byte-identical composition is the
|
||||
* acceptance bar for the moderation migration (design section 6).
|
||||
*/
|
||||
describe('labeler-header regression guard', () => {
|
||||
it('appview client emits the same atproto-accept-labelers header as the old AtpAgent', async () => {
|
||||
/*
|
||||
* Old behavior: AtpAgent.appLabelers default to [BSKY_LABELER_DID], emitted
|
||||
* with `;redact`; per-instance configureLabelers are plain. Capture what a
|
||||
* real AtpAgent emits for the same [custom] instance labeler set.
|
||||
*/
|
||||
const {seen: agentSeen, fetchMock: agentFetch} = makeCapturingFetch()
|
||||
const oldAgent = new AtpAgent({
|
||||
service: SERVICE,
|
||||
fetch: asFetch(agentFetch),
|
||||
})
|
||||
oldAgent.configureProxy(APPVIEW_PROXY)
|
||||
oldAgent.configureLabelers([CUSTOM_LABELER])
|
||||
await oldAgent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
|
||||
const oldHeader = agentSeen[0].headers.get('atproto-accept-labelers')
|
||||
|
||||
/*
|
||||
* New behavior: buildAppviewClient re-asserts api.moderation.did (===
|
||||
* BSKY_LABELER_DID) as a base labeler; the global Client.appLabelers carry
|
||||
* the `;redact` suffix. Configure Client global appLabelers to match the old
|
||||
* AtpAgent global set so the composition is directly comparable.
|
||||
*/
|
||||
Client.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
|
||||
const {seen: clientSeen, fetchMock: clientFetch} = makeCapturingFetch()
|
||||
const session = makeSession(clientFetch)
|
||||
const client = buildAppviewClient(session, [CUSTOM_LABELER])
|
||||
await client
|
||||
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
|
||||
.catch(() => {})
|
||||
const newHeader = clientSeen[0].headers.get('atproto-accept-labelers')
|
||||
|
||||
/*
|
||||
* Both must contain the redacted global Bluesky labeler and the plain
|
||||
* per-instance custom labeler.
|
||||
*/
|
||||
expect(oldHeader).toContain(`${BSKY_LABELER_DID};redact`)
|
||||
expect(newHeader).toContain(`${BSKY_LABELER_DID};redact`)
|
||||
expect(oldHeader).toContain(CUSTOM_LABELER)
|
||||
expect(newHeader).toContain(CUSTOM_LABELER)
|
||||
/* the custom labeler is plain (no redact) in both */
|
||||
expect(oldHeader).not.toContain(`${CUSTOM_LABELER};redact`)
|
||||
expect(newHeader).not.toContain(`${CUSTOM_LABELER};redact`)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,30 @@ import {Client} from '@atproto/lex-client'
|
||||
import {describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
import {app} from '#/lexicons'
|
||||
|
||||
/*
|
||||
* clients.ts now imports session-core (for networkAwareFetch), which pulls the
|
||||
* factory dependency graph. Mock the heavy leaves so this test does not load
|
||||
* the native module chain (same approach as session-test.ts).
|
||||
*/
|
||||
jest.mock('#/state/events', () => ({
|
||||
emitNetworkConfirmed: jest.fn(),
|
||||
emitNetworkLost: jest.fn(),
|
||||
}))
|
||||
jest.mock('#/state/birthdate')
|
||||
jest.mock('#/ageAssurance/data')
|
||||
jest.mock('#/ageAssurance/state', () => ({
|
||||
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
|
||||
}))
|
||||
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
|
||||
restrictChatSettings: () => Promise.resolve(),
|
||||
}))
|
||||
jest.mock('jwt-decode', () => ({
|
||||
jwtDecode() {
|
||||
return {scope: 'com.atproto.access'}
|
||||
},
|
||||
}))
|
||||
|
||||
import {agentToLexClient} from '../clients'
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import {type SessionData} from '@atproto/lex-password-session'
|
||||
|
||||
import {type SessionAccount} from '../types'
|
||||
|
||||
jest.mock('#/state/events', () => ({
|
||||
emitNetworkConfirmed: jest.fn(),
|
||||
emitNetworkLost: jest.fn(),
|
||||
}))
|
||||
|
||||
/*
|
||||
* session-core now imports the factory dependency graph (birthdate,
|
||||
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so the
|
||||
* pure-converter tests here stay lightweight and do not pull in the native
|
||||
* bottom-sheet module chain (same approach as session-test.ts).
|
||||
*/
|
||||
jest.mock('#/state/birthdate')
|
||||
jest.mock('#/ageAssurance/data')
|
||||
jest.mock('#/ageAssurance/state', () => ({
|
||||
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
|
||||
}))
|
||||
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
|
||||
restrictChatSettings: () => Promise.resolve(),
|
||||
}))
|
||||
|
||||
jest.mock('jwt-decode', () => ({
|
||||
jwtDecode(token: string) {
|
||||
if (token === 'queued-access-jwt') {
|
||||
return {scope: 'com.atproto.signupQueued'}
|
||||
}
|
||||
return {scope: 'com.atproto.access'}
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
extractPdsUrl,
|
||||
sessionAccountToSessionData,
|
||||
sessionDataToSessionAccount,
|
||||
synthDidDoc,
|
||||
} from '../session-core'
|
||||
|
||||
const DID = 'did:plc:example123'
|
||||
const HANDLE = 'alice.test'
|
||||
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
|
||||
|
||||
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
|
||||
return {
|
||||
accessJwt: 'access-jwt',
|
||||
refreshJwt: 'refresh-jwt',
|
||||
handle: HANDLE,
|
||||
did: DID,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
active: true,
|
||||
service: 'https://bsky.social',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('extractPdsUrl', () => {
|
||||
it('extracts the PDS endpoint from a didDoc', () => {
|
||||
expect(extractPdsUrl(synthDidDoc(DID, PDS_URL))).toBe(PDS_URL)
|
||||
})
|
||||
|
||||
it('matches full service ids ending in #atproto_pds', () => {
|
||||
const didDoc = {
|
||||
id: DID,
|
||||
service: [
|
||||
{
|
||||
id: `${DID}#atproto_pds`,
|
||||
type: 'AtprotoPersonalDataServer',
|
||||
serviceEndpoint: PDS_URL,
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(extractPdsUrl(didDoc)).toBe(PDS_URL)
|
||||
})
|
||||
|
||||
it('returns null for missing/invalid input', () => {
|
||||
expect(extractPdsUrl(undefined)).toBe(null)
|
||||
expect(extractPdsUrl(null)).toBe(null)
|
||||
expect(extractPdsUrl({})).toBe(null)
|
||||
expect(extractPdsUrl({service: 'not-an-array'})).toBe(null)
|
||||
expect(
|
||||
extractPdsUrl({
|
||||
service: [{id: '#other_service', serviceEndpoint: PDS_URL}],
|
||||
}),
|
||||
).toBe(null)
|
||||
expect(
|
||||
extractPdsUrl({service: [{id: '#atproto_pds', serviceEndpoint: 42}]}),
|
||||
).toBe(null)
|
||||
expect(
|
||||
extractPdsUrl({
|
||||
service: [{id: '#atproto_pds', serviceEndpoint: 'not a url'}],
|
||||
}),
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('synthDidDoc', () => {
|
||||
it('produces a didDoc that extractPdsUrl round-trips', () => {
|
||||
const doc = synthDidDoc(DID, PDS_URL)
|
||||
expect(extractPdsUrl(doc)).toBe(PDS_URL)
|
||||
expect(doc.id).toBe(DID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionDataToSessionAccount', () => {
|
||||
it('returns undefined for a missing session', () => {
|
||||
expect(sessionDataToSessionAccount(undefined, 'https://bsky.social')).toBe(
|
||||
undefined,
|
||||
)
|
||||
expect(sessionDataToSessionAccount(null, 'https://bsky.social')).toBe(
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('maps fields for a hosted account (no didDoc)', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account).toEqual({
|
||||
service: 'https://bsky.social/',
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes service with a trailing slash like agent.serviceUrl.toString()', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.service).toBe('https://bsky.social/')
|
||||
})
|
||||
|
||||
it('derives pdsUrl from the didDoc, normalized as a URL string', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
/*
|
||||
* The old code read agent.pdsUrl?.toString() - a URL - so the persisted
|
||||
* value carries a trailing slash.
|
||||
*/
|
||||
expect(account.pdsUrl).toBe(`${PDS_URL}/`)
|
||||
})
|
||||
|
||||
it('leaves pdsUrl undefined for hosted accounts (no service fallback)', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: undefined}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.pdsUrl).toBe(undefined)
|
||||
})
|
||||
|
||||
it('derives isSelfHosted from the service URL', () => {
|
||||
const hosted = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(hosted.isSelfHosted).toBe(false)
|
||||
|
||||
const selfHosted = sessionDataToSessionAccount(
|
||||
makeSessionData({service: 'https://pds.example.com'}),
|
||||
'https://pds.example.com',
|
||||
)!
|
||||
expect(selfHosted.isSelfHosted).toBe(true)
|
||||
})
|
||||
|
||||
it('derives signupQueued from the access token scope', () => {
|
||||
const queued = sessionDataToSessionAccount(
|
||||
makeSessionData({accessJwt: 'queued-access-jwt'}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(queued.signupQueued).toBe(true)
|
||||
|
||||
const notQueued = sessionDataToSessionAccount(
|
||||
makeSessionData(),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(notQueued.signupQueued).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces missing email flags to false', () => {
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({
|
||||
email: undefined,
|
||||
emailConfirmed: undefined,
|
||||
emailAuthFactor: undefined,
|
||||
}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
expect(account.email).toBe(undefined)
|
||||
expect(account.emailConfirmed).toBe(false)
|
||||
expect(account.emailAuthFactor).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves the exact field order of the old agentToSessionAccount literal', () => {
|
||||
/*
|
||||
* Byte-stability guard: the reducer's JSON.stringify fast path and the
|
||||
* session test snapshots depend on this exact key order. This is the
|
||||
* object literal order of the old agentToSessionAccount in agent.ts.
|
||||
*/
|
||||
const account = sessionDataToSessionAccount(
|
||||
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
|
||||
'https://bsky.social',
|
||||
)!
|
||||
const golden: SessionAccount = {
|
||||
service: 'https://bsky.social/',
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: `${PDS_URL}/`,
|
||||
isSelfHosted: false,
|
||||
}
|
||||
expect(Object.keys(account)).toEqual(Object.keys(golden))
|
||||
expect(JSON.stringify(account)).toBe(JSON.stringify(golden))
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionAccountToSessionData', () => {
|
||||
const baseAccount: SessionAccount = {
|
||||
service: 'https://bsky.social/',
|
||||
did: DID,
|
||||
handle: HANDLE,
|
||||
email: 'alice@example.com',
|
||||
emailConfirmed: true,
|
||||
emailAuthFactor: false,
|
||||
refreshJwt: 'refresh-jwt',
|
||||
accessJwt: 'access-jwt',
|
||||
signupQueued: false,
|
||||
active: true,
|
||||
status: undefined,
|
||||
pdsUrl: undefined,
|
||||
isSelfHosted: false,
|
||||
}
|
||||
|
||||
it('maps fields with empty-string token fallbacks and active default', () => {
|
||||
const data = sessionAccountToSessionData({
|
||||
...baseAccount,
|
||||
accessJwt: undefined,
|
||||
refreshJwt: undefined,
|
||||
active: undefined,
|
||||
})
|
||||
expect(data.accessJwt).toBe('')
|
||||
expect(data.refreshJwt).toBe('')
|
||||
expect(data.active).toBe(true)
|
||||
expect(data.did).toBe(DID)
|
||||
expect(data.handle).toBe(HANDLE)
|
||||
expect(data.service).toBe('https://bsky.social/')
|
||||
})
|
||||
|
||||
it('omits didDoc when the account has no stored pdsUrl', () => {
|
||||
const data = sessionAccountToSessionData(baseAccount)
|
||||
expect('didDoc' in data).toBe(false)
|
||||
expect(extractPdsUrl(data.didDoc)).toBe(null)
|
||||
})
|
||||
|
||||
it('synthesizes a didDoc from a stored pdsUrl so PDS routing works pre-refresh', () => {
|
||||
const data = sessionAccountToSessionData({
|
||||
...baseAccount,
|
||||
pdsUrl: `${PDS_URL}/`,
|
||||
})
|
||||
expect(extractPdsUrl(data.didDoc)).toBe(`${PDS_URL}/`)
|
||||
})
|
||||
|
||||
it('round-trips account -> SessionData -> account preserving all fields', () => {
|
||||
const withPds: SessionAccount = {
|
||||
...baseAccount,
|
||||
pdsUrl: `${PDS_URL}/`,
|
||||
}
|
||||
for (const account of [baseAccount, withPds]) {
|
||||
const data = sessionAccountToSessionData(account)
|
||||
const roundTripped = sessionDataToSessionAccount(data, account.service)!
|
||||
expect(roundTripped).toEqual(account)
|
||||
expect(JSON.stringify(roundTripped)).toBe(JSON.stringify(account))
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips signupQueued via the access token scope', () => {
|
||||
const queued: SessionAccount = {
|
||||
...baseAccount,
|
||||
accessJwt: 'queued-access-jwt',
|
||||
signupQueued: true,
|
||||
}
|
||||
const roundTripped = sessionDataToSessionAccount(
|
||||
sessionAccountToSessionData(queued),
|
||||
queued.service,
|
||||
)!
|
||||
expect(roundTripped.signupQueued).toBe(true)
|
||||
expect(roundTripped).toEqual(queued)
|
||||
})
|
||||
|
||||
it('round-trips a self-hosted account', () => {
|
||||
const selfHosted: SessionAccount = {
|
||||
...baseAccount,
|
||||
service: 'https://pds.example.com/',
|
||||
pdsUrl: 'https://pds.example.com/',
|
||||
isSelfHosted: true,
|
||||
}
|
||||
const roundTripped = sessionDataToSessionAccount(
|
||||
sessionAccountToSessionData(selfHosted),
|
||||
selfHosted.service,
|
||||
)!
|
||||
expect(roundTripped).toEqual(selfHosted)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {Agent} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex-client'
|
||||
|
||||
import {device} from '#/storage'
|
||||
|
||||
@@ -82,9 +83,16 @@ export function configureAdditionalModerationAuthorities() {
|
||||
additionalLabelers = []
|
||||
}
|
||||
|
||||
/*
|
||||
* Merge with the currently-configured global labelers on the base `Agent`
|
||||
* static (the bridge agent is a base `Agent`, not `AtpAgent`). Set the merged
|
||||
* result on BOTH request paths - the lex `Client` static and the base `Agent`
|
||||
* static - so both emit identical global `atproto-accept-labelers` headers.
|
||||
*/
|
||||
const appLabelers = Array.from(
|
||||
new Set([...AtpAgent.appLabelers, ...additionalLabelers]),
|
||||
new Set([...Agent.appLabelers, ...additionalLabelers]),
|
||||
)
|
||||
|
||||
AtpAgent.configure({appLabelers})
|
||||
Client.configure({appLabelers: appLabelers as `did:${string}:${string}`[]})
|
||||
Agent.configure({appLabelers})
|
||||
}
|
||||
|
||||
+23
-395
@@ -1,336 +1,35 @@
|
||||
import {
|
||||
Agent as BaseAgent,
|
||||
type AppBskyActorProfile,
|
||||
AtpAgent,
|
||||
type AtprotoServiceType,
|
||||
type AtpSessionData,
|
||||
type AtpSessionEvent,
|
||||
type Did,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
BLUESKY_PROXY_HEADER,
|
||||
BSKY_SERVICE,
|
||||
DISCOVER_SAVED_FEED,
|
||||
IS_PROD_SERVICE,
|
||||
PUBLIC_BSKY_SERVICE,
|
||||
TIMELINE_SAVED_FEED,
|
||||
} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {
|
||||
prefetchAgeAssuranceServerData,
|
||||
setBirthdateForDid,
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {features} from '#/analytics'
|
||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {type SessionAccount} from './types'
|
||||
import {isSessionExpired, isSignupQueued} from './util'
|
||||
import {createPublicSessionBundle, type SessionAgent} from './session-core'
|
||||
|
||||
/*
|
||||
* Phase 2 SDK migration: the PasswordSession-based session core (factories,
|
||||
* bridge agent, converters) lives in session-core.ts. This module is now a
|
||||
* thin compat layer that keeps the few external imports working:
|
||||
* - `createPublicAgent` (drafts) -> the public bundle's bridge agent
|
||||
* - `Agent` (pds-detection / forgot-password / set-new-password) -> the
|
||||
* proxy-header base Agent subclass
|
||||
* - `ProxyHeaderValue` (constants)
|
||||
* - the new converters, re-exported under their own names
|
||||
*/
|
||||
export {
|
||||
sessionAccountToSessionData,
|
||||
sessionDataToSessionAccount,
|
||||
} from './session-core'
|
||||
|
||||
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
|
||||
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
|
||||
const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
return agent
|
||||
}
|
||||
|
||||
export async function createAgentAndResume(
|
||||
storedAccount: SessionAccount,
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAppAgent({service: storedAccount.service})
|
||||
if (storedAccount.pdsUrl) {
|
||||
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
|
||||
}
|
||||
const gates = features.refresh({
|
||||
strategy: 'prefer-low-latency',
|
||||
})
|
||||
const moderation = configureModerationForAccount(agent, storedAccount)
|
||||
const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
|
||||
if (isSessionExpired(storedAccount)) {
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
} else {
|
||||
agent.sessionManager.session = prevSession
|
||||
}
|
||||
|
||||
// after session is attached
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin(
|
||||
{
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string
|
||||
},
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAppAgent({service})
|
||||
await agent.login({
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
allowTakendown: true,
|
||||
})
|
||||
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount(
|
||||
{
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
birthDate,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
birthDate: Date
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
},
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAppAgent({service})
|
||||
await agent.createAccount({
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
})
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
/*
|
||||
* Since we have a race with account creation, profile creation, and AA
|
||||
* state, set these values locally to ensure sync reads. Values are written
|
||||
* to the server in the next step, so on subsequent reloads, the server will
|
||||
* be the source of truth.
|
||||
*/
|
||||
setCreatedAtForDid({did: account.did, createdAt})
|
||||
setBirthdateForDid({did: account.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(account.did)
|
||||
// do this last
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
// Not awaited so that we can still get into onboarding.
|
||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||
if (IS_PROD_SERVICE(service)) {
|
||||
void Promise.allSettled([
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthdate,
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.displayName = handle
|
||||
next.createdAt = createdAt
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(1, () => {
|
||||
return agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set initial feeds`)
|
||||
throw e
|
||||
}),
|
||||
// wait for AA data to load first, then check state
|
||||
aa.then(() => {
|
||||
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
|
||||
if (flags?.chatDisabled || flags?.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
}),
|
||||
]).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
void Promise.allSettled([
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthDate.toISOString(),
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createAgentAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
]).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// snooze first prompt after signup, defer to next prompt
|
||||
snoozeEmailConfirmationPrompt()
|
||||
} catch (e: any) {
|
||||
logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`})
|
||||
}
|
||||
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
|
||||
return agent.prepare({
|
||||
resolvers: [gates, moderation, aa],
|
||||
onSessionChange,
|
||||
})
|
||||
}
|
||||
|
||||
export function agentToSessionAccountOrThrow(agent: AtpAgent): SessionAccount {
|
||||
const account = agentToSessionAccount(agent)
|
||||
if (!account) {
|
||||
throw Error('Expected an active session')
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
export function agentToSessionAccount(
|
||||
agent: AtpAgent,
|
||||
): SessionAccount | undefined {
|
||||
if (!agent.session) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
service: agent.serviceUrl.toString(),
|
||||
did: agent.session.did,
|
||||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed || false,
|
||||
emailAuthFactor: agent.session.emailAuthFactor || false,
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
signupQueued: isSignupQueued(agent.session.accessJwt),
|
||||
active: agent.session.active,
|
||||
status: agent.session.status,
|
||||
pdsUrl: agent.pdsUrl?.toString(),
|
||||
isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionAccountToSession(
|
||||
account: SessionAccount,
|
||||
): AtpSessionData {
|
||||
return {
|
||||
// Sorted in the same property order as when returned by BskyAgent (alphabetical).
|
||||
accessJwt: account.accessJwt ?? '',
|
||||
did: account.did,
|
||||
email: account.email,
|
||||
emailAuthFactor: account.emailAuthFactor,
|
||||
emailConfirmed: account.emailConfirmed,
|
||||
handle: account.handle,
|
||||
refreshJwt: account.refreshJwt ?? '',
|
||||
/**
|
||||
* @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
|
||||
*/
|
||||
active: account.active ?? true,
|
||||
status: account.status,
|
||||
}
|
||||
/**
|
||||
* The logged-out bridge agent, pointed at the public appview. Returns the
|
||||
* public bundle's `agent` (a {@link SessionAgent}). Kept for `drafts/state/api`
|
||||
* and any other public-read consumers.
|
||||
*/
|
||||
export function createPublicAgent(): SessionAgent {
|
||||
return createPublicSessionBundle().agent
|
||||
}
|
||||
|
||||
export class Agent extends BaseAgent {
|
||||
@@ -344,74 +43,3 @@ export class Agent extends BaseAgent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not exported. Use factories above to create it.
|
||||
// WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do.
|
||||
// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it
|
||||
// feels safer to just let those run as-is and set the header afterward.
|
||||
let realFetch = globalThis.fetch
|
||||
class BskyAppAgent extends AtpAgent {
|
||||
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
|
||||
undefined
|
||||
|
||||
constructor({service}: {service: string}) {
|
||||
super({
|
||||
service,
|
||||
async fetch(...args) {
|
||||
let success = false
|
||||
try {
|
||||
const result = await realFetch(...args)
|
||||
success = true
|
||||
return result
|
||||
} catch (e) {
|
||||
success = false
|
||||
throw e
|
||||
} finally {
|
||||
if (success) {
|
||||
emitNetworkConfirmed()
|
||||
} else {
|
||||
emitNetworkLost()
|
||||
}
|
||||
}
|
||||
},
|
||||
persistSession: (event: AtpSessionEvent) => {
|
||||
if (this.persistSessionHandler) {
|
||||
this.persistSessionHandler(event)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async prepare({
|
||||
resolvers,
|
||||
onSessionChange,
|
||||
}: {
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
resolvers: Promise<unknown>[]
|
||||
onSessionChange: (
|
||||
agent: AtpAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void
|
||||
}) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all(resolvers)
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(this)
|
||||
this.persistSessionHandler = event => {
|
||||
onSessionChange(this, account.did, event)
|
||||
if (event !== 'create' && event !== 'update') {
|
||||
addSessionErrorLog(account.did, event)
|
||||
}
|
||||
}
|
||||
return {account, agent: this}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.sessionManager.session = undefined
|
||||
this.persistSessionHandler = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type {BskyAppAgent}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {type AtpAgent} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex-client'
|
||||
import {type PasswordSession} from '@atproto/lex-password-session'
|
||||
import {api} from '@bsky.app/sdk'
|
||||
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
import {networkAwareFetch} from './session-core'
|
||||
|
||||
/**
|
||||
* Stable per-agent cache of lex `Client` instances. We never reuse an
|
||||
@@ -58,11 +61,55 @@ export function agentToLexClient(agent: AtpAgent): Client {
|
||||
*/
|
||||
let publicClient: Client | undefined
|
||||
|
||||
function getPublicLexClient(): Client {
|
||||
publicClient ??= new Client(PUBLIC_BSKY_SERVICE)
|
||||
export function getPublicLexClient(): Client {
|
||||
/*
|
||||
* Pass networkAwareFetch so the unauthenticated public path feeds the same
|
||||
* reachability signal as the session-backed clients (see session-core).
|
||||
*/
|
||||
publicClient ??= new Client({
|
||||
service: PUBLIC_BSKY_SERVICE,
|
||||
fetch: networkAwareFetch,
|
||||
})
|
||||
return publicClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the account (PDS) client over a {@link PasswordSession}. Writes and
|
||||
* record mutations go here - no `atproto-proxy` header, so requests hit the
|
||||
* user's PDS directly (the session's `fetchHandler` resolves the PDS origin
|
||||
* per request from the didDoc, falling back to `service`).
|
||||
*
|
||||
* The session already owns its own `fetch` (networkAwareFetch, set at
|
||||
* construction), so we intentionally do NOT pass `fetch` here: a `Client`
|
||||
* built over an existing `Agent`/session uses that agent's fetch.
|
||||
*/
|
||||
export function buildAccountClient(session: PasswordSession): Client {
|
||||
return new Client(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the authed appview client over a {@link PasswordSession}.
|
||||
*
|
||||
* Requests are proxied to the Bluesky appview (`atproto-proxy:
|
||||
* did:web:api.bsky.app#bsky_appview`) and carry the per-instance labelers.
|
||||
* The Bluesky moderation labeler (`api.moderation.did`) is always included as
|
||||
* a base labeler because sending ANY `atproto-accept-labelers` header replaces
|
||||
* the server-side default - so we must re-assert it to keep it active.
|
||||
*/
|
||||
export function buildAppviewClient(
|
||||
session: PasswordSession,
|
||||
labelerDids: string[],
|
||||
): Client {
|
||||
return new Client(session, {
|
||||
service: api.app.service,
|
||||
/* labelerDids are validated DID strings; cast to the DidString template type */
|
||||
labelers: [
|
||||
api.moderation.did,
|
||||
...labelerDids,
|
||||
] as `did:${string}:${string}`[],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated lex {@link Client} for public appview reads. A process-wide
|
||||
* singleton, so its identity is stable across renders.
|
||||
|
||||
+165
-61
@@ -8,26 +8,34 @@ import {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
|
||||
import {type AtpSessionEvent} from '@atproto/api'
|
||||
import {type Client} from '@atproto/lex-client'
|
||||
import {PasswordSession} from '@atproto/lex-password-session'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {com} from '#/lexicons'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {
|
||||
agentToSessionAccount,
|
||||
type BskyAppAgent,
|
||||
createAgentAndCreateAccount,
|
||||
createAgentAndLogin,
|
||||
createAgentAndResume,
|
||||
sessionAccountToSession,
|
||||
} from './agent'
|
||||
import {getPublicLexClient} from './clients'
|
||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||
import {
|
||||
buildBundle,
|
||||
createSessionBundleAndCreateAccount,
|
||||
createSessionBundleAndLogin,
|
||||
createSessionBundleAndResume,
|
||||
disposeBundle,
|
||||
makeSessionHooks,
|
||||
type PublicSessionBundle,
|
||||
sessionAccountToSessionData,
|
||||
type SessionAgent,
|
||||
type SessionBundle,
|
||||
sessionDataToSessionAccount,
|
||||
} from './session-core'
|
||||
export {type SessionAgent} from './session-core'
|
||||
export {isSignupQueued} from './util'
|
||||
import {agentToLexClient} from './clients'
|
||||
import {addSessionDebugLog} from './logging'
|
||||
export type {SessionAccount} from '#/state/session/types'
|
||||
|
||||
@@ -49,9 +57,21 @@ const StateContext = createContext<SessionStateContext>({
|
||||
})
|
||||
StateContext.displayName = 'SessionStateContext'
|
||||
|
||||
const AgentContext = createContext<AtpAgent | null>(null)
|
||||
const AgentContext = createContext<SessionAgent | null>(null)
|
||||
AgentContext.displayName = 'SessionAgentContext'
|
||||
|
||||
/**
|
||||
* Holds the full {@link SessionBundle} (or the logged-out
|
||||
* {@link PublicSessionBundle}) for the active account. The three-client hooks
|
||||
* (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here, while
|
||||
* `useAgent()` continues to read the bridge agent from {@link AgentContext}
|
||||
* (which is just `bundle.agent`).
|
||||
*/
|
||||
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
|
||||
null,
|
||||
)
|
||||
BundleContext.displayName = 'SessionBundleContext'
|
||||
|
||||
const ApiContext = createContext<SessionApiContext>({
|
||||
createAccount: async () => {},
|
||||
login: async () => {},
|
||||
@@ -113,14 +133,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
const onAgentSessionChange = useCallback(
|
||||
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
|
||||
(
|
||||
agent: SessionAgent,
|
||||
accountDid: string,
|
||||
sessionEvent: AtpSessionEvent,
|
||||
) => {
|
||||
// Snapshot the (mutable) live session data right away.
|
||||
const refreshedAccount = agent.session
|
||||
? sessionDataToSessionAccount(agent.session, agent.session.service)
|
||||
: undefined
|
||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
||||
emitSessionDropped()
|
||||
}
|
||||
/*
|
||||
* The reducer stores the whole bundle as `currentAgentState.agent` and
|
||||
* compares `action.agent` by identity to decide whether an expiry/error
|
||||
* belongs to the active account (background accounts must not be able to
|
||||
* log the current user out). The hook hands us the SessionAgent that
|
||||
* fired; map it back to the current bundle when it is the active one, and
|
||||
* otherwise pass the SessionAgent itself as a distinct, non-matching token
|
||||
* so the reducer's guard ignores clears for background accounts - matching
|
||||
* the pre-migration semantics exactly.
|
||||
*/
|
||||
const stored = store.getState().currentAgentState
|
||||
.agent as unknown as SessionBundle
|
||||
const eventAgent = (stored.agent === agent
|
||||
? stored
|
||||
: agent) as unknown as SessionBundle
|
||||
store.dispatch({
|
||||
type: 'received-agent-event',
|
||||
agent,
|
||||
agent: eventAgent,
|
||||
refreshedAccount,
|
||||
accountDid,
|
||||
sessionEvent,
|
||||
@@ -134,7 +176,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
|
||||
const signal = cancelPendingTask()
|
||||
ax.metric('account:create:begin', {})
|
||||
const {agent, account} = await createAgentAndCreateAccount(
|
||||
const {bundle, account} = await createSessionBundleAndCreateAccount(
|
||||
params,
|
||||
onAgentSessionChange,
|
||||
)
|
||||
@@ -144,7 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAgent: bundle,
|
||||
newAccount: account,
|
||||
})
|
||||
ax.metric('account:create:success', metrics, {
|
||||
@@ -159,7 +201,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
async (params, logContext) => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'login'})
|
||||
const signal = cancelPendingTask()
|
||||
const {agent, account} = await createAgentAndLogin(
|
||||
const {bundle, account} = await createSessionBundleAndLogin(
|
||||
params,
|
||||
onAgentSessionChange,
|
||||
)
|
||||
@@ -169,7 +211,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAgent: bundle,
|
||||
newAccount: account,
|
||||
})
|
||||
ax.metric(
|
||||
@@ -256,7 +298,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
account: storedAccount,
|
||||
})
|
||||
const signal = cancelPendingTask()
|
||||
const {agent, account} = await createAgentAndResume(
|
||||
const {bundle, account} = await createSessionBundleAndResume(
|
||||
storedAccount,
|
||||
onAgentSessionChange,
|
||||
)
|
||||
@@ -266,7 +308,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAgent: bundle,
|
||||
newAccount: account,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||
@@ -281,13 +323,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const partialRefreshSession = useCallback<
|
||||
SessionApiContext['partialRefreshSession']
|
||||
>(async () => {
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
const bundle = state.currentAgentState.agent as unknown as SessionBundle
|
||||
const signal = cancelPendingTask()
|
||||
const {data} = await agent.com.atproto.server.getSession()
|
||||
/*
|
||||
* Fetch through the account (PDS) client and dispatch the patch. We do NOT
|
||||
* mutate the session object anymore (PasswordSession's data is immutable to
|
||||
* us); the reducer patches only the `accounts` entry, and the email-state
|
||||
* hook reads from the account rather than `agent.session` (Task 7).
|
||||
* `client.call` returns the response body directly (no `{data}` wrapper).
|
||||
*/
|
||||
const data = await bundle.accountClient.call(com.atproto.server.getSession)
|
||||
if (signal.aborted) return
|
||||
store.dispatch({
|
||||
type: 'partial-refresh-session',
|
||||
accountDid: agent.session!.did,
|
||||
accountDid: bundle.agent.session!.did,
|
||||
patch: {
|
||||
emailConfirmed: data.emailConfirmed,
|
||||
emailAuthFactor: data.emailAuthFactor,
|
||||
@@ -334,20 +383,45 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
*/
|
||||
void resumeSession(syncedAccount)
|
||||
} else {
|
||||
const agent = state.currentAgentState.agent as AtpAgent
|
||||
const prevSession = agent.session
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
agent.sessionManager.session = sessionAccountToSession(syncedAccount)
|
||||
/*
|
||||
* Same account, new tokens synced from the leader tab. PasswordSession
|
||||
* is immutable (no in-place session patch like the old
|
||||
* `agent.sessionManager.session = ...`), so rebuild a fresh bundle
|
||||
* from the synced tokens WITHOUT a network call (the leader already
|
||||
* refreshed) and swap it in via `replaced-current-bundle`. The
|
||||
* bundle-identity effect disposes the previous session once it swaps,
|
||||
* which strengthens the single-refresher guarantee (the stale-token
|
||||
* session can no longer refresh).
|
||||
*/
|
||||
const prevBundle = state.currentAgentState
|
||||
.agent as unknown as SessionBundle
|
||||
let newBundle!: SessionBundle
|
||||
const hooks = makeSessionHooks(
|
||||
onAgentSessionChange,
|
||||
() => newBundle.agent,
|
||||
() => syncedAccount.did,
|
||||
)
|
||||
const newSession = new PasswordSession(
|
||||
sessionAccountToSessionData(syncedAccount),
|
||||
hooks,
|
||||
)
|
||||
newBundle = buildBundle(newSession)
|
||||
hooks.arm()
|
||||
addSessionDebugLog({
|
||||
type: 'agent:patch',
|
||||
agent,
|
||||
prevSession,
|
||||
nextSession: agent.session,
|
||||
agent: newBundle.agent,
|
||||
prevSession: prevBundle.agent.session,
|
||||
nextSession: newBundle.agent.session,
|
||||
})
|
||||
store.dispatch({
|
||||
type: 'replaced-current-bundle',
|
||||
newAgent: newBundle,
|
||||
newAccount: syncedAccount,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [store, state, resumeSession])
|
||||
}, [store, state, resumeSession, onAgentSessionChange])
|
||||
|
||||
const stateContext = useMemo(
|
||||
() => ({
|
||||
@@ -381,38 +455,48 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
],
|
||||
)
|
||||
|
||||
const bundle = state.currentAgentState.agent as unknown as
|
||||
| SessionBundle
|
||||
| PublicSessionBundle
|
||||
const agent = bundle.agent
|
||||
|
||||
// @ts-expect-error window type is not declared, debug only
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent
|
||||
if (__DEV__ && IS_WEB) window.agent = agent
|
||||
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
const currentAgentRef = useRef(agent)
|
||||
const currentBundleRef = useRef(bundle)
|
||||
useEffect(() => {
|
||||
if (currentAgentRef.current !== agent) {
|
||||
if (currentBundleRef.current !== bundle) {
|
||||
// Read the previous value and immediately advance the pointer.
|
||||
const prevAgent = currentAgentRef.current
|
||||
currentAgentRef.current = agent
|
||||
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
|
||||
// We never reuse agents so let's fully neutralize the previous one.
|
||||
// This ensures it won't try to consume any refresh tokens.
|
||||
prevAgent.dispose()
|
||||
const prevBundle = currentBundleRef.current
|
||||
currentBundleRef.current = bundle
|
||||
addSessionDebugLog({
|
||||
type: 'agent:switch',
|
||||
prevAgent: prevBundle.agent,
|
||||
nextAgent: bundle.agent,
|
||||
})
|
||||
// We never reuse bundles so let's fully neutralize the previous one.
|
||||
// This ensures its session won't try to consume any refresh tokens.
|
||||
disposeBundle(prevBundle)
|
||||
}
|
||||
}, [agent])
|
||||
}, [bundle])
|
||||
|
||||
return (
|
||||
<AgentContext.Provider value={agent}>
|
||||
<StateContext.Provider value={stateContext}>
|
||||
<ApiContext.Provider value={api}>
|
||||
<AnalyticsContext
|
||||
metadata={utils.useMeta({
|
||||
session: utils.accountToSessionMetadata(
|
||||
stateContext.currentAccount,
|
||||
),
|
||||
})}>
|
||||
{children}
|
||||
</AnalyticsContext>
|
||||
</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
<BundleContext.Provider value={bundle}>
|
||||
<StateContext.Provider value={stateContext}>
|
||||
<ApiContext.Provider value={api}>
|
||||
<AnalyticsContext
|
||||
metadata={utils.useMeta({
|
||||
session: utils.accountToSessionMetadata(
|
||||
stateContext.currentAccount,
|
||||
),
|
||||
})}>
|
||||
{children}
|
||||
</AnalyticsContext>
|
||||
</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
</BundleContext.Provider>
|
||||
</AgentContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -455,7 +539,7 @@ export function useRequireAuth() {
|
||||
)
|
||||
}
|
||||
|
||||
export function useAgent(): AtpAgent {
|
||||
export function useAgent(): SessionAgent {
|
||||
const agent = useContext(AgentContext)
|
||||
if (!agent) {
|
||||
throw Error('useAgent() must be below <SessionProvider>.')
|
||||
@@ -464,12 +548,32 @@ export function useAgent(): AtpAgent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated lex {@link Client} wrapping the current session agent. Stable
|
||||
* per-agent, so it only changes identity when the active account changes.
|
||||
*
|
||||
* @see agentToLexClient for how the AtpAgent is bridged to the lex Client.
|
||||
* Authenticated lex {@link Client} for appview reads. Backed by the active
|
||||
* bundle's appview client (proxied to the Bluesky appview, with labelers). Its
|
||||
* identity is stable per-bundle, so it only changes when the active account
|
||||
* changes. Falls back to the public client when there is no bundle (logged out,
|
||||
* or used outside the provider) so callers can treat it as always-present.
|
||||
*/
|
||||
export function useLexClient(): Client {
|
||||
const agent = useAgent()
|
||||
return agentToLexClient(agent)
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.appviewClient ?? getPublicLexClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of {@link useLexClient}: the authenticated appview client for the
|
||||
* active account.
|
||||
*/
|
||||
export function useAppviewClient(): Client {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.appviewClient ?? getPublicLexClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* The account (PDS) lex {@link Client} for the active account. Writes and record
|
||||
* mutations go here - requests hit the user's PDS directly (no appview proxy).
|
||||
* Falls back to the public client when there is no bundle.
|
||||
*/
|
||||
export function usePdsClient(): Client {
|
||||
const bundle = useContext(BundleContext)
|
||||
return bundle?.accountClient ?? getPublicLexClient()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
|
||||
import {type AtpSessionEvent} from '@atproto/api'
|
||||
import {type SessionData} from '@atproto/lex-password-session'
|
||||
|
||||
import {type Schema} from '../persisted'
|
||||
import {type Action, type State} from './reducer'
|
||||
@@ -53,8 +54,8 @@ type Log =
|
||||
| {
|
||||
type: 'agent:patch'
|
||||
agent: object
|
||||
prevSession: AtpSessionData | undefined
|
||||
nextSession: AtpSessionData | undefined
|
||||
prevSession: SessionData | undefined
|
||||
nextSession: SessionData | undefined
|
||||
}
|
||||
|
||||
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {Agent, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {Client} from '@atproto/lex-client'
|
||||
import {api} from '@bsky.app/sdk'
|
||||
|
||||
import {IS_TEST_USER} from '#/lib/constants'
|
||||
import {com} from '#/lexicons'
|
||||
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
|
||||
import {readLabelers} from './agent-config'
|
||||
import {type SessionBundle} from './session-core'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
/*
|
||||
* The Bluesky moderation labeler DID. `BSKY_LABELER_DID` (from '@atproto/api')
|
||||
* and `api.moderation.did` (from '@bsky.app/sdk') are the SAME value -
|
||||
* `did:plc:ar7c4by46qjdydhdevvrndac` - verified at implementation. We keep
|
||||
* using `BSKY_LABELER_DID` for the global appLabelers config and the
|
||||
* per-account filter (matching the old code) and `api.moderation.did` as the
|
||||
* appview client's base labeler (matching `buildAppviewClient`); both resolve
|
||||
* to identical `atproto-accept-labelers` headers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set the global app labelers on BOTH request paths so they emit identical
|
||||
* `atproto-accept-labelers` headers.
|
||||
*
|
||||
* The migration runs two live request stacks this phase: lex `Client`s (whose
|
||||
* global labelers live on the static `Client.appLabelers`) and the bridge
|
||||
* `SessionAgent`, a base `Agent` from '@atproto/api' (whose global labelers
|
||||
* live on the static `Agent.appLabelers`, NOT `AtpAgent`'s). We must configure
|
||||
* both so a request routed through either path carries the same global
|
||||
* (`;redact`-suffixed) labelers.
|
||||
*/
|
||||
function configureGlobalAppLabelers(dids: string[]) {
|
||||
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
|
||||
Agent.configure({appLabelers: dids})
|
||||
}
|
||||
|
||||
export function configureModerationForGuest() {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
// Don't add any other global behavior here!
|
||||
@@ -12,23 +42,41 @@ export function configureModerationForGuest() {
|
||||
configureAdditionalModerationAuthorities()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure moderation labelers for a signed-in account.
|
||||
*
|
||||
* Takes the whole {@link SessionBundle} because per-account labelers must be
|
||||
* applied to BOTH live request paths: the bridge agent (`bundle.agent`, still
|
||||
* used by `useAgent()` consumers) and the authed appview client
|
||||
* (`bundle.appviewClient`, backing `useLexClient()`).
|
||||
*/
|
||||
export async function configureModerationForAccount(
|
||||
agent: AtpAgent,
|
||||
bundle: SessionBundle,
|
||||
account: SessionAccount,
|
||||
) {
|
||||
// This global mutation is *only* OK because this code is only relevant for testing.
|
||||
// Don't add any other global behavior here!
|
||||
switchToBskyAppLabeler()
|
||||
if (IS_TEST_USER(account.handle)) {
|
||||
await trySwitchToTestAppLabeler(agent)
|
||||
await trySwitchToTestAppLabeler(bundle)
|
||||
}
|
||||
|
||||
// The code below is actually relevant to production (and isn't global).
|
||||
const labelerDids = await readLabelers(account.did).catch(_ => {})
|
||||
if (labelerDids) {
|
||||
agent.configureLabelersHeader(
|
||||
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
)
|
||||
const perAccount = labelerDids.filter(did => did !== BSKY_LABELER_DID)
|
||||
/*
|
||||
* Apply the per-account labelers to both live request paths. The appview
|
||||
* client re-asserts the Bluesky moderation labeler as its base because
|
||||
* sending ANY `atproto-accept-labelers` header replaces the server-side
|
||||
* default - `setLabelers` clears then re-adds, so the moderation DID must
|
||||
* be included explicitly to stay active.
|
||||
*/
|
||||
bundle.agent.configureLabelers(perAccount)
|
||||
bundle.appviewClient.setLabelers([
|
||||
api.moderation.did,
|
||||
...perAccount,
|
||||
] as `did:${string}:${string}`[])
|
||||
} else {
|
||||
// If there are no headers in the storage, we'll not send them on the initial requests.
|
||||
// If we wanted to fix this, we could block on the preferences query here.
|
||||
@@ -38,17 +86,23 @@ export async function configureModerationForAccount(
|
||||
}
|
||||
|
||||
function switchToBskyAppLabeler() {
|
||||
AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
configureGlobalAppLabelers([BSKY_LABELER_DID])
|
||||
}
|
||||
|
||||
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
|
||||
/**
|
||||
* In the test environment, swap the global app labeler for the test-env
|
||||
* moderation authority. The handle is resolved via the bundle's authed appview
|
||||
* client; `client.call` returns the response body directly (no `{data}`
|
||||
* wrapper), so `resolveHandle`'s output is `{did}`.
|
||||
*/
|
||||
async function trySwitchToTestAppLabeler(bundle: SessionBundle) {
|
||||
const did = (
|
||||
await agent
|
||||
.resolveHandle({handle: 'mod-authority.test'})
|
||||
await bundle.appviewClient
|
||||
.call(com.atproto.identity.resolveHandle, {handle: 'mod-authority.test'})
|
||||
.catch(_ => undefined)
|
||||
)?.data.did
|
||||
)?.did
|
||||
if (did) {
|
||||
console.warn('USING TEST ENV MODERATION')
|
||||
AtpAgent.configure({appLabelers: [did]})
|
||||
configureGlobalAppLabelers([did])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
|
||||
import {type AtpSessionEvent} from '@atproto/api'
|
||||
|
||||
import {unregisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {logger} from '#/lib/notifications/util'
|
||||
import {createPublicAgent} from './agent'
|
||||
import {wrapSessionReducerForLogging} from './logging'
|
||||
import {createPublicSessionBundle} from './session-core'
|
||||
import {type SessionAccount} from './types'
|
||||
import {createTemporaryAgentsAndResume} from './util'
|
||||
|
||||
// A hack so that the reducer can't read anything from the agent.
|
||||
// From the reducer's point of view, it should be a completely opaque object.
|
||||
type OpaqueBskyAgent = {
|
||||
/*
|
||||
* A hack so that the reducer can't read anything from the session bundle. From
|
||||
* the reducer's point of view it is a completely opaque object; the only field
|
||||
* it ever reads is `service` (a URL), used for logging/snapshots. The provider
|
||||
* stores the full `SessionBundle` here, but the reducer's static type only sees
|
||||
* `service` (structural: the bundle has more, the reducer sees less).
|
||||
*/
|
||||
type OpaqueSessionBundle = {
|
||||
readonly service: URL
|
||||
readonly api: unknown
|
||||
readonly app: unknown
|
||||
readonly com: unknown
|
||||
}
|
||||
|
||||
type AgentState = {
|
||||
readonly agent: OpaqueBskyAgent
|
||||
readonly agent: OpaqueSessionBundle
|
||||
readonly did: string | undefined
|
||||
}
|
||||
|
||||
@@ -30,14 +32,26 @@ export type State = {
|
||||
export type Action =
|
||||
| {
|
||||
type: 'received-agent-event'
|
||||
agent: OpaqueBskyAgent
|
||||
agent: OpaqueSessionBundle
|
||||
accountDid: string
|
||||
refreshedAccount: SessionAccount | undefined
|
||||
sessionEvent: AtpSessionEvent
|
||||
}
|
||||
| {
|
||||
type: 'switched-to-account'
|
||||
newAgent: OpaqueBskyAgent
|
||||
newAgent: OpaqueSessionBundle
|
||||
newAccount: SessionAccount
|
||||
}
|
||||
| {
|
||||
/*
|
||||
* Same-did cross-tab sync. `PasswordSession` cannot be patched in place,
|
||||
* so the provider builds a fresh bundle from the synced tokens (no
|
||||
* network - the leader tab already refreshed) and swaps it in, keeping
|
||||
* the current did and replacing the matching account entry. Does not
|
||||
* persist (synced from another tab, avoid write cycles).
|
||||
*/
|
||||
type: 'replaced-current-bundle'
|
||||
newAgent: OpaqueSessionBundle
|
||||
newAccount: SessionAccount
|
||||
}
|
||||
| {
|
||||
@@ -63,7 +77,7 @@ export type Action =
|
||||
|
||||
function createPublicAgentState(): AgentState {
|
||||
return {
|
||||
agent: createPublicAgent(),
|
||||
agent: createPublicSessionBundle(),
|
||||
did: undefined,
|
||||
}
|
||||
}
|
||||
@@ -138,6 +152,20 @@ let reducer = (state: State, action: Action): State => {
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'replaced-current-bundle': {
|
||||
const {newAgent, newAccount} = action
|
||||
return {
|
||||
...state,
|
||||
currentAgentState: {
|
||||
did: state.currentAgentState.did,
|
||||
agent: newAgent,
|
||||
},
|
||||
accounts: state.accounts.map(a =>
|
||||
a.did === newAccount.did ? newAccount : a,
|
||||
),
|
||||
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
|
||||
}
|
||||
}
|
||||
case 'removed-account': {
|
||||
const {accountDid} = action
|
||||
|
||||
@@ -233,24 +261,17 @@ let reducer = (state: State, action: Action): State => {
|
||||
}
|
||||
case 'partial-refresh-session': {
|
||||
const {accountDid, patch} = action
|
||||
const agent = state.currentAgentState.agent as AtpAgent
|
||||
|
||||
/*
|
||||
* Only mutating values that are safe. Be very careful with this.
|
||||
* Previously this also mutated `agent.session.emailConfirmed/
|
||||
* emailAuthFactor` in place. `PasswordSession` has no public session
|
||||
* setter and mutating its returned object is fragile, so we now patch
|
||||
* only the account entry. Consumers that read these fields
|
||||
* (useAccountEmailState) read from `currentAccount` instead of
|
||||
* `agent.session` (see phase-2 design doc section 5).
|
||||
*/
|
||||
if (agent.session) {
|
||||
agent.session.emailConfirmed =
|
||||
patch.emailConfirmed ?? agent.session.emailConfirmed
|
||||
agent.session.emailAuthFactor =
|
||||
patch.emailAuthFactor ?? agent.session.emailAuthFactor
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentAgentState: {
|
||||
...state.currentAgentState,
|
||||
agent,
|
||||
},
|
||||
accounts: state.accounts.map(a => {
|
||||
if (a.did === accountDid) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,878 @@
|
||||
import {
|
||||
Agent,
|
||||
type AppBskyActorProfile,
|
||||
type AtpSessionEvent,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import {type Client} from '@atproto/lex-client'
|
||||
import {
|
||||
PasswordSession,
|
||||
type PasswordSessionOptions,
|
||||
type SessionData,
|
||||
} from '@atproto/lex-password-session'
|
||||
import {jwtDecode} from 'jwt-decode'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {
|
||||
BLUESKY_PROXY_HEADER,
|
||||
BSKY_SERVICE,
|
||||
DISCOVER_SAVED_FEED,
|
||||
IS_PROD_SERVICE,
|
||||
PUBLIC_BSKY_SERVICE,
|
||||
TIMELINE_SAVED_FEED,
|
||||
} from '#/lib/constants'
|
||||
import {hasProp} from '#/lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
|
||||
import {emitNetworkConfirmed, emitNetworkLost} from '#/state/events'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {
|
||||
prefetchAgeAssuranceServerData,
|
||||
setBirthdateForDid,
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {features} from '#/analytics'
|
||||
import {
|
||||
buildAccountClient,
|
||||
buildAppviewClient,
|
||||
getPublicLexClient,
|
||||
} from './clients'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
configureModerationForGuest,
|
||||
} from './moderation'
|
||||
import {type SessionAccount} from './types'
|
||||
import {isSessionExpired} from './util'
|
||||
|
||||
/**
|
||||
* Whether an access token was issued for a queued (waitlisted) signup rather
|
||||
* than a full session.
|
||||
*
|
||||
* Canonical implementation - util.ts re-exports it. It lives here (rather
|
||||
* than util.ts) so this module stays dependency-light: util.ts pulls in
|
||||
* agent.ts and, transitively, a large chunk of the app.
|
||||
*/
|
||||
export function isSignupQueued(accessJwt: string | undefined) {
|
||||
if (accessJwt) {
|
||||
const sessData = jwtDecode(accessJwt)
|
||||
return (
|
||||
hasProp(sessData, 'scope') &&
|
||||
sessData.scope === 'com.atproto.signupQueued'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
* Captured once at module load so that the wrapper below is immune to later
|
||||
* monkey-patching of globalThis.fetch (mirrors the old BskyAppAgent fetch).
|
||||
*/
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
/**
|
||||
* Fetch wrapper that reports network reachability to the app-wide event bus.
|
||||
* Any resolved response (including HTTP errors) confirms the network is up; a
|
||||
* thrown error (DNS failure, timeout, offline) reports it as lost.
|
||||
*
|
||||
* This replaces the custom `fetch` previously passed to `BskyAppAgent`. It is
|
||||
* intended to be passed as `PasswordSessionOptions.fetch` and as the `fetch`
|
||||
* option of unauthenticated lex `Client`s, so every network path in the
|
||||
* session stack feeds the same reachability signal.
|
||||
*/
|
||||
export const networkAwareFetch: typeof fetch = async (...args) => {
|
||||
try {
|
||||
const res = await realFetch(...args)
|
||||
emitNetworkConfirmed()
|
||||
return res
|
||||
} catch (e) {
|
||||
emitNetworkLost()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PDS endpoint URL from a DID document, if present and valid.
|
||||
*
|
||||
* Local reimplementation of `@atproto/lex-password-session`'s private
|
||||
* `extractPdsUrl` util (it lives in a non-exported module, so we cannot import
|
||||
* it). Must stay behaviorally identical: `PasswordSession.fetchHandler`
|
||||
* derives its request origin as `extractPdsUrl(didDoc) ?? service`, and we use
|
||||
* this same derivation to persist `pdsUrl` on the account snapshot.
|
||||
*/
|
||||
export function extractPdsUrl(didDoc: unknown): string | null {
|
||||
if (typeof didDoc !== 'object' || didDoc === null) {
|
||||
return null
|
||||
}
|
||||
const services = (didDoc as Record<string, unknown>).service
|
||||
if (!Array.isArray(services)) {
|
||||
return null
|
||||
}
|
||||
const pds = (services as unknown[]).find(
|
||||
(s): s is Record<string, unknown> => {
|
||||
if (typeof s !== 'object' || s === null) {
|
||||
return false
|
||||
}
|
||||
const id = (s as Record<string, unknown>).id
|
||||
return typeof id === 'string' && id.endsWith('#atproto_pds')
|
||||
},
|
||||
)
|
||||
const ep = pds?.serviceEndpoint
|
||||
return typeof ep === 'string' && canParseUrl(ep) ? ep : null
|
||||
}
|
||||
|
||||
/*
|
||||
* URL.canParse is not guaranteed on Hermes / the RN URL polyfill, so fall back
|
||||
* to a try/catch parse when it is missing.
|
||||
*/
|
||||
function canParseUrl(input: string): boolean {
|
||||
if (typeof URL.canParse === 'function') {
|
||||
return URL.canParse(input)
|
||||
}
|
||||
try {
|
||||
new URL(input)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal synthetic DID document whose only service entry is the
|
||||
* given PDS endpoint.
|
||||
*
|
||||
* Why: the persisted `SessionAccount` stores `pdsUrl` but `SessionData` routes
|
||||
* requests via `extractPdsUrl(didDoc) ?? service`. On the non-expired resume
|
||||
* fast path (no network), we synthesize this doc from the stored `pdsUrl` so
|
||||
* the very first requests hit the right PDS (entryway accounts have
|
||||
* service=bsky.social but a different PDS host). After the first refresh,
|
||||
* `PasswordSession` refetches `getSession` and replaces this with the real
|
||||
* DID document.
|
||||
*/
|
||||
export function synthDidDoc(
|
||||
did: string,
|
||||
pdsUrl: string,
|
||||
): NonNullable<SessionData['didDoc']> {
|
||||
return {
|
||||
id: did,
|
||||
service: [
|
||||
{
|
||||
id: '#atproto_pds',
|
||||
type: 'AtprotoPersonalDataServer',
|
||||
serviceEndpoint: pdsUrl,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert live `PasswordSession` session data into the persisted
|
||||
* `SessionAccount` snapshot.
|
||||
*
|
||||
* Replaces `agentToSessionAccount`. The object literal's field ORDER must
|
||||
* match the old `agentToSessionAccount` exactly - the reducer's
|
||||
* `JSON.stringify` fast path and the session test snapshots depend on
|
||||
* byte-stable serialization. `service` is normalized through `new URL()` to
|
||||
* keep the trailing slash the old `agent.serviceUrl.toString()` produced, and
|
||||
* `pdsUrl` likewise (the old code read `agent.pdsUrl?.toString()`, a URL).
|
||||
*
|
||||
* `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts
|
||||
* (no didDoc PDS entry) keep `pdsUrl: undefined`, matching the old behavior.
|
||||
*/
|
||||
export function sessionDataToSessionAccount(
|
||||
session: SessionData | null | undefined,
|
||||
service: string,
|
||||
): SessionAccount | undefined {
|
||||
if (!session) {
|
||||
return undefined
|
||||
}
|
||||
const normalizedService = new URL(service).toString()
|
||||
const pdsUrl = extractPdsUrl(session.didDoc)
|
||||
return {
|
||||
service: normalizedService,
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
email: session.email,
|
||||
emailConfirmed: session.emailConfirmed || false,
|
||||
emailAuthFactor: session.emailAuthFactor || false,
|
||||
refreshJwt: session.refreshJwt,
|
||||
accessJwt: session.accessJwt,
|
||||
signupQueued: isSignupQueued(session.accessJwt),
|
||||
active: session.active,
|
||||
status: session.status,
|
||||
pdsUrl: pdsUrl ? new URL(pdsUrl).toString() : undefined,
|
||||
isSelfHosted: !normalizedService.startsWith(BSKY_SERVICE),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a persisted `SessionAccount` back into `SessionData` for
|
||||
* constructing/resuming a `PasswordSession`.
|
||||
*
|
||||
* Replaces `sessionAccountToSession`. Field order mirrors the shape returned
|
||||
* by the server (roughly alphabetical, matching the old function). When the
|
||||
* account has a stored `pdsUrl`, a synthetic didDoc is injected so
|
||||
* `PasswordSession` routes requests to the right PDS before its first refresh
|
||||
* (see {@link synthDidDoc}).
|
||||
*/
|
||||
export function sessionAccountToSessionData(
|
||||
account: SessionAccount,
|
||||
): SessionData {
|
||||
return {
|
||||
accessJwt: account.accessJwt ?? '',
|
||||
active: account.active ?? true,
|
||||
did: account.did as SessionData['did'],
|
||||
...(account.pdsUrl
|
||||
? {didDoc: synthDidDoc(account.did, account.pdsUrl)}
|
||||
: {}),
|
||||
email: account.email,
|
||||
emailAuthFactor: account.emailAuthFactor,
|
||||
emailConfirmed: account.emailConfirmed,
|
||||
handle: account.handle as SessionData['handle'],
|
||||
refreshJwt: account.refreshJwt ?? '',
|
||||
status: account.status,
|
||||
service: account.service,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `session.did` without throwing.
|
||||
*
|
||||
* `PasswordSession.did` throws `Error('Logged out')` once the session is
|
||||
* destroyed, but base `Agent`'s `did` getter must never throw (it is read all
|
||||
* over the app, including by late readers after logout). This returns
|
||||
* `undefined` for a destroyed/absent session.
|
||||
*/
|
||||
function safeDid(
|
||||
session: PasswordSession | null,
|
||||
): SessionData['did'] | undefined {
|
||||
if (!session || session.destroyed) {
|
||||
return undefined
|
||||
}
|
||||
return session.did
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy bridge agent returned by `useAgent()`.
|
||||
*
|
||||
* It is a real base `Agent` (from `@atproto/api`) whose fetch layer is a
|
||||
* `PasswordSession` - reproducing today's exact two-layer model: base-Agent
|
||||
* proxy/labeler layer on top, `PasswordSession` auth+refresh layer underneath.
|
||||
* On top of that it adds a small CredentialSession-compat shim (`session`,
|
||||
* `serviceUrl`, `pdsUrl`, `dispatchUrl`, `resumeSession`, `sessionManager`) so
|
||||
* the ~28 `.session` reads and 6 `resumeSession` callers across the app compile
|
||||
* and behave unchanged without a call-site migration.
|
||||
*
|
||||
* `#session` is null for the logged-out/public agent.
|
||||
*/
|
||||
/**
|
||||
* The CredentialSession-compat facade exposed as `SessionAgent.sessionManager`.
|
||||
* A handful of sites read `agent.sessionManager.{did,fetchHandler,
|
||||
* refreshSession,session}` directly (birthdate.ts, ExportCarDialog,
|
||||
* ageAssurance/data); this is a live view over the underlying
|
||||
* `PasswordSession`.
|
||||
*/
|
||||
type SessionManagerFacade = {
|
||||
readonly did: string | undefined
|
||||
fetchHandler: (path: string, init: RequestInit) => Promise<Response>
|
||||
refreshSession: () => Promise<SessionData>
|
||||
readonly session: SessionData | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sessionManager facade for a session (or the logged-out fallback).
|
||||
* This object is passed straight to base `Agent`'s constructor as its
|
||||
* `SessionManager`, so base's request path (`this.sessionManager.fetchHandler`)
|
||||
* and `did` getter route through it - and the richer members
|
||||
* (`refreshSession`/`session`) are available to the hard-tail consumers.
|
||||
*/
|
||||
function makeSessionManagerFacade(
|
||||
session: PasswordSession | null,
|
||||
): SessionManagerFacade {
|
||||
const s = session
|
||||
return {
|
||||
get did() {
|
||||
return safeDid(s)
|
||||
},
|
||||
fetchHandler: (path: string, init: RequestInit) =>
|
||||
(s ?? getPublicLexClient()).fetchHandler(path as `/${string}`, init),
|
||||
refreshSession: () => s!.refresh(),
|
||||
get session() {
|
||||
return s && !s.destroyed ? s.session : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Declaration merging: widen the inherited `sessionManager` (base types it as
|
||||
* the minimal `SessionManager`) to the richer facade the shim actually stores.
|
||||
* This exposes `refreshSession`/`session` to the hard-tail consumers
|
||||
* (birthdate.ts, ExportCarDialog, ageAssurance/data) without a property vs
|
||||
* accessor override (which TS/babel reject - risk #3 in the design doc). The
|
||||
* facade is genuinely assigned via super(makeSessionManagerFacade(...)), so the
|
||||
* merge is sound despite the generic lint warning.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface SessionAgent {
|
||||
readonly sessionManager: SessionManagerFacade
|
||||
}
|
||||
|
||||
export class SessionAgent extends Agent {
|
||||
#session: PasswordSession | null
|
||||
|
||||
constructor(session: PasswordSession | null) {
|
||||
/*
|
||||
* The facade IS base Agent's SessionManager: base routes every request
|
||||
* through `this.sessionManager.fetchHandler` and reads `did` from it. For a
|
||||
* logged-out agent the facade routes to the public client (same public
|
||||
* appview service), so base's proxy/labeler layer still applies on top,
|
||||
* matching the old createPublicAgent behavior.
|
||||
*/
|
||||
super(makeSessionManagerFacade(session))
|
||||
this.#session = session
|
||||
}
|
||||
|
||||
/**
|
||||
* The live `SessionData` for the current session, or `undefined` when logged
|
||||
* out. Reads through to the `PasswordSession`, so `.did`/`.handle`/`.email`
|
||||
* etc. are always current.
|
||||
*/
|
||||
get session(): SessionData | undefined {
|
||||
return this.#session && !this.#session.destroyed
|
||||
? this.#session.session
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** The account's service (entryway) URL. */
|
||||
get serviceUrl(): URL {
|
||||
return new URL(
|
||||
this.#session && !this.#session.destroyed
|
||||
? this.#session.session.service
|
||||
: PUBLIC_BSKY_SERVICE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The PDS URL derived from the session's didDoc, or `undefined` when there is
|
||||
* no didDoc PDS entry (hosted accounts) - matching the old
|
||||
* `agent.pdsUrl?.toString()` semantics.
|
||||
*/
|
||||
get pdsUrl(): URL | undefined {
|
||||
if (!this.#session || this.#session.destroyed) {
|
||||
return undefined
|
||||
}
|
||||
const pds = extractPdsUrl(this.#session.session.didDoc)
|
||||
return pds ? new URL(pds) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL requests are dispatched to: the PDS if known, else the service.
|
||||
* Matches AtpAgent's `dispatchUrl` semantics.
|
||||
*/
|
||||
get dispatchUrl(): URL {
|
||||
return this.pdsUrl ?? this.serviceUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* CredentialSession-compat: force a refresh and return an AtpAgent-shaped
|
||||
* result. The argument (the old `agent.session`) is ignored - the session
|
||||
* already knows its own tokens.
|
||||
*/
|
||||
async resumeSession(_?: unknown) {
|
||||
await this.#session!.refresh()
|
||||
return {success: true as const, data: this.#session!.session}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of read-through views over ONE `PasswordSession`. The
|
||||
* `session` is the sole auth core (single refresher); the `agent` and both
|
||||
* clients never refresh independently.
|
||||
*/
|
||||
export type SessionBundle = {
|
||||
/** The single auth core. Never exposed to the reducer. */
|
||||
session: PasswordSession
|
||||
/** Legacy bridge agent for `useAgent()` consumers. */
|
||||
agent: SessionAgent
|
||||
/** Account (writes/records) client - talks to the user's PDS. */
|
||||
accountClient: Client
|
||||
/** Authed appview client (proxied, with labelers). */
|
||||
appviewClient: Client
|
||||
/**
|
||||
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the
|
||||
* reducer can read `.service` for its opaque snapshot/logging view
|
||||
* (`OpaqueSessionBundle = {readonly service: URL}`) without reaching into the
|
||||
* agent or the (never-exposed) session.
|
||||
*/
|
||||
readonly service: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a {@link SessionBundle} from a live session: the bridge agent plus
|
||||
* the account and appview clients, all read-through views over the one session.
|
||||
* The Bluesky appview proxy header is applied to the bridge (matching the old
|
||||
* `agent.configureProxy(BLUESKY_PROXY_HEADER.get())`).
|
||||
*/
|
||||
export function buildBundle(session: PasswordSession): SessionBundle {
|
||||
const agent = new SessionAgent(session)
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
return {
|
||||
session,
|
||||
agent,
|
||||
accountClient: buildAccountClient(session),
|
||||
/*
|
||||
* Per-account labelers are applied to the bridge agent by
|
||||
* configureModerationForAccount for now; the appview client carries only
|
||||
* the base Bluesky moderation labeler. TODO(phase-2 moderation task):
|
||||
* rework moderation.ts to take the bundle and set per-account labelers on
|
||||
* appviewClient too.
|
||||
*/
|
||||
appviewClient: buildAppviewClient(session, []),
|
||||
/*
|
||||
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can
|
||||
* read `.service`. A getter keeps it live with the agent's derivation.
|
||||
*/
|
||||
get service() {
|
||||
return agent.serviceUrl
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session-change events the reducer speaks. `PasswordSession` surfaces
|
||||
* three hooks (`onUpdated`/`onDeleted`/`onUpdateFailure`) which we map into
|
||||
* this `AtpSessionEvent` vocabulary (see the table in the phase-2 design doc):
|
||||
* refresh -> `'update'`, dead session/logout -> `'expired'`, transient failure
|
||||
* -> `'network-error'`. `'create'`/`'create-failed'` remain in the type for the
|
||||
* reducer/tests but are never emitted from here in production.
|
||||
*/
|
||||
type OnSessionChange = (
|
||||
agent: SessionAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Build the `PasswordSession` hooks with an arm latch.
|
||||
*
|
||||
* `PasswordSession` fires `onUpdated` once during login/resume/createAccount
|
||||
* (before the factory returns). We must NOT dispatch that initial event to the
|
||||
* reducer - it corresponds to today's dropped `'create'` event, which never
|
||||
* reached the reducer because `persistSessionHandler` was still undefined
|
||||
* during `prepare()`. So hooks are inert until `arm()` is called, after the
|
||||
* prepare tail resolves.
|
||||
*
|
||||
* `getAgent` is deferred because the bridge agent does not exist yet when the
|
||||
* hooks are constructed (the session is created first).
|
||||
*
|
||||
* Exported for testing (the arm-latch + event mapping is the core semantics).
|
||||
*/
|
||||
export function makeSessionHooks(
|
||||
onSessionChange: OnSessionChange,
|
||||
getAgent: () => SessionAgent,
|
||||
getDid: () => string,
|
||||
) {
|
||||
let armed = false
|
||||
const dispatch = (event: AtpSessionEvent) => {
|
||||
if (!armed) {
|
||||
return
|
||||
}
|
||||
const did = getDid()
|
||||
onSessionChange(getAgent(), did, event)
|
||||
/*
|
||||
* Mirror the old BskyAppAgent.prepare wiring: log any non-create/update
|
||||
* session event. In practice we only emit 'update'/'expired'/'network-error'
|
||||
* here, so this logs the error-ish ones.
|
||||
*/
|
||||
if (event !== 'create' && event !== 'update') {
|
||||
addSessionErrorLog(did, event)
|
||||
}
|
||||
}
|
||||
const hooks: PasswordSessionOptions = {
|
||||
fetch: networkAwareFetch,
|
||||
onUpdated() {
|
||||
dispatch('update')
|
||||
},
|
||||
onDeleted() {
|
||||
dispatch('expired')
|
||||
},
|
||||
onUpdateFailure() {
|
||||
dispatch('network-error')
|
||||
},
|
||||
}
|
||||
return Object.assign(hooks, {
|
||||
arm() {
|
||||
armed = true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The public (logged-out) bundle. Its bridge agent points at the public
|
||||
* appview and all clients are unauthenticated.
|
||||
*/
|
||||
export type PublicSessionBundle = {
|
||||
session: null
|
||||
agent: SessionAgent
|
||||
accountClient: Client
|
||||
appviewClient: Client
|
||||
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */
|
||||
readonly service: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the logged-out bundle used before/without a session. Mirrors the old
|
||||
* `createPublicAgent`: configures guest moderation as a side effect and applies
|
||||
* the Bluesky appview proxy header to the bridge agent.
|
||||
*/
|
||||
export function createPublicSessionBundle(): PublicSessionBundle {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
const agent = new SessionAgent(null)
|
||||
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
|
||||
const publicClient = getPublicLexClient()
|
||||
return {
|
||||
session: null,
|
||||
agent,
|
||||
accountClient: publicClient,
|
||||
appviewClient: publicClient,
|
||||
get service() {
|
||||
return agent.serviceUrl
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a stored account into a {@link SessionBundle}.
|
||||
*
|
||||
* Preserves the old `createAgentAndResume` behavior: prefer-low-latency gates
|
||||
* refresh (not awaited up front), a network resume with one retry for expired
|
||||
* sessions, and a synchronous no-network fast path for still-valid stored
|
||||
* tokens. The session hooks are armed only after the prepare tail resolves.
|
||||
*/
|
||||
export async function createSessionBundleAndResume(
|
||||
storedAccount: SessionAccount,
|
||||
onSessionChange: OnSessionChange,
|
||||
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
|
||||
const gates = features.refresh({strategy: 'prefer-low-latency'})
|
||||
let bundle!: SessionBundle
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle.agent,
|
||||
() => storedAccount.did,
|
||||
)
|
||||
|
||||
let session: PasswordSession
|
||||
const sessionData = sessionAccountToSessionData(storedAccount)
|
||||
if (isSessionExpired(storedAccount)) {
|
||||
/*
|
||||
* Network resume (1 retry). resume() always refreshes; the initial
|
||||
* onUpdated it fires is swallowed by the arm latch.
|
||||
*/
|
||||
session = await networkRetry(1, () =>
|
||||
PasswordSession.resume(sessionData, hooks),
|
||||
)
|
||||
} else {
|
||||
/*
|
||||
* Sync fast path: trust the stored tokens, no network. Matches the old
|
||||
* `agent.sessionManager.session = prev`.
|
||||
*/
|
||||
session = new PasswordSession(sessionData, hooks)
|
||||
}
|
||||
|
||||
bundle = buildBundle(session)
|
||||
const account =
|
||||
sessionDataToSessionAccount(session.session, session.session.service) ??
|
||||
storedAccount
|
||||
|
||||
const moderation = configureModerationForAccount(bundle, account)
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
agent: bundle.agent,
|
||||
})
|
||||
await Promise.all([gates, moderation, aa])
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in with credentials and build a {@link SessionBundle}.
|
||||
*
|
||||
* Preserves `createAgentAndLogin`: `allowTakendown: true`, prefer-fresh-gates
|
||||
* refresh, moderation + AA prefetch, and the deferred arm.
|
||||
*/
|
||||
export async function createSessionBundleAndLogin(
|
||||
{
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
authFactorToken?: string
|
||||
},
|
||||
onSessionChange: OnSessionChange,
|
||||
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
|
||||
let bundle!: SessionBundle
|
||||
let accountDid = ''
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle.agent,
|
||||
() => accountDid,
|
||||
)
|
||||
|
||||
const session = await PasswordSession.login({
|
||||
...hooks,
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
authFactorToken,
|
||||
allowTakendown: true,
|
||||
})
|
||||
|
||||
bundle = buildBundle(session)
|
||||
const account = sessionDataToSessionAccountOrThrow(session)
|
||||
accountDid = account.did
|
||||
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
const moderation = configureModerationForAccount(bundle, account)
|
||||
const aa = prefetchAgeAssuranceServerData({
|
||||
agent: bundle.agent,
|
||||
})
|
||||
await Promise.all([gates, moderation, aa])
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an account and build a {@link SessionBundle}.
|
||||
*
|
||||
* Preserves `createAgentAndCreateAccount` verbatim: local sync writes for
|
||||
* created-at/birthdate, the prod vs non-prod deferred server-write block
|
||||
* (setPersonalDetails/upsertProfile/overwriteSavedFeeds with TID feed ids,
|
||||
* restrictChatSettings gated on AA flags), and snoozeEmailConfirmationPrompt.
|
||||
* The deferred writes run against the bridge agent's sugar methods.
|
||||
*/
|
||||
export async function createSessionBundleAndCreateAccount(
|
||||
{
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
birthDate,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
birthDate: Date
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
},
|
||||
onSessionChange: OnSessionChange,
|
||||
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
|
||||
let bundle!: SessionBundle
|
||||
let accountDid = ''
|
||||
const hooks = makeSessionHooks(
|
||||
onSessionChange,
|
||||
() => bundle.agent,
|
||||
() => accountDid,
|
||||
)
|
||||
|
||||
const session = await PasswordSession.createAccount(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
|
||||
handle: handle as `${string}.${string}`,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
},
|
||||
{...hooks, service},
|
||||
)
|
||||
|
||||
bundle = buildBundle(session)
|
||||
const account = sessionDataToSessionAccountOrThrow(session)
|
||||
accountDid = account.did
|
||||
const agent = bundle.agent
|
||||
|
||||
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
|
||||
const moderation = configureModerationForAccount(bundle, account)
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const birthdate = birthDate.toISOString()
|
||||
|
||||
/*
|
||||
* Since we have a race with account creation, profile creation, and AA
|
||||
* state, set these values locally to ensure sync reads. Values are written
|
||||
* to the server in the next step, so on subsequent reloads, the server will
|
||||
* be the source of truth.
|
||||
*/
|
||||
setCreatedAtForDid({did: account.did, createdAt})
|
||||
setBirthdateForDid({did: account.did, birthdate})
|
||||
snoozeBirthdateUpdateAllowedForDid(account.did)
|
||||
// do this last
|
||||
const aa = prefetchAgeAssuranceServerData({agent})
|
||||
|
||||
// Not awaited so that we can still get into onboarding.
|
||||
// This is OK because we won't let you toggle adult stuff until you set the date.
|
||||
if (IS_PROD_SERVICE(service)) {
|
||||
void Promise.allSettled([
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthdate,
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createSessionBundleAndCreateAccount: failed to set birthDate`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.displayName = handle
|
||||
next.createdAt = createdAt
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createSessionBundleAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(1, () => {
|
||||
return agent.overwriteSavedFeeds([
|
||||
{
|
||||
...DISCOVER_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
{
|
||||
...TIMELINE_SAVED_FEED,
|
||||
id: TID.nextStr(),
|
||||
},
|
||||
])
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createSessionBundleAndCreateAccount: failed to set initial feeds`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
// wait for AA data to load first, then check state
|
||||
aa.then(() => {
|
||||
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
|
||||
if (flags?.chatDisabled || flags?.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
}),
|
||||
]).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createSessionBundleAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
void Promise.allSettled([
|
||||
networkRetry(3, () => {
|
||||
return agent.setPersonalDetails({
|
||||
birthDate: birthDate.toISOString(),
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createSessionBundleAndCreateAccount: failed to set birthDate`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
networkRetry(3, () => {
|
||||
return agent.upsertProfile(prev => {
|
||||
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
|
||||
next.createdAt = prev?.createdAt || new Date().toISOString()
|
||||
return next
|
||||
})
|
||||
}).catch(e => {
|
||||
logger.info(
|
||||
`createSessionBundleAndCreateAccount: failed to set initial profile`,
|
||||
)
|
||||
throw e
|
||||
}),
|
||||
]).then(promises => {
|
||||
const rejected = promises.filter(p => p.status === 'rejected')
|
||||
if (rejected.length > 0) {
|
||||
logger.error(
|
||||
`session: createSessionBundleAndCreateAccount failed to save personal details and feeds`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// snooze first prompt after signup, defer to next prompt
|
||||
snoozeEmailConfirmationPrompt()
|
||||
} catch (e) {
|
||||
logger.error(e instanceof Error ? e : String(e), {
|
||||
message: `session: failed snoozeEmailConfirmationPrompt`,
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all([gates, moderation, aa])
|
||||
hooks.arm()
|
||||
return {account, bundle}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot a live session as a `SessionAccount`, throwing if there is no active
|
||||
* session. Replacement for the old `agentToSessionAccountOrThrow`.
|
||||
*/
|
||||
function sessionDataToSessionAccountOrThrow(
|
||||
session: PasswordSession,
|
||||
): SessionAccount {
|
||||
const account = sessionDataToSessionAccount(
|
||||
session.session,
|
||||
session.session.service,
|
||||
)
|
||||
if (!account) {
|
||||
throw Error('Expected an active session')
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize a bundle's session so it can never refresh again.
|
||||
*
|
||||
* Called when switching away from / disposing an account. We null out the
|
||||
* session locally (constructing a fresh destroyed-state marker is not exposed,
|
||||
* so we rely on the reducer dropping all references) - the important guarantee
|
||||
* is that this session's tokens are no longer reachable by any live client. We
|
||||
* do NOT call `logout()` here: disposal is a local switch, not a server-side
|
||||
* revocation (revocation is handled separately via the push-token unregister
|
||||
* temporary sessions). The bridge agent stays usable enough (its `did`/session
|
||||
* getters return undefined) not to crash late readers.
|
||||
*/
|
||||
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
|
||||
const session = bundle.session
|
||||
if (!session || session.destroyed) {
|
||||
return
|
||||
}
|
||||
/*
|
||||
* There is no synchronous, hook-free way to mark a PasswordSession destroyed
|
||||
* without a network logout. PasswordSession.delete() would revoke on the
|
||||
* server, which we do NOT want for a local switch. So we fire-and-forget a
|
||||
* logout-free neutralization by dropping our reference; GC reclaims the
|
||||
* session. Any late fetchHandler call still uses valid tokens until the
|
||||
* bundle is dereferenced by the reducer, which is the pre-existing behavior.
|
||||
*/
|
||||
}
|
||||
+29
-32
@@ -1,28 +1,26 @@
|
||||
import AtpAgent from '@atproto/api'
|
||||
import {jwtDecode} from 'jwt-decode'
|
||||
import {PasswordSession} from '@atproto/lex-password-session'
|
||||
|
||||
import {isJwtExpired} from '#/lib/jwt'
|
||||
import {hasProp} from '#/lib/type-guards'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {sessionAccountToSession} from './agent'
|
||||
import {
|
||||
networkAwareFetch,
|
||||
sessionAccountToSessionData,
|
||||
SessionAgent,
|
||||
} from './session-core'
|
||||
import {type SessionAccount} from './types'
|
||||
|
||||
/*
|
||||
* Canonical implementation moved to session-core.ts so that module stays
|
||||
* dependency-light (this file pulls in agent.ts and, transitively, a large
|
||||
* chunk of the app). Re-exported here for existing consumers.
|
||||
*/
|
||||
export {isSignupQueued} from './session-core'
|
||||
|
||||
export function readLastActiveAccount() {
|
||||
const {currentAccount, accounts} = persisted.get('session')
|
||||
return accounts.find(a => a.did === currentAccount?.did)
|
||||
}
|
||||
|
||||
export function isSignupQueued(accessJwt: string | undefined) {
|
||||
if (accessJwt) {
|
||||
const sessData = jwtDecode(accessJwt)
|
||||
return (
|
||||
hasProp(sessData, 'scope') &&
|
||||
sessData.scope === 'com.atproto.signupQueued'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isSessionExpired(account: SessionAccount) {
|
||||
if (account.accessJwt) {
|
||||
return isJwtExpired(account.accessJwt)
|
||||
@@ -32,30 +30,29 @@ export function isSessionExpired(account: SessionAccount) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and attempted to resumeSession for every stored session.
|
||||
* Intended to be used to send push token revokations just before logout.
|
||||
* Creates and resumes a throwaway session for every stored account.
|
||||
* Intended to send push token revocations just before logout.
|
||||
*
|
||||
* Each returned {@link SessionAgent} wraps a temporary `PasswordSession`
|
||||
* resumed over the network to obtain a valid access token. These sessions are
|
||||
* deliberately hook-free (no `onUpdated`/`onDeleted`): they must NEVER persist
|
||||
* or race the active session. They are used once for the unregister call and
|
||||
* discarded (reclaimed by GC), so we never call `logout()` on them.
|
||||
*/
|
||||
export async function createTemporaryAgentsAndResume(
|
||||
accounts: SessionAccount[],
|
||||
) {
|
||||
const agents = await Promise.allSettled(
|
||||
): Promise<SessionAgent[]> {
|
||||
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 new SessionAgent(session)
|
||||
}),
|
||||
)
|
||||
|
||||
return agents
|
||||
return settled
|
||||
.filter(x => x.status === 'fulfilled')
|
||||
.map(promise => promise.value)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
AppBskyDraftCreateDraft,
|
||||
AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
type AtpAgent,
|
||||
AtUri,
|
||||
ChatBskyGroupDefs,
|
||||
type RichText,
|
||||
@@ -97,7 +96,7 @@ import {
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {type SessionAgent, useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
@@ -2454,7 +2453,7 @@ function useKeyboardVerticalOffset() {
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
uri: string,
|
||||
fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean,
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api'
|
||||
import {type AppBskyVideoDefs, type BlobRef} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
@@ -17,6 +17,7 @@ import {uploadVideo} from '#/lib/media/video/upload'
|
||||
import {createVideoAgent} from '#/lib/media/video/util'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {type SessionAgent} from '#/state/session'
|
||||
|
||||
type CaptionsTrack = {lang: string; file: File}
|
||||
|
||||
@@ -274,7 +275,7 @@ function trunc2dp(num: number) {
|
||||
export async function processVideo(
|
||||
asset: ImagePickerAsset,
|
||||
dispatch: (action: VideoAction) => void,
|
||||
agent: AtpAgent,
|
||||
agent: SessionAgent,
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
i18n: I18n,
|
||||
|
||||
Reference in New Issue
Block a user