Improve textinput performance in login and account creation (#4673)
* Change login form to use uncontrolled inputs * Debounce state updates in account creation to reduce flicker * Refactor state-control of account creation forms to fix perf without relying on debounces * Remove canNext and enforce is13 * Re-add live validation to signup form (#4720) * Update validation in real time * Disable on invalid * Clear server error on typing * Remove unnecessary clearing of error --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>zio/stable
parent
4bb4452f08
commit
63bb8fda2d
|
@ -60,12 +60,13 @@ export const LoginForm = ({
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
|
const [isReady, setIsReady] = useState<boolean>(false)
|
||||||
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
||||||
useState<boolean>(false)
|
useState<boolean>(false)
|
||||||
const [identifier, setIdentifier] = useState<string>(initialHandle)
|
const identifierValueRef = useRef<string>(initialHandle || '')
|
||||||
const [password, setPassword] = useState<string>('')
|
const passwordValueRef = useRef<string>('')
|
||||||
const [authFactorToken, setAuthFactorToken] = useState<string>('')
|
const authFactorTokenValueRef = useRef<string>('')
|
||||||
const passwordInputRef = useRef<TextInput>(null)
|
const passwordRef = useRef<TextInput>(null)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {login} = useSessionApi()
|
const {login} = useSessionApi()
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
|
@ -84,6 +85,10 @@ export const LoginForm = ({
|
||||||
setError('')
|
setError('')
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
|
|
||||||
|
const identifier = identifierValueRef.current.toLowerCase().trim()
|
||||||
|
const password = passwordValueRef.current
|
||||||
|
const authFactorToken = authFactorTokenValueRef.current
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// try to guess the handle if the user just gave their own username
|
// try to guess the handle if the user just gave their own username
|
||||||
let fullIdent = identifier
|
let fullIdent = identifier
|
||||||
|
@ -152,7 +157,22 @@ export const LoginForm = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isReady = !!serviceDescription && !!identifier && !!password
|
const checkIsReady = () => {
|
||||||
|
if (
|
||||||
|
!!serviceDescription &&
|
||||||
|
!!identifierValueRef.current &&
|
||||||
|
!!passwordValueRef.current
|
||||||
|
) {
|
||||||
|
if (!isReady) {
|
||||||
|
setIsReady(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isReady) {
|
||||||
|
setIsReady(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
||||||
<View>
|
<View>
|
||||||
|
@ -181,14 +201,15 @@ export const LoginForm = ({
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
textContentType="username"
|
textContentType="username"
|
||||||
|
defaultValue={initialHandle || ''}
|
||||||
|
onChangeText={v => {
|
||||||
|
identifierValueRef.current = v
|
||||||
|
checkIsReady()
|
||||||
|
}}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
passwordInputRef.current?.focus()
|
passwordRef.current?.focus()
|
||||||
}}
|
}}
|
||||||
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
||||||
value={identifier}
|
|
||||||
onChangeText={str =>
|
|
||||||
setIdentifier((str || '').toLowerCase().trim())
|
|
||||||
}
|
|
||||||
editable={!isProcessing}
|
editable={!isProcessing}
|
||||||
accessibilityHint={_(
|
accessibilityHint={_(
|
||||||
msg`Input the username or email address you used at signup`,
|
msg`Input the username or email address you used at signup`,
|
||||||
|
@ -200,7 +221,7 @@ export const LoginForm = ({
|
||||||
<TextField.Icon icon={Lock} />
|
<TextField.Icon icon={Lock} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
testID="loginPasswordInput"
|
testID="loginPasswordInput"
|
||||||
inputRef={passwordInputRef}
|
inputRef={passwordRef}
|
||||||
label={_(msg`Password`)}
|
label={_(msg`Password`)}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
|
@ -210,16 +231,14 @@ export const LoginForm = ({
|
||||||
secureTextEntry={true}
|
secureTextEntry={true}
|
||||||
textContentType="password"
|
textContentType="password"
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
value={password}
|
onChangeText={v => {
|
||||||
onChangeText={setPassword}
|
passwordValueRef.current = v
|
||||||
|
checkIsReady()
|
||||||
|
}}
|
||||||
onSubmitEditing={onPressNext}
|
onSubmitEditing={onPressNext}
|
||||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||||
editable={!isProcessing}
|
editable={!isProcessing}
|
||||||
accessibilityHint={
|
accessibilityHint={_(msg`Input your password`)}
|
||||||
identifier === ''
|
|
||||||
? _(msg`Input your password`)
|
|
||||||
: _(msg`Input the password tied to ${identifier}`)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
testID="forgotPasswordButton"
|
testID="forgotPasswordButton"
|
||||||
|
@ -258,8 +277,9 @@ export const LoginForm = ({
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
textContentType="username"
|
textContentType="username"
|
||||||
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
|
||||||
value={authFactorToken}
|
onChangeText={v => {
|
||||||
onChangeText={setAuthFactorToken}
|
authFactorTokenValueRef.current = v
|
||||||
|
}}
|
||||||
onSubmitEditing={onPressNext}
|
onSubmitEditing={onPressNext}
|
||||||
editable={!isProcessing}
|
editable={!isProcessing}
|
||||||
accessibilityHint={_(
|
accessibilityHint={_(
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
|
||||||
|
export interface BackNextButtonsProps {
|
||||||
|
hideNext?: boolean
|
||||||
|
showRetry?: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
isNextDisabled?: boolean
|
||||||
|
onBackPress: () => void
|
||||||
|
onNextPress?: () => void
|
||||||
|
onRetryPress?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackNextButtons({
|
||||||
|
hideNext,
|
||||||
|
showRetry,
|
||||||
|
isLoading,
|
||||||
|
isNextDisabled,
|
||||||
|
onBackPress,
|
||||||
|
onNextPress,
|
||||||
|
onRetryPress,
|
||||||
|
}: BackNextButtonsProps) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_row, a.justify_between, a.pb_lg, a.pt_3xl]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Go back to previous step`)}
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onBackPress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Back</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{!hideNext &&
|
||||||
|
(showRetry ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Press to retry`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
onPress={onRetryPress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Retry</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isLoading && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
testID="nextBtn"
|
||||||
|
label={_(msg`Continue to next step`)}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
disabled={isLoading || isNextDisabled}
|
||||||
|
onPress={onNextPress}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Next</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{isLoading && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
|
||||||
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
|
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {FormError} from '#/components/forms/FormError'
|
import {FormError} from '#/components/forms/FormError'
|
||||||
|
import {BackNextButtons} from '../BackNextButtons'
|
||||||
|
|
||||||
const CAPTCHA_PATH = '/gate/signup'
|
const CAPTCHA_PATH = '/gate/signup'
|
||||||
|
|
||||||
|
@ -61,6 +62,16 @@ export function StepCaptcha() {
|
||||||
[_, dispatch, state.handle],
|
[_, dispatch, state.handle],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onBackPress = React.useCallback(() => {
|
||||||
|
logger.error('Signup Flow Error', {
|
||||||
|
errorMessage:
|
||||||
|
'User went back from captcha step. Possibly encountered an error.',
|
||||||
|
registrationHandle: state.handle,
|
||||||
|
})
|
||||||
|
|
||||||
|
dispatch({type: 'prev'})
|
||||||
|
}, [dispatch, state.handle])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenTransition>
|
<ScreenTransition>
|
||||||
<View style={[a.gap_lg]}>
|
<View style={[a.gap_lg]}>
|
||||||
|
@ -86,6 +97,11 @@ export function StepCaptcha() {
|
||||||
</View>
|
</View>
|
||||||
<FormError error={state.error} />
|
<FormError error={state.error} />
|
||||||
</View>
|
</View>
|
||||||
|
<BackNextButtons
|
||||||
|
hideNext
|
||||||
|
isLoading={state.isLoading}
|
||||||
|
onBackPress={onBackPress}
|
||||||
|
/>
|
||||||
</ScreenTransition>
|
</ScreenTransition>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,56 +1,96 @@
|
||||||
import React from 'react'
|
import React, {useRef} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
|
|
||||||
import {
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
createFullHandle,
|
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
|
||||||
IsValidHandle,
|
import {useAgent} from '#/state/session'
|
||||||
validateHandle,
|
|
||||||
} from '#/lib/strings/handles'
|
|
||||||
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
||||||
import {useSignupContext} from '#/screens/Signup/state'
|
import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
|
||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {BackNextButtons} from './BackNextButtons'
|
||||||
|
|
||||||
export function StepHandle() {
|
export function StepHandle() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {state, dispatch} = useSignupContext()
|
const {state, dispatch} = useSignupContext()
|
||||||
|
const submit = useSubmitSignup({state, dispatch})
|
||||||
|
const agent = useAgent()
|
||||||
|
const handleValueRef = useRef<string>(state.handle)
|
||||||
|
const [draftValue, setDraftValue] = React.useState(state.handle)
|
||||||
|
|
||||||
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
|
const onNextPress = React.useCallback(async () => {
|
||||||
handleChars: false,
|
const handle = handleValueRef.current.trim()
|
||||||
hyphenStartOrEnd: false,
|
|
||||||
frontLength: false,
|
|
||||||
totalLength: true,
|
|
||||||
overall: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
setValidCheck(validateHandle(state.handle, state.userDomain))
|
|
||||||
}, [state.handle, state.userDomain]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const onHandleChange = React.useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
if (state.error) {
|
|
||||||
dispatch({type: 'setError', value: ''})
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'setHandle',
|
type: 'setHandle',
|
||||||
value,
|
value: handle,
|
||||||
})
|
})
|
||||||
},
|
|
||||||
[dispatch, state.error],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
const newValidCheck = validateHandle(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.`),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Don't have to handle
|
||||||
|
} finally {
|
||||||
|
dispatch({type: 'setIsLoading', value: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent('signup:nextPressed', {
|
||||||
|
activeStep: state.activeStep,
|
||||||
|
phoneVerificationRequired:
|
||||||
|
state.serviceDescription?.phoneVerificationRequired,
|
||||||
|
})
|
||||||
|
// phoneVerificationRequired is actually whether a captcha is required
|
||||||
|
if (!state.serviceDescription?.phoneVerificationRequired) {
|
||||||
|
submit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({type: 'next'})
|
||||||
|
}, [
|
||||||
|
_,
|
||||||
|
dispatch,
|
||||||
|
state.activeStep,
|
||||||
|
state.serviceDescription?.phoneVerificationRequired,
|
||||||
|
state.userDomain,
|
||||||
|
submit,
|
||||||
|
agent,
|
||||||
|
])
|
||||||
|
|
||||||
|
const onBackPress = React.useCallback(() => {
|
||||||
|
const handle = handleValueRef.current.trim()
|
||||||
|
dispatch({
|
||||||
|
type: 'setHandle',
|
||||||
|
value: handle,
|
||||||
|
})
|
||||||
|
dispatch({type: 'prev'})
|
||||||
|
logEvent('signup:backPressed', {
|
||||||
|
activeStep: state.activeStep,
|
||||||
|
})
|
||||||
|
}, [dispatch, state.activeStep])
|
||||||
|
|
||||||
|
const validCheck = validateHandle(draftValue, state.userDomain)
|
||||||
return (
|
return (
|
||||||
<ScreenTransition>
|
<ScreenTransition>
|
||||||
<View style={[a.gap_lg]}>
|
<View style={[a.gap_lg]}>
|
||||||
|
@ -59,9 +99,17 @@ export function StepHandle() {
|
||||||
<TextField.Icon icon={At} />
|
<TextField.Icon icon={At} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
testID="handleInput"
|
testID="handleInput"
|
||||||
onChangeText={onHandleChange}
|
onChangeText={val => {
|
||||||
|
if (state.error) {
|
||||||
|
dispatch({type: 'setError', value: ''})
|
||||||
|
}
|
||||||
|
|
||||||
|
// These need to always be in sync.
|
||||||
|
handleValueRef.current = val
|
||||||
|
setDraftValue(val)
|
||||||
|
}}
|
||||||
label={_(msg`Input your user handle`)}
|
label={_(msg`Input your user handle`)}
|
||||||
defaultValue={state.handle}
|
defaultValue={draftValue}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
@ -69,13 +117,16 @@ export function StepHandle() {
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
|
{draftValue !== '' && (
|
||||||
<Text style={[a.text_md]}>
|
<Text style={[a.text_md]}>
|
||||||
<Trans>Your full handle will be</Trans>{' '}
|
<Trans>Your full handle will be</Trans>{' '}
|
||||||
<Text style={[a.text_md, a.font_bold]}>
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
@{createFullHandle(state.handle, state.userDomain)}
|
@{createFullHandle(draftValue, state.userDomain)}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftValue !== '' && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
|
@ -121,7 +172,14 @@ export function StepHandle() {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
<BackNextButtons
|
||||||
|
isLoading={state.isLoading}
|
||||||
|
isNextDisabled={!validCheck.overall}
|
||||||
|
onBackPress={onBackPress}
|
||||||
|
onNextPress={onNextPress}
|
||||||
|
/>
|
||||||
</ScreenTransition>
|
</ScreenTransition>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react'
|
import React, {useRef} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import * as EmailValidator from 'email-validator'
|
||||||
|
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
||||||
import {is13, is18, useSignupContext} from '#/screens/Signup/state'
|
import {is13, is18, useSignupContext} from '#/screens/Signup/state'
|
||||||
|
@ -16,6 +18,7 @@ import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/E
|
||||||
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
|
||||||
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
|
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {BackNextButtons} from '../BackNextButtons'
|
||||||
|
|
||||||
function sanitizeDate(date: Date): Date {
|
function sanitizeDate(date: Date): Date {
|
||||||
if (!date || date.toString() === 'Invalid Date') {
|
if (!date || date.toString() === 'Invalid Date') {
|
||||||
|
@ -28,13 +31,72 @@ function sanitizeDate(date: Date): Date {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepInfo({
|
export function StepInfo({
|
||||||
|
onPressBack,
|
||||||
|
isServerError,
|
||||||
|
refetchServer,
|
||||||
isLoadingStarterPack,
|
isLoadingStarterPack,
|
||||||
}: {
|
}: {
|
||||||
|
onPressBack: () => void
|
||||||
|
isServerError: boolean
|
||||||
|
refetchServer: () => void
|
||||||
isLoadingStarterPack: boolean
|
isLoadingStarterPack: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {state, dispatch} = useSignupContext()
|
const {state, dispatch} = useSignupContext()
|
||||||
|
|
||||||
|
const inviteCodeValueRef = useRef<string>(state.inviteCode)
|
||||||
|
const emailValueRef = useRef<string>(state.email)
|
||||||
|
const passwordValueRef = useRef<string>(state.password)
|
||||||
|
|
||||||
|
const onNextPress = React.useCallback(async () => {
|
||||||
|
const inviteCode = inviteCodeValueRef.current
|
||||||
|
const email = emailValueRef.current
|
||||||
|
const password = passwordValueRef.current
|
||||||
|
|
||||||
|
if (!is13(state.dateOfBirth)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.serviceDescription?.inviteCodeRequired && !inviteCode) {
|
||||||
|
return dispatch({
|
||||||
|
type: 'setError',
|
||||||
|
value: _(msg`Please enter your invite code.`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
return dispatch({
|
||||||
|
type: 'setError',
|
||||||
|
value: _(msg`Please enter your email.`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!EmailValidator.validate(email)) {
|
||||||
|
return dispatch({
|
||||||
|
type: 'setError',
|
||||||
|
value: _(msg`Your email appears to be invalid.`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
return dispatch({
|
||||||
|
type: 'setError',
|
||||||
|
value: _(msg`Please choose your password.`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({type: 'setInviteCode', value: inviteCode})
|
||||||
|
dispatch({type: 'setEmail', value: email})
|
||||||
|
dispatch({type: 'setPassword', value: password})
|
||||||
|
dispatch({type: 'next'})
|
||||||
|
logEvent('signup:nextPressed', {
|
||||||
|
activeStep: state.activeStep,
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
_,
|
||||||
|
dispatch,
|
||||||
|
state.activeStep,
|
||||||
|
state.dateOfBirth,
|
||||||
|
state.serviceDescription?.inviteCodeRequired,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenTransition>
|
<ScreenTransition>
|
||||||
<View style={[a.gap_md]}>
|
<View style={[a.gap_md]}>
|
||||||
|
@ -65,10 +127,7 @@ export function StepInfo({
|
||||||
<TextField.Icon icon={Ticket} />
|
<TextField.Icon icon={Ticket} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
onChangeText={value => {
|
onChangeText={value => {
|
||||||
dispatch({
|
inviteCodeValueRef.current = value.trim()
|
||||||
type: 'setInviteCode',
|
|
||||||
value: value.trim(),
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
label={_(msg`Required for this provider`)}
|
label={_(msg`Required for this provider`)}
|
||||||
defaultValue={state.inviteCode}
|
defaultValue={state.inviteCode}
|
||||||
|
@ -88,10 +147,7 @@ export function StepInfo({
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
testID="emailInput"
|
testID="emailInput"
|
||||||
onChangeText={value => {
|
onChangeText={value => {
|
||||||
dispatch({
|
emailValueRef.current = value.trim()
|
||||||
type: 'setEmail',
|
|
||||||
value: value.trim(),
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
label={_(msg`Enter your email address`)}
|
label={_(msg`Enter your email address`)}
|
||||||
defaultValue={state.email}
|
defaultValue={state.email}
|
||||||
|
@ -110,10 +166,7 @@ export function StepInfo({
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
testID="passwordInput"
|
testID="passwordInput"
|
||||||
onChangeText={value => {
|
onChangeText={value => {
|
||||||
dispatch({
|
passwordValueRef.current = value
|
||||||
type: 'setPassword',
|
|
||||||
value,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
label={_(msg`Choose your password`)}
|
label={_(msg`Choose your password`)}
|
||||||
defaultValue={state.password}
|
defaultValue={state.password}
|
||||||
|
@ -147,6 +200,14 @@ export function StepInfo({
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</View>
|
</View>
|
||||||
|
<BackNextButtons
|
||||||
|
hideNext={!is13(state.dateOfBirth)}
|
||||||
|
showRetry={isServerError}
|
||||||
|
isLoading={state.isLoading}
|
||||||
|
onBackPress={onPressBack}
|
||||||
|
onNextPress={onNextPress}
|
||||||
|
onRetryPress={refetchServer}
|
||||||
|
/>
|
||||||
</ScreenTransition>
|
</ScreenTransition>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,11 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
|
||||||
import {createFullHandle} from '#/lib/strings/handles'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {useServiceQuery} from '#/state/queries/service'
|
import {useServiceQuery} from '#/state/queries/service'
|
||||||
import {useAgent} from '#/state/session'
|
|
||||||
import {useStarterPackQuery} from 'state/queries/starter-packs'
|
import {useStarterPackQuery} from 'state/queries/starter-packs'
|
||||||
import {useActiveStarterPack} from 'state/shell/starter-pack'
|
import {useActiveStarterPack} from 'state/shell/starter-pack'
|
||||||
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
|
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
|
||||||
|
@ -20,14 +16,12 @@ import {
|
||||||
reducer,
|
reducer,
|
||||||
SignupContext,
|
SignupContext,
|
||||||
SignupStep,
|
SignupStep,
|
||||||
useSubmitSignup,
|
|
||||||
} from '#/screens/Signup/state'
|
} from '#/screens/Signup/state'
|
||||||
import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
|
import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
|
||||||
import {StepHandle} from '#/screens/Signup/StepHandle'
|
import {StepHandle} from '#/screens/Signup/StepHandle'
|
||||||
import {StepInfo} from '#/screens/Signup/StepInfo'
|
import {StepInfo} from '#/screens/Signup/StepInfo'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
|
import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
@ -38,9 +32,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {screen} = useAnalytics()
|
const {screen} = useAnalytics()
|
||||||
const [state, dispatch] = React.useReducer(reducer, initialState)
|
const [state, dispatch] = React.useReducer(reducer, initialState)
|
||||||
const submit = useSubmitSignup({state, dispatch})
|
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const agent = useAgent()
|
|
||||||
|
|
||||||
const activeStarterPack = useActiveStarterPack()
|
const activeStarterPack = useActiveStarterPack()
|
||||||
const {
|
const {
|
||||||
|
@ -89,72 +81,6 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
}
|
}
|
||||||
}, [_, serviceInfo, isError])
|
}, [_, serviceInfo, isError])
|
||||||
|
|
||||||
const onNextPress = React.useCallback(async () => {
|
|
||||||
if (state.activeStep === SignupStep.HANDLE) {
|
|
||||||
try {
|
|
||||||
dispatch({type: 'setIsLoading', value: true})
|
|
||||||
|
|
||||||
const res = await agent.resolveHandle({
|
|
||||||
handle: createFullHandle(state.handle, state.userDomain),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.data.did) {
|
|
||||||
dispatch({
|
|
||||||
type: 'setError',
|
|
||||||
value: _(msg`That handle is already taken.`),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Don't have to handle
|
|
||||||
} finally {
|
|
||||||
dispatch({type: 'setIsLoading', value: false})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logEvent('signup:nextPressed', {
|
|
||||||
activeStep: state.activeStep,
|
|
||||||
phoneVerificationRequired:
|
|
||||||
state.serviceDescription?.phoneVerificationRequired,
|
|
||||||
})
|
|
||||||
|
|
||||||
// phoneVerificationRequired is actually whether a captcha is required
|
|
||||||
if (
|
|
||||||
state.activeStep === SignupStep.HANDLE &&
|
|
||||||
!state.serviceDescription?.phoneVerificationRequired
|
|
||||||
) {
|
|
||||||
submit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({type: 'next'})
|
|
||||||
}, [
|
|
||||||
_,
|
|
||||||
state.activeStep,
|
|
||||||
state.handle,
|
|
||||||
state.serviceDescription?.phoneVerificationRequired,
|
|
||||||
state.userDomain,
|
|
||||||
submit,
|
|
||||||
agent,
|
|
||||||
])
|
|
||||||
|
|
||||||
const onBackPress = React.useCallback(() => {
|
|
||||||
if (state.activeStep !== SignupStep.INFO) {
|
|
||||||
if (state.activeStep === SignupStep.CAPTCHA) {
|
|
||||||
logger.error('Signup Flow Error', {
|
|
||||||
errorMessage:
|
|
||||||
'User went back from captcha step. Possibly encountered an error.',
|
|
||||||
registrationHandle: state.handle,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
dispatch({type: 'prev'})
|
|
||||||
} else {
|
|
||||||
onPressBack()
|
|
||||||
}
|
|
||||||
logEvent('signup:backPressed', {
|
|
||||||
activeStep: state.activeStep,
|
|
||||||
})
|
|
||||||
}, [onPressBack, state.activeStep, state.handle])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignupContext.Provider value={{state, dispatch}}>
|
<SignupContext.Provider value={{state, dispatch}}>
|
||||||
<LoggedOutLayout
|
<LoggedOutLayout
|
||||||
|
@ -215,13 +141,15 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[a.pb_3xl]}>
|
|
||||||
<LayoutAnimationConfig skipEntering skipExiting>
|
<LayoutAnimationConfig skipEntering skipExiting>
|
||||||
{state.activeStep === SignupStep.INFO ? (
|
{state.activeStep === SignupStep.INFO ? (
|
||||||
<StepInfo
|
<StepInfo
|
||||||
|
onPressBack={onPressBack}
|
||||||
isLoadingStarterPack={
|
isLoadingStarterPack={
|
||||||
isFetchingStarterPack && !isErrorStarterPack
|
isFetchingStarterPack && !isErrorStarterPack
|
||||||
}
|
}
|
||||||
|
isServerError={isError}
|
||||||
|
refetchServer={refetch}
|
||||||
/>
|
/>
|
||||||
) : state.activeStep === SignupStep.HANDLE ? (
|
) : state.activeStep === SignupStep.HANDLE ? (
|
||||||
<StepHandle />
|
<StepHandle />
|
||||||
|
@ -229,50 +157,6 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
<StepCaptcha />
|
<StepCaptcha />
|
||||||
)}
|
)}
|
||||||
</LayoutAnimationConfig>
|
</LayoutAnimationConfig>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[a.flex_row, a.justify_between, a.pb_lg]}>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Go back to previous step`)}
|
|
||||||
variant="solid"
|
|
||||||
color="secondary"
|
|
||||||
size="medium"
|
|
||||||
onPress={onBackPress}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
{state.activeStep !== SignupStep.CAPTCHA && (
|
|
||||||
<>
|
|
||||||
{isError ? (
|
|
||||||
<Button
|
|
||||||
label={_(msg`Press to retry`)}
|
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
|
||||||
size="medium"
|
|
||||||
disabled={state.isLoading}
|
|
||||||
onPress={() => refetch()}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Retry</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
testID="nextBtn"
|
|
||||||
label={_(msg`Continue to next step`)}
|
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
|
||||||
size="medium"
|
|
||||||
disabled={!state.canNext || state.isLoading}
|
|
||||||
onPress={onNextPress}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import * as EmailValidator from 'email-validator'
|
||||||
|
|
||||||
import {DEFAULT_SERVICE} from '#/lib/constants'
|
import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
|
import {createFullHandle} from '#/lib/strings/handles'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useSessionApi} from '#/state/session'
|
import {useSessionApi} from '#/state/session'
|
||||||
|
@ -28,7 +28,6 @@ export enum SignupStep {
|
||||||
|
|
||||||
export type SignupState = {
|
export type SignupState = {
|
||||||
hasPrev: boolean
|
hasPrev: boolean
|
||||||
canNext: boolean
|
|
||||||
activeStep: SignupStep
|
activeStep: SignupStep
|
||||||
|
|
||||||
serviceUrl: string
|
serviceUrl: string
|
||||||
|
@ -58,12 +57,10 @@ export type SignupAction =
|
||||||
| {type: 'setHandle'; value: string}
|
| {type: 'setHandle'; value: string}
|
||||||
| {type: 'setVerificationCode'; value: string}
|
| {type: 'setVerificationCode'; value: string}
|
||||||
| {type: 'setError'; value: string}
|
| {type: 'setError'; value: string}
|
||||||
| {type: 'setCanNext'; value: boolean}
|
|
||||||
| {type: 'setIsLoading'; value: boolean}
|
| {type: 'setIsLoading'; value: boolean}
|
||||||
|
|
||||||
export const initialState: SignupState = {
|
export const initialState: SignupState = {
|
||||||
hasPrev: false,
|
hasPrev: false,
|
||||||
canNext: false,
|
|
||||||
activeStep: SignupStep.INFO,
|
activeStep: SignupStep.INFO,
|
||||||
|
|
||||||
serviceUrl: DEFAULT_SERVICE,
|
serviceUrl: DEFAULT_SERVICE,
|
||||||
|
@ -144,10 +141,6 @@ export function reducer(s: SignupState, a: SignupAction): SignupState {
|
||||||
next.handle = a.value
|
next.handle = a.value
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'setCanNext': {
|
|
||||||
next.canNext = a.value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'setIsLoading': {
|
case 'setIsLoading': {
|
||||||
next.isLoading = a.value
|
next.isLoading = a.value
|
||||||
break
|
break
|
||||||
|
@ -160,23 +153,6 @@ export function reducer(s: SignupState, a: SignupAction): SignupState {
|
||||||
|
|
||||||
next.hasPrev = next.activeStep !== SignupStep.INFO
|
next.hasPrev = next.activeStep !== SignupStep.INFO
|
||||||
|
|
||||||
switch (next.activeStep) {
|
|
||||||
case SignupStep.INFO: {
|
|
||||||
const isValidEmail = EmailValidator.validate(next.email)
|
|
||||||
next.canNext =
|
|
||||||
!!(next.email && next.password && next.dateOfBirth) &&
|
|
||||||
(!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
|
|
||||||
is13(next.dateOfBirth) &&
|
|
||||||
isValidEmail
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case SignupStep.HANDLE: {
|
|
||||||
next.canNext =
|
|
||||||
!!next.handle && validateHandle(next.handle, next.userDomain).overall
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('signup', next)
|
logger.debug('signup', next)
|
||||||
|
|
||||||
if (s.activeStep !== next.activeStep) {
|
if (s.activeStep !== next.activeStep) {
|
||||||
|
|
Loading…
Reference in New Issue