diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/Signup/BackNextButtons.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/index.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle.tsx | 217 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle/HandleSuggestions.tsx | 80 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle/index.tsx | 279 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo/index.tsx | 2 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 7 |
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 ? ( |