about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/models/root-store.ts20
-rw-r--r--src/state/models/session.ts443
-rw-r--r--src/state/models/ui/create-account.ts11
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/queries/index.ts5
-rw-r--r--src/state/queries/profile.ts13
-rw-r--r--src/state/session/index.tsx250
7 files changed, 209 insertions, 535 deletions
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index d11e9a148..4085a52c3 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -63,7 +63,6 @@ export class RootStoreModel {
   serialize(): unknown {
     return {
       appInfo: this.appInfo,
-      session: this.session.serialize(),
       me: this.me.serialize(),
       preferences: this.preferences.serialize(),
     }
@@ -80,9 +79,6 @@ export class RootStoreModel {
       if (hasProp(v, 'me')) {
         this.me.hydrate(v.me)
       }
-      if (hasProp(v, 'session')) {
-        this.session.hydrate(v.session)
-      }
       if (hasProp(v, 'preferences')) {
         this.preferences.hydrate(v.preferences)
       }
@@ -92,18 +88,7 @@ export class RootStoreModel {
   /**
    * Called during init to resume any stored session.
    */
-  async attemptSessionResumption() {
-    logger.debug('RootStoreModel:attemptSessionResumption')
-    try {
-      await this.session.attemptSessionResumption()
-      logger.debug('Session initialized', {
-        hasSession: this.session.hasSession,
-      })
-      this.updateSessionState()
-    } catch (e: any) {
-      logger.warn('Failed to initialize session', {error: e})
-    }
-  }
+  async attemptSessionResumption() {}
 
   /**
    * Called by the session model. Refreshes session-oriented state.
@@ -135,11 +120,10 @@ export class RootStoreModel {
   }
 
   /**
-   * Clears all session-oriented state.
+   * Clears all session-oriented state, previously called on LOGOUT
    */
   clearAllSessionState() {
     logger.debug('RootStoreModel:clearAllSessionState')
-    this.session.clear()
     resetToTab('HomeTab')
     this.me.clear()
   }
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 5b95c7d32..2c66cfdf6 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -1,274 +1,26 @@
-import {makeAutoObservable, runInAction} from 'mobx'
+import {makeAutoObservable} from 'mobx'
 import {
   BskyAgent,
-  AtpSessionEvent,
-  AtpSessionData,
   ComAtprotoServerDescribeServer as DescribeServer,
 } from '@atproto/api'
-import normalizeUrl from 'normalize-url'
-import {isObj, hasProp} from 'lib/type-guards'
-import {networkRetry} from 'lib/async/retry'
-import {z} from 'zod'
 import {RootStoreModel} from './root-store'
-import {IS_PROD} from 'lib/constants'
-import {track} from 'lib/analytics/analytics'
-import {logger} from '#/logger'
 
 export type ServiceDescription = DescribeServer.OutputSchema
 
-export const activeSession = z.object({
-  service: z.string(),
-  did: z.string(),
-})
-export type ActiveSession = z.infer<typeof activeSession>
-
-export const accountData = z.object({
-  service: z.string(),
-  refreshJwt: z.string().optional(),
-  accessJwt: z.string().optional(),
-  handle: z.string(),
-  did: z.string(),
-  email: z.string().optional(),
-  displayName: z.string().optional(),
-  aviUrl: z.string().optional(),
-  emailConfirmed: z.boolean().optional(),
-})
-export type AccountData = z.infer<typeof accountData>
-
-interface AdditionalAccountData {
-  displayName?: string
-  aviUrl?: string
-}
-
 export class SessionModel {
-  // DEBUG
-  // emergency log facility to help us track down this logout issue
-  // remove when resolved
-  // -prf
-  _log(message: string, details?: Record<string, any>) {
-    details = details || {}
-    details.state = {
-      data: this.data,
-      accounts: this.accounts.map(
-        a =>
-          `${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${
-            a.service
-          })`,
-      ),
-      isResumingSession: this.isResumingSession,
-    }
-    logger.debug(message, details, logger.DebugContext.session)
-  }
-
-  /**
-   * Currently-active session
-   */
-  data: ActiveSession | null = null
-  /**
-   * A listing of the currently & previous sessions
-   */
-  accounts: AccountData[] = []
-  /**
-   * Flag to indicate if we're doing our initial-load session resumption
-   */
-  isResumingSession = false
-
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
       rootStore: false,
-      serialize: false,
-      hydrate: false,
       hasSession: false,
     })
   }
 
-  get currentSession() {
-    if (!this.data) {
-      return undefined
-    }
-    const {did, service} = this.data
-    return this.accounts.find(
-      account =>
-        normalizeUrl(account.service) === normalizeUrl(service) &&
-        account.did === did &&
-        !!account.accessJwt &&
-        !!account.refreshJwt,
-    )
+  get currentSession(): any {
+    return undefined
   }
 
   get hasSession() {
-    return !!this.currentSession && !!this.rootStore.agent.session
-  }
-
-  get hasAccounts() {
-    return this.accounts.length >= 1
-  }
-
-  get switchableAccounts() {
-    return this.accounts.filter(acct => acct.did !== this.data?.did)
-  }
-
-  get emailNeedsConfirmation() {
-    return !this.currentSession?.emailConfirmed
-  }
-
-  get isSandbox() {
-    if (!this.data) {
-      return false
-    }
-    return !IS_PROD(this.data.service)
-  }
-
-  serialize(): unknown {
-    return {
-      data: this.data,
-      accounts: this.accounts,
-    }
-  }
-
-  hydrate(v: unknown) {
-    this.accounts = []
-    if (isObj(v)) {
-      if (hasProp(v, 'data') && activeSession.safeParse(v.data)) {
-        this.data = v.data as ActiveSession
-      }
-      if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) {
-        for (const account of v.accounts) {
-          if (accountData.safeParse(account)) {
-            this.accounts.push(account as AccountData)
-          }
-        }
-      }
-    }
-  }
-
-  clear() {
-    this.data = null
-  }
-
-  /**
-   * Attempts to resume the previous session loaded from storage
-   */
-  async attemptSessionResumption() {
-    const sess = this.currentSession
-    if (sess) {
-      this._log('SessionModel:attemptSessionResumption found stored session')
-      this.isResumingSession = true
-      try {
-        return await this.resumeSession(sess)
-      } finally {
-        runInAction(() => {
-          this.isResumingSession = false
-        })
-      }
-    } else {
-      this._log(
-        'SessionModel:attemptSessionResumption has no session to resume',
-      )
-    }
-  }
-
-  /**
-   * Sets the active session
-   */
-  async setActiveSession(agent: BskyAgent, did: string) {
-    this._log('SessionModel:setActiveSession')
-    const hadSession = !!this.data
-    this.data = {
-      service: agent.service.toString(),
-      did,
-    }
-    await this.rootStore.handleSessionChange(agent, {hadSession})
-  }
-
-  /**
-   * Upserts a session into the accounts
-   */
-  persistSession(
-    service: string,
-    did: string,
-    event: AtpSessionEvent,
-    session?: AtpSessionData,
-    addedInfo?: AdditionalAccountData,
-  ) {
-    this._log('SessionModel:persistSession', {
-      service,
-      did,
-      event,
-      hasSession: !!session,
-    })
-
-    const existingAccount = this.accounts.find(
-      account => account.service === service && account.did === did,
-    )
-
-    // fall back to any preexisting access tokens
-    let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt
-    let accessJwt = session?.accessJwt || existingAccount?.accessJwt
-    if (event === 'expired') {
-      // only clear the tokens when they're known to have expired
-      refreshJwt = undefined
-      accessJwt = undefined
-    }
-
-    const newAccount = {
-      service,
-      did,
-      refreshJwt,
-      accessJwt,
-
-      handle: session?.handle || existingAccount?.handle || '',
-      email: session?.email || existingAccount?.email || '',
-      displayName: addedInfo
-        ? addedInfo.displayName
-        : existingAccount?.displayName || '',
-      aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '',
-      emailConfirmed: session?.emailConfirmed,
-    }
-    if (!existingAccount) {
-      this.accounts.push(newAccount)
-    } else {
-      this.accounts = [
-        newAccount,
-        ...this.accounts.filter(
-          account => !(account.service === service && account.did === did),
-        ),
-      ]
-    }
-
-    // if the session expired, fire an event to let the user know
-    if (event === 'expired') {
-      this.rootStore.handleSessionDrop()
-    }
-  }
-
-  /**
-   * Clears any session tokens from the accounts; used on logout.
-   */
-  clearSessionTokens() {
-    this._log('SessionModel:clearSessionTokens')
-    this.accounts = this.accounts.map(acct => ({
-      service: acct.service,
-      handle: acct.handle,
-      did: acct.did,
-      displayName: acct.displayName,
-      aviUrl: acct.aviUrl,
-      email: acct.email,
-      emailConfirmed: acct.emailConfirmed,
-    }))
-  }
-
-  /**
-   * Fetches additional information about an account on load.
-   */
-  async loadAccountInfo(agent: BskyAgent, did: string) {
-    const res = await agent.getProfile({actor: did}).catch(_e => undefined)
-    if (res) {
-      return {
-        displayName: res.data.displayName,
-        aviUrl: res.data.avatar,
-      }
-    }
+    return false
   }
 
   /**
@@ -281,192 +33,7 @@ export class SessionModel {
   }
 
   /**
-   * Attempt to resume a session that we still have access tokens for.
-   */
-  async resumeSession(account: AccountData): Promise<boolean> {
-    this._log('SessionModel:resumeSession')
-    if (!(account.accessJwt && account.refreshJwt && account.service)) {
-      this._log(
-        'SessionModel:resumeSession aborted due to lack of access tokens',
-      )
-      return false
-    }
-
-    const agent = new BskyAgent({
-      service: account.service,
-      persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => {
-        this.persistSession(account.service, account.did, evt, sess)
-      },
-    })
-
-    try {
-      await networkRetry(3, () =>
-        agent.resumeSession({
-          accessJwt: account.accessJwt || '',
-          refreshJwt: account.refreshJwt || '',
-          did: account.did,
-          handle: account.handle,
-          email: account.email,
-          emailConfirmed: account.emailConfirmed,
-        }),
-      )
-      const addedInfo = await this.loadAccountInfo(agent, account.did)
-      this.persistSession(
-        account.service,
-        account.did,
-        'create',
-        agent.session,
-        addedInfo,
-      )
-      this._log('SessionModel:resumeSession succeeded')
-    } catch (e: any) {
-      this._log('SessionModel:resumeSession failed', {
-        error: e.toString(),
-      })
-      return false
-    }
-
-    await this.setActiveSession(agent, account.did)
-    return true
-  }
-
-  /**
-   * Create a new session.
-   */
-  async login({
-    service,
-    identifier,
-    password,
-  }: {
-    service: string
-    identifier: string
-    password: string
-  }) {
-    this._log('SessionModel:login')
-    const agent = new BskyAgent({service})
-    await agent.login({identifier, password})
-    if (!agent.session) {
-      throw new Error('Failed to establish session')
-    }
-
-    const did = agent.session.did
-    const addedInfo = await this.loadAccountInfo(agent, did)
-
-    this.persistSession(service, did, 'create', agent.session, addedInfo)
-    agent.setPersistSessionHandler(
-      (evt: AtpSessionEvent, sess?: AtpSessionData) => {
-        this.persistSession(service, did, evt, sess)
-      },
-    )
-
-    await this.setActiveSession(agent, did)
-    this._log('SessionModel:login succeeded')
-  }
-
-  async createAccount({
-    service,
-    email,
-    password,
-    handle,
-    inviteCode,
-  }: {
-    service: string
-    email: string
-    password: string
-    handle: string
-    inviteCode?: string
-  }) {
-    this._log('SessionModel:createAccount')
-    const agent = new BskyAgent({service})
-    await agent.createAccount({
-      handle,
-      password,
-      email,
-      inviteCode,
-    })
-    if (!agent.session) {
-      throw new Error('Failed to establish session')
-    }
-
-    const did = agent.session.did
-    const addedInfo = await this.loadAccountInfo(agent, did)
-
-    this.persistSession(service, did, 'create', agent.session, addedInfo)
-    agent.setPersistSessionHandler(
-      (evt: AtpSessionEvent, sess?: AtpSessionData) => {
-        this.persistSession(service, did, evt, sess)
-      },
-    )
-
-    await this.setActiveSession(agent, did)
-    this._log('SessionModel:createAccount succeeded')
-    track('Create Account Successfully')
-  }
-
-  /**
-   * Close all sessions across all accounts.
-   */
-  async logout() {
-    this._log('SessionModel:logout')
-    // TODO
-    // need to evaluate why deleting the session has caused errors at times
-    // -prf
-    /*if (this.hasSession) {
-      this.rootStore.agent.com.atproto.session.delete().catch((e: any) => {
-        this.rootStore.log.warn(
-          '(Minor issue) Failed to delete session on the server',
-          e,
-        )
-      })
-    }*/
-    this.clearSessionTokens()
-    this.rootStore.clearAllSessionState()
-  }
-
-  /**
-   * Removes an account from the list of stored accounts.
-   */
-  removeAccount(handle: string) {
-    this.accounts = this.accounts.filter(acc => acc.handle !== handle)
-  }
-
-  /**
    * Reloads the session from the server. Useful when account details change, like the handle.
    */
-  async reloadFromServer() {
-    const sess = this.currentSession
-    if (!sess) {
-      return
-    }
-    const res = await this.rootStore.agent
-      .getProfile({actor: sess.did})
-      .catch(_e => undefined)
-    if (res?.success) {
-      const updated = {
-        ...sess,
-        handle: res.data.handle,
-        displayName: res.data.displayName,
-        aviUrl: res.data.avatar,
-      }
-      runInAction(() => {
-        this.accounts = [
-          updated,
-          ...this.accounts.filter(
-            account =>
-              !(
-                account.service === updated.service &&
-                account.did === updated.did
-              ),
-          ),
-        ]
-      })
-      await this.rootStore.me.load()
-    }
-  }
-
-  updateLocalAccountData(changes: Partial<AccountData>) {
-    this.accounts = this.accounts.map(acct =>
-      acct.did === this.data?.did ? {...acct, ...changes} : acct,
-    )
-  }
+  async reloadFromServer() {}
 }
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index 39c881db6..6d76784c1 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -10,6 +10,7 @@ import {getAge} from 'lib/strings/time'
 import {track} from 'lib/analytics/analytics'
 import {logger} from '#/logger'
 import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
+import {ApiContext as SessionApiContext} from '#/state/session'
 
 const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
 
@@ -91,7 +92,13 @@ export class CreateAccountModel {
     }
   }
 
