about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/constants.ts5
-rw-r--r--src/state/models/root-store.ts20
-rw-r--r--src/state/models/session.ts443
-rw-r--r--src/state/session/index.tsx36
-rw-r--r--src/view/com/auth/login/ChooseAccountForm.tsx3
-rw-r--r--src/view/com/auth/login/Login.tsx5
-rw-r--r--src/view/com/feeds/FeedPage.tsx15
-rw-r--r--src/view/com/modals/SwitchAccount.tsx2
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx4
-rw-r--r--src/view/com/testing/TestCtrls.e2e.tsx8
-rw-r--r--src/view/com/util/AccountDropdownBtn.tsx6
-rw-r--r--src/view/com/util/PostSandboxWarning.tsx6
-rw-r--r--src/view/screens/Settings.tsx2
-rw-r--r--src/view/shell/Drawer.tsx98
-rw-r--r--src/view/shell/desktop/RightNav.tsx5
15 files changed, 126 insertions, 532 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 472b59d76..89c441e98 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -43,7 +43,10 @@ export function IS_PROD(url: string) {
   // until open federation, "production" is defined as the main server
   // this definition will not work once federation is enabled!
   // -prf
-  return url.startsWith('https://bsky.social')
+  return (
+    url.startsWith('https://bsky.social') ||
+    url.startsWith('https://api.bsky.app')
+  )
 }
 
 export const PROD_TEAM_HANDLES = [
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/session/index.tsx b/src/state/session/index.tsx
index 90948b01e..d0ca10137 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,20 +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 = {
+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: {
@@ -30,15 +35,13 @@ 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
-  selectAccount: (account: persisted.PersistedAccount) => Promise<void>
+  initSession: (account: SessionAccount) => Promise<void>
+  resumeSession: (account?: SessionAccount) => Promise<void>
+  removeAccount: (account: SessionAccount) => void
+  selectAccount: (account: SessionAccount) => Promise<void>
   updateCurrentAccount: (
     account: Partial<
-      Pick<persisted.PersistedAccount, 'handle' | 'email' | 'emailConfirmed'>
+      Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
     >,
   ) => void
   clearCurrentAccount: () => void
@@ -46,11 +49,12 @@ export type ApiContext = {
 
 const StateContext = React.createContext<StateContext>({
   agent: PUBLIC_BSKY_AGENT,
-  hasSession: false,
   isInitialLoad: true,
   isSwitchingAccounts: false,
   accounts: [],
   currentAccount: undefined,
+  hasSession: false,
+  isSandbox: false,
 })
 
 const ApiContext = React.createContext<ApiContext>({
@@ -94,6 +98,8 @@ function createPersistSessionHandler(
       logger.DebugContext.session,
     )
 
+    if (expired) DeviceEventEmitter.emit('session-dropped')
+
     persistSessionCallback({
       expired,
       refreshedAccount,
@@ -103,9 +109,8 @@ function createPersistSessionHandler(
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   const isDirty = React.useRef(false)
-  const [state, setState] = React.useState<StateContext>({
+  const [state, setState] = React.useState<SessionState>({
     agent: PUBLIC_BSKY_AGENT,
-    hasSession: false,
     isInitialLoad: true, // try to resume the session first
     isSwitchingAccounts: false,
     accounts: persisted.get('session').accounts,
@@ -113,7 +118,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   })
 
   const setStateAndPersist = React.useCallback(
-    (fn: (prev: StateContext) => StateContext) => {
+    (fn: (prev: SessionState) => SessionState) => {
       isDirty.current = true
       setState(fn)
     },
@@ -312,9 +317,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       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),
         }
       })
     },
@@ -431,6 +434,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
     () => ({
       ...state,
       hasSession: !!state.currentAccount,
+      isSandbox: state.currentAccount
+        ? !IS_PROD(state.currentAccount?.service)
+        : false,
     }),
     [state],
   )
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
index c4a3b158c..8c94ef2da 100644
--- a/src/view/com/auth/login/ChooseAccountForm.tsx
+++ b/src/view/com/auth/login/ChooseAccountForm.tsx
@@ -5,7 +5,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {Text} from '../../util/text/Text'
 import {UserAvatar} from '../../util/UserAvatar'
 import {s} from 'lib/styles'
-import {AccountData} from 'state/models/session'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -62,7 +61,7 @@ export const ChooseAccountForm = ({
   onSelectAccount,
   onPressBack,
 }: {
-  onSelectAccount: (account?: AccountData) => void
+  onSelectAccount: (account?: SessionAccount) => void
   onPressBack: () => void
 }) => {
   const {track, screen} = useAnalytics()
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index de00d6ca2..27d08812c 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -4,7 +4,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
 import {useStores, DEFAULT_SERVICE} from 'state/index'
 import {ServiceDescription} from 'state/models/session'
-import {AccountData} from 'state/models/session'
 import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
 import {ChooseAccountForm} from './ChooseAccountForm'
@@ -14,7 +13,7 @@ import {SetNewPasswordForm} from './SetNewPasswordForm'
 import {PasswordUpdatedForm} from './PasswordUpdatedForm'
 import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
-import {useSession} from '#/state/session'
+import {useSession, SessionAccount} from '#/state/session'
 
 enum Forms {
   Login,
@@ -41,7 +40,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
     accounts.length ? Forms.ChooseAccount : Forms.Login,
   )
 
-  const onSelectAccount = (account?: AccountData) => {
+  const onSelectAccount = (account?: SessionAccount) => {
     if (account?.service) {
       setServiceUrl(account.service)
     }
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index ffae6cbf4..6a846f677 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -23,6 +23,7 @@ import useAppState from 'react-native-appstate-hook'
 import {logger} from '#/logger'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
 
 export const FeedPage = observer(function FeedPageImpl({
   testID,
@@ -38,6 +39,7 @@ export const FeedPage = observer(function FeedPageImpl({
   renderEndOfFeed?: () => JSX.Element
 }) {
   const store = useStores()
+  const {isSandbox} = useSession()
   const pal = usePalette('default')
   const {_} = useLingui()
   const {isDesktop} = useWebMediaQueries()
@@ -140,7 +142,7 @@ export const FeedPage = observer(function FeedPageImpl({
             style={[pal.text, {fontWeight: 'bold'}]}
             text={
               <>
-                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+                {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
                 {hasNew && (
                   <View
                     style={{
@@ -173,7 +175,16 @@ export const FeedPage = observer(function FeedPageImpl({
       )
     }
     return <></>
-  }, [isDesktop, pal.view, pal.text, pal.textLight, store, hasNew, _])
+  }, [
+    isDesktop,
+    pal.view,
+    pal.text,
+    pal.textLight,
+    store,
+    hasNew,
+    _,
+    isSandbox,
+  ])
 
   return (
     <View testID={testID} style={s.h100pct}>
diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx
index 05d0da37a..3481b861c 100644
--- a/src/view/com/modals/SwitchAccount.tsx
+++ b/src/view/com/modals/SwitchAccount.tsx
@@ -64,7 +64,7 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
           </Text>
         </TouchableOpacity>
       ) : (
-        <AccountDropdownBtn handle={account.handle} />
+        <AccountDropdownBtn account={account} />
       )}
     </View>
   )
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 8c29ad6ab..d79bfe94e 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -19,12 +19,14 @@ import {useLingui} from '@lingui/react'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {useSetDrawerOpen} from '#/state/shell/drawer-open'
 import {useShellLayout} from '#/state/shell/shell-layout'
+import {useSession} from '#/state/session'
 
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const pal = usePalette('default')
   const store = useStores()
+  const {isSandbox} = useSession()
   const {_} = useLingui()
   const setDrawerOpen = useSetDrawerOpen()
   const items = useHomeTabs(store.preferences.pinnedFeeds)
@@ -59,7 +61,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
           </TouchableOpacity>
         </View>
         <Text style={[brandBlue, s.bold, styles.title]}>
-          {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}
+          {isSandbox ? 'SANDBOX' : 'Bluesky'}
         </Text>
         <View style={[pal.view]}>
           <Link
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 2f36609e9..489705d10 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -3,6 +3,7 @@ import {Pressable, View} from 'react-native'
 import {useStores} from 'state/index'
 import {navigate} from '../../../Navigation'
 import {useModalControls} from '#/state/modals'
+import {useSessionApi} from '#/state/session'
 
 /**
  * This utility component is only included in the test simulator
@@ -14,16 +15,17 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
 
 export function TestCtrls() {
   const store = useStores()
+  const {logout, login} = useSessionApi()
   const {openModal} = useModalControls()
   const onPressSignInAlice = async () => {
-    await store.session.login({
+    await login({
       service: 'http://localhost:3000',
       identifier: 'alice.test',
       password: 'hunter2',
     })
   }
   const onPressSignInBob = async () => {
-    await store.session.login({
+    await login({
       service: 'http://localhost:3000',
       identifier: 'bob.test',
       password: 'hunter2',
@@ -45,7 +47,7 @@ export function TestCtrls() {
       />
       <Pressable
         testID="e2eSignOut"
-        onPress={() => store.session.logout()}
+        onPress={() => logout()}
         accessibilityRole="button"
         style={BTN}
       />
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx
index 158ed9b6d..96ce678ff 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -8,11 +8,11 @@ import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
 import * as Toast from '../../com/util/Toast'
-import {useSessionApi} from '#/state/session'
+import {useSessionApi, SessionAccount} from '#/state/session'
 import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
 
-export function AccountDropdownBtn({handle}: {handle: string}) {
+export function AccountDropdownBtn({account}: {account: SessionAccount}) {
   const pal = usePalette('default')
   const {removeAccount} = useSessionApi()
   const {_} = useLingui()
@@ -21,7 +21,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) {
     {
       label: 'Remove account',
       onPress: () => {
-        removeAccount({handle})
+        removeAccount(account)
         Toast.show('Account removed from quick access')
       },
       icon: {
diff --git a/src/view/com/util/PostSandboxWarning.tsx b/src/view/com/util/PostSandboxWarning.tsx
index 21f5f7b90..b2375c703 100644
--- a/src/view/com/util/PostSandboxWarning.tsx
+++ b/src/view/com/util/PostSandboxWarning.tsx
@@ -1,13 +1,13 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useSession} from '#/state/session'
 
 export function PostSandboxWarning() {
-  const store = useStores()
+  const {isSandbox} = useSession()
   const pal = usePalette('default')
-  if (store.session.isSandbox) {
+  if (isSandbox) {
     return (
       <View style={styles.container}>
         <Text
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index e2cd4c9e7..e56a50d79 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -102,7 +102,7 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
           </Text>
         </TouchableOpacity>
       ) : (
-        <AccountDropdownBtn handle={account.handle} />
+        <AccountDropdownBtn account={account} />
       )}
     </View>
   )
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 99e1d7d98..609348e4d 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -47,6 +47,57 @@ import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSetDrawerOpen} from '#/state/shell'
 import {useModalControls} from '#/state/modals'
+import {useSession, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+
+export function DrawerProfileCard({
+  account,
+  onPressProfile,
+}: {
+  account: SessionAccount
+  onPressProfile: () => void
+}) {
+  const {_} = useLingui()
+  const pal = usePalette('default')
+  const {data: profile} = useProfileQuery({did: account.did})
+
+  return (
+    <TouchableOpacity
+      testID="profileCardButton"
+      accessibilityLabel={_(msg`Profile`)}
+      accessibilityHint="Navigates to your profile"
+      onPress={onPressProfile}>
+      <UserAvatar
+        size={80}
+        avatar={profile?.avatar}
+        // See https://github.com/bluesky-social/social-app/pull/1801:
+        usePlainRNImage={true}
+      />
+      <Text
+        type="title-lg"
+        style={[pal.text, s.bold, styles.profileCardDisplayName]}
+        numberOfLines={1}>
+        {profile?.displayName || account.handle}
+      </Text>
+      <Text
+        type="2xl"
+        style={[pal.textLight, styles.profileCardHandle]}
+        numberOfLines={1}>
+        @{account.handle}
+      </Text>
+      <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
+        <Text type="xl-medium" style={pal.text}>
+          {formatCountShortOnly(profile?.followersCount ?? 0)}
+        </Text>{' '}
+        {pluralize(profile?.followersCount || 0, 'follower')} &middot;{' '}
+        <Text type="xl-medium" style={pal.text}>
+          {formatCountShortOnly(profile?.followsCount ?? 0)}
+        </Text>{' '}
+        following
+      </Text>
+    </TouchableOpacity>
+  )
+}
 
 export const DrawerContent = observer(function DrawerContentImpl() {
   const theme = useTheme()
@@ -58,6 +109,7 @@ export const DrawerContent = observer(function DrawerContentImpl() {
   const {track} = useAnalytics()
   const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
+  const {currentAccount} = useSession()
 
   const {notifications} = store.me
 
@@ -135,11 +187,11 @@ export const DrawerContent = observer(function DrawerContentImpl() {
     track('Menu:FeedbackClicked')
     Linking.openURL(
       FEEDBACK_FORM_URL({
-        email: store.session.currentSession?.email,
-        handle: store.session.currentSession?.handle,
+        email: currentAccount?.email,
+        handle: currentAccount?.handle,
       }),
     )
-  }, [track, store.session.currentSession])
+  }, [track, currentAccount])
 
   const onPressHelp = React.useCallback(() => {
     track('Menu:HelpClicked')
@@ -159,42 +211,12 @@ export const DrawerContent = observer(function DrawerContentImpl() {
       <SafeAreaView style={s.flex1}>
         <ScrollView style={styles.main}>
           <View style={{}}>
-            <TouchableOpacity
-              testID="profileCardButton"
-              accessibilityLabel={_(msg`Profile`)}
-              accessibilityHint="Navigates to your profile"
-              onPress={onPressProfile}>
-              <UserAvatar
-                size={80}
-                avatar={store.me.avatar}
-                // See https://github.com/bluesky-social/social-app/pull/1801:
-                usePlainRNImage={true}
+            {currentAccount && (
+              <DrawerProfileCard
+                account={currentAccount}
+                onPressProfile={onPressProfile}
               />
-              <Text
-                type="title-lg"
-                style={[pal.text, s.bold, styles.profileCardDisplayName]}
-                numberOfLines={1}>
-                {store.me.displayName || store.me.handle}
-              </Text>
-              <Text
-                type="2xl"
-                style={[pal.textLight, styles.profileCardHandle]}
-                numberOfLines={1}>
-                @{store.me.handle}
-              </Text>
-              <Text
-                type="xl"
-                style={[pal.textLight, styles.profileCardFollowers]}>
-                <Text type="xl-medium" style={pal.text}>
-                  {formatCountShortOnly(store.me.followersCount ?? 0)}
-                </Text>{' '}
-                {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
-                <Text type="xl-medium" style={pal.text}>
-                  {formatCountShortOnly(store.me.followsCount ?? 0)}
-                </Text>{' '}
-                following
-              </Text>
-            </TouchableOpacity>
+            )}
           </View>
 
           <InviteCodes style={{paddingLeft: 0}} />
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index cb62b4be2..98f54c7ed 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -17,10 +17,9 @@ import {useModalControls} from '#/state/modals'
 import {useSession} from '#/state/session'
 
 export const DesktopRightNav = observer(function DesktopRightNavImpl() {
-  const store = useStores()
   const pal = usePalette('default')
   const palError = usePalette('error')
-  const {hasSession, currentAccount} = useSession()
+  const {isSandbox, hasSession, currentAccount} = useSession()
 
   const {isTablet} = useWebMediaQueries()
   if (isTablet) {
@@ -32,7 +31,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
       {hasSession && <DesktopSearch />}
       {hasSession && <DesktopFeeds />}
       <View style={styles.message}>
-        {store.session.isSandbox ? (
+        {isSandbox ? (
           <View style={[palError.view, styles.messageLine, s.p10]}>
             <Text type="md" style={[palError.text, s.bold]}>
               SANDBOX. Posts and accounts are not permanent.