about summary refs log tree commit diff
path: root/src/state/session/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/session/index.tsx')
-rw-r--r--src/state/session/index.tsx366
1 files changed, 70 insertions, 296 deletions
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 0a015d56e..a8bd90ca1 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -2,9 +2,7 @@ import React from 'react'
 import {AtpSessionEvent, BskyAgent} from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
-import {networkRetry} from '#/lib/async/retry'
-import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
-import {logEvent, tryFetchGates} from '#/lib/statsig/statsig'
+import {logEvent} from '#/lib/statsig/statsig'
 import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -13,22 +11,15 @@ import {IS_DEV} from '#/env'
 import {emitSessionDropped} from '../events'
 import {
   agentToSessionAccount,
-  configureModerationForAccount,
-  configureModerationForGuest,
   createAgentAndCreateAccount,
   createAgentAndLogin,
-  isSessionDeactivated,
-  isSessionExpired,
-} from './util'
+  createAgentAndResume,
+} from './agent'
+import {getInitialState, reducer} from './reducer'
 
+export {isSessionDeactivated} from './util'
 export type {SessionAccount} from '#/state/session/types'
-import {
-  SessionAccount,
-  SessionApiContext,
-  SessionStateContext,
-} from '#/state/session/types'
-
-export {isSessionDeactivated}
+import {SessionApiContext, SessionStateContext} from '#/state/session/types'
 
 const StateContext = React.createContext<SessionStateContext>({
   accounts: [],
@@ -42,190 +33,16 @@ const ApiContext = React.createContext<SessionApiContext>({
   createAccount: async () => {},
   login: async () => {},
   logout: async () => {},
-  initSession: async () => {},
+  resumeSession: async () => {},
   removeAccount: () => {},
   updateCurrentAccount: () => {},
 })
 
-type AgentState = {
-  readonly agent: BskyAgent
-  readonly did: string | undefined
-}
-
-type State = {
-  accounts: SessionStateContext['accounts']
-  currentAgentState: AgentState
-  needsPersist: boolean
-}
-
-type Action =
-  | {
-      type: 'received-agent-event'
-      agent: BskyAgent
-      accountDid: string
-      refreshedAccount: SessionAccount | undefined
-      sessionEvent: AtpSessionEvent
-    }
-  | {
-      type: 'switched-to-account'
-      newAgent: BskyAgent
-      newAccount: SessionAccount
-    }
-  | {
-      type: 'updated-current-account'
-      updatedFields: Partial<
-        Pick<
-          SessionAccount,
-          'handle' | 'email' | 'emailConfirmed' | 'emailAuthFactor'
-        >
-      >
-    }
-  | {
-      type: 'removed-account'
-      accountDid: string
-    }
-  | {
-      type: 'logged-out'
-    }
-  | {
-      type: 'synced-accounts'
-      syncedAccounts: SessionAccount[]
-      syncedCurrentDid: string | undefined
-    }
-
-function createPublicAgentState() {
-  configureModerationForGuest() // Side effect but only relevant for tests
-  return {
-    agent: new BskyAgent({service: PUBLIC_BSKY_SERVICE}),
-    did: undefined,
-  }
-}
-
-function getInitialState(): State {
-  return {
-    accounts: persisted.get('session').accounts,
-    currentAgentState: createPublicAgentState(),
-    needsPersist: false,
-  }
-}
-
-function reducer(state: State, action: Action): State {
-  switch (action.type) {
-    case 'received-agent-event': {
-      const {agent, accountDid, refreshedAccount, sessionEvent} = action
-      if (agent !== state.currentAgentState.agent) {
-        // Only consider events from the active agent.
-        return state
-      }
-      if (sessionEvent === 'network-error') {
-        // Don't change stored accounts but kick to the choose account screen.
-        return {
-          accounts: state.accounts,
-          currentAgentState: createPublicAgentState(),
-          needsPersist: true,
-        }
-      }
-      const existingAccount = state.accounts.find(a => a.did === accountDid)
-      if (
-        !existingAccount ||
-        JSON.stringify(existingAccount) === JSON.stringify(refreshedAccount)
-      ) {
-        // Fast path without a state update.
-        return state
-      }
-      return {
-        accounts: state.accounts.map(a => {
-          if (a.did === accountDid) {
-            if (refreshedAccount) {
-              return refreshedAccount
-            } else {
-              return {
-                ...a,
-                // If we didn't receive a refreshed account, clear out the tokens.
-                accessJwt: undefined,
-                refreshJwt: undefined,
-              }
-            }
-          } else {
-            return a
-          }
-        }),
-        currentAgentState: refreshedAccount
-          ? state.currentAgentState
-          : createPublicAgentState(), // Log out if expired.
-        needsPersist: true,
-      }
-    }
-    case 'switched-to-account': {
-      const {newAccount, newAgent} = action
-      return {
-        accounts: [
-          newAccount,
-          ...state.accounts.filter(a => a.did !== newAccount.did),
-        ],
-        currentAgentState: {
-          did: newAccount.did,
-          agent: newAgent,
-        },
-        needsPersist: true,
-      }
-    }
-    case 'updated-current-account': {
-      const {updatedFields} = action
-      return {
-        accounts: state.accounts.map(a => {
-          if (a.did === state.currentAgentState.did) {
-            return {
-              ...a,
-              ...updatedFields,
-            }
-          } else {
-            return a
-          }
-        }),
-        currentAgentState: state.currentAgentState,
-        needsPersist: true,
-      }
-    }
-    case 'removed-account': {
-      const {accountDid} = action
-      return {
-        accounts: state.accounts.filter(a => a.did !== accountDid),
-        currentAgentState:
-          state.currentAgentState.did === accountDid
-            ? createPublicAgentState() // Log out if removing the current one.
-            : state.currentAgentState,
-        needsPersist: true,
-      }
-    }
-    case 'logged-out': {
-      return {
-        accounts: state.accounts.map(a => ({
-          ...a,
-          // Clear tokens for *every* account (this is a hard logout).
-          refreshJwt: undefined,
-          accessJwt: undefined,
-        })),
-        currentAgentState: createPublicAgentState(),
-        needsPersist: true,
-      }
-    }
-    case 'synced-accounts': {
-      const {syncedAccounts, syncedCurrentDid} = action
-      return {
-        accounts: syncedAccounts,
-        currentAgentState:
-          syncedCurrentDid === state.currentAgentState.did
-            ? state.currentAgentState
-            : createPublicAgentState(), // Log out if different user.
-        needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
-      }
-    }
-  }
-}
-
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const [state, dispatch] = React.useReducer(reducer, null, getInitialState)
+  const cancelPendingTask = useOneTaskAtATime()
+  const [state, dispatch] = React.useReducer(reducer, null, () =>
+    getInitialState(persisted.get('session').accounts),
+  )
 
   const onAgentSessionChange = React.useCallback(
     (agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
@@ -245,34 +62,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 
   const createAccount = React.useCallback<SessionApiContext['createAccount']>(
-    async ({
-      service,
-      email,
-      password,
-      handle,
-      birthDate,
-      inviteCode,
-      verificationPhone,
-      verificationCode,
-    }) => {
+    async params => {
+      const signal = cancelPendingTask()
       track('Try Create Account')
       logEvent('account:create:begin', {})
-      const {agent, account, fetchingGates} = await createAgentAndCreateAccount(
-        {
-          service,
-          email,
-          password,
-          handle,
-          birthDate,
-          inviteCode,
-          verificationPhone,
-          verificationCode,
-        },
+      const {agent, account} = await createAgentAndCreateAccount(
+        params,
+        onAgentSessionChange,
       )
-      agent.setPersistSessionHandler(event => {
-        onAgentSessionChange(agent, account.did, event)
-      })
-      await fetchingGates
+
+      if (signal.aborted) {
+        return
+      }
       dispatch({
         type: 'switched-to-account',
         newAgent: agent,
@@ -281,21 +82,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       track('Create Account')
       logEvent('account:create:success', {})
     },
-    [onAgentSessionChange],
+    [onAgentSessionChange, cancelPendingTask],
   )
 
   const login = React.useCallback<SessionApiContext['login']>(
-    async ({service, identifier, password, authFactorToken}, logContext) => {
-      const {agent, account, fetchingGates} = await createAgentAndLogin({
-        service,
-        identifier,
-        password,
-        authFactorToken,
-      })
-      agent.setPersistSessionHandler(event => {
-        onAgentSessionChange(agent, account.did, event)
-      })
-      await fetchingGates
+    async (params, logContext) => {
+      const signal = cancelPendingTask()
+      const {agent, account} = await createAgentAndLogin(
+        params,
+        onAgentSessionChange,
+      )
+
+      if (signal.aborted) {
+        return
+      }
       dispatch({
         type: 'switched-to-account',
         newAgent: agent,
@@ -304,88 +104,49 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       track('Sign In', {resumedSession: false})
       logEvent('account:loggedIn', {logContext, withPassword: true})
     },
-    [onAgentSessionChange],
+    [onAgentSessionChange, cancelPendingTask],
   )
 
   const logout = React.useCallback<SessionApiContext['logout']>(
-    async logContext => {
+    logContext => {
+      cancelPendingTask()
       dispatch({
         type: 'logged-out',
       })
       logEvent('account:loggedOut', {logContext})
     },
-    [],
+    [cancelPendingTask],
   )
 
-  const initSession = React.useCallback<SessionApiContext['initSession']>(
-    async account => {
-      const fetchingGates = tryFetchGates(account.did, 'prefer-low-latency')
-      const agent = new BskyAgent({service: account.service})
-      // restore the correct PDS URL if available
-      if (account.pdsUrl) {
-        agent.pdsUrl = agent.api.xrpc.uri = new URL(account.pdsUrl)
-      }
-      agent.setPersistSessionHandler(event => {
-        onAgentSessionChange(agent, account.did, event)
-      })
-      await configureModerationForAccount(agent, account)
-
-      const prevSession = {
-        accessJwt: account.accessJwt ?? '',
-        refreshJwt: account.refreshJwt ?? '',
-        did: account.did,
-        handle: account.handle,
-      }
-
-      if (isSessionExpired(account)) {
-        const freshAccount = await resumeSessionWithFreshAccount()
-        await fetchingGates
-        dispatch({
-          type: 'switched-to-account',
-          newAgent: agent,
-          newAccount: freshAccount,
-        })
-      } else {
-        agent.session = prevSession
-        await fetchingGates
-        dispatch({
-          type: 'switched-to-account',
-          newAgent: agent,
-          newAccount: account,
-        })
-        if (isSessionDeactivated(account.accessJwt) || account.deactivated) {
-          // don't attempt to resume
-          // use will be taken to the deactivated screen
-          return
-        }
-        // Intentionally not awaited to unblock the UI:
-        resumeSessionWithFreshAccount()
-      }
+  const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
+    async storedAccount => {
+      const signal = cancelPendingTask()
+      const {agent, account} = await createAgentAndResume(
+        storedAccount,
+        onAgentSessionChange,
+      )
 
-      async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
-        await networkRetry(1, () => agent.resumeSession(prevSession))
-        const sessionAccount = agentToSessionAccount(agent)
-        /*
-         * If `agent.resumeSession` fails above, it'll throw. This is just to
-         * make TypeScript happy.
-         */
-        if (!sessionAccount) {
-          throw new Error(`session: initSession failed to establish a session`)
-        }
-        return sessionAccount
+      if (signal.aborted) {
+        return
       }
+      dispatch({
+        type: 'switched-to-account',
+        newAgent: agent,
+        newAccount: account,
+      })
     },
-    [onAgentSessionChange],
+    [onAgentSessionChange, cancelPendingTask],
   )
 
   const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
     account => {
+      cancelPendingTask()
       dispatch({
         type: 'removed-account',
         accountDid: account.did,
       })
     },
-    [],
+    [cancelPendingTask],
   )
 
   const updateCurrentAccount = React.useCallback<
@@ -422,14 +183,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       )
       if (syncedAccount && syncedAccount.refreshJwt) {
         if (syncedAccount.did !== state.currentAgentState.did) {
-          initSession(syncedAccount)
+          resumeSession(syncedAccount)
         } else {
           // @ts-ignore we checked for `refreshJwt` above
           state.currentAgentState.agent.session = syncedAccount
         }
       }
     })
-  }, [state, initSession])
+  }, [state, resumeSession])
 
   const stateContext = React.useMemo(
     () => ({
@@ -447,7 +208,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       createAccount,
       login,
       logout,
-      initSession,
+      resumeSession,
       removeAccount,
       updateCurrentAccount,
     }),
@@ -455,7 +216,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       createAccount,
       login,
       logout,
-      initSession,
+      resumeSession,
       removeAccount,
       updateCurrentAccount,
     ],
@@ -464,8 +225,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   // @ts-ignore
   if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent
 
+  const agent = state.currentAgentState.agent as BskyAgent
   return (
-    <AgentContext.Provider value={state.currentAgentState.agent}>
+    <AgentContext.Provider value={agent}>
       <StateContext.Provider value={stateContext}>
         <ApiContext.Provider value={api}>{children}</ApiContext.Provider>
       </StateContext.Provider>
@@ -473,6 +235,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 }
 
+function useOneTaskAtATime() {
+  const abortController = React.useRef<AbortController | null>(null)
+  const cancelPendingTask = React.useCallback(() => {
+    if (abortController.current) {
+      abortController.current.abort()
+    }
+    abortController.current = new AbortController()
+    return abortController.current.signal
+  }, [])
+  return cancelPendingTask
+}
+
 export function useSession() {
   return React.useContext(StateContext)
 }