about summary refs log tree commit diff
path: root/src/view/com/auth/CreateAccount.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/auth/CreateAccount.tsx')
-rw-r--r--src/view/com/auth/CreateAccount.tsx584
1 files changed, 584 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,
+  },
+})