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/SplashScreen.tsx20
-rw-r--r--src/view/com/auth/SplashScreen.web.tsx5
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx17
-rw-r--r--src/view/com/auth/create/Step1.tsx19
-rw-r--r--src/view/com/auth/create/Step2.tsx31
-rw-r--r--src/view/com/auth/create/Step3.tsx11
-rw-r--r--src/view/com/auth/login/ChooseAccountForm.tsx119
-rw-r--r--src/view/com/auth/login/ForgotPasswordForm.tsx197
-rw-r--r--src/view/com/auth/login/Login.tsx888
-rw-r--r--src/view/com/auth/login/LoginForm.tsx288
-rw-r--r--src/view/com/auth/login/PasswordUpdatedForm.tsx48
-rw-r--r--src/view/com/auth/login/SetNewPasswordForm.tsx181
-rw-r--r--src/view/com/auth/login/styles.ts118
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx5
-rw-r--r--src/view/com/auth/onboarding/WelcomeMobile.tsx27
15 files changed, 1060 insertions, 914 deletions
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx
index 67453f111..05e72a2e6 100644
--- a/src/view/com/auth/SplashScreen.tsx
+++ b/src/view/com/auth/SplashScreen.tsx
@@ -5,6 +5,8 @@ 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,14 +16,18 @@ 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.title, pal.link]}>
+              <Trans>Bluesky</Trans>
+            </Text>
             <Text style={[styles.subtitle, pal.textLight]}>
-              See what's next
+              <Trans>See what's next</Trans>
             </Text>
           </View>
           <View testID="signinOrCreateAccount" style={styles.btns}>
