about summary refs log tree commit diff
path: root/src/view/com/auth
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/auth')
-rw-r--r--src/view/com/auth/LoggedOut.tsx75
-rw-r--r--src/view/com/auth/Onboarding.tsx35
-rw-r--r--src/view/com/auth/SplashScreen.tsx70
-rw-r--r--src/view/com/auth/SplashScreen.web.tsx109
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx140
-rw-r--r--src/view/com/auth/create/Policies.tsx6
-rw-r--r--src/view/com/auth/create/Step1.tsx73
-rw-r--r--src/view/com/auth/create/Step2.tsx82
-rw-r--r--src/view/com/auth/create/Step3.tsx34
-rw-r--r--src/view/com/auth/create/state.ts242
-rw-r--r--src/view/com/auth/login/ChooseAccountForm.tsx158
-rw-r--r--src/view/com/auth/login/ForgotPasswordForm.tsx197
-rw-r--r--src/view/com/auth/login/Login.tsx948
-rw-r--r--src/view/com/auth/login/LoginForm.tsx290
-rw-r--r--src/view/com/auth/login/PasswordUpdatedForm.tsx48
-rw-r--r--src/view/com/auth/login/SetNewPasswordForm.tsx179
-rw-r--r--src/view/com/auth/login/styles.ts118
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx120
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx55
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollows.tsx184
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx114
-rw-r--r--src/view/com/auth/onboarding/WelcomeDesktop.tsx7
-rw-r--r--src/view/com/auth/onboarding/WelcomeMobile.tsx37
-rw-r--r--src/view/com/auth/withAuthRequired.tsx78
24 files changed, 1973 insertions, 1426 deletions
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index 3e2c9c1bf..030ae68b1 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -1,15 +1,19 @@
 import React from 'react'
