about summary refs log tree commit diff
path: root/src/screens/Signup
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Signup')
-rw-r--r--src/screens/Signup/BackNextButtons.tsx2
-rw-r--r--src/screens/Signup/StepCaptcha/index.tsx2
-rw-r--r--src/screens/Signup/StepHandle.tsx217
-rw-r--r--src/screens/Signup/StepHandle/HandleSuggestions.tsx80
-rw-r--r--src/screens/Signup/StepHandle/index.tsx279
-rw-r--r--src/screens/Signup/StepInfo/index.tsx2
-rw-r--r--src/screens/Signup/index.tsx7
7 files changed, 366 insertions, 223 deletions
diff --git a/src/screens/Signup/BackNextButtons.tsx b/src/screens/Signup/BackNextButtons.tsx
index 888b9071e..5a85a85d1 100644
--- a/src/screens/Signup/BackNextButtons.tsx
+++ b/src/screens/Signup/BackNextButtons.tsx
@@ -9,7 +9,7 @@ import {Loader} from '#/components/Loader'
 export interface BackNextButtonsProps {
   hideNext?: boolean
   showRetry?: boolean
-  isLoading: boolean
+  isLoading?: boolean
   isNextDisabled?: boolean
   onBackPress: () => void
   onNextPress?: () => void
diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
index e2f249a13..8ea893c4a 100644
--- a/src/screens/Signup/StepCaptcha/index.tsx
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -144,7 +144,7 @@ function StepCaptchaInner({
 
   return (
     <ScreenTransition>
-      <View style={[a.gap_lg]}>
+      <View style={[a.gap_lg, a.pt_lg]}>
         <View
           style={[
             a.w_full,
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
deleted file mode 100644
index 8bf0c3364..000000000
--- a/src/screens/Signup/StepHandle.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import React, {useRef} from 'react'
-import {View} from 'react-native'
-import {msg, Plural, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {
-  createFullHandle,
-  MAX_SERVICE_HANDLE_LENGTH,
-  validateServiceHandle,
-} from '#/lib/strings/handles'
-import {logger} from '#/logger'
-import {useAgent} from '#/state/session'
-import {ScreenTransition} from '#/screens/Login/ScreenTransition'
-import {useSignupContext} from '#/screens/Signup/state'
-import {atoms as a, useTheme} from '#/alf'
-import * as TextField from '#/components/forms/TextField'
-import {useThrottledValue} from '#/components/hooks/useThrottledValue'
-import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
-import {Text} from '#/components/Typography'
-import {BackNextButtons} from './BackNextButtons'
-
-export function StepHandle() {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {state, dispatch} = useSignupContext()
-  const agent = useAgent()
-  const handleValueRef = useRef<string>(state.handle)
-  const [draftValue, setDraftValue] = React.useState(state.handle)
-  const isLoading = useThrottledValue(state.isLoading, 500)
-
-  const onNextPress = React.useCallback(async () => {
-    const handle = handleValueRef.current.trim()
-    dispatch({
-      type: 'setHandle',
-      value: handle,
-    })
-
-    const newValidCheck = validateServiceHandle(handle, state.userDomain)
-    if (!newValidCheck.overall) {
-      return
-    }
-
-    try {
-      dispatch({type: 'setIsLoading', value: true})
-
-      const res = await agent.resolveHandle({
-        handle: createFullHandle(handle, state.userDomain),
-      })
-
-      if (res.data.did) {
-        dispatch({
-          type: 'setError',
-          value: _(msg`That handle is already taken.`),
-          field: 'handle',
-        })
-        logger.metric('signup:handleTaken', {}, {statsig: true})
-        return
-      }
-    } catch (e) {
-      // Don't have to handle
-    } finally {
-      dispatch({type: 'setIsLoading', value: false})
-    }
-
-    logger.metric(
-      'signup:nextPressed',
-      {
-        activeStep: state.activeStep,
-        phoneVerificationRequired:
-          state.serviceDescription?.phoneVerificationRequired,
-      },
-      {statsig: true},
-    )
-    // phoneVerificationRequired is actually whether a captcha is required
-    if (!state.serviceDescription?.phoneVerificationRequired) {
-      dispatch({
-        type: 'submit',
-        task: {verificationCode: undefined, mutableProcessed: false},
-      })
-      return
-    }
-    dispatch({type: 'next'})
-  }, [
-    _,
-    dispatch,
-    state.activeStep,
-    state.serviceDescription?.phoneVerificationRequired,
-    state.userDomain,
-    agent,
-  ])
-
-  const onBackPress = React.useCallback(() => {
-    const handle = handleValueRef.current.trim()
-    dispatch({
-      type: 'setHandle',
-      value: handle,
-    })
-    dispatch({type: 'prev'})
-    logger.metric(
-      'signup:backPressed',
-      {activeStep: state.activeStep},
-      {statsig: true},
-    )
-  }, [dispatch, state.activeStep])
-
-  const validCheck = validateServiceHandle(draftValue, state.userDomain)
-  return (
-    <ScreenTransition>
-      <View style={[a.gap_lg]}>
-        <View>
-          <TextField.Root>
-            <TextField.Icon icon={At} />
-            <TextField.Input
-              testID="handleInput"
-              onChangeText={val => {
-                if (state.error) {
-                  dispatch({type: 'setError', value: ''})
-                }
-
-                // These need to always be in sync.
-                handleValueRef.current = val
-                setDraftValue(val)
-              }}
-              label={_(msg`Type your desired username`)}
-              defaultValue={draftValue}
-              autoCapitalize="none"
-              autoCorrect={false}
-              autoFocus
-              autoComplete="off"
-            />
-          </TextField.Root>
-        </View>
-        {draftValue !== '' && (
-          <Text style={[a.text_md]}>
-            <Trans>
-              Your full username will be{' '}
-              <Text style={[a.text_md, a.font_bold]}>
-                @{createFullHandle(draftValue, state.userDomain)}
-              </Text>
-            </Trans>
-          </Text>
-        )}
-
-        {draftValue !== '' && (
-          <View
-            style={[
-              a.w_full,
-              a.rounded_sm,
-              a.border,
-              a.p_md,
-              a.gap_sm,
-              t.atoms.border_contrast_low,
-            ]}>
-            {state.error ? (
-              <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-                <IsValidIcon valid={false} />
-                <Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
-              </View>
-            ) : undefined}
-            {validCheck.hyphenStartOrEnd ? (
-              <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-                <IsValidIcon valid={validCheck.handleChars} />
-                <Text style={[a.text_md, a.flex_1]}>
-                  <Trans>Only contains letters, numbers, and hyphens</Trans>
-                </Text>
-              </View>
-            ) : (
-              <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-                <IsValidIcon valid={validCheck.hyphenStartOrEnd} />
-                <Text style={[a.text_md, a.flex_1]}>
-                  <Trans>Doesn't begin or end with a hyphen</Trans>
-                </Text>
-              </View>
-            )}
-            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
-              <IsValidIcon
-                valid={validCheck.frontLength && validCheck.totalLength}
-              />
-              {!validCheck.totalLength ||
-              draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? (
-                <Text style={[a.text_md, a.flex_1]}>
-                  <Trans>
-                    No longer than{' '}
-                    <Plural
-                      value={MAX_SERVICE_HANDLE_LENGTH}
-                      other="# characters"
-                    />
-                  </Trans>
-                </Text>
-              ) : (
-                <Text style={[a.text_md, a.flex_1]}>
-                  <Trans>At least 3 characters</Trans>
-                </Text>
-              )}
-            </View>
-          </View>
-        )}
-      </View>
-      <BackNextButtons
-        isLoading={isLoading}
-        isNextDisabled={!validCheck.overall}
-        onBackPress={onBackPress}
-        onNextPress={onNextPress}
-      />
-    </ScreenTransition>
-  )
-}
-
-function IsValidIcon({valid}: {valid: boolean}) {
-  const t = useTheme()
-  if (!valid) {
-    return <Times size="md" style={{color: t.palette.negative_500}} />
-  }
-  return <Check size="md" style={{color: t.palette.positive_700}} />
-}
diff --git a/src/screens/Signup/StepHandle/HandleSuggestions.tsx b/src/screens/Signup/StepHandle/HandleSuggestions.tsx
new file mode 100644
index 000000000..3d219d886
--- /dev/null
+++ b/src/screens/Signup/StepHandle/HandleSuggestions.tsx
@@ -0,0 +1,80 @@
+import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated'
+import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, native, useTheme} from '#/alf'
+import {borderRadius} from '#/alf/tokens'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+
+export function HandleSuggestions({
+  suggestions,
+  onSelect,
+}: {
+  suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[]
+  onSelect: (
+    suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion,
+  ) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Animated.View
+      entering={native(FadeInDown.easing(Easing.out(Easing.exp)))}
+      exiting={native(FadeOut)}
+      style={[
+        a.flex_1,
+        a.border,
+        a.rounded_sm,
+        t.atoms.shadow_sm,
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.mt_xs,
+        a.z_50,
+        a.w_full,
+        a.zoom_fade_in,
+      ]}>
+      {suggestions.map((suggestion, index) => (
+        <Button
+          label={_(
+            msg({
+              message: `Select ${suggestion.handle}`,
+              comment: `Accessibility label for a username suggestion in the account creation flow`,
+            }),
+          )}
+          key={index}
+          onPress={() => onSelect(suggestion)}
+          hoverStyle={[t.atoms.bg_contrast_25]}
+          style={[
+            a.w_full,
+            a.flex_row,
+            a.align_center,
+            a.justify_between,
+            a.p_md,
+            a.border_b,
+            t.atoms.border_contrast_low,
+            index === 0 && {
+              borderTopStartRadius: borderRadius.sm,
+              borderTopEndRadius: borderRadius.sm,
+            },
+            index === suggestions.length - 1 && [
+              {
+                borderBottomStartRadius: borderRadius.sm,
+                borderBottomEndRadius: borderRadius.sm,
+              },
+              a.border_b_0,
+            ],
+          ]}>
+          <Text style={[a.text_md]}>{suggestion.handle}</Text>
+          <Text style={[a.text_sm, {color: t.palette.positive_700}]}>
+            <Trans comment="Shown next to an available username suggestion in the account creation flow">
+              Available
+            </Trans>
+          </Text>
+        </Button>
+      ))}
+    </Animated.View>
+  )
+}
diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx
new file mode 100644
index 000000000..aaab435ae
--- /dev/null
+++ b/src/screens/Signup/StepHandle/index.tsx
@@ -0,0 +1,279 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+  LinearTransition,
+} from 'react-native-reanimated'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {
+  createFullHandle,
+  MAX_SERVICE_HANDLE_LENGTH,
+  validateServiceHandle,
+} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {
+  checkHandleAvailability,
+  useHandleAvailabilityQuery,
+} from '#/state/queries/handle-availability'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {useSignupContext} from '#/screens/Signup/state'
+import {atoms as a, native, useTheme} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
+import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {Text} from '#/components/Typography'
+import {IS_INTERNAL} from '#/env'
+import {BackNextButtons} from '../BackNextButtons'
+import {HandleSuggestions} from './HandleSuggestions'
+
+export function StepHandle() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const gate = useGate()
+  const {state, dispatch} = useSignupContext()
+  const [draftValue, setDraftValue] = useState(state.handle)
+  const isNextLoading = useThrottledValue(state.isLoading, 500)
+
+  const validCheck = validateServiceHandle(draftValue, state.userDomain)
+
+  const {
+    debouncedUsername: debouncedDraftValue,
+    enabled: queryEnabled,
+    query: {data: isHandleAvailable, isPending},
+  } = useHandleAvailabilityQuery({
+    username: draftValue,
+    serviceDid: state.serviceDescription?.did ?? 'UNKNOWN',
+    serviceDomain: state.userDomain,
+    birthDate: state.dateOfBirth.toISOString(),
+    email: state.email,
+    enabled: validCheck.overall,
+  })
+
+  const onNextPress = async () => {
+    const handle = draftValue.trim()
+    dispatch({
+      type: 'setHandle',
+      value: handle,
+    })
+
+    if (!validCheck.overall) {
+      return
+    }
+
+    dispatch({type: 'setIsLoading', value: true})
+
+    try {
+      const {available: handleAvailable} = await checkHandleAvailability(
+        createFullHandle(handle, state.userDomain),
+        state.serviceDescription?.did ?? 'UNKNOWN',
+        {typeahead: false},
+      )
+
+      if (!handleAvailable) {
+        dispatch({
+          type: 'setError',
+          value: _(msg`That username is already taken`),
+          field: 'handle',
+        })
+        return
+      }
+    } catch (error) {
+      logger.error('Failed to check handle availability on next press', {
+        safeMessage: error,
+      })
+      // do nothing on error, let them pass
+    } finally {
+      dispatch({type: 'setIsLoading', value: false})
+    }
+
+    logger.metric(
+      'signup:nextPressed',
+      {
+        activeStep: state.activeStep,
+        phoneVerificationRequired:
+          state.serviceDescription?.phoneVerificationRequired,
+      },
+      {statsig: true},
+    )
+    // phoneVerificationRequired is actually whether a captcha is required
+    if (!state.serviceDescription?.phoneVerificationRequired) {
+      dispatch({
+        type: 'submit',
+        task: {verificationCode: undefined, mutableProcessed: false},
+      })
+      return
+    }
+    dispatch({type: 'next'})
+  }
+
+  const onBackPress = () => {
+    const handle = draftValue.trim()
+    dispatch({
+      type: 'setHandle',
+      value: handle,
+    })
+    dispatch({type: 'prev'})
+    logger.metric(
+      'signup:backPressed',
+      {activeStep: state.activeStep},
+      {statsig: true},
+    )
+  }
+
+  const hasDebounceSettled = draftValue === debouncedDraftValue
+  const isHandleTaken =
+    !isPending &&
+    queryEnabled &&
+    isHandleAvailable &&
+    !isHandleAvailable.available
+  const isNotReady = isPending || !hasDebounceSettled
+  const isNextDisabled =
+    !validCheck.overall || !!state.error || isNotReady ? true : isHandleTaken
+
+  const textFieldInvalid =
+    isHandleTaken ||
+    !validCheck.frontLengthNotTooLong ||
+    !validCheck.handleChars ||
+    !validCheck.hyphenStartOrEnd ||
+    !validCheck.totalLength
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_sm, a.pt_lg, a.z_10]}>
+        <View>
+          <TextField.Root isInvalid={textFieldInvalid}>
+            <TextField.Icon icon={AtIcon} />
+            <TextField.Input
+              testID="handleInput"
+              onChangeText={val => {
+                if (state.error) {
+                  dispatch({type: 'setError', value: ''})
+                }
+                setDraftValue(val.toLocaleLowerCase())
+              }}
+              label={state.userDomain}
+              value={draftValue}
+              keyboardType="ascii-capable" // fix for iOS replacing -- with —
+              autoCapitalize="none"
+              autoCorrect={false}
+              autoFocus
+              autoComplete="off"
+            />
+            {draftValue.length > 0 && (
+              <TextField.GhostText value={state.userDomain}>
+                {draftValue}
+              </TextField.GhostText>
+            )}
+            {isHandleAvailable?.available && (
+              <CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} />
+            )}
+          </TextField.Root>
+        </View>
+        <LayoutAnimationConfig skipEntering skipExiting>
+          <View style={[a.gap_xs]}>
+            {state.error && (
+              <Requirement>
+                <RequirementText>{state.error}</RequirementText>
+              </Requirement>
+            )}
+            {isHandleTaken && validCheck.overall && (
+              <>
+                <Requirement>
+                  <RequirementText>
+                    <Trans>
+                      {createFullHandle(draftValue, state.userDomain)} is not
+                      available
+                    </Trans>
+                  </RequirementText>
+                </Requirement>
+                {isHandleAvailable.suggestions &&
+                  isHandleAvailable.suggestions.length > 0 &&
+                  (gate('handle_suggestions') || IS_INTERNAL) && (
+                    <HandleSuggestions
+                      suggestions={isHandleAvailable.suggestions}
+                      onSelect={suggestion => {
+                        setDraftValue(
+                          suggestion.handle.slice(
+                            0,
+                            state.userDomain.length * -1,
+                          ),
+                        )
+                        logger.metric('signup:handleSuggestionSelected', {
+                          method: suggestion.method,
+                        })
+                      }}
+                    />
+                  )}
+              </>
+            )}
+            {(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && (
+              <Requirement>
+                {!validCheck.hyphenStartOrEnd ? (
+                  <RequirementText>
+                    <Trans>Username cannot begin or end with a hyphen</Trans>
+                  </RequirementText>
+                ) : (
+                  <RequirementText>
+                    <Trans>
+                      Username must only contain letters (a-z), numbers, and
+                      hyphens
+                    </Trans>
+                  </RequirementText>
+                )}
+              </Requirement>
+            )}
+            <Requirement>
+              {(!validCheck.frontLengthNotTooLong ||
+                !validCheck.totalLength) && (
+                <RequirementText>
+                  <Trans>
+                    Username cannot be longer than{' '}
+                    <Plural
+                      value={MAX_SERVICE_HANDLE_LENGTH}
+                      other="# characters"
+                    />
+                  </Trans>
+                </RequirementText>
+              )}
+            </Requirement>
+          </View>
+        </LayoutAnimationConfig>
+      </View>
+      <Animated.View layout={native(LinearTransition)}>
+        <BackNextButtons
+          isLoading={isNextLoading}
+          isNextDisabled={isNextDisabled}
+          onBackPress={onBackPress}
+          onNextPress={onNextPress}
+        />
+      </Animated.View>
+    </ScreenTransition>
+  )
+}
+
+function Requirement({children}: {children: React.ReactNode}) {
+  return (
+    <Animated.View
+      style={[a.w_full]}
+      layout={native(LinearTransition)}
+      entering={native(FadeIn)}
+      exiting={native(FadeOut)}>
+      {children}
+    </Animated.View>
+  )
+}
+
+function RequirementText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <Text style={[a.text_sm, a.flex_1, {color: t.palette.negative_500}]}>
+      {children}
+    </Text>
+  )
+}
diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx
index f24cd0e45..cf4a9297e 100644
--- a/src/screens/Signup/StepInfo/index.tsx
+++ b/src/screens/Signup/StepInfo/index.tsx
@@ -144,7 +144,7 @@ export function StepInfo({
 
   return (
     <ScreenTransition>
-      <View style={[a.gap_md]}>
+      <View style={[a.gap_md, a.pt_lg]}>
         <FormError error={state.error} />
         <HostingProvider
           minimal
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
index 50cc5aa26..807bbff4f 100644
--- a/src/screens/Signup/index.tsx
+++ b/src/screens/Signup/index.tsx
@@ -157,8 +157,9 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
               a.pt_2xl,
               !gtMobile && {paddingBottom: 100},
             ]}>
-            <View style={[a.gap_sm, a.pb_3xl]}>
-              <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+            <View style={[a.gap_sm, a.pb_sm]}>
+              <Text
+                style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
                 <Trans>
                   Step {state.activeStep + 1} of{' '}
                   {state.serviceDescription &&
@@ -167,7 +168,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
                     : '3'}
                 </Trans>
               </Text>
-              <Text style={[a.text_3xl, a.font_bold]}>
+              <Text style={[a.text_3xl, a.font_heavy]}>
                 {state.activeStep === SignupStep.INFO ? (
                   <Trans>Your account</Trans>
                 ) : state.activeStep === SignupStep.HANDLE ? (