@@ -30,10 +36,10 @@ export const SplashScreen = ({
               style={[styles.btn, {backgroundColor: colors.blue3}]}
               onPress={onPressCreateAccount}
               accessibilityRole="button"
-              accessibilityLabel="Create new account"
+              accessibilityLabel={_(msg`Create new account`)}
               accessibilityHint="Opens flow to create a new Bluesky account">
               <Text style={[s.white, styles.btnLabel]}>
-                Create a new account
+                <Trans>Create a new account</Trans>
               </Text>
             </TouchableOpacity>
             <TouchableOpacity
@@ -41,9 +47,11 @@ export const SplashScreen = ({
               style={[styles.btn, pal.btn]}
               onPress={onPressSignin}
               accessibilityRole="button"
-              accessibilityLabel="Sign in"
+              accessibilityLabel={_(msg`Sign in`)}
               accessibilityHint="Opens flow to sign into your existing Bluesky account">
-              <Text style={[pal.text, styles.btnLabel]}>Sign In</Text>
+              <Text style={[pal.text, styles.btnLabel]}>
+                <Trans>Sign In</Trans>
+              </Text>
             </TouchableOpacity>
           </View>
         </ErrorBoundary>
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx
index cef9618ef..f10dc4f98 100644
--- a/src/view/com/auth/SplashScreen.web.tsx
+++ b/src/view/com/auth/SplashScreen.web.tsx
@@ -8,6 +8,7 @@ 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 = ({
   onPressSignin,
@@ -54,7 +55,9 @@ export const SplashScreen = ({
               onPress={onPressSignin}
               // TODO: web accessibility
               accessibilityRole="button">
-              <Text style={[pal.text, styles.btnLabel]}>Sign In</Text>
+              <Text style={[pal.text, styles.btnLabel]}>
+                <Trans>Sign In</Trans>
+              </Text>
             </TouchableOpacity>
           </View>
         </ErrorBoundary>
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index c3cfb3ad3..8e2bbed85 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -15,6 +15,8 @@ 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 {Step1} from './Step1'
@@ -30,6 +32,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
   const pal = usePalette('default')
   const store = useStores()
   const model = React.useMemo(() => new CreateAccountModel(store), [store])
+  const {_} = useLingui()
   const onboardingDispatch = useOnboardingDispatch()
 
   React.useEffect(() => {
@@ -73,8 +76,8 @@ export const CreateAccount = observer(function CreateAccountImpl({
   return (
     <LoggedOutLayout
       leadin={`Step ${model.step}`}
-      title="Create Account"
-      description="We're so excited to have you join us!">
+      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}>
@@ -88,7 +91,7 @@ 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} />
@@ -101,7 +104,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
                   <ActivityIndicator />
                 ) : (
                   <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                    Next
+                    <Trans>Next</Trans>
                   </Text>
                 )}
               </TouchableOpacity>
@@ -110,18 +113,18 @@ export const CreateAccount = observer(function CreateAccountImpl({
                 testID="retryConnectBtn"
                 onPress={onPressRetryConnect}
                 accessibilityRole="button"
-                accessibilityLabel="Retry"
+                accessibilityLabel={_(msg`Retry`)}
                 accessibilityHint="Retries account creation"
                 accessibilityLiveRegion="polite">
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
-                  Retry
+                  <Trans>Retry</Trans>
                 </Text>
               </TouchableOpacity>
             ) : model.isFetchingServiceDescription ? (
               <>
                 <ActivityIndicator color="#fff" />
                 <Text type="xl" style={[pal.text, s.pr5]}>
-                  Connecting...
+                  <Trans>Connecting...</Trans>
                 </Text>
               </>
             ) : undefined}
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index cdd5cb21d..7e3ea062d 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -12,6 +12,8 @@ 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 {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
@@ -27,6 +29,7 @@ export const Step1 = observer(function Step1Impl({
 }) {
   const pal = usePalette('default')
   const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
+  const {_} = useLingui()
 
   const onPressDefault = React.useCallback(() => {
     setIsDefaultSelected(true)
@@ -63,9 +66,9 @@ export const Step1 = observer(function Step1Impl({
 
   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 +84,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"
+            placeholder={_(msg`Hosting provider address`)}
             value={model.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,13 +103,13 @@ export const Step1 = observer(function Step1Impl({
                 testID="stagingServerBtn"
                 type="default"
                 style={s.mr5}
-                label="Staging"
+                label={_(msg`Staging`)}
                 onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
               />
               <Button
                 testID="localDevServerBtn"
                 type="default"
-                label="Dev Server"
+                label={_(msg`Dev Server`)}
                 onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
               />
             </View>
@@ -116,7 +119,7 @@ export const Step1 = observer(function Step1Impl({
       {model.error ? (
         <ErrorMessage message={model.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>
   )
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index b2054150b..3cc8ae934 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -11,6 +11,8 @@ import {TextInput} from '../util/TextInput'
 import {Policies} from './Policies'
 import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 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
@@ -28,6 +30,7 @@ export const Step2 = observer(function Step2Impl({
   model: CreateAccountModel
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const {openModal} = useModalControls()
 
   const onPressWaitlist = React.useCallback(() => {
@@ -36,7 +39,7 @@ export const Step2 = observer(function Step2Impl({
 
   return (
     <View>
-      <StepHeader step="2" title="Your account" />
+      <StepHeader step="2" title={_(msg`Your account`)} />
 
       {model.isInviteCodeRequired && (
         <View style={s.pb20}>
@@ -46,11 +49,11 @@ export const Step2 = observer(function Step2Impl({
           <TextInput
             testID="inviteCodeInput"
             icon="ticket"
-            placeholder="Required for this provider"
+            placeholder={_(msg`Required for this provider`)}
             value={model.inviteCode}
             editable
             onChange={model.setInviteCode}
-            accessibilityLabel="Invite code"
+            accessibilityLabel={_(msg`Invite code`)}
             accessibilityHint="Input invite code to proceed"
           />
         </View>
@@ -61,10 +64,12 @@ export const Step2 = observer(function Step2Impl({
           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 +77,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"
+              placeholder={_(msg`Enter your email address`)}
               value={model.email}
               editable
               onChange={model.setEmail}
-              accessibilityLabel="Email"
+              accessibilityLabel={_(msg`Email`)}
               accessibilityHint="Input email for Bluesky waitlist"
               accessibilityLabelledBy="email"
             />
@@ -92,17 +97,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"
+              placeholder={_(msg`Choose your password`)}
               value={model.password}
               editable
               secureTextEntry
               onChange={model.setPassword}
-              accessibilityLabel="Password"
+              accessibilityLabel={_(msg`Password`)}
               accessibilityHint="Set password"
               accessibilityLabelledBy="password"
             />
@@ -113,7 +118,7 @@ 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"
@@ -122,7 +127,7 @@ export const Step2 = observer(function Step2Impl({
               buttonType="default-light"
               buttonStyle={[pal.border, styles.dateInputButton]}
               buttonLabelType="lg"
-              accessibilityLabel="Birthday"
+              accessibilityLabel={_(msg`Birthday`)}
               accessibilityHint="Enter your birth date"
               accessibilityLabelledBy="birthDate"
             />
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index beb756ac1..09fba0714 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -9,6 +9,8 @@ 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
@@ -19,9 +21,10 @@ export const Step3 = observer(function Step3Impl({
   model: CreateAccountModel
 }) {
   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"
@@ -31,12 +34,12 @@ export const Step3 = observer(function Step3Impl({
           editable
           onChange={model.setHandle}
           // 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}>
+          <Trans>Your full handle will be</Trans>
+          <Text type="lg-bold" style={[pal.text, s.ml5]}>
             @{createFullHandle(model.handle, model.userDomain)}
           </Text>
         </Text>
diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx
new file mode 100644
index 000000000..596a8e411
--- /dev/null
+++ b/src/view/com/auth/login/ChooseAccountForm.tsx
@@ -0,0 +1,119 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  ScrollView,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {Text} from '../../util/text/Text'
+import {UserAvatar} from '../../util/UserAvatar'
+import {s} from 'lib/styles'
+import {RootStoreModel} from 'state/index'
+import {AccountData} from 'state/models/session'
+import {usePalette} from 'lib/hooks/usePalette'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {styles} from './styles'
+
+export 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)
+  const {_} = useLingui()
+
+  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]}>
+        <Trans>Sign in as...</Trans>
+      </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={_(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={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={_(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} />
+        {isProcessing && <ActivityIndicator />}
+      </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..9bfab18b5
--- /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 * 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 {RootStoreModel} from 'state/index'
+import {ServiceDescription} from 'state/models/session'
+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'
+
+export const ForgotPasswordForm = ({
+  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()
+  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 24a657c66..401b7d980 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -1,37 +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 {useStores, DEFAULT_SERVICE} from 'state/index'
 import {ServiceDescription} from 'state/models/session'
 import {AccountData} from 'state/models/session'
-import {isNetworkError} from 'lib/strings/errors'
 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 {useModalControls} from '#/state/modals'
+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'
 
 enum Forms {
   Login,
@@ -45,6 +27,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
   const pal = usePalette('default')
   const store = useStores()
   const {track} = useAnalytics()
+  const {_} = useLingui()
   const [error, setError] = useState<string>('')
   const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
   const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
@@ -87,14 +70,16 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
           error: err,
         })
         setError(
-          'Unable to contact your service. Please check your Internet connection.',
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
         )
       },
     )
     return () => {
       aborted = true
     }
-  }, [store.session, serviceUrl, retryDescribeTrigger])
+  }, [store.session, serviceUrl, retryDescribeTrigger, _])
 
   const onPressRetryConnect = () => setRetryDescribeTrigger({})
   const onPressForgotPassword = () => {
@@ -107,8 +92,8 @@ 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}
@@ -126,8 +111,8 @@ 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}
@@ -138,8 +123,8 @@ 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}
@@ -155,8 +140,8 @@ 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}
@@ -173,830 +158,3 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
     </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 {openModal} = useModalControls()
-
-  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],
-          )
-        }
-      }
-
-      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 = ({
-  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()
-  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]}>
-          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..9779b939a
--- /dev/null
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -0,0 +1,288 @@
+import React, {useState, useRef} from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  TextInput,
+  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 {s} from 'lib/styles'
+import {createFullHandle} from 'lib/strings/handles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {RootStoreModel} from 'state/index'
+import {ServiceDescription} from 'state/models/session'
+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 {styles} from './styles'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+
+export 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 {_} = useLingui()
+  const {openModal} = useModalControls()
+
+  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],
+          )
+        }
+      }
+
+      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(_(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..04eaa2842
--- /dev/null
+++ b/src/view/com/auth/login/SetNewPasswordForm.tsx
@@ -0,0 +1,181 @@
+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 {RootStoreModel} from 'state/index'
+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,
+}: {
+  store: RootStoreModel
+  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/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index f672372b8..7ec78bd7f 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -14,6 +14,7 @@ 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'
 
 type Props = {
   item: SuggestedActor
@@ -115,7 +116,9 @@ 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>
diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx
index 1f0a64370..ef70a1fe3 100644
--- a/src/view/com/auth/onboarding/WelcomeMobile.tsx
+++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx
@@ -7,6 +7,7 @@ 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
@@ -32,7 +33,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}
@@ -45,17 +48,21 @@ 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>
+          <Text style={[pal.text, pal.link, styles.title]}>
+            <Trans>Bluesky</Trans>
+          </Text>
         </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 +70,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 +81,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>