-import {SafeAreaView} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {View, Pressable} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {isIOS} from 'platform/detection'
 import {Login} from 'view/com/auth/login/Login'
 import {CreateAccount} from 'view/com/auth/create/CreateAccount'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {SplashScreen} from './SplashScreen'
 import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 
 enum ScreenState {
   S_LoginOrCreateAccount,
@@ -17,35 +21,66 @@ enum ScreenState {
   S_CreateAccount,
 }
 
-export const LoggedOut = observer(function LoggedOutImpl() {
+export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
+  const {_} = useLingui()
   const pal = usePalette('default')
-  const store = useStores()
   const setMinimalShellMode = useSetMinimalShellMode()
   const {screen} = useAnalytics()
   const [screenState, setScreenState] = React.useState<ScreenState>(
     ScreenState.S_LoginOrCreateAccount,
   )
+  const {isMobile} = useWebMediaQueries()
 
   React.useEffect(() => {
     screen('Login')
     setMinimalShellMode(true)
   }, [screen, setMinimalShellMode])
 
-  if (
-    store.session.isResumingSession ||
-    screenState === ScreenState.S_LoginOrCreateAccount
-  ) {
-    return (
-      <SplashScreen
-        onPressSignin={() => setScreenState(ScreenState.S_Login)}
-        onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
-      />
-    )
-  }
-
   return (
-    <SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}>
+    <View
+      testID="noSessionView"
+      style={[
+        s.hContentRegion,
+        pal.view,
+        {
+          // only needed if dismiss button is present
+          paddingTop: onDismiss && isMobile ? 40 : 0,
+        },
+      ]}>
       <ErrorBoundary>
+        {onDismiss && (
+          <Pressable
+            accessibilityHint={_(msg`Go back`)}
+            accessibilityLabel={_(msg`Go back`)}
+            accessibilityRole="button"
+            style={{
+              position: 'absolute',
+              top: isIOS ? 0 : 20,
+              right: 20,
+              padding: 10,
+              zIndex: 100,
+              backgroundColor: pal.text.color,
+              borderRadius: 100,
+            }}
+            onPress={onDismiss}>
+            <FontAwesomeIcon
+              icon="x"
+              size={12}
+              style={{
+                color: String(pal.textInverted.color),
+              }}
+            />
+          </Pressable>
+        )}
+
+        {screenState === ScreenState.S_LoginOrCreateAccount ? (
+          <SplashScreen
+            onPressSignin={() => setScreenState(ScreenState.S_Login)}
+            onPressCreateAccount={() =>
+              setScreenState(ScreenState.S_CreateAccount)
+            }
+          />
+        ) : undefined}
         {screenState === ScreenState.S_Login ? (
           <Login
             onPressBack={() =>
@@ -61,6 +96,6 @@ export const LoggedOut = observer(function LoggedOutImpl() {
           />
         ) : undefined}
       </ErrorBoundary>
-    </SafeAreaView>
+    </View>
   )
-})
+}
diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx
index bec1dc236..bdb7f27c8 100644
--- a/src/view/com/auth/Onboarding.tsx
+++ b/src/view/com/auth/Onboarding.tsx
@@ -1,40 +1,51 @@
 import React from 'react'
-import {SafeAreaView} from 'react-native'
-import {observer} from 'mobx-react-lite'
+import {SafeAreaView, Platform} from 'react-native'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {Welcome} from './onboarding/Welcome'
 import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
 import {RecommendedFollows} from './onboarding/RecommendedFollows'
 import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
+import {useOnboardingState, useOnboardingDispatch} from '#/state/shell'
 
-export const Onboarding = observer(function OnboardingImpl() {
+export function Onboarding() {
   const pal = usePalette('default')
-  const store = useStores()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const onboardingState = useOnboardingState()
+  const onboardingDispatch = useOnboardingDispatch()
 
   React.useEffect(() => {
     setMinimalShellMode(true)
   }, [setMinimalShellMode])
 
-  const next = () => store.onboarding.next()
-  const skip = () => store.onboarding.skip()
+  const next = () => onboardingDispatch({type: 'next'})
+  const skip = () => onboardingDispatch({type: 'skip'})
 
   return (
-    <SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
+    <SafeAreaView
+      testID="onboardingView"
+      style={[
+        s.hContentRegion,
+        pal.view,
+        // @ts-ignore web only -esb
+        Platform.select({
+          web: {
+            height: '100vh',
+          },
+        }),
+      ]}>
       <ErrorBoundary>
-        {store.onboarding.step === 'Welcome' && (
+        {onboardingState.step === 'Welcome' && (
           <Welcome skip={skip} next={next} />
         )}
-        {store.onboarding.step === 'RecommendedFeeds' && (
+        {onboardingState.step === 'RecommendedFeeds' && (
           <RecommendedFeeds next={next} />
         )}
-        {store.onboarding.step === 'RecommendedFollows' && (
+        {onboardingState.step === 'RecommendedFollows' && (
           <RecommendedFollows next={next} />
         )}
       </ErrorBoundary>
     </SafeAreaView>
   )
-})
+}
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx
index 67453f111..d88627f65 100644
--- a/src/view/com/auth/SplashScreen.tsx
+++ b/src/view/com/auth/SplashScreen.tsx
@@ -1,10 +1,12 @@
 import React from 'react'
-import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {Text} from 'view/com/util/text/Text'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {CenteredView} from '../util/Views'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 export const SplashScreen = ({
   onPressSignin,
@@ -14,40 +16,44 @@ export const SplashScreen = ({
   onPressCreateAccount: () => void
 }) => {
   const pal = usePalette('default')
+  const {_} = useLingui()
+
   return (
     <CenteredView style={[styles.container, pal.view]}>
-      <SafeAreaView testID="noSessionView" style={styles.container}>
-        <ErrorBoundary>
-          <View style={styles.hero}>
-            <Text style={[styles.title, pal.link]}>Bluesky</Text>
-            <Text style={[styles.subtitle, pal.textLight]}>
-              See what's next
+      <ErrorBoundary>
+        <View style={styles.hero}>
+          <Text style={[styles.title, pal.link]}>
+            <Trans>Bluesky</Trans>
+          </Text>
+          <Text style={[styles.subtitle, pal.textLight]}>
+            <Trans>See what's next</Trans>
+          </Text>
+        </View>
+        <View testID="signinOrCreateAccount" style={styles.btns}>
+          <TouchableOpacity
+            testID="createAccountButton"
+            style={[styles.btn, {backgroundColor: colors.blue3}]}
+            onPress={onPressCreateAccount}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Create new account`)}
+            accessibilityHint="Opens flow to create a new Bluesky account">
+            <Text style={[s.white, styles.btnLabel]}>
+              <Trans>Create a new account</Trans>
+            </Text>
+          </TouchableOpacity>
+          <TouchableOpacity
+            testID="signInButton"
+            style={[styles.btn, pal.btn]}
+            onPress={onPressSignin}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Sign in`)}
+            accessibilityHint="Opens flow to sign into your existing Bluesky account">
+            <Text style={[pal.text, styles.btnLabel]}>
+              <Trans>Sign In</Trans>
             </Text>
-          </View>
-          <View testID="signinOrCreateAccount" style={styles.btns}>
-            <TouchableOpacity
-              testID="createAccountButton"
-              style={[styles.btn, {backgroundColor: colors.blue3}]}
-              onPress={onPressCreateAccount}
-              accessibilityRole="button"
-              accessibilityLabel="Create new account"
-              accessibilityHint="Opens flow to create a new Bluesky account">
-              <Text style={[s.white, styles.btnLabel]}>
-                Create a new account
-              </Text>
-            </TouchableOpacity>
-            <TouchableOpacity
-              testID="signInButton"
-              style={[styles.btn, pal.btn]}
-              onPress={onPressSignin}
-              accessibilityRole="button"
-              accessibilityLabel="Sign in"
-              accessibilityHint="Opens flow to sign into your existing Bluesky account">
-              <Text style={[pal.text, styles.btnLabel]}>Sign In</Text>
-            </TouchableOpacity>
-          </View>
-        </ErrorBoundary>
-      </SafeAreaView>
+          </TouchableOpacity>
+        </View>
+      </ErrorBoundary>
     </CenteredView>
   )
 }
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx
index cef9618ef..08cf701da 100644
--- a/src/view/com/auth/SplashScreen.web.tsx
+++ b/src/view/com/auth/SplashScreen.web.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from 'view/com/util/text/Text'
 import {TextLink} from '../util/Link'
 import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
@@ -8,11 +9,14 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {CenteredView} from '../util/Views'
 import {isWeb} from 'platform/detection'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {Trans} from '@lingui/macro'
 
 export const SplashScreen = ({
+  onDismiss,
   onPressSignin,
   onPressCreateAccount,
 }: {
+  onDismiss?: () => void
   onPressSignin: () => void
   onPressCreateAccount: () => void
 }) => {
@@ -22,45 +26,70 @@ export const SplashScreen = ({
   const isMobileWeb = isWeb && isTabletOrMobile
 
   return (
-    <CenteredView style={[styles.container, pal.view]}>
-      <View
-        testID="noSessionView"
-        style={[
-          styles.containerInner,
-          isMobileWeb && styles.containerInnerMobile,
-          pal.border,
-        ]}>
-        <ErrorBoundary>
-          <Text style={isMobileWeb ? styles.titleMobile : styles.title}>
-            Bluesky
-          </Text>
-          <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}>
-            See what's next
-          </Text>
-          <View testID="signinOrCreateAccount" style={styles.btns}>
-            <TouchableOpacity
-              testID="createAccountButton"
-              style={[styles.btn, {backgroundColor: colors.blue3}]}
-              onPress={onPressCreateAccount}
-              // TODO: web accessibility
-              accessibilityRole="button">
-              <Text style={[s.white, styles.btnLabel]}>
-                Create a new account
-              </Text>
-            </TouchableOpacity>
-            <TouchableOpacity
-              testID="signInButton"
-              style={[styles.btn, pal.btn]}
-              onPress={onPressSignin}
-              // TODO: web accessibility
-              accessibilityRole="button">
-              <Text style={[pal.text, styles.btnLabel]}>Sign In</Text>
-            </TouchableOpacity>
-          </View>
-        </ErrorBoundary>
-      </View>
-      <Footer styles={styles} />
-    </CenteredView>
+    <>
+      {onDismiss && (
+        <Pressable
+          accessibilityRole="button"
+          style={{
+            position: 'absolute',
+            top: 20,
+            right: 20,
+            padding: 20,
+            zIndex: 100,
+          }}
+          onPress={onDismiss}>
+          <FontAwesomeIcon
+            icon="x"
+            size={24}
+            style={{
+              color: String(pal.text.color),
+            }}
+          />
+        </Pressable>
+      )}
+
+      <CenteredView style={[styles.container, pal.view]}>
+        <View
+          testID="noSessionView"
+          style={[
+            styles.containerInner,
+            isMobileWeb && styles.containerInnerMobile,
+            pal.border,
+          ]}>
+          <ErrorBoundary>
+            <Text style={isMobileWeb ? styles.titleMobile : styles.title}>
+              Bluesky
+            </Text>
+            <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}>
+              See what's next
+            </Text>
+            <View testID="signinOrCreateAccount" style={styles.btns}>
+              <TouchableOpacity
+                testID="createAccountButton"
+                style={[styles.btn, {backgroundColor: colors.blue3}]}
+                onPress={onPressCreateAccount}
+                // TODO: web accessibility
+                accessibilityRole="button">
+                <Text style={[s.white, styles.btnLabel]}>
+                  Create a new account
+                </Text>
+              </TouchableOpacity>
+              <TouchableOpacity
+                testID="signInButton"
+                style={[styles.btn, pal.btn]}
+                onPress={onPressSignin}
+                // TODO: web accessibility
+                accessibilityRole="button">
+                <Text style={[pal.text, styles.btnLabel]}>
+                  <Trans>Sign In</Trans>
+                </Text>
+              </TouchableOpacity>
+            </View>
+          </ErrorBoundary>
+        </View>
+        <Footer styles={styles} />
+      </CenteredView>
+    </>
   )
 }
 
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 1d64cc067..ab6d34584 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -7,78 +7,134 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {Text} from '../../util/text/Text'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
-import {CreateAccountModel} from 'state/models/ui/create-account'
 import {usePalette} from 'lib/hooks/usePalette'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useOnboardingDispatch} from '#/state/shell'
+import {useSessionApi} from '#/state/session'
+import {useCreateAccount, submit} from './state'
+import {useServiceQuery} from '#/state/queries/service'
+import {
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+  DEFAULT_PROD_FEEDS,
+} from '#/state/queries/preferences'
+import {IS_PROD} from '#/lib/constants'
 
 import {Step1} from './Step1'
 import {Step2} from './Step2'
 import {Step3} from './Step3'
 
-export const CreateAccount = observer(function CreateAccountImpl({
-  onPressBack,
-}: {
-  onPressBack: () => void
-}) {
+export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {track, screen} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
-  const model = React.useMemo(() => new CreateAccountModel(store), [store])
+  const {_} = useLingui()
+  const [uiState, uiDispatch] = useCreateAccount()
+  const onboardingDispatch = useOnboardingDispatch()
+  const {createAccount} = useSessionApi()
+  const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
 
   React.useEffect(() => {
     screen('CreateAccount')
   }, [screen])
 
+  // fetch service info
+  // =
+
+  const {
+    data: serviceInfo,
+    isFetching: serviceInfoIsFetching,
+    error: serviceInfoError,
+    refetch: refetchServiceInfo,
+  } = useServiceQuery(uiState.serviceUrl)
+
   React.useEffect(() => {
-    model.fetchServiceDescription()
-  }, [model])
+    if (serviceInfo) {
+      uiDispatch({type: 'set-service-description', value: serviceInfo})
+      uiDispatch({type: 'set-error', value: ''})
+    } else if (serviceInfoError) {
+      uiDispatch({
+        type: 'set-error',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    }
+  }, [_, uiDispatch, serviceInfo, serviceInfoError])
 
-  const onPressRetryConnect = React.useCallback(
-    () => model.fetchServiceDescription(),
-    [model],
-  )
+  // event handlers
+  // =
 
   const onPressBackInner = React.useCallback(() => {
-    if (model.canBack) {
-      model.back()
+    if (uiState.canBack) {
+      uiDispatch({type: 'back'})
     } else {
       onPressBack()
     }
-  }, [model, onPressBack])
+  }, [uiState, uiDispatch, onPressBack])
 
   const onPressNext = React.useCallback(async () => {
-    if (!model.canNext) {
+    if (!uiState.canNext) {
       return
     }
-    if (model.step < 3) {
-      model.next()
+    if (uiState.step < 3) {
+      uiDispatch({type: 'next'})
     } else {
       try {
-        await model.submit()
+        await submit({
+          onboardingDispatch,
+          createAccount,
+          uiState,
+          uiDispatch,
+          _,
+        })
+        track('Create Account')
+        setBirthDate({birthDate: uiState.birthDate})
+        if (IS_PROD(uiState.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
       } catch {
         // dont need to handle here
       } finally {
         track('Try Create Account')
       }
     }
-  }, [model, track])
+  }, [
+    uiState,
+    uiDispatch,
+    track,
+    onboardingDispatch,
+    createAccount,
+    setBirthDate,
+    setSavedFeeds,
+    _,
+  ])
+
+  // rendering
+  // =
 
   return (
     <LoggedOutLayout
-      leadin={`Step ${model.step}`}
-      title="Create Account"
-      description="We're so excited to have you join us!">
+      leadin={`Step ${uiState.step}`}
+      title={_(msg`Create Account`)}
+      description={_(msg`We're so excited to have you join us!`)}>
       <ScrollView testID="createAccount" style={pal.view}>
         <KeyboardAvoidingView behavior="padding">
           <View style={styles.stepContainer}>
-            {model.step === 1 && <Step1 model={model} />}
-            {model.step === 2 && <Step2 model={model} />}
-            {model.step === 3 && <Step3 model={model} />}
+            {uiState.step === 1 && (
+              <Step1 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 2 && (
+              <Step2 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 3 && (
+              <Step3 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
           </View>
           <View style={[s.flexRow, s.pl20, s.pr20]}>
             <TouchableOpacity
@@ -86,40 +142,40 @@ export const CreateAccount = observer(function CreateAccountImpl({
               testID="backBtn"
               accessibilityRole="button">
               <Text type="xl" style={pal.link}>
-                Back
+                <Trans>Back</Trans>
               </Text>
             </TouchableOpacity>
             <View style={s.flex1} />
-            {model.canNext ? (
+            {uiState.canNext ? (
               <TouchableOpacity
                 testID="nextBtn"
                 onPress={onPressNext}
                 accessibilityRole="button">
-                {model.isProcessing ? (
+                {uiState.isProcessing ? (
                   <ActivityIndicator />
                 ) : (
                   <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                    Next
+                    <Trans>Next</Trans>
                   </Text>
                 )}
               </TouchableOpacity>
-            ) : model.didServiceDescriptionFetchFail ? (
+            ) : serviceInfoError ? (
               <TouchableOpacity
                 testID="retryConnectBtn"
-                onPress={onPressRetryConnect}
+                onPress={() => refetchServiceInfo()}
                 accessibilityRole="button"
-                accessibilityLabel="Retry"
-                accessibilityHint="Retries account creation"
+                accessibilityLabel={_(msg`Retry`)}
+                accessibilityHint=""
                 accessibilityLiveRegion="polite">
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                  Retry
+                  <Trans>Retry</Trans>
                 </Text>
               </TouchableOpacity>
-            ) : model.isFetchingServiceDescription ? (
+            ) : serviceInfoIsFetching ? (
               <>
                 <ActivityIndicator color="#fff" />
                 <Text type="xl" style={[pal.text, s.pr5]}>
-                  Connecting...
+                  <Trans>Connecting...</Trans>
                 </Text>
               </>
             ) : undefined}
@@ -129,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
       </ScrollView>
     </LoggedOutLayout>
   )
-})
+}
 
 const styles = StyleSheet.create({
   stepContainer: {
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
index 8eb669bcf..a52f07531 100644
--- a/src/view/com/auth/create/Policies.tsx
+++ b/src/view/com/auth/create/Policies.tsx
@@ -4,12 +4,14 @@ import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
 import {TextLink} from '../../util/Link'
 import {Text} from '../../util/text/Text'
 import {s, colors} from 'lib/styles'
-import {ServiceDescription} from 'state/models/session'
 import {usePalette} from 'lib/hooks/usePalette'
 
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
 export const Policies = ({
   serviceDescription,
   needsGuardian,
@@ -93,7 +95,7 @@ function validWebLink(url?: string): string | undefined {
 
 const styles = StyleSheet.create({
   policies: {
-    flexDirection: 'row',
+    flexDirection: 'column',
     gap: 8,
   },
   errorIcon: {
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index cdd5cb21d..c9d19e868 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -1,10 +1,8 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
@@ -12,60 +10,49 @@ import {HelpTip} from '../util/HelpTip'
 import {TextInput} from '../util/TextInput'
 import {Button} from 'view/com/util/forms/Button'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
+import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants'
 import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
 
 /** STEP 1: Your hosting provider
  * @field Bluesky (default)
  * @field Other (staging, local dev, your own PDS, etc.)
  */
-export const Step1 = observer(function Step1Impl({
-  model,
+export function Step1({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
   const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
+  const {_} = useLingui()
 
   const onPressDefault = React.useCallback(() => {
     setIsDefaultSelected(true)
-    model.setServiceUrl(PROD_SERVICE)
-    model.fetchServiceDescription()
-  }, [setIsDefaultSelected, model])
+    uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onPressOther = React.useCallback(() => {
     setIsDefaultSelected(false)
-    model.setServiceUrl('https://')
-    model.setServiceDescription(undefined)
-  }, [setIsDefaultSelected, model])
-
-  const fetchServiceDescription = React.useMemo(
-    () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms)
-    [model],
-  )
+    uiDispatch({type: 'set-service-url', value: 'https://'})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onChangeServiceUrl = React.useCallback(
     (v: string) => {
-      model.setServiceUrl(v)
-      fetchServiceDescription()
-    },
-    [model, fetchServiceDescription],
-  )
-
-  const onDebugChangeServiceUrl = React.useCallback(
-    (v: string) => {
-      model.setServiceUrl(v)
-      model.fetchServiceDescription()
+      uiDispatch({type: 'set-service-url', value: v})
     },
-    [model],
+    [uiDispatch],
   )
 
   return (
     <View>
-      <StepHeader step="1" title="Your hosting provider" />
+      <StepHeader step="1" title={_(msg`Your hosting provider`)} />
       <Text style={[pal.text, s.mb10]}>
-        This is the service that keeps you online.
+        <Trans>This is the service that keeps you online.</Trans>
       </Text>
       <Option
         testID="blueskyServerBtn"
@@ -81,17 +68,17 @@ export const Step1 = observer(function Step1Impl({
         onPress={onPressOther}>
         <View style={styles.otherForm}>
           <Text nativeID="addressProvider" style={[pal.text, s.mb5]}>
-            Enter the address of your provider:
+            <Trans>Enter the address of your provider:</Trans>
           </Text>
           <TextInput
             testID="customServerInput"
             icon="globe"
-            placeholder="Hosting provider address"
-            value={model.serviceUrl}
+            placeholder={_(msg`Hosting provider address`)}
+            value={uiState.serviceUrl}
             editable
             onChange={onChangeServiceUrl}
             accessibilityHint="Input hosting provider address"
-            accessibilityLabel="Hosting provider address"
+            accessibilityLabel={_(msg`Hosting provider address`)}
             accessibilityLabelledBy="addressProvider"
           />
           {LOGIN_INCLUDE_DEV_SERVERS && (
@@ -100,27 +87,27 @@ export const Step1 = observer(function Step1Impl({
                 testID="stagingServerBtn"
                 type="default"
                 style={s.mr5}
-                label="Staging"
-                onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
+                label={_(msg`Staging`)}
+                onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
               />
               <Button
                 testID="localDevServerBtn"
                 type="default"
-                label="Dev Server"
-                onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
+                label={_(msg`Dev Server`)}
+                onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
               />
             </View>
           )}
         </View>
       </Option>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : (
-        <HelpTip text="You can change hosting providers at any time." />
+        <HelpTip text={_(msg`You can change hosting providers at any time.`)} />
       )}
     </View>
   )
-})
+}
 
 function Option({
   children,
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 60e197564..89fd070ad 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch, is18} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
@@ -10,8 +9,10 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {TextInput} from '../util/TextInput'
 import {Policies} from './Policies'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
-import {useStores} from 'state/index'
 import {isWeb} from 'platform/detection'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
 
 /** STEP 2: Your account
  * @field Invite code or waitlist
@@ -22,23 +23,26 @@ import {isWeb} from 'platform/detection'
  * @field Birth date
  * @readonly Terms of service & privacy policy
  */
-export const Step2 = observer(function Step2Impl({
-  model,
+export function Step2({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
-  const store = useStores()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
 
   const onPressWaitlist = React.useCallback(() => {
-    store.shell.openModal({name: 'waitlist'})
-  }, [store])
+    openModal({name: 'waitlist'})
+  }, [openModal])
 
   return (
     <View>
-      <StepHeader step="2" title="Your account" />
+      <StepHeader step="2" title={_(msg`Your account`)} />
 
-      {model.isInviteCodeRequired && (
+      {uiState.isInviteCodeRequired && (
         <View style={s.pb20}>
           <Text type="md-medium" style={[pal.text, s.mb2]}>
             Invite code
@@ -46,25 +50,27 @@ export const Step2 = observer(function Step2Impl({
           <TextInput
             testID="inviteCodeInput"
             icon="ticket"
-            placeholder="Required for this provider"
-            value={model.inviteCode}
+            placeholder={_(msg`Required for this provider`)}
+            value={uiState.inviteCode}
             editable
-            onChange={model.setInviteCode}
-            accessibilityLabel="Invite code"
+            onChange={value => uiDispatch({type: 'set-invite-code', value})}
+            accessibilityLabel={_(msg`Invite code`)}
             accessibilityHint="Input invite code to proceed"
           />
         </View>
       )}
 
-      {!model.inviteCode && model.isInviteCodeRequired ? (
+      {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
         <Text style={[s.alignBaseline, pal.text]}>
           Don't have an invite code?{' '}
           <TouchableWithoutFeedback
             onPress={onPressWaitlist}
-            accessibilityLabel="Join the waitlist."
+            accessibilityLabel={_(msg`Join the waitlist.`)}
             accessibilityHint="">
             <View style={styles.touchable}>
-              <Text style={pal.link}>Join the waitlist.</Text>
+              <Text style={pal.link}>
+                <Trans>Join the waitlist.</Trans>
+              </Text>
             </View>
           </TouchableWithoutFeedback>
         </Text>
@@ -72,16 +78,16 @@ export const Step2 = observer(function Step2Impl({
         <>
           <View style={s.pb20}>
             <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email">
-              Email address
+              <Trans>Email address</Trans>
             </Text>
             <TextInput
               testID="emailInput"
               icon="envelope"
-              placeholder="Enter your email address"
-              value={model.email}
+              placeholder={_(msg`Enter your email address`)}
+              value={uiState.email}
               editable
-              onChange={model.setEmail}
-              accessibilityLabel="Email"
+              onChange={value => uiDispatch({type: 'set-email', value})}
+              accessibilityLabel={_(msg`Email`)}
               accessibilityHint="Input email for Bluesky waitlist"
               accessibilityLabelledBy="email"
             />
@@ -92,17 +98,17 @@ export const Step2 = observer(function Step2Impl({
               type="md-medium"
               style={[pal.text, s.mb2]}
               nativeID="password">
-              Password
+              <Trans>Password</Trans>
             </Text>
             <TextInput
               testID="passwordInput"
               icon="lock"
-              placeholder="Choose your password"
-              value={model.password}
+              placeholder={_(msg`Choose your password`)}
+              value={uiState.password}
               editable
               secureTextEntry
-              onChange={model.setPassword}
-              accessibilityLabel="Password"
+              onChange={value => uiDispatch({type: 'set-password', value})}
+              accessibilityLabel={_(msg`Password`)}
               accessibilityHint="Set password"
               accessibilityLabelledBy="password"
             />
@@ -113,35 +119,35 @@ export const Step2 = observer(function Step2Impl({
               type="md-medium"
               style={[pal.text, s.mb2]}
               nativeID="birthDate">
-              Your birth date
+              <Trans>Your birth date</Trans>
             </Text>
             <DateInput
               testID="birthdayInput"
-              value={model.birthDate}
-              onChange={model.setBirthDate}
+              value={uiState.birthDate}
+              onChange={value => uiDispatch({type: 'set-birth-date', value})}
               buttonType="default-light"
               buttonStyle={[pal.border, styles.dateInputButton]}
               buttonLabelType="lg"
-              accessibilityLabel="Birthday"
+              accessibilityLabel={_(msg`Birthday`)}
               accessibilityHint="Enter your birth date"
               accessibilityLabelledBy="birthDate"
             />
           </View>
 
-          {model.serviceDescription && (
+          {uiState.serviceDescription && (
             <Policies
-              serviceDescription={model.serviceDescription}
-              needsGuardian={!model.isAge18}
+              serviceDescription={uiState.serviceDescription}
+              needsGuardian={!is18(uiState)}
             />
           )}
         </>
       )}
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index beb756ac1..3b628b6b6 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
@@ -9,44 +8,49 @@ import {TextInput} from '../util/TextInput'
 import {createFullHandle} from 'lib/strings/handles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 /** STEP 3: Your user handle
  * @field User handle
  */
-export const Step3 = observer(function Step3Impl({
-  model,
+export function Step3({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   return (
     <View>
-      <StepHeader step="3" title="Your user handle" />
+      <StepHeader step="3" title={_(msg`Your user handle`)} />
       <View style={s.pb10}>
         <TextInput
           testID="handleInput"
           icon="at"
           placeholder="e.g. alice"
-          value={model.handle}
+          value={uiState.handle}
           editable
-          onChange={model.setHandle}
+          onChange={value => uiDispatch({type: 'set-handle', value})}
           // TODO: Add explicit text label
-          accessibilityLabel="User handle"
+          accessibilityLabel={_(msg`User handle`)}
           accessibilityHint="Input your user handle"
         />
         <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
-          Your full handle will be{' '}
-          <Text type="lg-bold" style={pal.text}>
-            @{createFullHandle(model.handle, model.userDomain)}
+          <Trans>Your full handle will be</Trans>
+          <Text type="lg-bold" style={[pal.text, s.ml5]}>
+            @{createFullHandle(uiState.handle, uiState.userDomain)}
           </Text>
         </Text>
       </View>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
new file mode 100644
index 000000000..4df82f8fc
--- /dev/null
+++ b/src/view/com/auth/create/state.ts
@@ -0,0 +1,242 @@
+import {useReducer} from 'react'
+import {
+  ComAtprotoServerDescribeServer,
+  ComAtprotoServerCreateAccount,
+} from '@atproto/api'
+import {I18nContext, useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import * as EmailValidator from 'email-validator'
+import {getAge} from 'lib/strings/time'
+import {logger} from '#/logger'
+import {createFullHandle} from '#/lib/strings/handles'
+import {cleanError} from '#/lib/strings/errors'
+import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
+import {ApiContext as SessionApiContext} from '#/state/session'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export type CreateAccountAction =
+  | {type: 'set-step'; value: number}
+  | {type: 'set-error'; value: string | undefined}
+  | {type: 'set-processing'; value: boolean}
+  | {type: 'set-service-url'; value: string}
+  | {type: 'set-service-description'; value: ServiceDescription | undefined}
+  | {type: 'set-user-domain'; value: string}
+  | {type: 'set-invite-code'; value: string}
+  | {type: 'set-email'; value: string}
+  | {type: 'set-password'; value: string}
+  | {type: 'set-handle'; value: string}
+  | {type: 'set-birth-date'; value: Date}
+  | {type: 'next'}
+  | {type: 'back'}
+
+export interface CreateAccountState {
+  // state
+  step: number
+  error: string | undefined
+  isProcessing: boolean
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  userDomain: string
+  inviteCode: string
+  email: string
+  password: string
+  handle: string
+  birthDate: Date
+
+  // computed
+  canBack: boolean
+  canNext: boolean
+  isInviteCodeRequired: boolean
+}
+
+export type CreateAccountDispatch = (action: CreateAccountAction) => void
+
+export function useCreateAccount() {
+  const {_} = useLingui()
+  return useReducer(createReducer({_}), {
+    step: 1,
+    error: undefined,
+    isProcessing: false,
+    serviceUrl: DEFAULT_SERVICE,
+    serviceDescription: undefined,
+    userDomain: '',
+    inviteCode: '',
+    email: '',
+    password: '',
+    handle: '',
+    birthDate: DEFAULT_DATE,
+
+    canBack: false,
+    canNext: false,
+    isInviteCodeRequired: false,
+  })
+}
+
+export async function submit({
+  createAccount,
+  onboardingDispatch,
+  uiState,
+  uiDispatch,
+  _,
+}: {
+  createAccount: SessionApiContext['createAccount']
+  onboardingDispatch: OnboardingDispatchContext
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
+  _: I18nContext['_']
+}) {
+  if (!uiState.email) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please enter your email.`),
+    })
+  }
+  if (!EmailValidator.validate(uiState.email)) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Your email appears to be invalid.`),
+    })
+  }
+  if (!uiState.password) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your password.`),
+    })
+  }
+  if (!uiState.handle) {
+    uiDispatch({type: 'set-step', value: 3})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your handle.`),
+    })
+  }
+  uiDispatch({type: 'set-error', value: ''})
+  uiDispatch({type: 'set-processing', value: true})
+
+  try {
+    onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+    await createAccount({
+      service: uiState.serviceUrl,
+      email: uiState.email,
+      handle: createFullHandle(uiState.handle, uiState.userDomain),
+      password: uiState.password,
+      inviteCode: uiState.inviteCode.trim(),
+    })
+  } catch (e: any) {
+    onboardingDispatch({type: 'skip'}) // undo starting the onboard
+    let errMsg = e.toString()
+    if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+      errMsg = _(
+        msg`Invite code not accepted. Check that you input it correctly and try again.`,
+      )
+    }
+    logger.error('Failed to create account', {error: e})
+    uiDispatch({type: 'set-processing', value: false})
+    uiDispatch({type: 'set-error', value: cleanError(errMsg)})
+    throw e
+  }
+}
+
+export function is13(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+export function is18(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+function createReducer({_}: {_: I18nContext['_']}) {
+  return function reducer(
+    state: CreateAccountState,
+    action: CreateAccountAction,
+  ): CreateAccountState {
+    switch (action.type) {
+      case 'set-step': {
+        return compute({...state, step: action.value})
+      }
+      case 'set-error': {
+        return compute({...state, error: action.value})
+      }
+      case 'set-processing': {
+        return compute({...state, isProcessing: action.value})
+      }
+      case 'set-service-url': {
+        return compute({
+          ...state,
+          serviceUrl: action.value,
+          serviceDescription:
+            state.serviceUrl !== action.value
+              ? undefined
+              : state.serviceDescription,
+        })
+      }
+      case 'set-service-description': {
+        return compute({
+          ...state,
+          serviceDescription: action.value,
+          userDomain: action.value?.availableUserDomains[0] || '',
+        })
+      }
+      case 'set-user-domain': {
+        return compute({...state, userDomain: action.value})
+      }
+      case 'set-invite-code': {
+        return compute({...state, inviteCode: action.value})
+      }
+      case 'set-email': {
+        return compute({...state, email: action.value})
+      }
+      case 'set-password': {
+        return compute({...state, password: action.value})
+      }
+      case 'set-handle': {
+        return compute({...state, handle: action.value})
+      }
+      case 'set-birth-date': {
+        return compute({...state, birthDate: action.value})
+      }
+      case 'next': {
+        if (state.step === 2) {
+          if (!is13(state)) {
+            return compute({
+              ...state,
+              error: _(
+                msg`Unfortunately, you do not meet the requirements to create an account.`,
+              ),
+            })
+          }
+        }
+        return compute({...state, error: '', step: state.step + 1})
+      }
+      case 'back': {
+        return compute({...state, error: '', step: state.step - 1})
+      }
+    }
+  }
+}
+
+function compute(state: CreateAccountState): CreateAccountState {
+  let canNext = true
+  if (state.step === 1) {
+    canNext = !!state.serviceDescription
+  } else if (state.step === 2) {
+    canNext =
+      (!state.isInviteCodeRequired || !!state.inviteCode) &&
+      !!state.email &&
+      !!state.password
+  } else if (state.step === 3) {
+    canNext = !!state.handle
+  }
+  return {
+    ...state,
+    canBack: state.step > 1,
+    canNext,
+    isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
+  }
+}
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
new file mode 100644
index 000000000..73ddfc9d6
--- /dev/null
+++ b/src/view/com/auth/login/ChooseAccountForm.tsx
@@ -0,0 +1,158 @@
+import React from 'react'
+import {ScrollView, TouchableOpacity, View} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {UserAvatar} from '../../util/UserAvatar'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {styles} from './styles'
+import {useSession, useSessionApi, SessionAccount} from '#/state/session'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import * as Toast from '#/view/com/util/Toast'
+
+function AccountItem({
+  account,
+  onSelect,
+  isCurrentAccount,
+}: {
+  account: SessionAccount
+  onSelect: (account: SessionAccount) => void
+  isCurrentAccount: boolean
+}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {data: profile} = useProfileQuery({did: account.did})
+
+  const onPress = React.useCallback(() => {
+    onSelect(account)
+  }, [account, onSelect])
+
+  return (
+    <TouchableOpacity
+      testID={`chooseAccountBtn-${account.handle}`}
+      key={account.did}
+      style={[pal.view, pal.border, styles.account]}
+      onPress={onPress}
+      accessibilityRole="button"
+      accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
+      accessibilityHint="Double tap to sign in">
+      <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+        <View style={s.p10}>
+          <UserAvatar avatar={profile?.avatar} size={30} />
+        </View>
+        <Text style={styles.accountText}>
+          <Text type="lg-bold" style={pal.text}>
+            {profile?.displayName || account.handle}{' '}
+          </Text>
+          <Text type="lg" style={[pal.textLight]}>
+            {account.handle}
+          </Text>
+        </Text>
+        {isCurrentAccount ? (
+          <FontAwesomeIcon
+            icon="check"
+            size={16}
+            style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]}
+          />
+        ) : (
+          <FontAwesomeIcon
+            icon="angle-right"
+            size={16}
+            style={[pal.text, s.mr10]}
+          />
+        )}
+      </View>
+    </TouchableOpacity>
+  )
+}
+export const ChooseAccountForm = ({
+  onSelectAccount,
+  onPressBack,
+}: {
+  onSelectAccount: (account?: SessionAccount) => void
+  onPressBack: () => void
+}) => {
+  const {track, screen} = useAnalytics()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {accounts, currentAccount} = useSession()
+  const {initSession} = useSessionApi()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+
+  React.useEffect(() => {
+    screen('Choose Account')
+  }, [screen])
+
+  const onSelect = React.useCallback(
+    async (account: SessionAccount) => {
+      if (account.accessJwt) {
+        if (account.did === currentAccount?.did) {
+          setShowLoggedOut(false)
+          Toast.show(`Already signed in as @${account.handle}`)
+        } else {
+          await initSession(account)
+          track('Sign In', {resumedSession: true})
+          setTimeout(() => {
+            Toast.show(`Signed in as @${account.handle}`)
+          }, 100)
+        }
+      } else {
+        onSelectAccount(account)
+      }
+    },
+    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut],
+  )
+
+  return (
+    <ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
+      <Text
+        type="2xl-medium"
+        style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
+        <Trans>Sign in as...</Trans>
+      </Text>
+      {accounts.map(account => (
+        <AccountItem
+          key={account.did}
+          account={account}
+          onSelect={onSelect}
+          isCurrentAccount={account.did === currentAccount?.did}
+        />
+      ))}
+      <TouchableOpacity
+        testID="chooseNewAccountBtn"
+        style={[pal.view, pal.border, styles.account, styles.accountLast]}
+        onPress={() => onSelectAccount(undefined)}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Login to account that is not listed`)}
+        accessibilityHint="">
+        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+          <Text style={[styles.accountText, styles.accountTextOther]}>
+            <Text type="lg" style={pal.text}>
+              <Trans>Other account</Trans>
+            </Text>
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            size={16}
+            style={[pal.text, s.mr10]}
+          />
+        </View>
+      </TouchableOpacity>
+      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+          <Text type="xl" style={[pal.link, s.pl5]}>
+            <Trans>Back</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+      </View>
+    </ScrollView>
+  )
+}
diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx
new file mode 100644
index 000000000..215c393d9
--- /dev/null
+++ b/src/view/com/auth/login/ForgotPasswordForm.tsx
@@ -0,0 +1,197 @@
+import React, {useState, useEffect} from 'react'
+import {
+  ActivityIndicator,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import * as EmailValidator from 'email-validator'
+import {BskyAgent} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {styles} from './styles'
+import {useModalControls} from '#/state/modals'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const ForgotPasswordForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  setError,
+  setServiceUrl,
+  onPressBack,
+  onEmailSent,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressBack: () => void
+  onEmailSent: () => void
+}) => {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [email, setEmail] = useState<string>('')
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+
+  useEffect(() => {
+    screen('Signin:ForgotPassword')
+  }, [screen])
+
+  const onPressSelectService = () => {
+    openModal({
+      name: 'server-input',
+      initialService: serviceUrl,
+      onSelect: setServiceUrl,
+    })
+  }
+
+  const onPressNext = async () => {
+    if (!EmailValidator.validate(email)) {
+      return setError('Your email appears to be invalid.')
+    }
+
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.requestPasswordReset({email})
+      onEmailSent()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to request password reset', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <>
+      <View>
+        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
+          <Trans>Reset password</Trans>
+        </Text>
+        <Text type="md" style={[pal.text, styles.instructions]}>
+          <Trans>
+            Enter the email you used to create your account. We'll send you a
+            "reset code" so you can set a new password.
+          </Trans>
+        </Text>
+        <View
+          testID="forgotPasswordView"
+          style={[pal.borderDark, pal.view, styles.group]}>
+          <TouchableOpacity
+            testID="forgotPasswordSelectServiceButton"
+            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
+            onPress={onPressSelectService}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Hosting provider`)}
+            accessibilityHint="Sets hosting provider for password reset">
+            <FontAwesomeIcon
+              icon="globe"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <Text style={[pal.text, styles.textInput]} numberOfLines={1}>
+              {toNiceDomain(serviceUrl)}
+            </Text>
+            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
+              <FontAwesomeIcon
+                icon="pen"
+                size={12}
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+          </TouchableOpacity>
+          <View style={[pal.borderDark, styles.groupContent]}>
+            <FontAwesomeIcon
+              icon="envelope"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TextInput
+              testID="forgotPasswordEmail"
+              style={[pal.text, styles.textInput]}
+              placeholder="Email address"
+              placeholderTextColor={pal.colors.textLight}
+              autoCapitalize="none"
+              autoFocus
+              autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
+              value={email}
+              onChangeText={setEmail}
+              editable={!isProcessing}
+              accessibilityLabel={_(msg`Email`)}
+              accessibilityHint="Sets email for password reset"
+            />
+          </View>
+        </View>
+        {error ? (
+          <View style={styles.error}>
+            <View style={styles.errorIcon}>
+              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+            </View>
+            <View style={s.flex1}>
+              <Text style={[s.white, s.bold]}>{error}</Text>
+            </View>
+          </View>
+        ) : undefined}
+        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+            <Text type="xl" style={[pal.link, s.pl5]}>
+              <Trans>Back</Trans>
+            </Text>
+          </TouchableOpacity>
+          <View style={s.flex1} />
+          {!serviceDescription || isProcessing ? (
+            <ActivityIndicator />
+          ) : !email ? (
+            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
+              <Trans>Next</Trans>
+            </Text>
+          ) : (
+            <TouchableOpacity
+              testID="newPasswordButton"
+              onPress={onPressNext}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Go to next`)}
+              accessibilityHint="Navigates to the next screen">
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                <Trans>Next</Trans>
+              </Text>
+            </TouchableOpacity>
+          )}
+          {!serviceDescription || isProcessing ? (
+            <Text type="xl" style={[pal.textLight, s.pl10]}>
+              <Trans>Processing...</Trans>
+            </Text>
+          ) : undefined}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index acc05b6ca..67d0afdf1 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -1,36 +1,19 @@
-import React, {useState, useEffect, useRef} from 'react'
-import {
-  ActivityIndicator,
-  Keyboard,
-  KeyboardAvoidingView,
-  ScrollView,
-  StyleSheet,
-  TextInput,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import * as EmailValidator from 'email-validator'
-import {BskyAgent} from '@atproto/api'
+import React, {useState, useEffect} from 'react'
+import {KeyboardAvoidingView} from 'react-native'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {Text} from '../../util/text/Text'
-import {UserAvatar} from '../../util/UserAvatar'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
-import {s, colors} from 'lib/styles'
-import {createFullHandle} from 'lib/strings/handles'
-import {toNiceDomain} from 'lib/strings/url-helpers'
-import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
-import {ServiceDescription} from 'state/models/session'
-import {AccountData} from 'state/models/session'
-import {isNetworkError} from 'lib/strings/errors'
+import {DEFAULT_SERVICE} from '#/lib/constants'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {cleanError} from 'lib/strings/errors'
-import {isWeb} from 'platform/detection'
 import {logger} from '#/logger'
+import {ChooseAccountForm} from './ChooseAccountForm'
+import {LoginForm} from './LoginForm'
+import {ForgotPasswordForm} from './ForgotPasswordForm'
+import {SetNewPasswordForm} from './SetNewPasswordForm'
+import {PasswordUpdatedForm} from './PasswordUpdatedForm'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {useSession, SessionAccount} from '#/state/session'
+import {useServiceQuery} from '#/state/queries/service'
 
 enum Forms {
   Login,
@@ -42,20 +25,22 @@ enum Forms {
 
 export const Login = ({onPressBack}: {onPressBack: () => void}) => {
   const pal = usePalette('default')
-  const store = useStores()
+  const {accounts} = useSession()
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const [error, setError] = useState<string>('')
-  const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
   const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
-  const [serviceDescription, setServiceDescription] = useState<
-    ServiceDescription | undefined
-  >(undefined)
   const [initialHandle, setInitialHandle] = useState<string>('')
   const [currentForm, setCurrentForm] = useState<Forms>(
-    store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login,
+    accounts.length ? Forms.ChooseAccount : Forms.Login,
   )
+  const {
+    data: serviceDescription,
+    error: serviceError,
+    refetch: refetchService,
+  } = useServiceQuery(serviceUrl)
 
-  const onSelectAccount = (account?: AccountData) => {
+  const onSelectAccount = (account?: SessionAccount) => {
     if (account?.service) {
       setServiceUrl(account.service)
     }
@@ -69,33 +54,21 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
   }
 
   useEffect(() => {
-    let aborted = false
-    setError('')
-    store.session.describeService(serviceUrl).then(
-      desc => {
-        if (aborted) {
-          return
-        }
-        setServiceDescription(desc)
-      },
-      err => {
-        if (aborted) {
-          return
-        }
-        logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
-          error: err,
-        })
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      },
-    )
-    return () => {
-      aborted = true
+    if (serviceError) {
+      setError(
+        _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      )
+      logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
+        error: String(serviceError),
+      })
+    } else {
+      setError('')
     }
-  }, [store.session, serviceUrl, retryDescribeTrigger])
+  }, [serviceError, serviceUrl, _])
 
-  const onPressRetryConnect = () => setRetryDescribeTrigger({})
+  const onPressRetryConnect = () => refetchService()
   const onPressForgotPassword = () => {
     track('Signin:PressedForgotPassword')
     setCurrentForm(Forms.ForgotPassword)
@@ -106,10 +79,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.Login ? (
         <LoggedOutLayout
           leadin=""
-          title="Sign in"
-          description="Enter your username and password">
+          title={_(msg`Sign in`)}
+          description={_(msg`Enter your username and password`)}>
           <LoginForm
-            store={store}
             error={error}
             serviceUrl={serviceUrl}
             serviceDescription={serviceDescription}
@@ -125,10 +97,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.ChooseAccount ? (
         <LoggedOutLayout
           leadin=""
-          title="Sign in as..."
-          description="Select from an existing account">
+          title={_(msg`Sign in as...`)}
+          description={_(msg`Select from an existing account`)}>
           <ChooseAccountForm
-            store={store}
             onSelectAccount={onSelectAccount}
             onPressBack={onPressBack}
           />
@@ -137,10 +108,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.ForgotPassword ? (
         <LoggedOutLayout
           leadin=""
-          title="Forgot Password"
-          description="Let's get your password reset!">
+          title={_(msg`Forgot Password`)}
+          description={_(msg`Let's get your password reset!`)}>
           <ForgotPasswordForm
-            store={store}
             error={error}
             serviceUrl={serviceUrl}
             serviceDescription={serviceDescription}
@@ -154,10 +124,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
       {currentForm === Forms.SetNewPassword ? (
         <LoggedOutLayout
           leadin=""
-          title="Forgot Password"
-          description="Let's get your password reset!">
+          title={_(msg`Forgot Password`)}
+          description={_(msg`Let's get your password reset!`)}>
           <SetNewPasswordForm
-            store={store}
             error={error}
             serviceUrl={serviceUrl}
             setError={setError}
@@ -167,834 +136,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
         </LoggedOutLayout>
       ) : undefined}
       {currentForm === Forms.PasswordUpdated ? (
-        <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
+        <LoggedOutLayout
+          leadin=""
+          title={_(msg`Password updated`)}
+          description={_(msg`You can now sign in with your new password.`)}>
+          <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
+        </LoggedOutLayout>
       ) : undefined}
     </KeyboardAvoidingView>
   )
 }
-
-const ChooseAccountForm = ({
-  store,
-  onSelectAccount,
-  onPressBack,
-}: {
-  store: RootStoreModel
-  onSelectAccount: (account?: AccountData) => void
-  onPressBack: () => void
-}) => {
-  const {track, screen} = useAnalytics()
-  const pal = usePalette('default')
-  const [isProcessing, setIsProcessing] = React.useState(false)
-
-  React.useEffect(() => {
-    screen('Choose Account')
-  }, [screen])
-
-  const onTryAccount = async (account: AccountData) => {
-    if (account.accessJwt && account.refreshJwt) {
-      setIsProcessing(true)
-      if (await store.session.resumeSession(account)) {
-        track('Sign In', {resumedSession: true})
-        setIsProcessing(false)
-        return
-      }
-      setIsProcessing(false)
-    }
-    onSelectAccount(account)
-  }
-
-  return (
-    <ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
-      <Text
-        type="2xl-medium"
-        style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}>
-        Sign in as...
-      </Text>
-      {store.session.accounts.map(account => (
-        <TouchableOpacity
-          testID={`chooseAccountBtn-${account.handle}`}
-          key={account.did}
-          style={[pal.view, pal.border, styles.account]}
-          onPress={() => onTryAccount(account)}
-          accessibilityRole="button"
-          accessibilityLabel={`Sign in as ${account.handle}`}
-          accessibilityHint="Double tap to sign in">
-          <View
-            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-            <View style={s.p10}>
-              <UserAvatar avatar={account.aviUrl} size={30} />
-            </View>
-            <Text style={styles.accountText}>
-              <Text type="lg-bold" style={pal.text}>
-                {account.displayName || account.handle}{' '}
-              </Text>
-              <Text type="lg" style={[pal.textLight]}>
-                {account.handle}
-              </Text>
-            </Text>
-            <FontAwesomeIcon
-              icon="angle-right"
-              size={16}
-              style={[pal.text, s.mr10]}
-            />
-          </View>
-        </TouchableOpacity>
-      ))}
-      <TouchableOpacity
-        testID="chooseNewAccountBtn"
-        style={[pal.view, pal.border, styles.account, styles.accountLast]}
-        onPress={() => onSelectAccount(undefined)}
-        accessibilityRole="button"
-        accessibilityLabel="Login to account that is not listed"
-        accessibilityHint="">
-        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-          <Text style={[styles.accountText, styles.accountTextOther]}>
-            <Text type="lg" style={pal.text}>
-              Other account
-            </Text>
-          </Text>
-          <FontAwesomeIcon
-            icon="angle-right"
-            size={16}
-            style={[pal.text, s.mr10]}
-          />
-        </View>
-      </TouchableOpacity>
-      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-          <Text type="xl" style={[pal.link, s.pl5]}>
-            Back
-          </Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        {isProcessing && <ActivityIndicator />}
-      </View>
-    </ScrollView>
-  )
-}
-
-const LoginForm = ({
-  store,
-  error,
-  serviceUrl,
-  serviceDescription,
-  initialHandle,
-  setError,
-  setServiceUrl,
-  onPressRetryConnect,
-  onPressBack,
-  onPressForgotPassword,
-}: {
-  store: RootStoreModel
-  error: string
-  serviceUrl: string
-  serviceDescription: ServiceDescription | undefined
-  initialHandle: string
-  setError: (v: string) => void
-  setServiceUrl: (v: string) => void
-  onPressRetryConnect: () => void
-  onPressBack: () => void
-  onPressForgotPassword: () => void
-}) => {
-  const {track} = useAnalytics()
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [identifier, setIdentifier] = useState<string>(initialHandle)
-  const [password, setPassword] = useState<string>('')
-  const passwordInputRef = useRef<TextInput>(null)
-
-  const onPressSelectService = () => {
-    store.shell.openModal({
-      name: 'server-input',
-      initialService: serviceUrl,
-      onSelect: setServiceUrl,
-    })
-    Keyboard.dismiss()
-    track('Signin:PressedSelectService')
-  }
-
-  const onPressNext = async () => {
-    Keyboard.dismiss()
-    setError('')
-    setIsProcessing(true)
-
-    try {
-      // try to guess the handle if the user just gave their own username
-      let fullIdent = identifier
-      if (
-        !identifier.includes('@') && // not an email
-        !identifier.includes('.') && // not a domain
-        serviceDescription &&
-        serviceDescription.availableUserDomains.length > 0
-      ) {
-        let matched = false
-        for (const domain of serviceDescription.availableUserDomains) {
-          if (fullIdent.endsWith(domain)) {
-            matched = true
-          }
-        }
-        if (!matched) {
-          fullIdent = createFullHandle(
-            identifier,
-            serviceDescription.availableUserDomains[0],
-          )
-        }
-      }
-
-      await store.session.login({
-        service: serviceUrl,
-        identifier: fullIdent,
-        password,
-      })
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to login', {error: e})
-      setIsProcessing(false)
-      if (errMsg.includes('Authentication Required')) {
-        setError('Invalid username or password')
-      } else if (isNetworkError(e)) {
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    } finally {
-      track('Sign In', {resumedSession: false})
-    }
-  }
-
-  const isReady = !!serviceDescription && !!identifier && !!password
-  return (
-    <View testID="loginForm">
-      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
-        Sign into
-      </Text>
-      <View style={[pal.borderDark, styles.group]}>
-        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-          <FontAwesomeIcon
-            icon="globe"
-            style={[pal.textLight, styles.groupContentIcon]}
-          />
-          <TouchableOpacity
-            testID="loginSelectServiceButton"
-            style={styles.textBtn}
-            onPress={onPressSelectService}
-            accessibilityRole="button"
-            accessibilityLabel="Select service"
-            accessibilityHint="Sets server for the Bluesky client">
-            <Text type="xl" style={[pal.text, styles.textBtnLabel]}>
-              {toNiceDomain(serviceUrl)}
-            </Text>
-            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
-              <FontAwesomeIcon
-                icon="pen"
-                size={12}
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            </View>
-          </TouchableOpacity>
-        </View>
-      </View>
-      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
-        Account
-      </Text>
-      <View style={[pal.borderDark, styles.group]}>
-        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-          <FontAwesomeIcon
-            icon="at"
-            style={[pal.textLight, styles.groupContentIcon]}
-          />
-          <TextInput
-            testID="loginUsernameInput"
-            style={[pal.text, styles.textInput]}
-            placeholder="Username or email address"
-            placeholderTextColor={pal.colors.textLight}
-            autoCapitalize="none"
-            autoFocus
-            autoCorrect={false}
-            autoComplete="username"
-            returnKeyType="next"
-            onSubmitEditing={() => {
-              passwordInputRef.current?.focus()
-            }}
-            blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
-            keyboardAppearance={theme.colorScheme}
-            value={identifier}
-            onChangeText={str =>
-              setIdentifier((str || '').toLowerCase().trim())
-            }
-            editable={!isProcessing}
-            accessibilityLabel="Username or email address"
-            accessibilityHint="Input the username or email address you used at signup"
-          />
-        </View>
-        <View style={[pal.borderDark, styles.groupContent]}>
-          <FontAwesomeIcon
-            icon="lock"
-            style={[pal.textLight, styles.groupContentIcon]}
-          />
-          <TextInput
-            testID="loginPasswordInput"
-            ref={passwordInputRef}
-            style={[pal.text, styles.textInput]}
-            placeholder="Password"
-            placeholderTextColor={pal.colors.textLight}
-            autoCapitalize="none"
-            autoCorrect={false}
-            autoComplete="password"
-            returnKeyType="done"
-            enablesReturnKeyAutomatically={true}
-            keyboardAppearance={theme.colorScheme}
-            secureTextEntry={true}
-            textContentType="password"
-            clearButtonMode="while-editing"
-            value={password}
-            onChangeText={setPassword}
-            onSubmitEditing={onPressNext}
-            blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
-            editable={!isProcessing}
-            accessibilityLabel="Password"
-            accessibilityHint={
-              identifier === ''
-                ? 'Input your password'
-                : `Input the password tied to ${identifier}`
-            }
-          />
-          <TouchableOpacity
-            testID="forgotPasswordButton"
-            style={styles.textInputInnerBtn}
-            onPress={onPressForgotPassword}
-            accessibilityRole="button"
-            accessibilityLabel="Forgot password"
-            accessibilityHint="Opens password reset form">
-            <Text style={pal.link}>Forgot</Text>
-          </TouchableOpacity>
-        </View>
-      </View>
-      {error ? (
-        <View style={styles.error}>
-          <View style={styles.errorIcon}>
-            <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-          </View>
-          <View style={s.flex1}>
-            <Text style={[s.white, s.bold]}>{error}</Text>
-          </View>
-        </View>
-      ) : undefined}
-      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-          <Text type="xl" style={[pal.link, s.pl5]}>
-            Back
-          </Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        {!serviceDescription && error ? (
-          <TouchableOpacity
-            testID="loginRetryButton"
-            onPress={onPressRetryConnect}
-            accessibilityRole="button"
-            accessibilityLabel="Retry"
-            accessibilityHint="Retries login">
-            <Text type="xl-bold" style={[pal.link, s.pr5]}>
-              Retry
-            </Text>
-          </TouchableOpacity>
-        ) : !serviceDescription ? (
-          <>
-            <ActivityIndicator />
-            <Text type="xl" style={[pal.textLight, s.pl10]}>
-              Connecting...
-            </Text>
-          </>
-        ) : isProcessing ? (
-          <ActivityIndicator />
-        ) : isReady ? (
-          <TouchableOpacity
-            testID="loginNextButton"
-            onPress={onPressNext}
-            accessibilityRole="button"
-            accessibilityLabel="Go to next"
-            accessibilityHint="Navigates to the next screen">
-            <Text type="xl-bold" style={[pal.link, s.pr5]}>
-              Next
-            </Text>
-          </TouchableOpacity>
-        ) : undefined}
-      </View>
-    </View>
-  )
-}
-
-const ForgotPasswordForm = ({
-  store,
-  error,
-  serviceUrl,
-  serviceDescription,
-  setError,
-  setServiceUrl,
-  onPressBack,
-  onEmailSent,
-}: {
-  store: RootStoreModel
-  error: string
-  serviceUrl: string
-  serviceDescription: ServiceDescription | undefined
-  setError: (v: string) => void
-  setServiceUrl: (v: string) => void
-  onPressBack: () => void
-  onEmailSent: () => void
-}) => {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [email, setEmail] = useState<string>('')
-  const {screen} = useAnalytics()
-
-  useEffect(() => {
-    screen('Signin:ForgotPassword')
-  }, [screen])
-
-  const onPressSelectService = () => {
-    store.shell.openModal({
-      name: 'server-input',
-      initialService: serviceUrl,
-      onSelect: setServiceUrl,
-    })
-  }
-
-  const onPressNext = async () => {
-    if (!EmailValidator.validate(email)) {
-      return setError('Your email appears to be invalid.')
-    }
-
-    setError('')
-    setIsProcessing(true)
-
-    try {
-      const agent = new BskyAgent({service: serviceUrl})
-      await agent.com.atproto.server.requestPasswordReset({email})
-      onEmailSent()
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to request password reset', {error: e})
-      setIsProcessing(false)
-      if (isNetworkError(e)) {
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    }
-  }
-
-  return (
-    <>
-      <View>
-        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
-          Reset password
-        </Text>
-        <Text type="md" style={[pal.text, styles.instructions]}>
-          Enter the email you used to create your account. We'll send you a
-          "reset code" so you can set a new password.
-        </Text>
-        <View
-          testID="forgotPasswordView"
-          style={[pal.borderDark, pal.view, styles.group]}>
-          <TouchableOpacity
-            testID="forgotPasswordSelectServiceButton"
-            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}
-            onPress={onPressSelectService}
-            accessibilityRole="button"
-            accessibilityLabel="Hosting provider"
-            accessibilityHint="Sets hosting provider for password reset">
-            <FontAwesomeIcon
-              icon="globe"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <Text style={[pal.text, styles.textInput]} numberOfLines={1}>
-              {toNiceDomain(serviceUrl)}
-            </Text>
-            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
-              <FontAwesomeIcon
-                icon="pen"
-                size={12}
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-          </TouchableOpacity>
-          <View style={[pal.borderDark, styles.groupContent]}>
-            <FontAwesomeIcon
-              icon="envelope"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <TextInput
-              testID="forgotPasswordEmail"
-              style={[pal.text, styles.textInput]}
-              placeholder="Email address"
-              placeholderTextColor={pal.colors.textLight}
-              autoCapitalize="none"
-              autoFocus
-              autoCorrect={false}
-              keyboardAppearance={theme.colorScheme}
-              value={email}
-              onChangeText={setEmail}
-              editable={!isProcessing}
-              accessibilityLabel="Email"
-              accessibilityHint="Sets email for password reset"
-            />
-          </View>
-        </View>
-        {error ? (
-          <View style={styles.error}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-            </View>
-            <View style={s.flex1}>
-              <Text style={[s.white, s.bold]}>{error}</Text>
-            </View>
-          </View>
-        ) : undefined}
-        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-            <Text type="xl" style={[pal.link, s.pl5]}>
-              Back
-            </Text>
-          </TouchableOpacity>
-          <View style={s.flex1} />
-          {!serviceDescription || isProcessing ? (
-            <ActivityIndicator />
-          ) : !email ? (
-            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
-              Next
-            </Text>
-          ) : (
-            <TouchableOpacity
-              testID="newPasswordButton"
-              onPress={onPressNext}
-              accessibilityRole="button"
-              accessibilityLabel="Go to next"
-              accessibilityHint="Navigates to the next screen">
-              <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                Next
-              </Text>
-            </TouchableOpacity>
-          )}
-          {!serviceDescription || isProcessing ? (
-            <Text type="xl" style={[pal.textLight, s.pl10]}>
-              Processing...
-            </Text>
-          ) : undefined}
-        </View>
-      </View>
-    </>
-  )
-}
-
-const SetNewPasswordForm = ({
-  error,
-  serviceUrl,
-  setError,
-  onPressBack,
-  onPasswordSet,
-}: {
-  store: RootStoreModel
-  error: string
-  serviceUrl: string
-  setError: (v: string) => void
-  onPressBack: () => void
-  onPasswordSet: () => void
-}) => {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {screen} = useAnalytics()
-
-  useEffect(() => {
-    screen('Signin:SetNewPasswordForm')
-  }, [screen])
-
-  const [isProcessing, setIsProcessing] = useState<boolean>(false)
-  const [resetCode, setResetCode] = useState<string>('')
-  const [password, setPassword] = useState<string>('')
-
-  const onPressNext = async () => {
-    setError('')
-    setIsProcessing(true)
-
-    try {
-      const agent = new BskyAgent({service: serviceUrl})
-      const token = resetCode.replace(/\s/g, '')
-      await agent.com.atproto.server.resetPassword({
-        token,
-        password,
-      })
-      onPasswordSet()
-    } catch (e: any) {
-      const errMsg = e.toString()
-      logger.warn('Failed to set new password', {error: e})
-      setIsProcessing(false)
-      if (isNetworkError(e)) {
-        setError(
-          'Unable to contact your service. Please check your Internet connection.',
-        )
-      } else {
-        setError(cleanError(errMsg))
-      }
-    }
-  }
-
-  return (
-    <>
-      <View>
-        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
-          Set new password
-        </Text>
-        <Text type="lg" style={[pal.text, styles.instructions]}>
-          You will receive an email with a "reset code." Enter that code here,
-          then enter your new password.
-        </Text>
-        <View
-          testID="newPasswordView"
-          style={[pal.view, pal.borderDark, styles.group]}>
-          <View
-            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
-            <FontAwesomeIcon
-              icon="ticket"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <TextInput
-              testID="resetCodeInput"
-              style={[pal.text, styles.textInput]}
-              placeholder="Reset code"
-              placeholderTextColor={pal.colors.textLight}
-              autoCapitalize="none"
-              autoCorrect={false}
-              keyboardAppearance={theme.colorScheme}
-              autoFocus
-              value={resetCode}
-              onChangeText={setResetCode}
-              editable={!isProcessing}
-              accessible={true}
-              accessibilityLabel="Reset code"
-              accessibilityHint="Input code sent to your email for password reset"
-            />
-          </View>
-          <View style={[pal.borderDark, styles.groupContent]}>
-            <FontAwesomeIcon
-              icon="lock"
-              style={[pal.textLight, styles.groupContentIcon]}
-            />
-            <TextInput
-              testID="newPasswordInput"
-              style={[pal.text, styles.textInput]}
-              placeholder="New password"
-              placeholderTextColor={pal.colors.textLight}
-              autoCapitalize="none"
-              autoCorrect={false}
-              keyboardAppearance={theme.colorScheme}
-              secureTextEntry
-              value={password}
-              onChangeText={setPassword}
-              editable={!isProcessing}
-              accessible={true}
-              accessibilityLabel="Password"
-              accessibilityHint="Input new password"
-            />
-          </View>
-        </View>
-        {error ? (
-          <View style={styles.error}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
-            </View>
-            <View style={s.flex1}>
-              <Text style={[s.white, s.bold]}>{error}</Text>
-            </View>
-          </View>
-        ) : undefined}
-        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
-            <Text type="xl" style={[pal.link, s.pl5]}>
-              Back
-            </Text>
-          </TouchableOpacity>
-          <View style={s.flex1} />
-          {isProcessing ? (
-            <ActivityIndicator />
-          ) : !resetCode || !password ? (
-            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
-              Next
-            </Text>
-          ) : (
-            <TouchableOpacity
-              testID="setNewPasswordButton"
-              onPress={onPressNext}
-              accessibilityRole="button"
-              accessibilityLabel="Go to next"
-              accessibilityHint="Navigates to the next screen">
-              <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                Next
-              </Text>
-            </TouchableOpacity>
-          )}
-          {isProcessing ? (
-            <Text type="xl" style={[pal.textLight, s.pl10]}>
-              Updating...
-            </Text>
-          ) : undefined}
-        </View>
-      </View>
-    </>
-  )
-}
-
-const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
-  const {screen} = useAnalytics()
-
-  useEffect(() => {
-    screen('Signin:PasswordUpdatedForm')
-  }, [screen])
-
-  const pal = usePalette('default')
-  return (
-    <>
-      <View>
-        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
-          Password updated!
-        </Text>
-        <Text type="lg" style={[pal.text, styles.instructions]}>
-          You can now sign in with your new password.
-        </Text>
-        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
-          <View style={s.flex1} />
-          <TouchableOpacity
-            onPress={onPressNext}
-            accessibilityRole="button"
-            accessibilityLabel="Close alert"
-            accessibilityHint="Closes password update alert">
-            <Text type="xl-bold" style={[pal.link, s.pr5]}>
-              Okay
-            </Text>
-          </TouchableOpacity>
-        </View>
-      </View>
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  screenTitle: {
-    marginBottom: 10,
-    marginHorizontal: 20,
-  },
-  instructions: {
-    marginBottom: 20,
-    marginHorizontal: 20,
-  },
-  group: {
-    borderWidth: 1,
-    borderRadius: 10,
-    marginBottom: 20,
-    marginHorizontal: 20,
-  },
-  groupLabel: {
-    paddingHorizontal: 20,
-    paddingBottom: 5,
-  },
-  groupContent: {
-    borderTopWidth: 1,
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  noTopBorder: {
-    borderTopWidth: 0,
-  },
-  groupContentIcon: {
-    marginLeft: 10,
-  },
-  account: {
-    borderTopWidth: 1,
-    paddingHorizontal: 20,
-    paddingVertical: 4,
-  },
-  accountLast: {
-    borderBottomWidth: 1,
-    marginBottom: 20,
-    paddingVertical: 8,
-  },
-  textInput: {
-    flex: 1,
-    width: '100%',
-    paddingVertical: 10,
-    paddingHorizontal: 12,
-    fontSize: 17,
-    letterSpacing: 0.25,
-    fontWeight: '400',
-    borderRadius: 10,
-  },
-  textInputInnerBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 6,
-    paddingHorizontal: 8,
-    marginHorizontal: 6,
-  },
-  textBtn: {
-    flexDirection: 'row',
-    flex: 1,
-    alignItems: 'center',
-  },
-  textBtnLabel: {
-    flex: 1,
-    paddingVertical: 10,
-    paddingHorizontal: 12,
-  },
-  textBtnFakeInnerBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 6,
-    paddingVertical: 6,
-    paddingHorizontal: 8,
-    marginHorizontal: 6,
-  },
-  accountText: {
-    flex: 1,
-    flexDirection: 'row',
-    alignItems: 'baseline',
-    paddingVertical: 10,
-  },
-  accountTextOther: {
-    paddingLeft: 12,
-  },
-  error: {
-    backgroundColor: colors.red4,
-    flexDirection: 'row',
-    alignItems: 'center',
-    marginTop: -5,
-    marginHorizontal: 20,
-    marginBottom: 15,
-    borderRadius: 8,
-    paddingHorizontal: 8,
-    paddingVertical: 8,
-  },
-  errorIcon: {
-    borderWidth: 1,
-    borderColor: colors.white,
-    color: colors.white,
-    borderRadius: 30,
-    width: 16,
-    height: 16,
-    alignItems: 'center',
-    justifyContent: 'center',
-    marginRight: 5,
-  },
-  dimmed: {opacity: 0.5},
-
-  maxHeight: {
-    // @ts-ignore web only -prf
-    maxHeight: isWeb ? '100vh' : undefined,
-    height: !isWeb ? '100%' : undefined,
-  },
-})
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
new file mode 100644
index 000000000..365f2e253
--- /dev/null
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -0,0 +1,290 @@
+import React, {useState, useRef} from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {createFullHandle} from 'lib/strings/handles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {useSessionApi} from '#/state/session'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {styles} from './styles'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const LoginForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  initialHandle,
+  setError,
+  setServiceUrl,
+  onPressRetryConnect,
+  onPressBack,
+  onPressForgotPassword,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  initialHandle: string
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressRetryConnect: () => void
+  onPressBack: () => void
+  onPressForgotPassword: () => void
+}) => {
+  const {track} = useAnalytics()
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [identifier, setIdentifier] = useState<string>(initialHandle)
+  const [password, setPassword] = useState<string>('')
+  const passwordInputRef = useRef<TextInput>(null)
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const {login} = useSessionApi()
+
+  const onPressSelectService = () => {
+    openModal({
+      name: 'server-input',
+      initialService: serviceUrl,
+      onSelect: setServiceUrl,
+    })
+    Keyboard.dismiss()
+    track('Signin:PressedSelectService')
+  }
+
+  const onPressNext = async () => {
+    Keyboard.dismiss()
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      // try to guess the handle if the user just gave their own username
+      let fullIdent = identifier
+      if (
+        !identifier.includes('@') && // not an email
+        !identifier.includes('.') && // not a domain
+        serviceDescription &&
+        serviceDescription.availableUserDomains.length > 0
+      ) {
+        let matched = false
+        for (const domain of serviceDescription.availableUserDomains) {
+          if (fullIdent.endsWith(domain)) {
+            matched = true
+          }
+        }
+        if (!matched) {
+          fullIdent = createFullHandle(
+            identifier,
+            serviceDescription.availableUserDomains[0],
+          )
+        }
+      }
+
+      // TODO remove double login
+      await login({
+        service: serviceUrl,
+        identifier: fullIdent,
+        password,
+      })
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to login', {error: e})
+      setIsProcessing(false)
+      if (errMsg.includes('Authentication Required')) {
+        setError(_(msg`Invalid username or password`))
+      } else if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    } finally {
+      track('Sign In', {resumedSession: false})
+    }
+  }
+
+  const isReady = !!serviceDescription && !!identifier && !!password
+  return (
+    <View testID="loginForm">
+      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
+        <Trans>Sign into</Trans>
+      </Text>
+      <View style={[pal.borderDark, styles.group]}>
+        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+          <FontAwesomeIcon
+            icon="globe"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TouchableOpacity
+            testID="loginSelectServiceButton"
+            style={styles.textBtn}
+            onPress={onPressSelectService}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Select service`)}
+            accessibilityHint="Sets server for the Bluesky client">
+            <Text type="xl" style={[pal.text, styles.textBtnLabel]}>
+              {toNiceDomain(serviceUrl)}
+            </Text>
+            <View style={[pal.btn, styles.textBtnFakeInnerBtn]}>
+              <FontAwesomeIcon
+                icon="pen"
+                size={12}
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </View>
+          </TouchableOpacity>
+        </View>
+      </View>
+      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
+        <Trans>Account</Trans>
+      </Text>
+      <View style={[pal.borderDark, styles.group]}>
+        <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+          <FontAwesomeIcon
+            icon="at"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TextInput
+            testID="loginUsernameInput"
+            style={[pal.text, styles.textInput]}
+            placeholder={_(msg`Username or email address`)}
+            placeholderTextColor={pal.colors.textLight}
+            autoCapitalize="none"
+            autoFocus
+            autoCorrect={false}
+            autoComplete="username"
+            returnKeyType="next"
+            onSubmitEditing={() => {
+              passwordInputRef.current?.focus()
+            }}
+            blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
+            keyboardAppearance={theme.colorScheme}
+            value={identifier}
+            onChangeText={str =>
+              setIdentifier((str || '').toLowerCase().trim())
+            }
+            editable={!isProcessing}
+            accessibilityLabel={_(msg`Username or email address`)}
+            accessibilityHint="Input the username or email address you used at signup"
+          />
+        </View>
+        <View style={[pal.borderDark, styles.groupContent]}>
+          <FontAwesomeIcon
+            icon="lock"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TextInput
+            testID="loginPasswordInput"
+            ref={passwordInputRef}
+            style={[pal.text, styles.textInput]}
+            placeholder="Password"
+            placeholderTextColor={pal.colors.textLight}
+            autoCapitalize="none"
+            autoCorrect={false}
+            autoComplete="password"
+            returnKeyType="done"
+            enablesReturnKeyAutomatically={true}
+            keyboardAppearance={theme.colorScheme}
+            secureTextEntry={true}
+            textContentType="password"
+            clearButtonMode="while-editing"
+            value={password}
+            onChangeText={setPassword}
+            onSubmitEditing={onPressNext}
+            blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
+            editable={!isProcessing}
+            accessibilityLabel={_(msg`Password`)}
+            accessibilityHint={
+              identifier === ''
+                ? 'Input your password'
+                : `Input the password tied to ${identifier}`
+            }
+          />
+          <TouchableOpacity
+            testID="forgotPasswordButton"
+            style={styles.textInputInnerBtn}
+            onPress={onPressForgotPassword}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Forgot password`)}
+            accessibilityHint="Opens password reset form">
+            <Text style={pal.link}>
+              <Trans>Forgot</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </View>
+      {error ? (
+        <View style={styles.error}>
+          <View style={styles.errorIcon}>
+            <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+          </View>
+          <View style={s.flex1}>
+            <Text style={[s.white, s.bold]}>{error}</Text>
+          </View>
+        </View>
+      ) : undefined}
+      <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+        <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+          <Text type="xl" style={[pal.link, s.pl5]}>
+            <Trans>Back</Trans>
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        {!serviceDescription && error ? (
+          <TouchableOpacity
+            testID="loginRetryButton"
+            onPress={onPressRetryConnect}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Retry`)}
+            accessibilityHint="Retries login">
+            <Text type="xl-bold" style={[pal.link, s.pr5]}>
+              <Trans>Retry</Trans>
+            </Text>
+          </TouchableOpacity>
+        ) : !serviceDescription ? (
+          <>
+            <ActivityIndicator />
+            <Text type="xl" style={[pal.textLight, s.pl10]}>
+              <Trans>Connecting...</Trans>
+            </Text>
+          </>
+        ) : isProcessing ? (
+          <ActivityIndicator />
+        ) : isReady ? (
+          <TouchableOpacity
+            testID="loginNextButton"
+            onPress={onPressNext}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Go to next`)}
+            accessibilityHint="Navigates to the next screen">
+            <Text type="xl-bold" style={[pal.link, s.pr5]}>
+              <Trans>Next</Trans>
+            </Text>
+          </TouchableOpacity>
+        ) : undefined}
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx
new file mode 100644
index 000000000..1e07588a9
--- /dev/null
+++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx
@@ -0,0 +1,48 @@
+import React, {useEffect} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {styles} from './styles'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export const PasswordUpdatedForm = ({
+  onPressNext,
+}: {
+  onPressNext: () => void
+}) => {
+  const {screen} = useAnalytics()
+  const pal = usePalette('default')
+  const {_} = useLingui()
+
+  useEffect(() => {
+    screen('Signin:PasswordUpdatedForm')
+  }, [screen])
+
+  return (
+    <>
+      <View>
+        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
+          <Trans>Password updated!</Trans>
+        </Text>
+        <Text type="lg" style={[pal.text, styles.instructions]}>
+          <Trans>You can now sign in with your new password.</Trans>
+        </Text>
+        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+          <View style={s.flex1} />
+          <TouchableOpacity
+            onPress={onPressNext}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Close alert`)}
+            accessibilityHint="Closes password update alert">
+            <Text type="xl-bold" style={[pal.link, s.pr5]}>
+              <Trans>Okay</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx
new file mode 100644
index 000000000..2bb614df2
--- /dev/null
+++ b/src/view/com/auth/login/SetNewPasswordForm.tsx
@@ -0,0 +1,179 @@
+import React, {useState, useEffect} from 'react'
+import {
+  ActivityIndicator,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {BskyAgent} from '@atproto/api'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {s} from 'lib/styles'
+import {isNetworkError} from 'lib/strings/errors'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {cleanError} from 'lib/strings/errors'
+import {logger} from '#/logger'
+import {styles} from './styles'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export const SetNewPasswordForm = ({
+  error,
+  serviceUrl,
+  setError,
+  onPressBack,
+  onPasswordSet,
+}: {
+  error: string
+  serviceUrl: string
+  setError: (v: string) => void
+  onPressBack: () => void
+  onPasswordSet: () => void
+}) => {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+
+  useEffect(() => {
+    screen('Signin:SetNewPasswordForm')
+  }, [screen])
+
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [resetCode, setResetCode] = useState<string>('')
+  const [password, setPassword] = useState<string>('')
+
+  const onPressNext = async () => {
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      const token = resetCode.replace(/\s/g, '')
+      await agent.com.atproto.server.resetPassword({
+        token,
+        password,
+      })
+      onPasswordSet()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to set new password', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <>
+      <View>
+        <Text type="title-lg" style={[pal.text, styles.screenTitle]}>
+          <Trans>Set new password</Trans>
+        </Text>
+        <Text type="lg" style={[pal.text, styles.instructions]}>
+          <Trans>
+            You will receive an email with a "reset code." Enter that code here,
+            then enter your new password.
+          </Trans>
+        </Text>
+        <View
+          testID="newPasswordView"
+          style={[pal.view, pal.borderDark, styles.group]}>
+          <View
+            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+            <FontAwesomeIcon
+              icon="ticket"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TextInput
+              testID="resetCodeInput"
+              style={[pal.text, styles.textInput]}
+              placeholder="Reset code"
+              placeholderTextColor={pal.colors.textLight}
+              autoCapitalize="none"
+              autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
+              autoFocus
+              value={resetCode}
+              onChangeText={setResetCode}
+              editable={!isProcessing}
+              accessible={true}
+              accessibilityLabel={_(msg`Reset code`)}
+              accessibilityHint="Input code sent to your email for password reset"
+            />
+          </View>
+          <View style={[pal.borderDark, styles.groupContent]}>
+            <FontAwesomeIcon
+              icon="lock"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TextInput
+              testID="newPasswordInput"
+              style={[pal.text, styles.textInput]}
+              placeholder="New password"
+              placeholderTextColor={pal.colors.textLight}
+              autoCapitalize="none"
+              autoCorrect={false}
+              keyboardAppearance={theme.colorScheme}
+              secureTextEntry
+              value={password}
+              onChangeText={setPassword}
+              editable={!isProcessing}
+              accessible={true}
+              accessibilityLabel={_(msg`Password`)}
+              accessibilityHint="Input new password"
+            />
+          </View>
+        </View>
+        {error ? (
+          <View style={styles.error}>
+            <View style={styles.errorIcon}>
+              <FontAwesomeIcon icon="exclamation" style={s.white} size={10} />
+            </View>
+            <View style={s.flex1}>
+              <Text style={[s.white, s.bold]}>{error}</Text>
+            </View>
+          </View>
+        ) : undefined}
+        <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}>
+          <TouchableOpacity onPress={onPressBack} accessibilityRole="button">
+            <Text type="xl" style={[pal.link, s.pl5]}>
+              <Trans>Back</Trans>
+            </Text>
+          </TouchableOpacity>
+          <View style={s.flex1} />
+          {isProcessing ? (
+            <ActivityIndicator />
+          ) : !resetCode || !password ? (
+            <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}>
+              <Trans>Next</Trans>
+            </Text>
+          ) : (
+            <TouchableOpacity
+              testID="setNewPasswordButton"
+              onPress={onPressNext}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Go to next`)}
+              accessibilityHint="Navigates to the next screen">
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                <Trans>Next</Trans>
+              </Text>
+            </TouchableOpacity>
+          )}
+          {isProcessing ? (
+            <Text type="xl" style={[pal.textLight, s.pl10]}>
+              <Trans>Updating...</Trans>
+            </Text>
+          ) : undefined}
+        </View>
+      </View>
+    </>
+  )
+}
diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts
new file mode 100644
index 000000000..9dccc2803
--- /dev/null
+++ b/src/view/com/auth/login/styles.ts
@@ -0,0 +1,118 @@
+import {StyleSheet} from 'react-native'
+import {colors} from 'lib/styles'
+import {isWeb} from '#/platform/detection'
+
+export const styles = StyleSheet.create({
+  screenTitle: {
+    marginBottom: 10,
+    marginHorizontal: 20,
+  },
+  instructions: {
+    marginBottom: 20,
+    marginHorizontal: 20,
+  },
+  group: {
+    borderWidth: 1,
+    borderRadius: 10,
+    marginBottom: 20,
+    marginHorizontal: 20,
+  },
+  groupLabel: {
+    paddingHorizontal: 20,
+    paddingBottom: 5,
+  },
+  groupContent: {
+    borderTopWidth: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  noTopBorder: {
+    borderTopWidth: 0,
+  },
+  groupContentIcon: {
+    marginLeft: 10,
+  },
+  account: {
+    borderTopWidth: 1,
+    paddingHorizontal: 20,
+    paddingVertical: 4,
+  },
+  accountLast: {
+    borderBottomWidth: 1,
+    marginBottom: 20,
+    paddingVertical: 8,
+  },
+  textInput: {
+    flex: 1,
+    width: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 12,
+    fontSize: 17,
+    letterSpacing: 0.25,
+    fontWeight: '400',
+    borderRadius: 10,
+  },
+  textInputInnerBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 6,
+    paddingHorizontal: 8,
+    marginHorizontal: 6,
+  },
+  textBtn: {
+    flexDirection: 'row',
+    flex: 1,
+    alignItems: 'center',
+  },
+  textBtnLabel: {
+    flex: 1,
+    paddingVertical: 10,
+    paddingHorizontal: 12,
+  },
+  textBtnFakeInnerBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 6,
+    paddingVertical: 6,
+    paddingHorizontal: 8,
+    marginHorizontal: 6,
+  },
+  accountText: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'baseline',
+    paddingVertical: 10,
+  },
+  accountTextOther: {
+    paddingLeft: 12,
+  },
+  error: {
+    backgroundColor: colors.red4,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: -5,
+    marginHorizontal: 20,
+    marginBottom: 15,
+    borderRadius: 8,
+    paddingHorizontal: 8,
+    paddingVertical: 8,
+  },
+  errorIcon: {
+    borderWidth: 1,
+    borderColor: colors.white,
+    color: colors.white,
+    borderRadius: 30,
+    width: 16,
+    height: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 5,
+  },
+  dimmed: {opacity: 0.5},
+
+  maxHeight: {
+    // @ts-ignore web only -prf
+    maxHeight: isWeb ? '100vh' : undefined,
+    height: !isWeb ? '100%' : undefined,
+  },
+})
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index 400b836d0..d3318bffd 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -1,6 +1,5 @@
 import React from 'react'
 import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
 import {Text} from 'view/com/util/text/Text'
@@ -10,76 +9,55 @@ import {Button} from 'view/com/util/forms/Button'
 import {RecommendedFeedsItem} from './RecommendedFeedsItem'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useQuery} from '@tanstack/react-query'
-import {useStores} from 'state/index'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds'
 
 type Props = {
   next: () => void
 }
-export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
-  next,
-}: Props) {
-  const store = useStores()
+export function RecommendedFeeds({next}: Props) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isTabletOrMobile} = useWebMediaQueries()
-  const {isLoading, data: recommendedFeeds} = useQuery({
-    staleTime: Infinity, // fixed list rn, never refetch
-    queryKey: ['onboarding', 'recommended_feeds'],
-    async queryFn() {
-      try {
-        const {
-          data: {feeds},
-          success,
-        } = await store.agent.app.bsky.feed.getSuggestedFeeds()
+  const {isLoading, data} = useSuggestedFeedsQuery()
 
-        if (!success) {
-          return []
-        }
-
-        return (feeds.length ? feeds : []).map(feed => {
-          const model = new FeedSourceModel(store, feed.uri)
-          model.hydrateFeedGenerator(feed)
-          return model
-        })
-      } catch (e) {
-        return []
-      }
-    },
-  })
-
-  const hasFeeds = recommendedFeeds && recommendedFeeds.length
+  const hasFeeds = data && data.pages[0].feeds.length
 
   const title = (
     <>
-      <Text
-        style={[
-          pal.textLight,
-          tdStyles.title1,
-          isTabletOrMobile && tdStyles.title1Small,
-        ]}>
-        Choose your
-      </Text>
-      <Text
-        style={[
-          pal.link,
-          tdStyles.title2,
-          isTabletOrMobile && tdStyles.title2Small,
-        ]}>
-        Recommended
-      </Text>
-      <Text
-        style={[
-          pal.link,
-          tdStyles.title2,
-          isTabletOrMobile && tdStyles.title2Small,
-        ]}>
-        Feeds
-      </Text>
+      <Trans>
+        <Text
+          style={[
+            pal.textLight,
+            tdStyles.title1,
+            isTabletOrMobile && tdStyles.title1Small,
+          ]}>
+          Choose your
+        </Text>
+        <Text
+          style={[
+            pal.link,
+            tdStyles.title2,
+            isTabletOrMobile && tdStyles.title2Small,
+          ]}>
+          Recommended
+        </Text>
+        <Text
+          style={[
+            pal.link,
+            tdStyles.title2,
+            isTabletOrMobile && tdStyles.title2Small,
+          ]}>
+          Feeds
+        </Text>
+      </Trans>
       <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
-        Feeds are created by users to curate content. Choose some feeds that you
-        find interesting.
+        <Trans>
+          Feeds are created by users to curate content. Choose some feeds that
+          you find interesting.
+        </Trans>
       </Text>
       <View
         style={{
@@ -98,7 +76,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Next
+              <Trans>Next</Trans>
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
@@ -118,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
           contentStyle={{paddingHorizontal: 0}}>
           {hasFeeds ? (
             <FlatList
-              data={recommendedFeeds}
+              data={data.pages[0].feeds}
               renderItem={({item}) => <RecommendedFeedsItem item={item} />}
               keyExtractor={item => item.uri}
               style={{flex: 1}}
@@ -128,25 +106,27 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
               <ActivityIndicator size="large" />
             </View>
           ) : (
-            <ErrorMessage message="Failed to load recommended feeds" />
+            <ErrorMessage message={_(msg`Failed to load recommended feeds`)} />
           )}
         </TitleColumnLayout>
       </TabletOrDesktop>
       <Mobile>
         <View style={[mStyles.container]} testID="recommendedFeedsOnboarding">
           <ViewHeader
-            title="Recommended Feeds"
+            title={_(msg`Recommended Feeds`)}
             showBackButton={false}
             showOnDesktop
           />
           <Text type="lg-medium" style={[pal.text, mStyles.header]}>
-            Check out some recommended feeds. Tap + to add them to your list of
-            pinned feeds.
+            <Trans>
+              Check out some recommended feeds. Tap + to add them to your list
+              of pinned feeds.
+            </Trans>
           </Text>
 
           {hasFeeds ? (
             <FlatList
-              data={recommendedFeeds}
+              data={data.pages[0].feeds}
               renderItem={({item}) => <RecommendedFeedsItem item={item} />}
               keyExtractor={item => item.uri}
               style={{flex: 1}}
@@ -157,13 +137,15 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
             </View>
           ) : (
             <View style={{flex: 1}}>
-              <ErrorMessage message="Failed to load recommended feeds" />
+              <ErrorMessage
+                message={_(msg`Failed to load recommended feeds`)}
+              />
             </View>
           )}
 
           <Button
             onPress={next}
-            label="Continue"
+            label={_(msg`Continue`)}
             testID="continueBtn"
             style={mStyles.button}
             labelStyle={mStyles.buttonText}
@@ -172,7 +154,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
       </Mobile>
     </>
   )
-})
+}
 
 const tdStyles = StyleSheet.create({
   container: {
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index bee23c953..7417e5b06 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api'
 import {Text} from 'view/com/util/text/Text'
 import {RichText} from 'view/com/util/text/RichText'
 import {Button} from 'view/com/util/forms/Button'
@@ -11,33 +11,58 @@ import {HeartIcon} from 'lib/icons'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {FeedSourceModel} from 'state/models/content/feed-source'
+import {
+  usePreferencesQuery,
+  usePinFeedMutation,
+  useRemoveFeedMutation,
+} from '#/state/queries/preferences'
+import {logger} from '#/logger'
 
-export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
+export function RecommendedFeedsItem({
   item,
 }: {
-  item: FeedSourceModel
+  item: AppBskyFeedDefs.GeneratorView
 }) {
   const {isMobile} = useWebMediaQueries()
   const pal = usePalette('default')
-  if (!item) return null
+  const {data: preferences} = usePreferencesQuery()
+  const {
+    mutateAsync: pinFeed,
+    variables: pinnedFeed,
+    reset: resetPinFeed,
+  } = usePinFeedMutation()
+  const {
+    mutateAsync: removeFeed,
+    variables: removedFeed,
+    reset: resetRemoveFeed,
+  } = useRemoveFeedMutation()
+
+  if (!item || !preferences) return null
+
+  const isPinned =
+    !removedFeed?.uri &&
+    (pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri))
+
   const onToggle = async () => {
-    if (item.isSaved) {
+    if (isPinned) {
       try {
-        await item.unsave()
+        await removeFeed({uri: item.uri})
+        resetRemoveFeed()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        console.error('Failed to unsave feed', {e})
+        logger.error('Failed to unsave feed', {error: e})
       }
     } else {
       try {
-        await item.pin()
+        await pinFeed({uri: item.uri})
+        resetPinFeed()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        console.error('Failed to pin feed', {e})
+        logger.error('Failed to pin feed', {error: e})
       }
     }
   }
+
   return (
     <View testID={`feed-${item.displayName}`}>
       <View
@@ -66,10 +91,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
           </Text>
 
           <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
-            by {sanitizeHandle(item.creatorHandle, '@')}
+            by {sanitizeHandle(item.creator.handle, '@')}
           </Text>
 
-          {item.descriptionRT ? (
+          {item.description ? (
             <RichText
               type="xl"
               style={[
@@ -80,7 +105,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
                   marginBottom: 18,
                 },
               ]}
-              richText={item.descriptionRT}
+              richText={new BskRichText({text: item.description || ''})}
               numberOfLines={6}
             />
           ) : null}
@@ -97,7 +122,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
                   paddingRight: 2,
                   gap: 6,
                 }}>
-                {item.isSaved ? (
+                {isPinned ? (
                   <>
                     <FontAwesomeIcon
                       icon="check"
@@ -138,4 +163,4 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
       </View>
     </View>
   )
-})
+}
diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx
index f2710d2ac..372bbec6a 100644
--- a/src/view/com/auth/onboarding/RecommendedFollows.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
 import {Text} from 'view/com/util/text/Text'
 import {ViewHeader} from 'view/com/util/ViewHeader'
@@ -9,59 +9,62 @@ import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
 import {Button} from 'view/com/util/forms/Button'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {RecommendedFollowsItem} from './RecommendedFollowsItem'
+import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
+import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {logger} from '#/logger'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 type Props = {
   next: () => void
 }
-export const RecommendedFollows = observer(function RecommendedFollowsImpl({
-  next,
-}: Props) {
-  const store = useStores()
+export function RecommendedFollows({next}: Props) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {isTabletOrMobile} = useWebMediaQueries()
-
-  React.useEffect(() => {
-    // Load suggested actors if not already loaded
-    // prefetch should happen in the onboarding model
-    if (
-      !store.onboarding.suggestedActors.hasLoaded ||
-      store.onboarding.suggestedActors.isEmpty
-    ) {
-      store.onboarding.suggestedActors.loadMore(true)
-    }
-  }, [store])
+  const {data: suggestedFollows} = useSuggestedFollowsQuery()
+  const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
+  const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
+    [did: string]: AppBskyActorDefs.ProfileView[]
+  }>({})
+  const existingDids = React.useRef<string[]>([])
+  const moderationOpts = useModerationOpts()
 
   const title = (
     <>
-      <Text
-        style={[
-          pal.textLight,
-          tdStyles.title1,
-          isTabletOrMobile && tdStyles.title1Small,
-        ]}>
-        Follow some
-      </Text>
-      <Text
-        style={[
-          pal.link,
-          tdStyles.title2,
-          isTabletOrMobile && tdStyles.title2Small,
-        ]}>
-        Recommended
-      </Text>
-      <Text
-        style={[
-          pal.link,
-          tdStyles.title2,
-          isTabletOrMobile && tdStyles.title2Small,
-        ]}>
-        Users
-      </Text>
+      <Trans>
+        <Text
+          style={[
+            pal.textLight,
+            tdStyles.title1,
+            isTabletOrMobile && tdStyles.title1Small,
+          ]}>
+          Follow some
+        </Text>
+        <Text
+          style={[
+            pal.link,
+            tdStyles.title2,
+            isTabletOrMobile && tdStyles.title2Small,
+          ]}>
+          Recommended
+        </Text>
+        <Text
+          style={[
+            pal.link,
+            tdStyles.title2,
+            isTabletOrMobile && tdStyles.title2Small,
+          ]}>
+          Users
+        </Text>
+      </Trans>
       <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
-        Follow some users to get started. We can recommend you more users based
-        on who you find interesting.
+        <Trans>
+          Follow some users to get started. We can recommend you more users
+          based on who you find interesting.
+        </Trans>
       </Text>
       <View
         style={{
@@ -80,7 +83,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Done
+              <Trans>Done</Trans>
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
@@ -89,6 +92,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
     </>
   )
 
+  const suggestions = React.useMemo(() => {
+    if (!suggestedFollows) return []
+
+    const additional = Object.entries(additionalSuggestions)
+    const items = suggestedFollows.pages.flatMap(page => page.actors)
+
+    outer: while (additional.length) {
+      const additionalAccount = additional.shift()
+
+      if (!additionalAccount) break
+
+      const [followedUser, relatedAccounts] = additionalAccount
+
+      for (let i = 0; i < items.length; i++) {
+        if (items[i].did === followedUser) {
+          items.splice(i + 1, 0, ...relatedAccounts)
+          continue outer
+        }
+      }
+    }
+
+    existingDids.current = items.map(i => i.did)
+
+    return items
+  }, [suggestedFollows, additionalSuggestions])
+
+  const onFollowStateChange = React.useCallback(
+    async ({following, did}: {following: boolean; did: string}) => {
+      if (following) {
+        try {
+          const {suggestions: results} = await getSuggestedFollowsByActor(did)
+
+          if (results.length) {
+            const deduped = results.filter(
+              r => !existingDids.current.find(did => did === r.did),
+            )
+            setAdditionalSuggestions(s => ({
+              ...s,
+              [did]: deduped.slice(0, 3),
+            }))
+          }
+        } catch (e) {
+          logger.error('RecommendedFollows: failed to get suggestions', {
+            error: e,
+          })
+        }
+      }
+
+      // not handling the unfollow case
+    },
+    [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions],
+  )
+
   return (
     <>
       <TabletOrDesktop>
@@ -98,15 +154,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
           horizontal
           titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
           contentStyle={{paddingHorizontal: 0}}>
-          {store.onboarding.suggestedActors.isLoading ? (
+          {!suggestedFollows || !moderationOpts ? (
             <ActivityIndicator size="large" />
           ) : (
             <FlatList
-              data={store.onboarding.suggestedActors.suggestions}
-              renderItem={({item, index}) => (
-                <RecommendedFollowsItem item={item} index={index} />
+              data={suggestions}
+              renderItem={({item}) => (
+                <RecommendedFollowsItem
+                  profile={item}
+                  onFollowStateChange={onFollowStateChange}
+                  moderation={moderateProfile(item, moderationOpts)}
+                />
               )}
-              keyExtractor={(item, index) => item.did + index.toString()}
+              keyExtractor={item => item.did}
               style={{flex: 1}}
             />
           )}
@@ -117,30 +177,36 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
         <View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
           <View>
             <ViewHeader
-              title="Recommended Follows"
+              title={_(msg`Recommended Users`)}
               showBackButton={false}
               showOnDesktop
             />
             <Text type="lg-medium" style={[pal.text, mStyles.header]}>
-              Check out some recommended users. Follow them to see similar
-              users.
+              <Trans>
+                Check out some recommended users. Follow them to see similar
+                users.
+              </Trans>
             </Text>
           </View>
-          {store.onboarding.suggestedActors.isLoading ? (
+          {!suggestedFollows || !moderationOpts ? (
             <ActivityIndicator size="large" />
           ) : (
             <FlatList
-              data={store.onboarding.suggestedActors.suggestions}
-              renderItem={({item, index}) => (
-                <RecommendedFollowsItem item={item} index={index} />
+              data={suggestions}
+              renderItem={({item}) => (
+                <RecommendedFollowsItem
+                  profile={item}
+                  onFollowStateChange={onFollowStateChange}
+                  moderation={moderateProfile(item, moderationOpts)}
+                />
               )}
-              keyExtractor={(item, index) => item.did + index.toString()}
+              keyExtractor={item => item.did}
               style={{flex: 1}}
             />
           )}
           <Button
             onPress={next}
-            label="Continue"
+            label={_(msg`Continue`)}
             testID="continueBtn"
             style={mStyles.button}
             labelStyle={mStyles.buttonText}
@@ -149,7 +215,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
       </Mobile>
     </>
   )
-})
+}
 
 const tdStyles = StyleSheet.create({
   container: {
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 2b26918d0..93c515f38 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -1,11 +1,8 @@
-import React, {useMemo} from 'react'
+import React from 'react'
 import {View, StyleSheet, ActivityIndicator} from 'react-native'
-import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {FollowButton} from 'view/com/profile/FollowButton'
+import {ProfileModeration, AppBskyActorDefs} from '@atproto/api'
+import {Button} from '#/view/com/util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
-import {SuggestedActor} from 'state/models/discovery/suggested-actors'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {s} from 'lib/styles'
@@ -14,26 +11,32 @@ import {Text} from 'view/com/util/text/Text'
 import Animated, {FadeInRight} from 'react-native-reanimated'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
+import {Trans} from '@lingui/macro'
+import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
+import {useProfileFollowMutationQueue} from '#/state/queries/profile'
+import {logger} from '#/logger'
 
 type Props = {
-  item: SuggestedActor
-  index: number
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderation: ProfileModeration
+  onFollowStateChange: (props: {
+    did: string
+    following: boolean
+  }) => Promise<void>
 }
-export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
+
+export function RecommendedFollowsItem({
+  profile,
+  moderation,
+  onFollowStateChange,
+}: React.PropsWithChildren<Props>) {
   const pal = usePalette('default')
-  const store = useStores()
   const {isMobile} = useWebMediaQueries()
-  const delay = useMemo(() => {
-    return (
-      50 *
-      (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) %
-        5)
-    )
-  }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex])
+  const shadowedProfile = useProfileShadow(profile)
 
   return (
     <Animated.View
-      entering={FadeInRight.delay(delay).springify()}
+      entering={FadeInRight}
       style={[
         styles.cardContainer,
         pal.view,
@@ -43,24 +46,62 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
           borderRightWidth: isMobile ? undefined : 1,
         },
       ]}>
-      <ProfileCard key={item.did} profile={item} index={index} />
+      <ProfileCard
+        key={profile.did}
+        profile={shadowedProfile}
+        onFollowStateChange={onFollowStateChange}
+        moderation={moderation}
+      />
     </Animated.View>
   )
 }
 
-export const ProfileCard = observer(function ProfileCardImpl({
+export function ProfileCard({
   profile,
-  index,
+  onFollowStateChange,
+  moderation,
 }: {
-  profile: AppBskyActorDefs.ProfileViewBasic
-  index: number
+  profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
+  moderation: ProfileModeration
+  onFollowStateChange: (props: {
+    did: string
+    following: boolean
+  }) => Promise<void>
 }) {
   const {track} = useAnalytics()
-  const store = useStores()
   const pal = usePalette('default')
-  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
   const [addingMoreSuggestions, setAddingMoreSuggestions] =
     React.useState(false)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
+
+  const onToggleFollow = React.useCallback(async () => {
+    try {
+      if (profile.viewer?.following) {
+        await queueUnfollow()
+      } else {
+        setAddingMoreSuggestions(true)
+        await queueFollow()
+        await onFollowStateChange({did: profile.did, following: true})
+        setAddingMoreSuggestions(false)
+        track('Onboarding:SuggestedFollowFollowed')
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('RecommendedFollows: failed to toggle following', {
+          error: e,
+        })
+      }
+    } finally {
+      setAddingMoreSuggestions(false)
+    }
+  }, [
+    profile,
+    queueFollow,
+    queueUnfollow,
+    setAddingMoreSuggestions,
+    track,
+    onFollowStateChange,
+  ])
 
   return (
     <View style={styles.card}>
@@ -88,20 +129,11 @@ export const ProfileCard = observer(function ProfileCardImpl({
           </Text>
         </View>
 
-        <FollowButton
-          profile={profile}
+        <Button
+          type={profile.viewer?.following ? 'default' : 'inverted'}
           labelStyle={styles.followButton}
-          onToggleFollow={async isFollow => {
-            if (isFollow) {
-              setAddingMoreSuggestions(true)
-              await store.onboarding.suggestedActors.insertSuggestionsByActor(
-                profile.did,
-                index,
-              )
-              setAddingMoreSuggestions(false)
-              track('Onboarding:SuggestedFollowFollowed')
-            }
-          }}
+          onPress={onToggleFollow}
+          label={profile.viewer?.following ? 'Unfollow' : 'Follow'}
         />
       </View>
       {profile.description ? (
@@ -114,12 +146,14 @@ export const ProfileCard = observer(function ProfileCardImpl({
       {addingMoreSuggestions ? (
         <View style={styles.addingMoreContainer}>
           <ActivityIndicator size="small" color={pal.colors.text} />
-          <Text style={[pal.text]}>Finding similar accounts...</Text>
+          <Text style={[pal.text]}>
+            <Trans>Finding similar accounts...</Trans>
+          </Text>
         </View>
       ) : null}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   cardContainer: {
diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
index c066e9bd5..1a30c17f9 100644
--- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx
+++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx
@@ -7,16 +7,13 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
 import {Button} from 'view/com/util/forms/Button'
-import {observer} from 'mobx-react-lite'
 
 type Props = {
   next: () => void
   skip: () => void
 }
 
-export const WelcomeDesktop = observer(function WelcomeDesktopImpl({
-  next,
-}: Props) {
+export function WelcomeDesktop({next}: Props) {
   const pal = usePalette('default')
   const horizontal = useMediaQuery({minWidth: 1300})
   const title = (
@@ -105,7 +102,7 @@ export const WelcomeDesktop = observer(function WelcomeDesktopImpl({
       </View>
     </TitleColumnLayout>
   )
-})
+}
 
 const styles = StyleSheet.create({
   row: {
diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx
index 1f0a64370..5de1a7817 100644
--- a/src/view/com/auth/onboarding/WelcomeMobile.tsx
+++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx
@@ -5,18 +5,15 @@ import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Button} from 'view/com/util/forms/Button'
-import {observer} from 'mobx-react-lite'
 import {ViewHeader} from 'view/com/util/ViewHeader'
+import {Trans} from '@lingui/macro'
 
 type Props = {
   next: () => void
   skip: () => void
 }
 
-export const WelcomeMobile = observer(function WelcomeMobileImpl({
-  next,
-  skip,
-}: Props) {
+export function WelcomeMobile({next, skip}: Props) {
   const pal = usePalette('default')
 
   return (
@@ -32,7 +29,9 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
               accessibilityRole="button"
               style={[s.flexRow, s.alignCenter]}
               onPress={skip}>
-              <Text style={[pal.link]}>Skip</Text>
+              <Text style={[pal.link]}>
+                <Trans>Skip</Trans>
+              </Text>
               <FontAwesomeIcon
                 icon={'chevron-right'}
                 size={14}
@@ -44,18 +43,22 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
       />
       <View>
         <Text style={[pal.text, styles.title]}>
-          Welcome to{' '}
-          <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
+          <Trans>
+            Welcome to{' '}
+            <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
+          </Trans>
         </Text>
         <View style={styles.spacer} />
         <View style={[styles.row]}>
           <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
           <View style={[styles.rowText]}>
             <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is public.
+              <Trans>Bluesky is public.</Trans>
             </Text>
             <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Your posts, likes, and blocks are public. Mutes are private.
+              <Trans>
+                Your posts, likes, and blocks are public. Mutes are private.
+              </Trans>
             </Text>
           </View>
         </View>
@@ -63,10 +66,10 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
           <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
           <View style={[styles.rowText]}>
             <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is open.
+              <Trans>Bluesky is open.</Trans>
             </Text>
             <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Never lose access to your followers and data.
+              <Trans>Never lose access to your followers and data.</Trans>
             </Text>
           </View>
         </View>
@@ -74,11 +77,13 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
           <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
           <View style={[styles.rowText]}>
             <Text type="lg-bold" style={[pal.text]}>
-              Bluesky is flexible.
+              <Trans>Bluesky is flexible.</Trans>
             </Text>
             <Text type="lg-thin" style={[pal.text, s.pt2]}>
-              Choose the algorithms that power your experience with custom
-              feeds.
+              <Trans>
+                Choose the algorithms that power your experience with custom
+                feeds.
+              </Trans>
             </Text>
           </View>
         </View>
@@ -93,7 +98,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
       />
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx
deleted file mode 100644
index 25d12165f..000000000
--- a/src/view/com/auth/withAuthRequired.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React from 'react'
-import {
-  ActivityIndicator,
-  Linking,
-  StyleSheet,
-  TouchableOpacity,
-} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {CenteredView} from '../util/Views'
-import {LoggedOut} from './LoggedOut'
-import {Onboarding} from './Onboarding'
-import {Text} from '../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {STATUS_PAGE_URL} from 'lib/constants'
-
-export const withAuthRequired = <P extends object>(
-  Component: React.ComponentType<P>,
-): React.FC<P> =>
-  observer(function AuthRequired(props: P) {
-    const store = useStores()
-    if (store.session.isResumingSession) {
-      return <Loading />
-    }
-    if (!store.session.hasSession) {
-      return <LoggedOut />
-    }
-    if (store.onboarding.isActive) {
-      return <Onboarding />
-    }
-    return <Component {...props} />
-  })
-
-function Loading() {
-  const pal = usePalette('default')
-
-  const [isTakingTooLong, setIsTakingTooLong] = React.useState(false)
-  React.useEffect(() => {
-    const t = setTimeout(() => setIsTakingTooLong(true), 15e3) // 15 seconds
-    return () => clearTimeout(t)
-  }, [setIsTakingTooLong])
-
-  return (
-    <CenteredView style={[styles.loading, pal.view]}>
-      <ActivityIndicator size="large" />
-      <Text type="2xl" style={[styles.loadingText, pal.textLight]}>
-        {isTakingTooLong
-          ? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..."
-          : 'Connecting...'}
-      </Text>
-      {isTakingTooLong ? (
-        <TouchableOpacity
-          onPress={() => {
-            Linking.openURL(STATUS_PAGE_URL)
-          }}
-          accessibilityRole="button">
-          <Text type="2xl" style={[styles.loadingText, pal.link]}>
-            Check Bluesky status page
-          </Text>
-        </TouchableOpacity>
-      ) : null}
-    </CenteredView>
-  )
-}
-
-const styles = StyleSheet.create({
-  loading: {
-    height: '100%',
-    alignContent: 'center',
-    justifyContent: 'center',
-    paddingBottom: 100,
-  },
-  loadingText: {
-    paddingVertical: 20,
-    paddingHorizontal: 20,
-    textAlign: 'center',
-  },
-})