-  async submit(onboardingDispatch: OnboardingDispatchContext) {
+  async submit({
+    createAccount,
+    onboardingDispatch,
+  }: {
+    createAccount: SessionApiContext['createAccount']
+    onboardingDispatch: OnboardingDispatchContext
+  }) {
     if (!this.email) {
       this.setStep(2)
       return this.setError('Please enter your email.')
@@ -113,7 +120,7 @@ export class CreateAccountModel {
 
     try {
       onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
-      await this.rootStore.session.createAccount({
+      await createAccount({
         service: this.serviceUrl,
         email: this.email,
         handle: createFullHandle(this.handle, this.userDomain),
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index a510262fb..93547aa5b 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -7,6 +7,8 @@ const accountSchema = z.object({
   service: z.string(),
   did: z.string(),
   handle: z.string(),
+  email: z.string(),
+  emailConfirmed: z.boolean(),
   refreshJwt: z.string().optional(), // optional because it can expire
   accessJwt: z.string().optional(), // optional because it can expire
   // displayName: z.string().optional(),
diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts
new file mode 100644
index 000000000..ae3d1595c
--- /dev/null
+++ b/src/state/queries/index.ts
@@ -0,0 +1,5 @@
+import {BskyAgent} from '@atproto/api'
+
+export const PUBLIC_BSKY_AGENT = new BskyAgent({
+  service: 'https://api.bsky.app',
+})
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
new file mode 100644
index 000000000..c2cd19482
--- /dev/null
+++ b/src/state/queries/profile.ts
@@ -0,0 +1,13 @@
+import {useQuery} from '@tanstack/react-query'
+
+import {PUBLIC_BSKY_AGENT} from '#/state/queries'
+
+export function useProfileQuery({did}: {did: string}) {
+  return useQuery({
+    queryKey: ['getProfile', did],
+    queryFn: async () => {
+      const res = await PUBLIC_BSKY_AGENT.getProfile({actor: did})
+      return res.data
+    },
+  })
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 8e1f9c1a1..d0ca10137 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,18 +1,25 @@
 import React from 'react'
+import {DeviceEventEmitter} from 'react-native'
 import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
 
 import {networkRetry} from '#/lib/async/retry'
 import {logger} from '#/logger'
 import * as persisted from '#/state/persisted'
+import {PUBLIC_BSKY_AGENT} from '#/state/queries'
+import {IS_PROD} from '#/lib/constants'
 
 export type SessionAccount = persisted.PersistedAccount
 
-export type StateContext = {
-  isInitialLoad: boolean
+export type SessionState = {
   agent: BskyAgent
+  isInitialLoad: boolean
+  isSwitchingAccounts: boolean
   accounts: persisted.PersistedAccount[]
   currentAccount: persisted.PersistedAccount | undefined
+}
+export type StateContext = SessionState & {
   hasSession: boolean
+  isSandbox: boolean
 }
 export type ApiContext = {
   createAccount: (props: {
@@ -28,26 +35,26 @@ export type ApiContext = {
     password: string
   }) => Promise<void>
   logout: () => Promise<void>
-  initSession: (account: persisted.PersistedAccount) => Promise<void>
-  resumeSession: (account?: persisted.PersistedAccount) => Promise<void>
-  removeAccount: (
-    account: Partial<Pick<persisted.PersistedAccount, 'handle' | 'did'>>,
-  ) => void
+  initSession: (account: SessionAccount) => Promise<void>
+  resumeSession: (account?: SessionAccount) => Promise<void>
+  removeAccount: (account: SessionAccount) => void
+  selectAccount: (account: SessionAccount) => Promise<void>
   updateCurrentAccount: (
-    account: Pick<persisted.PersistedAccount, 'handle'>,
+    account: Partial<
+      Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
+    >,
   ) => void
+  clearCurrentAccount: () => void
 }
 
-export const PUBLIC_BSKY_AGENT = new BskyAgent({
-  service: 'https://api.bsky.app',
-})
-
 const StateContext = React.createContext<StateContext>({
-  hasSession: false,
+  agent: PUBLIC_BSKY_AGENT,
   isInitialLoad: true,
+  isSwitchingAccounts: false,
   accounts: [],
   currentAccount: undefined,
-  agent: PUBLIC_BSKY_AGENT,
+  hasSession: false,
+  isSandbox: false,
 })
 
 const ApiContext = React.createContext<ApiContext>({
@@ -57,7 +64,9 @@ const ApiContext = React.createContext<ApiContext>({
   initSession: async () => {},
   resumeSession: async () => {},
   removeAccount: () => {},
+  selectAccount: async () => {},
   updateCurrentAccount: () => {},
+  clearCurrentAccount: () => {},
 })
 
 function createPersistSessionHandler(
@@ -73,15 +82,23 @@ function createPersistSessionHandler(
       service: account.service,
       did: session?.did || account.did,
       handle: session?.handle || account.handle,
+      email: session?.email || account.email,
+      emailConfirmed: session?.emailConfirmed || account.emailConfirmed,
       refreshJwt: session?.refreshJwt, // undefined when expired or creation fails
       accessJwt: session?.accessJwt, // undefined when expired or creation fails
     }
 
-    logger.debug(`session: BskyAgent.persistSession`, {
-      expired,
-      did: refreshedAccount.did,
-      handle: refreshedAccount.handle,
-    })
+    logger.debug(
+      `session: BskyAgent.persistSession`,
+      {
+        expired,
+        did: refreshedAccount.did,
+        handle: refreshedAccount.handle,
+      },
+      logger.DebugContext.session,
+    )
+
+    if (expired) DeviceEventEmitter.emit('session-dropped')
 
     persistSessionCallback({
       expired,
@@ -91,17 +108,26 @@ function createPersistSessionHandler(
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const [state, setState] = React.useState<StateContext>({
-    hasSession: false,
+  const isDirty = React.useRef(false)
+  const [state, setState] = React.useState<SessionState>({
+    agent: PUBLIC_BSKY_AGENT,
     isInitialLoad: true, // try to resume the session first
+    isSwitchingAccounts: false,
     accounts: persisted.get('session').accounts,
     currentAccount: undefined, // assume logged out to start
-    agent: PUBLIC_BSKY_AGENT,
   })
 
+  const setStateAndPersist = React.useCallback(
+    (fn: (prev: SessionState) => SessionState) => {
+      isDirty.current = true
+      setState(fn)
+    },
+    [setState],
+  )
+
   const upsertAccount = React.useCallback(
     (account: persisted.PersistedAccount, expired = false) => {
-      setState(s => {
+      setStateAndPersist(s => {
         return {
           ...s,
           currentAccount: expired ? undefined : account,
@@ -109,16 +135,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         }
       })
     },
-    [setState],
+    [setStateAndPersist],
   )
 
-  // TODO have not connected this yet
   const createAccount = React.useCallback<ApiContext['createAccount']>(
     async ({service, email, password, handle, inviteCode}: any) => {
-      logger.debug(`session: creating account`, {
-        service,
-        handle,
-      })
+      logger.debug(
+        `session: creating account`,
+        {
+          service,
+          handle,
+        },
+        logger.DebugContext.session,
+      )
 
       const agent = new BskyAgent({service})
 
@@ -136,9 +165,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       const account: persisted.PersistedAccount = {
         service,
         did: agent.session.did,
+        handle: agent.session.handle,
+        email: agent.session.email!, // TODO this is always defined?
+        emailConfirmed: false,
         refreshJwt: agent.session.refreshJwt,
         accessJwt: agent.session.accessJwt,
-        handle: agent.session.handle,
       }
 
       agent.setPersistSessionHandler(
@@ -149,20 +180,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
       upsertAccount(account)
 
-      logger.debug(`session: created account`, {
-        service,
-        handle,
-      })
+      logger.debug(
+        `session: created account`,
+        {
+          service,
+          handle,
+        },
+        logger.DebugContext.session,
+      )
     },
     [upsertAccount],
   )
 
   const login = React.useCallback<ApiContext['login']>(
     async ({service, identifier, password}) => {
-      logger.debug(`session: login`, {
-        service,
-        identifier,
-      })
+      logger.debug(
+        `session: login`,
+        {
+          service,
+          identifier,
+        },
+        logger.DebugContext.session,
+      )
 
       const agent = new BskyAgent({service})
 
@@ -175,9 +214,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       const account: persisted.PersistedAccount = {
         service,
         did: agent.session.did,
+        handle: agent.session.handle,
+        email: agent.session.email!, // TODO this is always defined?
+        emailConfirmed: agent.session.emailConfirmed || false,
         refreshJwt: agent.session.refreshJwt,
         accessJwt: agent.session.accessJwt,
-        handle: agent.session.handle,
       }
 
       agent.setPersistSessionHandler(
@@ -189,17 +230,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       setState(s => ({...s, agent}))
       upsertAccount(account)
 
-      logger.debug(`session: logged in`, {
-        service,
-        identifier,
-      })
+      logger.debug(
+        `session: logged in`,
+        {
+          service,
+          identifier,
+        },
+        logger.DebugContext.session,
+      )
     },
     [upsertAccount],
   )
 
   const logout = React.useCallback<ApiContext['logout']>(async () => {
-    logger.debug(`session: logout`)
-    setState(s => {
+    logger.debug(`session: logout`, {}, logger.DebugContext.session)
+    setStateAndPersist(s => {
       return {
         ...s,
         agent: PUBLIC_BSKY_AGENT,
@@ -211,14 +256,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         })),
       }
     })
-  }, [setState])
+  }, [setStateAndPersist])
 
   const initSession = React.useCallback<ApiContext['initSession']>(
     async account => {
-      logger.debug(`session: initSession`, {
-        did: account.did,
-        handle: account.handle,
-      })
+      logger.debug(
+        `session: initSession`,
+        {
+          did: account.did,
+          handle: account.handle,
+        },
+        logger.DebugContext.session,
+      )
 
       const agent = new BskyAgent({
         service: account.service,
@@ -265,23 +314,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
   const removeAccount = React.useCallback<ApiContext['removeAccount']>(
     account => {
-      setState(s => {
+      setStateAndPersist(s => {
         return {
           ...s,
-          accounts: s.accounts.filter(
-            a => !(a.did === account.did || a.handle === account.handle),
-          ),
+          accounts: s.accounts.filter(a => a.did !== account.did),
         }
       })
     },
-    [setState],
+    [setStateAndPersist],
   )
 
   const updateCurrentAccount = React.useCallback<
     ApiContext['updateCurrentAccount']
   >(
     account => {
-      setState(s => {
+      setStateAndPersist(s => {
         const currentAccount = s.currentAccount
 
         // ignore, should never happen
@@ -289,52 +336,94 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
 
         const updatedAccount = {
           ...currentAccount,
-          handle: account.handle, // only update handle rn
+          handle: account.handle || currentAccount.handle,
+          email: account.email || currentAccount.email,
+          emailConfirmed:
+            account.emailConfirmed !== undefined
+              ? account.emailConfirmed
+              : currentAccount.emailConfirmed,
         }
 
         return {
           ...s,
           currentAccount: updatedAccount,
-          accounts: s.accounts.filter(a => a.did !== currentAccount.did),
+          accounts: [
+            updatedAccount,
+            ...s.accounts.filter(a => a.did !== currentAccount.did),
+          ],
         }
       })
     },
-    [setState],
+    [setStateAndPersist],
+  )
+
+  const selectAccount = React.useCallback<ApiContext['selectAccount']>(
+    async account => {
+      setState(s => ({...s, isSwitchingAccounts: true}))
+      try {
+        await initSession(account)
+        setState(s => ({...s, isSwitchingAccounts: false}))
+      } catch (e) {
+        // reset this in case of error
+        setState(s => ({...s, isSwitchingAccounts: false}))
+        // but other listeners need a throw
+        throw e
+      }
+    },
+    [setState, initSession],
   )
 
+  const clearCurrentAccount = React.useCallback(() => {
+    setStateAndPersist(s => ({
+      ...s,
+      currentAccount: undefined,
+    }))
+  }, [setStateAndPersist])
+
   React.useEffect(() => {
-    persisted.write('session', {
-      accounts: state.accounts,
-      currentAccount: state.currentAccount,
-    })
+    if (isDirty.current) {
+      isDirty.current = false
+      persisted.write('session', {
+        accounts: state.accounts,
+        currentAccount: state.currentAccount,
+      })
+    }
   }, [state])
 
   React.useEffect(() => {
     return persisted.onUpdate(() => {
       const session = persisted.get('session')
 
-      logger.debug(`session: onUpdate`)
+      logger.debug(`session: onUpdate`, {}, logger.DebugContext.session)
 
       if (session.currentAccount) {
         if (session.currentAccount?.did !== state.currentAccount?.did) {
-          logger.debug(`session: switching account`, {
-            from: {
-              did: state.currentAccount?.did,
-              handle: state.currentAccount?.handle,
-            },
-            to: {
-              did: session.currentAccount.did,
-              handle: session.currentAccount.handle,
+          logger.debug(
+            `session: switching account`,
+            {
+              from: {
+                did: state.currentAccount?.did,
+                handle: state.currentAccount?.handle,
+              },
+              to: {
+                did: session.currentAccount.did,
+                handle: session.currentAccount.handle,
+              },
             },
-          })
+            logger.DebugContext.session,
+          )
 
           initSession(session.currentAccount)
         }
       } else if (!session.currentAccount && state.currentAccount) {
-        logger.debug(`session: logging out`, {
-          did: state.currentAccount?.did,
-          handle: state.currentAccount?.handle,
-        })
+        logger.debug(
+          `session: logging out`,
+          {
+            did: state.currentAccount?.did,
+            handle: state.currentAccount?.handle,
+          },
+          logger.DebugContext.session,
+        )
 
         logout()
       }
@@ -345,6 +434,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     () => ({
       ...state,
       hasSession: !!state.currentAccount,
+      isSandbox: state.currentAccount
+        ? !IS_PROD(state.currentAccount?.service)
+        : false,
     }),
     [state],
   )
@@ -357,7 +449,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       initSession,
       resumeSession,
       removeAccount,
+      selectAccount,
       updateCurrentAccount,
+      clearCurrentAccount,
     }),
     [
       createAccount,
@@ -366,7 +460,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       initSession,
       resumeSession,
       removeAccount,
+      selectAccount,
       updateCurrentAccount,
+      clearCurrentAccount,
     ],
   )