import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react' 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 {getPublicLexClient, getUnauthenticatedClient} from './clients' import {type Action, getInitialState, reducer, type State} from './reducer' import { type AtpSessionEvent, buildBundle, createSessionBundleAndCreateAccount, createSessionBundleAndLogin, createSessionBundleAndResume, disposeBundle, makeSessionHooks, type PublicSessionBundle, sessionAccountToSessionData, type SessionBundle, sessionDataToSessionAccount, } from './session-core' export {isSignupQueued} from './util' import {addSessionDebugLog} from './logging' export type {SessionAccount} from '#/state/session/types' import {clearPersistedQueryStorage} from '#/lib/persisted-query-storage' import { type SessionApiContext, type SessionStateContext, } from '#/state/session/types' import {useOnboardingDispatch} from '#/state/shell/onboarding' import { clearAgeAssuranceServerDataForAll, clearAgeAssuranceServerDataForDid, } from '#/ageAssurance/data' const StateContext = createContext({ accounts: [], currentAccount: undefined, hasSession: false, }) StateContext.displayName = 'SessionStateContext' /** * 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. */ const BundleContext = createContext( null, ) BundleContext.displayName = 'SessionBundleContext' const ApiContext = createContext({ createAccount: async () => {}, login: async () => {}, logoutCurrentAccount: () => {}, logoutEveryAccount: () => {}, resumeSession: async () => {}, removeAccount: () => {}, partialRefreshSession: async () => {}, refreshSession: () => Promise.resolve(undefined), }) ApiContext.displayName = 'SessionApiContext' class SessionStore { private state: State private listeners = new Set<() => void>() constructor() { // Careful: By the time this runs, `persisted` needs to already be filled. const initialState = getInitialState(persisted.get('session').accounts) addSessionDebugLog({type: 'reducer:init', state: initialState}) this.state = initialState } getState = (): State => { return this.state } subscribe = (listener: () => void) => { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } dispatch = (action: Action) => { const nextState = reducer(this.state, action) this.state = nextState // Persist synchronously without waiting for the React render cycle. if (nextState.needsPersist) { nextState.needsPersist = false const persistedData = { accounts: nextState.accounts, currentAccount: nextState.accounts.find( a => a.did === nextState.currentAgentState.did, ), } addSessionDebugLog({type: 'persisted:broadcast', data: persistedData}) void persisted.write('session', persistedData) } this.listeners.forEach(listener => listener()) } } export function Provider({children}: React.PropsWithChildren<{}>) { const ax = useAnalyticsBase() const cancelPendingTask = useOneTaskAtATime() // eslint-disable-next-line react/hook-use-state const [store] = useState(() => new SessionStore()) const state = useSyncExternalStore(store.subscribe, store.getState) const onboardingDispatch = useOnboardingDispatch() const onSessionChange = useCallback( ( bundle: SessionBundle, accountDid: string, sessionEvent: AtpSessionEvent, ) => { // Snapshot the (mutable) live session data right away. const refreshedAccount = bundle.session && !bundle.session.destroyed ? sessionDataToSessionAccount( bundle.session.session, bundle.session.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 now hands us the bundle that fired, * so it IS the identity token: a same-bundle event acts on the active * account, a stale (background) bundle does not match and its clears are * ignored - matching the pre-migration semantics exactly. */ store.dispatch({ type: 'received-agent-event', agent: bundle, refreshedAccount, accountDid, sessionEvent, }) }, [store], ) const createAccount = useCallback( async (params, metrics) => { addSessionDebugLog({type: 'method:start', method: 'createAccount'}) const signal = cancelPendingTask() ax.metric('account:create:begin', {}) const {bundle, account} = await createSessionBundleAndCreateAccount( params, onSessionChange, ) if (signal.aborted) { return } store.dispatch({ type: 'switched-to-account', newAgent: bundle, newAccount: account, }) ax.metric('account:create:success', metrics, { session: utils.accountToSessionMetadata(account), }) addSessionDebugLog({type: 'method:end', method: 'createAccount', account}) }, [ax, store, onSessionChange, cancelPendingTask], ) const login = useCallback( async (params, logContext) => { addSessionDebugLog({type: 'method:start', method: 'login'}) const signal = cancelPendingTask() const {bundle, account} = await createSessionBundleAndLogin( params, onSessionChange, ) if (signal.aborted) { return } store.dispatch({ type: 'switched-to-account', newAgent: bundle, newAccount: account, }) ax.metric( 'account:loggedIn', {logContext, withPassword: true}, {session: utils.accountToSessionMetadata(account)}, ) addSessionDebugLog({type: 'method:end', method: 'login', account}) }, [ax, store, onSessionChange, cancelPendingTask], ) const logoutCurrentAccount = useCallback< SessionApiContext['logoutCurrentAccount'] >( logContext => { addSessionDebugLog({type: 'method:start', method: 'logout'}) cancelPendingTask() const prevState = store.getState() store.dispatch({ type: 'logged-out-current-account', }) ax.metric( 'account:loggedOut', {logContext, scope: 'current'}, { session: utils.accountToSessionMetadata( prevState.accounts.find( a => a.did === prevState.currentAgentState.did, ), ), }, ) addSessionDebugLog({type: 'method:end', method: 'logout'}) if (prevState.currentAgentState.did) { clearAgeAssuranceServerDataForDid({ did: prevState.currentAgentState.did, }) void clearPersistedQueryStorage(prevState.currentAgentState.did) } // reset onboarding flow on logout onboardingDispatch({type: 'skip'}) }, [ax, store, cancelPendingTask, onboardingDispatch], ) const logoutEveryAccount = useCallback< SessionApiContext['logoutEveryAccount'] >( logContext => { addSessionDebugLog({type: 'method:start', method: 'logout'}) cancelPendingTask() const prevState = store.getState() store.dispatch({ type: 'logged-out-every-account', }) ax.metric( 'account:loggedOut', {logContext, scope: 'every'}, { session: utils.accountToSessionMetadata( prevState.accounts.find( a => a.did === prevState.currentAgentState.did, ), ), }, ) addSessionDebugLog({type: 'method:end', method: 'logout'}) clearAgeAssuranceServerDataForAll() for (const account of prevState.accounts) { void clearPersistedQueryStorage(account.did) } // reset onboarding flow on logout onboardingDispatch({type: 'skip'}) }, [store, cancelPendingTask, onboardingDispatch, ax], ) const resumeSession = useCallback( async (storedAccount, isSwitchingAccounts = false) => { addSessionDebugLog({ type: 'method:start', method: 'resumeSession', account: storedAccount, }) const signal = cancelPendingTask() const {bundle, account} = await createSessionBundleAndResume( storedAccount, onSessionChange, ) if (signal.aborted) { return } store.dispatch({ type: 'switched-to-account', newAgent: bundle, newAccount: account, }) addSessionDebugLog({type: 'method:end', method: 'resumeSession', account}) if (isSwitchingAccounts) { // reset onboarding flow on switch account onboardingDispatch({type: 'skip'}) } }, [store, onSessionChange, cancelPendingTask, onboardingDispatch], ) const partialRefreshSession = useCallback< SessionApiContext['partialRefreshSession'] >(async () => { const bundle = state.currentAgentState.agent as unknown as SessionBundle const signal = cancelPendingTask() /* * 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 the session. * `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: bundle.session.did, patch: { emailConfirmed: data.emailConfirmed, emailAuthFactor: data.emailAuthFactor, }, }) }, [store, state, cancelPendingTask]) const refreshSession = useCallback< SessionApiContext['refreshSession'] >(async () => { const bundle = store.getState().currentAgentState.agent as unknown as | SessionBundle | PublicSessionBundle if (!bundle.session) return undefined // logged out: nothing to refresh /* * PasswordSession.refresh() re-runs com.atproto.server.refreshSession + * getSession. On success the session's onUpdated hook fires, which the * armed makeSessionHooks wiring maps to an 'update' event; the reducer * snapshots the refreshed account. No explicit dispatch is needed here. * The returned snapshot lets callers read post-refresh fields without * waiting on the (async) reducer update. */ await bundle.session.refresh() return sessionDataToSessionAccount( bundle.session.session, bundle.session.session.service, ) }, [store]) const removeAccount = useCallback( account => { addSessionDebugLog({ type: 'method:start', method: 'removeAccount', account, }) cancelPendingTask() store.dispatch({ type: 'removed-account', accountDid: account.did, }) addSessionDebugLog({type: 'method:end', method: 'removeAccount', account}) clearAgeAssuranceServerDataForDid({did: account.did}) }, [store, cancelPendingTask], ) useEffect(() => { return persisted.onUpdate('session', nextSession => { const synced = nextSession addSessionDebugLog({type: 'persisted:receive', data: synced}) store.dispatch({ type: 'synced-accounts', syncedAccounts: synced.accounts, syncedCurrentDid: synced.currentAccount?.did, }) const syncedAccount = synced.accounts.find( a => a.did === synced.currentAccount?.did, ) if (syncedAccount && syncedAccount.refreshJwt) { if (syncedAccount.did !== state.currentAgentState.did) { /* * Web handling: if leader tab has switched to a diff account that is * stale, it will refresh the session before triggering the update to * follower tabs. Follower tabs will therefore receive the fresh * session. See APP-1960, or ask Eric. */ void resumeSession(syncedAccount) } else { /* * Same account, new tokens synced from the leader tab. PasswordSession * is immutable (no in-place session patch), 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 | PublicSessionBundle let newBundle!: SessionBundle const hooks = makeSessionHooks( onSessionChange, () => newBundle, () => syncedAccount.did, ) const newSession = new PasswordSession( sessionAccountToSessionData(syncedAccount), hooks, ) newBundle = buildBundle(newSession) hooks.arm() addSessionDebugLog({ type: 'agent:patch', agent: newBundle, prevSession: prevBundle.session && !prevBundle.session.destroyed ? prevBundle.session.session : undefined, nextSession: newBundle.session.session, }) store.dispatch({ type: 'replaced-current-bundle', newAgent: newBundle, newAccount: syncedAccount, }) } } }) }, [store, state, resumeSession, onSessionChange]) const stateContext = useMemo( () => ({ accounts: state.accounts, currentAccount: state.accounts.find( a => a.did === state.currentAgentState.did, ), hasSession: !!state.currentAgentState.did, }), [state], ) const api = useMemo( () => ({ createAccount, login, logoutCurrentAccount, logoutEveryAccount, resumeSession, removeAccount, partialRefreshSession, refreshSession, }), [ createAccount, login, logoutCurrentAccount, logoutEveryAccount, resumeSession, removeAccount, partialRefreshSession, refreshSession, ], ) const bundle = state.currentAgentState.agent as unknown as | SessionBundle | PublicSessionBundle // @ts-expect-error window type is not declared, debug only // eslint-disable-next-line react-hooks/immutability if (__DEV__ && IS_WEB) window.bundle = bundle const currentBundleRef = useRef(bundle) useEffect(() => { if (currentBundleRef.current !== bundle) { // Read the previous value and immediately advance the pointer. const prevBundle = currentBundleRef.current currentBundleRef.current = bundle addSessionDebugLog({ type: 'agent:switch', prevAgent: prevBundle, nextAgent: bundle, }) // 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) } }, [bundle]) return ( {children} ) } function useOneTaskAtATime() { const abortController = useRef(null) const cancelPendingTask = useCallback(() => { if (abortController.current) { abortController.current.abort() } abortController.current = new AbortController() return abortController.current.signal }, []) return cancelPendingTask } export function useSession() { return useContext(StateContext) } export function useSessionApi() { return useContext(ApiContext) } export function useRequireAuth() { const {hasSession} = useSession() const closeAll = useCloseAllActiveElements() const {signinDialogControl} = useGlobalDialogsControlContext() return useCallback( (fn: () => unknown) => { if (hasSession) { fn() } else { closeAll() signinDialogControl.open() } }, [hasSession, signinDialogControl, closeAll], ) } /** * 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 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). * * Logged-out contract: returns a stable throwing client * ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any * request, BEFORE any network I/O. This is the write path - it must NOT fall * back to the public appview, so an unauthenticated write fails immediately and * legibly rather than silently hitting `public.api.bsky.app`. Components may * safely hold this client while logged out; only calling it throws. A component * that genuinely branches on auth state should use {@link useMaybePdsClient}. */ export function usePdsClient(): Client { const bundle = useContext(BundleContext) return bundle?.accountClient ?? getUnauthenticatedClient() } /** * The chat lex {@link Client} for the active account. `chat.bsky.*` calls go * here - proxied to `did:web:api.bsky.chat#bsky_chat`. * * Logged-out contract: returns a stable throwing client * ({@link getUnauthenticatedClient}) that throws `NotAuthenticatedError` on any * request, BEFORE any network I/O. Chat is meaningless logged out, so this must * NOT fall back to the public appview. A component that genuinely branches on * auth state should use {@link useMaybeChatClient}. */ export function useChatClient(): Client { const bundle = useContext(BundleContext) return bundle?.chatClient ?? getUnauthenticatedClient() } /** * The account (PDS) lex {@link Client} for the active account, or `null` when * there is no active session (logged out, or used outside the provider). * * The escape hatch for the rare component that genuinely renders a logged-out * branch and must decide whether a write path is available. Prefer * {@link usePdsClient} for the common case (a write only reachable while * authenticated); do NOT reach for this hook merely to dodge the throwing * client's `NotAuthenticatedError`. */ export function useMaybePdsClient(): Client | null { const bundle = useContext(BundleContext) return bundle?.session ? bundle.accountClient : null } /** * The chat lex {@link Client} for the active account, or `null` when there is * no active session (logged out, or used outside the provider). * * The escape hatch for the rare component that genuinely renders a logged-out * branch. Prefer {@link useChatClient} for the common case; do NOT reach for * this hook merely to dodge the throwing client's `NotAuthenticatedError`. */ export function useMaybeChatClient(): Client | null { const bundle = useContext(BundleContext) return bundle?.session ? bundle.chatClient : null }