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/CreateAccount.tsx584
-rw-r--r--src/view/com/auth/LoggedOut.tsx67
-rw-r--r--src/view/com/auth/Logo.tsx26
-rw-r--r--src/view/com/auth/Signin.tsx895
-rw-r--r--src/view/com/auth/SplashScreen.tsx92
-rw-r--r--src/view/com/auth/SplashScreen.web.tsx102
-rw-r--r--src/view/com/auth/withAuthRequired.tsx47
7 files changed, 1813 insertions, 0 deletions
diff --git a/src/view/com/auth/CreateAccount.tsx b/src/view/com/auth/CreateAccount.tsx
new file mode 100644
index 000000000..a24dc4e35
--- /dev/null
+++ b/src/view/com/auth/CreateAccount.tsx
@@ -0,0 +1,584 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  KeyboardAvoidingView,
+  ScrollView,
+  StyleSheet,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {ComAtprotoAccountCreate} from '@atproto/api'
+import * as EmailValidator from 'email-validator'
+import {sha256} from 'js-sha256'
+import {useAnalytics} from 'lib/analytics'
+import {LogoTextHero} from './Logo'
+import {Picker} from '../util/Picker'
+import {TextLink} from '../util/Link'
+import {Text} from '../util/text/Text'
+import {s, colors} from 'lib/styles'
+import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
+import {toNiceDomain} from 'lib/strings/url-helpers'
+import {useStores, DEFAULT_SERVICE} from 'state/index'
+import {ServiceDescription} from 'state/models/session'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {cleanError} from 'lib/strings/errors'
+
+export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
+  const {track, screen, identify} = useAnalytics()
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const store = useStores()
+  const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
+  const [serviceUrl, setServiceUrl] = React.useState<string>(DEFAULT_SERVICE)
+  const [error, setError] = React.useState<string>('')
+  const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>(
+    {},
+  )
+  const [serviceDescription, setServiceDescription] = React.useState<
+    ServiceDescription | undefined
+  >(undefined)
+  const [userDomain, setUserDomain] = React.useState<string>('')
+  const [inviteCode, setInviteCode] = React.useState<string>('')
+  const [email, setEmail] = React.useState<string>('')
+  const [password, setPassword] = React.useState<string>('')
+  const [handle, setHandle] = React.useState<string>('')
+  const [is13, setIs13] = React.useState<boolean>(false)
+
+  React.useEffect(() => {
+    screen('CreateAccount')
+  }, [screen])
+
+  React.useEffect(() => {
+    let aborted = false
+    setError('')
+    setServiceDescription(undefined)
+    store.session.describeService(serviceUrl).then(
+      desc => {
+        if (aborted) {
+          return
+        }
+        setServiceDescription(desc)
+        setUserDomain(desc.availableUserDomains[0])
+      },
+      err => {
+        if (aborted) {
+          return
+        }
+        store.log.warn(
+          `Failed to fetch service description for ${serviceUrl}`,
+          err,
+        )
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      },
+    )
+    return () => {
+      aborted = true
+    }
+  }, [serviceUrl, store.session, store.log, retryDescribeTrigger])
+
+  const onPressRetryConnect = React.useCallback(
+    () => setRetryDescribeTrigger({}),
+    [setRetryDescribeTrigger],
+  )
+
+  const onPressSelectService = React.useCallback(() => {
+    store.shell.openModal({
+      name: 'server-input',
+      initialService: serviceUrl,
+      onSelect: setServiceUrl,
+    })
+    Keyboard.dismiss()
+  }, [store, serviceUrl])
+
+  const onBlurInviteCode = React.useCallback(() => {
+    setInviteCode(inviteCode.trim())
+  }, [setInviteCode, inviteCode])
+
+  const onPressNext = React.useCallback(async () => {
+    if (!email) {
+      return setError('Please enter your email.')
+    }
+    if (!EmailValidator.validate(email)) {
+      return setError('Your email appears to be invalid.')
+    }
+    if (!password) {
+      return setError('Please choose your password.')
+    }
+    if (!handle) {
+      return setError('Please choose your username.')
+    }
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.session.createAccount({
+        service: serviceUrl,
+        email,
+        handle: createFullHandle(handle, userDomain),
+        password,
+        inviteCode,
+      })
+
+      const email_hashed = sha256(email)
+      identify(email_hashed, {email_hashed})
+
+      track('Create Account')
+    } catch (e: any) {
+      let errMsg = e.toString()
+      if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
+        errMsg =
+          'Invite code not accepted. Check that you input it correctly and try again.'
+      }
+      store.log.error('Failed to create account', e)
+      setIsProcessing(false)
+      setError(cleanError(errMsg))
+    }
+  }, [
+    serviceUrl,
+    userDomain,
+    inviteCode,
+    email,
+    password,
+    handle,
+    setError,
+    setIsProcessing,
+    store,
+    track,
+    identify,
+  ])
+
+  const isReady = !!email && !!password && !!handle && is13
+  return (
+    <ScrollView testID="createAccount" style={pal.view}>
+      <KeyboardAvoidingView behavior="padding">
+        <LogoTextHero />
+        {error ? (
+          <View style={[styles.error, styles.errorFloating]}>
+            <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={styles.groupLabel}>
+          <Text type="sm-bold" style={pal.text}>
+            Service provider
+          </Text>
+        </View>
+        <View style={[pal.borderDark, styles.group]}>
+          <View
+            style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
+            <FontAwesomeIcon
+              icon="globe"
+              style={[pal.textLight, styles.groupContentIcon]}
+            />
+            <TouchableOpacity
+              testID="registerSelectServiceButton"
+              style={styles.textBtn}
+              onPress={onPressSelectService}>
+              <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, styles.textBtnFakeInnerBtnIcon]}
+                />
+                <Text style={[pal.textLight]}>Change</Text>
+              </View>
+            </TouchableOpacity>
+          </View>
+        </View>
+        {serviceDescription ? (
+          <>
+            <View style={styles.groupLabel}>
+              <Text type="sm-bold" style={pal.text}>
+                Account details
+              </Text>
+            </View>
+            <View style={[pal.borderDark, styles.group]}>
+              {serviceDescription?.inviteCodeRequired ? (
+                <View
+                  style={[pal.border, styles.groupContent, styles.noTopBorder]}>
+                  <FontAwesomeIcon
+                    icon="ticket"
+                    style={[pal.textLight, styles.groupContentIcon]}
+                  />
+                  <TextInput
+                    style={[pal.text, styles.textInput]}
+                    placeholder="Invite code"
+                    placeholderTextColor={pal.colors.textLight}
+                    autoCapitalize="none"
+                    autoCorrect={false}
+                    autoFocus
+                    keyboardAppearance={theme.colorScheme}
+                    value={inviteCode}
+                    onChangeText={setInviteCode}
+                    onBlur={onBlurInviteCode}
+                    editable={!isProcessing}
+                  />
+                </View>
+              ) : undefined}
+              <View style={[pal.border, styles.groupContent]}>
+                <FontAwesomeIcon
+                  icon="envelope"
+                  style={[pal.textLight, styles.groupContentIcon]}
+                />
+                <TextInput
+                  testID="registerEmailInput"
+                  style={[pal.text, styles.textInput]}
+                  placeholder="Email address"
+                  placeholderTextColor={pal.colors.textLight}
+                  autoCapitalize="none"
+                  autoCorrect={false}
+                  value={email}
+                  onChangeText={setEmail}
+                  editable={!isProcessing}
+                />
+              </View>
+              <View style={[pal.border, styles.groupContent]}>
+                <FontAwesomeIcon
+                  icon="lock"
+                  style={[pal.textLight, styles.groupContentIcon]}
+                />
+                <TextInput
+                  testID="registerPasswordInput"
+                  style={[pal.text, styles.textInput]}
+                  placeholder="Choose your password"
+                  placeholderTextColor={pal.colors.textLight}
+                  autoCapitalize="none"
+                  autoCorrect={false}
+                  secureTextEntry
+                  value={password}
+                  onChangeText={setPassword}
+                  editable={!isProcessing}
+                />
+              </View>
+            </View>
+          </>
+        ) : undefined}
+        {serviceDescription ? (
+          <>
+            <View style={styles.groupLabel}>
+              <Text type="sm-bold" style={pal.text}>
+                Choose your username
+              </Text>
+            </View>
+            <View style={[pal.border, styles.group]}>
+              <View
+                style={[pal.border, styles.groupContent, styles.noTopBorder]}>
+                <FontAwesomeIcon
+                  icon="at"
+                  style={[pal.textLight, styles.groupContentIcon]}
+                />
+                <TextInput
+                  testID="registerHandleInput"
+                  style={[pal.text, styles.textInput]}
+                  placeholder="eg alice"
+                  placeholderTextColor={pal.colors.textLight}
+                  autoCapitalize="none"
+                  value={handle}
+                  onChangeText={v => setHandle(makeValidHandle(v))}
+                  editable={!isProcessing}
+                />
+              </View>
+              {serviceDescription.availableUserDomains.length > 1 && (
+                <View style={[pal.border, styles.groupContent]}>
+                  <FontAwesomeIcon
+                    icon="globe"
+                    style={styles.groupContentIcon}
+                  />
+                  <Picker
+                    style={[pal.text, styles.picker]}
+                    labelStyle={styles.pickerLabel}
+                    iconStyle={pal.textLight as FontAwesomeIconStyle}
+                    value={userDomain}
+                    items={serviceDescription.availableUserDomains.map(d => ({
+                      label: `.${d}`,
+                      value: d,
+                    }))}
+                    onChange={itemValue => setUserDomain(itemValue)}
+                    enabled={!isProcessing}
+                  />
+                </View>
+              )}
+              <View style={[pal.border, styles.groupContent]}>
+                <Text style={[pal.textLight, s.p10]}>
+                  Your full username will be{' '}
+                  <Text type="md-bold" style={pal.textLight}>
+                    @{createFullHandle(handle, userDomain)}
+                  </Text>
+                </Text>
+              </View>
+            </View>
+            <View style={styles.groupLabel}>
+              <Text type="sm-bold" style={pal.text}>
+                Legal
+              </Text>
+            </View>
+            <View style={[pal.border, styles.group]}>
+              <View
+                style={[pal.border, styles.groupContent, styles.noTopBorder]}>
+                <TouchableOpacity
+                  testID="registerIs13Input"
+                  style={styles.textBtn}
+                  onPress={() => setIs13(!is13)}>
+                  <View
+                    style={[
+                      pal.border,
+                      is13 ? styles.checkboxFilled : styles.checkbox,
+                    ]}>
+                    {is13 && (
+                      <FontAwesomeIcon icon="check" style={s.blue3} size={14} />
+                    )}
+                  </View>
+                  <Text style={[pal.text, styles.textBtnLabel]}>
+                    I am 13 years old or older
+                  </Text>
+                </TouchableOpacity>
+              </View>
+            </View>
+            <Policies serviceDescription={serviceDescription} />
+          </>
+        ) : undefined}
+        <View style={[s.flexRow, s.pl20, s.pr20]}>
+          <TouchableOpacity onPress={onPressBack}>
+            <Text type="xl" style={pal.link}>
+              Back
+            </Text>
+          </TouchableOpacity>
+          <View style={s.flex1} />
+          {isReady ? (
+            <TouchableOpacity
+              testID="createAccountButton"
+              onPress={onPressNext}>
+              {isProcessing ? (
+                <ActivityIndicator />
+              ) : (
+                <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                  Next
+                </Text>
+              )}
+            </TouchableOpacity>
+          ) : !serviceDescription && error ? (
+            <TouchableOpacity
+              testID="registerRetryButton"
+              onPress={onPressRetryConnect}>
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                Retry
+              </Text>
+            </TouchableOpacity>
+          ) : !serviceDescription ? (
+            <>
+              <ActivityIndicator color="#fff" />
+              <Text type="xl-bold" style={[pal.link, s.pr5]}>
+                Connecting...
+              </Text>
+            </>
+          ) : undefined}
+        </View>
+        <View style={s.footerSpacer} />
+      </KeyboardAvoidingView>
+    </ScrollView>
+  )
+}
+
+const Policies = ({
+  serviceDescription,
+}: {
+  serviceDescription: ServiceDescription
+}) => {
+  const pal = usePalette('default')
+  if (!serviceDescription) {
+    return <View />
+  }
+  const tos = validWebLink(serviceDescription.links?.termsOfService)
+  const pp = validWebLink(serviceDescription.links?.privacyPolicy)
+  if (!tos && !pp) {
+    return (
+      <View style={styles.policies}>
+        <View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}>
+          <FontAwesomeIcon
+            icon="exclamation"
+            style={pal.textLight as FontAwesomeIconStyle}
+            size={10}
+          />
+        </View>
+        <Text style={[pal.textLight, s.pl5, s.flex1]}>
+          This service has not provided terms of service or a privacy policy.
+        </Text>
+      </View>
+    )
+  }
+  const els = []
+  if (tos) {
+    els.push(
+      <TextLink
+        key="tos"
+        href={tos}
+        text="Terms of Service"
+        style={[pal.link, s.underline]}
+      />,
+    )
+  }
+  if (pp) {
+    els.push(
+      <TextLink
+        key="pp"
+        href={pp}
+        text="Privacy Policy"
+        style={[pal.link, s.underline]}
+      />,
+    )
+  }
+  if (els.length === 2) {
+    els.splice(
+      1,
+      0,
+      <Text key="and" style={pal.textLight}>
+        {' '}
+        and{' '}
+      </Text>,
+    )
+  }
+  return (
+    <View style={styles.policies}>
+      <Text style={pal.textLight}>
+        By creating an account you agree to the {els}.
+      </Text>
+    </View>
+  )
+}
+
+function validWebLink(url?: string): string | undefined {
+  return url && (url.startsWith('http://') || url.startsWith('https://'))
+    ? url
+    : undefined
+}
+
+const styles = StyleSheet.create({
+  noTopBorder: {
+    borderTopWidth: 0,
+  },
+  logoHero: {
+    paddingTop: 30,
+    paddingBottom: 40,
+  },
+  group: {
+    borderWidth: 1,
+    borderRadius: 10,
+    marginBottom: 20,
+    marginHorizontal: 20,
+  },
+  groupLabel: {
+    paddingHorizontal: 20,
+    paddingBottom: 5,
+  },
+  groupContent: {
+    borderTopWidth: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  groupContentIcon: {
+    marginLeft: 10,
+  },
+  textInput: {
+    flex: 1,
+    width: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 12,
+    fontSize: 17,
+    letterSpacing: 0.25,
+    fontWeight: '400',
+    borderRadius: 10,
+  },
+  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,
+  },
+  textBtnFakeInnerBtnIcon: {
+    marginRight: 4,
+  },
+  picker: {
+    flex: 1,
+    width: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 12,
+    fontSize: 17,
+    borderRadius: 10,
+  },
+  pickerLabel: {
+    fontSize: 17,
+  },
+  checkbox: {
+    borderWidth: 1,
+    borderRadius: 2,
+    width: 16,
+    height: 16,
+    marginLeft: 16,
+  },
+  checkboxFilled: {
+    borderWidth: 1,
+    borderRadius: 2,
+    width: 16,
+    height: 16,
+    marginLeft: 16,
+  },
+  policies: {
+    flexDirection: 'row',
+    alignItems: 'flex-start',
+    paddingHorizontal: 20,
+    paddingBottom: 20,
+  },
+  error: {
+    backgroundColor: colors.red4,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginTop: -5,
+    marginHorizontal: 20,
+    marginBottom: 15,
+    borderRadius: 8,
+    paddingHorizontal: 8,
+    paddingVertical: 8,
+  },
+  errorFloating: {
+    marginBottom: 20,
+    marginHorizontal: 20,
+    borderRadius: 8,
+  },
+  errorIcon: {
+    borderWidth: 1,
+    borderColor: colors.white,
+    borderRadius: 30,
+    width: 16,
+    height: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    marginRight: 5,
+  },
+})
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
new file mode 100644
index 000000000..47dd51d9c
--- /dev/null
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import {SafeAreaView} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Signin} from 'view/com/auth/Signin'
+import {CreateAccount} from 'view/com/auth/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'
+import {SplashScreen} from './SplashScreen'
+import {CenteredView} from '../util/Views'
+
+enum ScreenState {
+  S_SigninOrCreateAccount,
+  S_Signin,
+  S_CreateAccount,
+}
+
+export const LoggedOut = observer(() => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {screen} = useAnalytics()
+  const [screenState, setScreenState] = React.useState<ScreenState>(
+    ScreenState.S_SigninOrCreateAccount,
+  )
+
+  React.useEffect(() => {
+    screen('Login')
+    store.shell.setMinimalShellMode(true)
+  }, [store, screen])
+
+  if (
+    store.session.isResumingSession ||
+    screenState === ScreenState.S_SigninOrCreateAccount
+  ) {
+    return (
+      <SplashScreen
+        onPressSignin={() => setScreenState(ScreenState.S_Signin)}
+        onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)}
+      />
+    )
+  }
+
+  return (
+    <CenteredView style={[s.hContentRegion, pal.view]}>
+      <SafeAreaView testID="noSessionView" style={s.hContentRegion}>
+        <ErrorBoundary>
+          {screenState === ScreenState.S_Signin ? (
+            <Signin
+              onPressBack={() =>
+                setScreenState(ScreenState.S_SigninOrCreateAccount)
+              }
+            />
+          ) : undefined}
+          {screenState === ScreenState.S_CreateAccount ? (
+            <CreateAccount
+              onPressBack={() =>
+                setScreenState(ScreenState.S_SigninOrCreateAccount)
+              }
+            />
+          ) : undefined}
+        </ErrorBoundary>
+      </SafeAreaView>
+    </CenteredView>
+  )
+})
diff --git a/src/view/com/auth/Logo.tsx b/src/view/com/auth/Logo.tsx
new file mode 100644
index 000000000..ac408cd2f
--- /dev/null
+++ b/src/view/com/auth/Logo.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {s, colors} from 'lib/styles'
+import {Text} from '../util/text/Text'
+
+export const LogoTextHero = () => {
+  return (
+    <View style={[styles.textHero]}>
+      <Text type="title-lg" style={[s.white, s.bold]}>
+        Bluesky
+      </Text>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  textHero: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    paddingRight: 20,
+    paddingVertical: 15,
+    marginBottom: 20,
+    backgroundColor: colors.blue3,
+  },
+})
diff --git a/src/view/com/auth/Signin.tsx b/src/view/com/auth/Signin.tsx
new file mode 100644
index 000000000..6faf5ff12
--- /dev/null
+++ b/src/view/com/auth/Signin.tsx
@@ -0,0 +1,895 @@
+import React, {useState, useEffect} from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  KeyboardAvoidingView,
+  StyleSheet,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import * as EmailValidator from 'email-validator'
+import AtpAgent from '@atproto/api'
+import {useAnalytics} from 'lib/analytics'
+import {LogoTextHero} from './Logo'
+import {Text} from '../util/text/Text'
+import {UserAvatar} from '../util/UserAvatar'
+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 {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {cleanError} from 'lib/strings/errors'
+
+enum Forms {
+  Login,
+  ChooseAccount,
+  ForgotPassword,
+  SetNewPassword,
+  PasswordUpdated,
+}
+
+export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {track} = useAnalytics()
+  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,
+  )
+
+  const onSelectAccount = (account?: AccountData) => {
+    if (account?.service) {
+      setServiceUrl(account.service)
+    }
+    setInitialHandle(account?.handle || '')
+    setCurrentForm(Forms.Login)
+  }
+
+  const gotoForm = (form: Forms) => () => {
+    setError('')
+    setCurrentForm(form)
+  }
+
+  useEffect(() => {
+    let aborted = false
+    setError('')
+    store.session.describeService(serviceUrl).then(
+      desc => {
+        if (aborted) {
+          return
+        }
+        setServiceDescription(desc)
+      },
+      err => {
+        if (aborted) {
+          return
+        }
+        store.log.warn(
+          `Failed to fetch service description for ${serviceUrl}`,
+          err,
+        )
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      },
+    )
+    return () => {
+      aborted = true
+    }
+  }, [store.session, store.log, serviceUrl, retryDescribeTrigger])
+
+  const onPressRetryConnect = () => setRetryDescribeTrigger({})
+  const onPressForgotPassword = () => {
+    track('Signin:PressedForgotPassword')
+    setCurrentForm(Forms.ForgotPassword)
+  }
+
+  return (
+    <KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
+      {currentForm === Forms.Login ? (
+        <LoginForm
+          store={store}
+          error={error}
+          serviceUrl={serviceUrl}
+          serviceDescription={serviceDescription}
+          initialHandle={initialHandle}
+          setError={setError}
+          setServiceUrl={setServiceUrl}
+          onPressBack={onPressBack}
+          onPressForgotPassword={onPressForgotPassword}
+          onPressRetryConnect={onPressRetryConnect}
+        />
+      ) : undefined}
+      {currentForm === Forms.ChooseAccount ? (
+        <ChooseAccountForm
+          store={store}
+          onSelectAccount={onSelectAccount}
+          onPressBack={onPressBack}
+        />
+      ) : undefined}
+      {currentForm === Forms.ForgotPassword ? (
+        <ForgotPasswordForm
+          store={store}
+          error={error}
+          serviceUrl={serviceUrl}
+          serviceDescription={serviceDescription}
+          setError={setError}
+          setServiceUrl={setServiceUrl}
+          onPressBack={gotoForm(Forms.Login)}
+          onEmailSent={gotoForm(Forms.SetNewPassword)}
+        />
+      ) : undefined}
+      {currentForm === Forms.SetNewPassword ? (
+        <SetNewPasswordForm
+          store={store}
+          error={error}
+          serviceUrl={serviceUrl}
+          setError={setError}
+          onPressBack={gotoForm(Forms.ForgotPassword)}
+          onPasswordSet={gotoForm(Forms.PasswordUpdated)}
+        />
+      ) : undefined}
+      {currentForm === Forms.PasswordUpdated ? (
+        <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} />
+      ) : 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 (
+    <View testID="chooseAccountForm">
+      <LogoTextHero />
+      <Text type="sm-bold" style={[pal.text, styles.groupLabel]}>
+        Sign in as...
+      </Text>
+      {store.session.accounts.map(account => (
+        <TouchableOpacity
+          testID={`chooseAccountBtn-${account.handle}`}
+          key={account.did}
+          style={[pal.borderDark, styles.group, s.mb5]}
+          onPress={() => onTryAccount(account)}>
+          <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.borderDark, styles.group]}
+        onPress={() => onSelectAccount(undefined)}>
+        <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}>
+          <Text type="xl" style={[pal.link, s.pl5]}>
+            Back
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        {isProcessing && <ActivityIndicator />}
+      </View>
+    </View>
+  )
+}
+
+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 onPressSelectService = () => {
+    store.shell.openModal({
+      name: 'server-input',
+      initialService: serviceUrl,
+      onSelect: setServiceUrl,
+    })
+    Keyboard.dismiss()
+    track('Signin:PressedSelectService')
+  }
+
+  const onPressNext = async () => {
+    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,
+      })
+      track('Sign In', {resumedSession: false})
+    } catch (e: any) {
+      const errMsg = e.toString()
+      store.log.warn('Failed to login', 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))
+      }
+    }
+  }
+
+  const isReady = !!serviceDescription && !!identifier && !!password
+  return (
+    <View testID="loginForm">
+      <LogoTextHero />
+      <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}>
+            <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}
+            keyboardAppearance={theme.colorScheme}
+            value={identifier}
+            onChangeText={str => setIdentifier((str || '').toLowerCase())}
+            editable={!isProcessing}
+          />
+        </View>
+        <View style={[pal.borderDark, styles.groupContent]}>
+          <FontAwesomeIcon
+            icon="lock"
+            style={[pal.textLight, styles.groupContentIcon]}
+          />
+          <TextInput
+            testID="loginPasswordInput"
+            style={[pal.text, styles.textInput]}
+            placeholder="Password"
+            placeholderTextColor={pal.colors.textLight}
+            autoCapitalize="none"
+            autoCorrect={false}
+            keyboardAppearance={theme.colorScheme}
+            secureTextEntry
+            value={password}
+            onChangeText={setPassword}
+            editable={!isProcessing}
+          />
+          <TouchableOpacity
+            testID="forgotPasswordButton"
+            style={styles.textInputInnerBtn}
+            onPress={onPressForgotPassword}>
+            <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}>
+          <Text type="xl" style={[pal.link, s.pl5]}>
+            Back
+          </Text>
+        </TouchableOpacity>
+        <View style={s.flex1} />
+        {!serviceDescription && error ? (
+          <TouchableOpacity
+            testID="loginRetryButton"
+            onPress={onPressRetryConnect}>
+            <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}>
+            <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 AtpAgent({service: serviceUrl})
+      await agent.api.com.atproto.account.requestPasswordReset({email})
+      onEmailSent()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      store.log.warn('Failed to request password reset', e)
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <>
+      <LogoTextHero />
+      <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}>
+            <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}
+            />
+          </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}>
+            <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}>
+              <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 = ({
+  store,
+  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 AtpAgent({service: serviceUrl})
+      await agent.api.com.atproto.account.resetPassword({
+        token: resetCode,
+        password,
+      })
+      onPasswordSet()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      store.log.warn('Failed to set new password', e)
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <>
+      <LogoTextHero />
+      <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}
+            />
+          </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}
+            />
+          </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}>
+            <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}>
+              <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 (
+    <>
+      <LogoTextHero />
+      <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}>
+            <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,
+  },
+  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},
+})
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx
new file mode 100644
index 000000000..27943f64d
--- /dev/null
+++ b/src/view/com/auth/SplashScreen.tsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
+import Image, {Source as ImageSource} from 'view/com/util/images/Image'
+import {Text} from 'view/com/util/text/Text'
+import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
+import {colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CLOUD_SPLASH} from 'lib/assets'
+import {CenteredView} from '../util/Views'
+
+export const SplashScreen = ({
+  onPressSignin,
+  onPressCreateAccount,
+}: {
+  onPressSignin: () => void
+  onPressCreateAccount: () => void
+}) => {
+  const pal = usePalette('default')
+  return (
+    <CenteredView style={styles.container}>
+      <Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} />
+      <SafeAreaView testID="noSessionView" style={styles.container}>
+        <ErrorBoundary>
+          <View style={styles.hero}>
+            <View style={styles.heroText}>
+              <Text style={styles.title}>Bluesky</Text>
+            </View>
+          </View>
+          <View testID="signinOrCreateAccount" style={styles.btns}>
+            <TouchableOpacity
+              testID="createAccountButton"
+              style={[pal.view, styles.btn]}
+              onPress={onPressCreateAccount}>
+              <Text style={[pal.link, styles.btnLabel]}>
+                Create a new account
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              testID="signInButton"
+              style={[pal.view, styles.btn]}
+              onPress={onPressSignin}>
+              <Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
+            </TouchableOpacity>
+          </View>
+        </ErrorBoundary>
+      </SafeAreaView>
+    </CenteredView>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    height: '100%',
+  },
+  hero: {
+    flex: 2,
+    justifyContent: 'center',
+  },
+  bgImg: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    width: '100%',
+    height: '100%',
+  },
+  heroText: {
+    backgroundColor: colors.white,
+    paddingTop: 10,
+    paddingBottom: 20,
+  },
+  btns: {
+    paddingBottom: 40,
+  },
+  title: {
+    textAlign: 'center',
+    color: colors.blue3,
+    fontSize: 68,
+    fontWeight: 'bold',
+  },
+  btn: {
+    borderRadius: 4,
+    paddingVertical: 16,
+    marginBottom: 20,
+    marginHorizontal: 20,
+    backgroundColor: colors.blue3,
+  },
+  btnLabel: {
+    textAlign: 'center',
+    fontSize: 21,
+    color: colors.white,
+  },
+})
diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx
new file mode 100644
index 000000000..05d0355d9
--- /dev/null
+++ b/src/view/com/auth/SplashScreen.web.tsx
@@ -0,0 +1,102 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {Text} from 'view/com/util/text/Text'
+import {TextLink} from '../util/Link'
+import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CenteredView} from '../util/Views'
+
+export const SplashScreen = ({
+  onPressSignin,
+  onPressCreateAccount,
+}: {
+  onPressSignin: () => void
+  onPressCreateAccount: () => void
+}) => {
+  const pal = usePalette('default')
+  return (
+    <CenteredView style={styles.container}>
+      <View testID="noSessionView" style={styles.containerInner}>
+        <ErrorBoundary>
+          <Text style={styles.title}>Bluesky</Text>
+          <Text style={styles.subtitle}>See what's next</Text>
+          <View testID="signinOrCreateAccount" style={styles.btns}>
+            <TouchableOpacity
+              testID="createAccountButton"
+              style={[styles.btn, {backgroundColor: colors.blue3}]}
+              onPress={onPressCreateAccount}>
+              <Text style={[s.white, styles.btnLabel]}>
+                Create a new account
+              </Text>
+            </TouchableOpacity>
+            <TouchableOpacity
+              testID="signInButton"
+              style={[styles.btn, pal.btn]}
+              onPress={onPressSignin}>
+              <Text style={[pal.link, styles.btnLabel]}>Sign in</Text>
+            </TouchableOpacity>
+          </View>
+          <Text
+            type="xl"
+            style={[styles.notice, pal.textLight]}
+            lineHeight={1.3}>
+            Bluesky will launch soon.{' '}
+            <TextLink
+              type="xl"
+              text="Join the waitlist"
+              href="#"
+              style={pal.link}
+            />{' '}
+            to try the beta before it's publicly available.
+          </Text>
+        </ErrorBoundary>
+      </View>
+    </CenteredView>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    height: '100%',
+    backgroundColor: colors.gray1,
+  },
+  containerInner: {
+    backgroundColor: colors.white,
+    paddingVertical: 40,
+    paddingBottom: 50,
+    paddingHorizontal: 20,
+  },
+  title: {
+    textAlign: 'center',
+    color: colors.blue3,
+    fontSize: 68,
+    fontWeight: 'bold',
+    paddingBottom: 10,
+  },
+  subtitle: {
+    textAlign: 'center',
+    color: colors.gray5,
+    fontSize: 52,
+    fontWeight: 'bold',
+    paddingBottom: 30,
+  },
+  btns: {
+    flexDirection: 'row',
+    paddingBottom: 40,
+  },
+  btn: {
+    flex: 1,
+    borderRadius: 30,
+    paddingVertical: 12,
+    marginHorizontal: 10,
+  },
+  btnLabel: {
+    textAlign: 'center',
+    fontSize: 18,
+  },
+  notice: {
+    paddingHorizontal: 40,
+    textAlign: 'center',
+  },
+})
diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx
new file mode 100644
index 000000000..11b67f383
--- /dev/null
+++ b/src/view/com/auth/withAuthRequired.tsx
@@ -0,0 +1,47 @@
+import React from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {LoggedOut} from './LoggedOut'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+
+export const withAuthRequired = <P extends object>(
+  Component: React.ComponentType<P>,
+): React.FC<P> =>
+  observer((props: P) => {
+    const store = useStores()
+    if (store.session.isResumingSession) {
+      return <Loading />
+    }
+    if (!store.session.hasSession) {
+      return <LoggedOut />
+    }
+    return <Component {...props} />
+  })
+
+function Loading() {
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.loading, pal.view]}>
+      <ActivityIndicator size="large" />
+      <Text type="2xl" style={[styles.loadingText, pal.textLight]}>
+        Firing up the grill...
+      </Text>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  loading: {
+    height: '100%',
+    alignContent: 'center',
+    justifyContent: 'center',
+    paddingBottom: 100,
+  },
+  loadingText: {
+    paddingVertical: 20,
+    paddingHorizontal: 20,
+    textAlign: 'center',
+  },
+})