diff options
Diffstat (limited to 'src/screens/Signup')
-rw-r--r-- | src/screens/Signup/StepCaptcha/CaptchaWebView.tsx | 87 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx | 61 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/index.tsx | 80 | ||||
-rw-r--r-- | src/screens/Signup/StepHandle.tsx | 134 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo/Policies.tsx | 97 | ||||
-rw-r--r-- | src/screens/Signup/StepInfo/index.tsx | 146 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 228 | ||||
-rw-r--r-- | src/screens/Signup/state.ts | 320 |
8 files changed, 1153 insertions, 0 deletions
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx new file mode 100644 index 000000000..50918c4ce --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {WebView, WebViewNavigation} from 'react-native-webview' +import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' + +import {SignupState} from '#/screens/Signup/state' + +const ALLOWED_HOSTS = [ + 'bsky.social', + 'bsky.app', + 'staging.bsky.app', + 'staging.bsky.dev', + 'js.hcaptcha.com', + 'newassets.hcaptcha.com', + 'api2.hcaptcha.com', +] + +export function CaptchaWebView({ + url, + stateParam, + state, + onSuccess, + onError, +}: { + url: string + stateParam: string + state?: SignupState + onSuccess: (code: string) => void + onError: () => void +}) { + const redirectHost = React.useMemo(() => { + if (!state?.serviceUrl) return 'bsky.app' + + return state?.serviceUrl && + new URL(state?.serviceUrl).host === 'staging.bsky.dev' + ? 'staging.bsky.app' + : 'bsky.app' + }, [state?.serviceUrl]) + + const wasSuccessful = React.useRef(false) + + const onShouldStartLoadWithRequest = React.useCallback( + (event: ShouldStartLoadRequest) => { + const urlp = new URL(event.url) + return ALLOWED_HOSTS.includes(urlp.host) + }, + [], + ) + + const onNavigationStateChange = React.useCallback( + (e: WebViewNavigation) => { + if (wasSuccessful.current) return + + const urlp = new URL(e.url) + if (urlp.host !== redirectHost) return + + const code = urlp.searchParams.get('code') + if (urlp.searchParams.get('state') !== stateParam || !code) { + onError() + return + } + + wasSuccessful.current = true + onSuccess(code) + }, + [redirectHost, stateParam, onSuccess, onError], + ) + + return ( + <WebView + source={{uri: url}} + javaScriptEnabled + style={styles.webview} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + onNavigationStateChange={onNavigationStateChange} + scrollEnabled={false} + /> + ) +} + +const styles = StyleSheet.create({ + webview: { + flex: 1, + backgroundColor: 'transparent', + borderRadius: 10, + }, +}) diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx new file mode 100644 index 000000000..7791a58dd --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {StyleSheet} from 'react-native' + +// @ts-ignore web only, we will always redirect to the app on web (CORS) +const REDIRECT_HOST = new URL(window.location.href).host + +export function CaptchaWebView({ + url, + stateParam, + onSuccess, + onError, +}: { + url: string + stateParam: string + onSuccess: (code: string) => void + onError: () => void +}) { + const onLoad = React.useCallback(() => { + // @ts-ignore web + const frame: HTMLIFrameElement = document.getElementById( + 'captcha-iframe', + ) as HTMLIFrameElement + + try { + // @ts-ignore web + const href = frame?.contentWindow?.location.href + if (!href) return + const urlp = new URL(href) + + // This shouldn't happen with CORS protections, but for good measure + if (urlp.host !== REDIRECT_HOST) return + + const code = urlp.searchParams.get('code') + if (urlp.searchParams.get('state') !== stateParam || !code) { + onError() + return + } + onSuccess(code) + } catch (e) { + // We don't need to handle this + } + }, [stateParam, onSuccess, onError]) + + return ( + <iframe + src={url} + style={styles.iframe} + id="captcha-iframe" + onLoad={onLoad} + /> + ) +} + +const styles = StyleSheet.create({ + iframe: { + flex: 1, + borderWidth: 0, + borderRadius: 10, + backgroundColor: 'transparent', + }, +}) diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx new file mode 100644 index 000000000..2429b0c5e --- /dev/null +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import {ActivityIndicator, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {createFullHandle} from '#/lib/strings/handles' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' +import {atoms as a, useTheme} from '#/alf' +import {FormError} from '#/components/forms/FormError' + +const CAPTCHA_PATH = '/gate/signup' + +export function StepCaptcha() { + const {_} = useLingui() + const theme = useTheme() + const {state, dispatch} = useSignupContext() + const submit = useSubmitSignup({state, dispatch}) + + const [completed, setCompleted] = React.useState(false) + + const stateParam = React.useMemo(() => nanoid(15), []) + const url = React.useMemo(() => { + const newUrl = new URL(state.serviceUrl) + newUrl.pathname = CAPTCHA_PATH + newUrl.searchParams.set( + 'handle', + createFullHandle(state.handle, state.userDomain), + ) + newUrl.searchParams.set('state', stateParam) + newUrl.searchParams.set('colorScheme', theme.name) + + return newUrl.href + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + + const onSuccess = React.useCallback( + (code: string) => { + setCompleted(true) + submit(code) + }, + [submit], + ) + + const onError = React.useCallback(() => { + dispatch({ + type: 'setError', + value: _(msg`Error receiving captcha response.`), + }) + }, [_, dispatch]) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View + style={[ + a.w_full, + a.pb_xl, + a.overflow_hidden, + {minHeight: 500}, + completed && [a.align_center, a.justify_center], + ]}> + {!completed ? ( + <CaptchaWebView + url={url} + stateParam={stateParam} + state={state} + onSuccess={onSuccess} + onError={onError} + /> + ) : ( + <ActivityIndicator size="large" /> + )} + </View> + <FormError error={state.error} /> + </View> + </ScreenTransition> + ) +} diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx new file mode 100644 index 000000000..44a33b833 --- /dev/null +++ b/src/screens/Signup/StepHandle.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import { + createFullHandle, + IsValidHandle, + validateHandle, +} from '#/lib/strings/handles' +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 {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' + +export function StepHandle() { + const {_} = useLingui() + const t = useTheme() + const {state, dispatch} = useSignupContext() + + const [validCheck, setValidCheck] = React.useState<IsValidHandle>({ + handleChars: false, + 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({ + type: 'setHandle', + value, + }) + }, + [dispatch, state.error], + ) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View> + <TextField.Root> + <TextField.Icon icon={At} /> + <TextField.Input + onChangeText={onHandleChange} + label={_(msg`Input your user handle`)} + defaultValue={state.handle} + autoCapitalize="none" + autoCorrect={false} + autoFocus + autoComplete="off" + /> + </TextField.Root> + </View> + <Text style={[a.text_md]}> + <Trans>Your full handle will be</Trans>{' '} + <Text style={[a.text_md, a.font_bold]}> + @{createFullHandle(state.handle, state.userDomain)} + </Text> + </Text> + + <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 ? ( + <Text style={[a.text_md, a.flex_1]}> + <Trans>No longer than 253 characters</Trans> + </Text> + ) : ( + <Text style={[a.text_md, a.flex_1]}> + <Trans>At least 3 characters</Trans> + </Text> + )} + </View> + </View> + </View> + </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/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx new file mode 100644 index 000000000..4879ae7b3 --- /dev/null +++ b/src/screens/Signup/StepInfo/Policies.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {View} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' + +export const Policies = ({ + serviceDescription, + needsGuardian, + under13, +}: { + serviceDescription: ComAtprotoServerDescribeServer.OutputSchema + needsGuardian: boolean + under13: boolean +}) => { + const t = useTheme() + const {_} = useLingui() + + if (!serviceDescription) { + return <View /> + } + + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + + if (!tos && !pp) { + return ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <CircleInfo size="md" fill={t.atoms.text_contrast_low.color} /> + + <Text style={[t.atoms.text_contrast_medium]}> + <Trans> + This service has not provided terms of service or a privacy policy. + </Trans> + </Text> + </View> + ) + } + + const els = [] + if (tos) { + els.push( + <InlineLink key="tos" to={tos}> + {_(msg`Terms of Service`)} + </InlineLink>, + ) + } + if (pp) { + els.push( + <InlineLink key="pp" to={pp}> + {_(msg`Privacy Policy`)} + </InlineLink>, + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + <Text key="and" style={[t.atoms.text_contrast_medium]}> + {' '} + and{' '} + </Text>, + ) + } + + return ( + <View style={[a.gap_sm]}> + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans>By creating an account you agree to the {els}.</Trans> + </Text> + + {under13 ? ( + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> + <Trans>You must be 13 years of age or older to sign up.</Trans> + </Text> + ) : needsGuardian ? ( + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> + <Trans> + If you are not yet an adult according to the laws of your country, + your parent or legal guardian must read these Terms on your behalf. + </Trans> + </Text> + ) : undefined} + </View> + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx new file mode 100644 index 000000000..136592a0b --- /dev/null +++ b/src/screens/Signup/StepInfo/index.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {is13, is18, useSignupContext} from '#/screens/Signup/state' +import {Policies} from '#/screens/Signup/StepInfo/Policies' +import {atoms as a} from '#/alf' +import * as DateField from '#/components/forms/DateField' +import {FormError} from '#/components/forms/FormError' +import {HostingProvider} from '#/components/forms/HostingProvider' +import * as TextField from '#/components/forms/TextField' +import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' +import {Loader} from '#/components/Loader' + +function sanitizeDate(date: Date): Date { + if (!date || date.toString() === 'Invalid Date') { + logger.error(`Create account: handled invalid date for birthDate`, { + hasDate: !!date, + }) + return new Date() + } + return date +} + +export function StepInfo() { + const {_} = useLingui() + const {state, dispatch} = useSignupContext() + + return ( + <ScreenTransition> + <View style={[a.gap_md]}> + <FormError error={state.error} /> + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <HostingProvider + serviceUrl={state.serviceUrl} + onSelectServiceUrl={v => + dispatch({type: 'setServiceUrl', value: v}) + } + /> + </View> + {state.isLoading ? ( + <View style={[a.align_center]}> + <Loader size="xl" /> + </View> + ) : state.serviceDescription ? ( + <> + {state.serviceDescription.inviteCodeRequired && ( + <View> + <TextField.Label> + <Trans>Invite code</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Ticket} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setInviteCode', + value: value.trim(), + }) + }} + label={_(msg`Required for this provider`)} + defaultValue={state.inviteCode} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + </TextField.Root> + </View> + )} + <View> + <TextField.Label> + <Trans>Email</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Envelope} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setEmail', + value: value.trim(), + }) + }} + label={_(msg`Enter your email address`)} + defaultValue={state.email} + autoCapitalize="none" + autoComplete="email" + keyboardType="email-address" + /> + </TextField.Root> + </View> + <View> + <TextField.Label> + <Trans>Password</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + onChangeText={value => { + dispatch({ + type: 'setPassword', + value, + }) + }} + label={_(msg`Choose your password`)} + defaultValue={state.password} + secureTextEntry + autoComplete="new-password" + /> + </TextField.Root> + </View> + <View> + <DateField.Label> + <Trans>Your birth date</Trans> + </DateField.Label> + <DateField.DateField + testID="date" + value={DateField.utils.toSimpleDateString(state.dateOfBirth)} + onChangeDate={date => { + dispatch({ + type: 'setDateOfBirth', + value: sanitizeDate(new Date(date)), + }) + }} + label={_(msg`Date of birth`)} + accessibilityHint={_(msg`Select your date of birth`)} + /> + </View> + <Policies + serviceDescription={state.serviceDescription} + needsGuardian={!is18(state.dateOfBirth)} + under13={!is13(state.dateOfBirth)} + /> + </> + ) : undefined} + </View> + </ScreenTransition> + ) +} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx new file mode 100644 index 000000000..a085fe44c --- /dev/null +++ b/src/screens/Signup/index.tsx @@ -0,0 +1,228 @@ +import React from 'react' +import {View} from 'react-native' +import {LayoutAnimationConfig} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAnalytics} from '#/lib/analytics/analytics' +import {FEEDBACK_FORM_URL} from '#/lib/constants' +import {logEvent} from '#/lib/statsig/statsig' +import {createFullHandle} from '#/lib/strings/handles' +import {useServiceQuery} from '#/state/queries/service' +import {getAgent} from '#/state/session' +import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' +import { + initialState, + reducer, + SignupContext, + SignupStep, + useSubmitSignup, +} from '#/screens/Signup/state' +import {StepCaptcha} from '#/screens/Signup/StepCaptcha' +import {StepHandle} from '#/screens/Signup/StepHandle' +import {StepInfo} from '#/screens/Signup/StepInfo' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Divider} from '#/components/Divider' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Signup({onPressBack}: {onPressBack: () => void}) { + const {_} = useLingui() + const t = useTheme() + const {screen} = useAnalytics() + const [state, dispatch] = React.useReducer(reducer, initialState) + const submit = useSubmitSignup({state, dispatch}) + const {gtMobile} = useBreakpoints() + + const { + data: serviceInfo, + isFetching, + isError, + refetch, + } = useServiceQuery(state.serviceUrl) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + if (isFetching) { + dispatch({type: 'setIsLoading', value: true}) + } else if (!isFetching) { + dispatch({type: 'setIsLoading', value: false}) + } + }, [isFetching]) + + React.useEffect(() => { + if (isError) { + dispatch({type: 'setServiceDescription', value: undefined}) + dispatch({ + type: 'setError', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } else if (serviceInfo) { + dispatch({type: 'setServiceDescription', value: serviceInfo}) + dispatch({type: 'setError', value: ''}) + } + }, [_, serviceInfo, isError]) + + const onNextPress = React.useCallback(async () => { + if (state.activeStep === SignupStep.HANDLE) { + try { + dispatch({type: 'setIsLoading', value: true}) + + const res = await getAgent().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}) + } + } + + // phoneVerificationRequired is actually whether a captcha is required + if ( + state.activeStep === SignupStep.HANDLE && + !state.serviceDescription?.phoneVerificationRequired + ) { + submit() + return + } + + dispatch({type: 'next'}) + logEvent('signup:nextPressed', { + activeStep: state.activeStep, + }) + }, [ + _, + state.activeStep, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.userDomain, + submit, + ]) + + const onBackPress = React.useCallback(() => { + if (state.activeStep !== SignupStep.INFO) { + dispatch({type: 'prev'}) + } else { + onPressBack() + } + }, [onPressBack, state.activeStep]) + + return ( + <SignupContext.Provider value={{state, dispatch}}> + <LoggedOutLayout + leadin="" + title={_(msg`Create Account`)} + description={_(msg`We're so excited to have you join us!`)} + scrollable> + <View testID="createAccount" style={a.flex_1}> + <View + style={[ + a.flex_1, + a.px_xl, + a.pt_2xl, + !gtMobile && {paddingBottom: 100}, + ]}> + <View style={[a.gap_sm, a.pb_3xl]}> + <Text style={[a.font_semibold, t.atoms.text_contrast_medium]}> + <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '} + {state.serviceDescription && + !state.serviceDescription.phoneVerificationRequired + ? '2' + : '3'} + </Text> + <Text style={[a.text_3xl, a.font_bold]}> + {state.activeStep === SignupStep.INFO ? ( + <Trans>Your account</Trans> + ) : state.activeStep === SignupStep.HANDLE ? ( + <Trans>Your user handle</Trans> + ) : ( + <Trans>Complete the challenge</Trans> + )} + </Text> + </View> + + <View style={[a.pb_3xl]}> + <LayoutAnimationConfig skipEntering skipExiting> + {state.activeStep === SignupStep.INFO ? ( + <StepInfo /> + ) : state.activeStep === SignupStep.HANDLE ? ( + <StepHandle /> + ) : ( + <StepCaptcha /> + )} + </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 + 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 /> + + <View style={[a.w_full, a.py_lg]}> + <Text style={[t.atoms.text_contrast_medium]}> + <Trans>Having trouble?</Trans>{' '} + <InlineLink to={FEEDBACK_FORM_URL({email: state.email})}> + <Trans>Contact support</Trans> + </InlineLink> + </Text> + </View> + </View> + </View> + </LoggedOutLayout> + </SignupContext.Provider> + ) +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts new file mode 100644 index 000000000..86a144368 --- /dev/null +++ b/src/screens/Signup/state.ts @@ -0,0 +1,320 @@ +import React, {useCallback} from 'react' +import {LayoutAnimation} from 'react-native' +import { + ComAtprotoServerCreateAccount, + ComAtprotoServerDescribeServer, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as EmailValidator from 'email-validator' + +import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants' +import {cleanError} from '#/lib/strings/errors' +import {createFullHandle, validateHandle} from '#/lib/strings/handles' +import {getAge} from '#/lib/strings/time' +import {logger} from '#/logger' +import { + DEFAULT_PROD_FEEDS, + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, +} from '#/state/queries/preferences' +import {useSessionApi} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export enum SignupStep { + INFO, + HANDLE, + CAPTCHA, +} + +export type SignupState = { + hasPrev: boolean + canNext: boolean + activeStep: SignupStep + + serviceUrl: string + serviceDescription?: ServiceDescription + userDomain: string + dateOfBirth: Date + email: string + password: string + inviteCode: string + handle: string + + error: string + isLoading: boolean +} + +export type SignupAction = + | {type: 'prev'} + | {type: 'next'} + | {type: 'finish'} + | {type: 'setStep'; value: SignupStep} + | {type: 'setServiceUrl'; value: string} + | {type: 'setServiceDescription'; value: ServiceDescription | undefined} + | {type: 'setEmail'; value: string} + | {type: 'setPassword'; value: string} + | {type: 'setDateOfBirth'; value: Date} + | {type: 'setInviteCode'; value: string} + | {type: 'setHandle'; value: string} + | {type: 'setVerificationCode'; value: string} + | {type: 'setError'; value: string} + | {type: 'setCanNext'; value: boolean} + | {type: 'setIsLoading'; value: boolean} + +export const initialState: SignupState = { + hasPrev: false, + canNext: false, + activeStep: SignupStep.INFO, + + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + dateOfBirth: DEFAULT_DATE, + email: '', + password: '', + handle: '', + inviteCode: '', + + error: '', + isLoading: false, +} + +export function is13(date: Date) { + return getAge(date) >= 13 +} + +export function is18(date: Date) { + return getAge(date) >= 18 +} + +export function reducer(s: SignupState, a: SignupAction): SignupState { + let next = {...s} + + switch (a.type) { + case 'prev': { + if (s.activeStep !== SignupStep.INFO) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep-- + next.error = '' + } + break + } + case 'next': { + if (s.activeStep !== SignupStep.CAPTCHA) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + next.activeStep++ + next.error = '' + } + break + } + case 'setStep': { + next.activeStep = a.value + break + } + case 'setServiceUrl': { + next.serviceUrl = a.value + break + } + case 'setServiceDescription': { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + + next.serviceDescription = a.value + next.userDomain = a.value?.availableUserDomains[0] ?? '' + next.isLoading = false + break + } + + case 'setEmail': { + next.email = a.value + break + } + case 'setPassword': { + next.password = a.value + break + } + case 'setDateOfBirth': { + next.dateOfBirth = a.value + break + } + case 'setInviteCode': { + next.inviteCode = a.value + break + } + case 'setHandle': { + next.handle = a.value + break + } + case 'setCanNext': { + next.canNext = a.value + break + } + case 'setIsLoading': { + next.isLoading = a.value + break + } + case 'setError': { + next.error = a.value + break + } + } + + 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) + + if (s.activeStep !== next.activeStep) { + logger.debug('signup: step changed', {activeStep: next.activeStep}) + } + + return next +} + +interface IContext { + state: SignupState + dispatch: React.Dispatch<SignupAction> +} +export const SignupContext = React.createContext<IContext>({} as IContext) +export const useSignupContext = () => React.useContext(SignupContext) + +export function useSubmitSignup({ + state, + dispatch, +}: { + state: SignupState + dispatch: (action: SignupAction) => void +}) { + const {_} = useLingui() + const {createAccount} = useSessionApi() + const {mutateAsync: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() + const onboardingDispatch = useOnboardingDispatch() + + return useCallback( + async (verificationCode?: string) => { + if (!state.email) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(state.email)) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!state.password) { + dispatch({type: 'setStep', value: SignupStep.INFO}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your password.`), + }) + } + if (!state.handle) { + dispatch({type: 'setStep', value: SignupStep.HANDLE}) + return dispatch({ + type: 'setError', + value: _(msg`Please choose your handle.`), + }) + } + if ( + state.serviceDescription?.phoneVerificationRequired && + !verificationCode + ) { + dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) + return dispatch({ + type: 'setError', + value: _(msg`Please complete the verification captcha.`), + }) + } + dispatch({type: 'setError', value: ''}) + dispatch({type: 'setIsLoading', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: state.serviceUrl, + email: state.email, + handle: createFullHandle(state.handle, state.userDomain), + password: state.password, + inviteCode: state.inviteCode.trim(), + verificationCode: verificationCode, + }) + await setBirthDate({birthDate: state.dateOfBirth}) + if (IS_PROD_SERVICE(state.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + dispatch({ + type: 'setError', + value: _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ), + }) + dispatch({type: 'setStep', value: SignupStep.INFO}) + return + } + + if ([400, 429].includes(e.status)) { + logger.warn('Failed to create account', {message: e}) + } else { + logger.error(`Failed to create account (${e.status} status)`, { + message: e, + }) + } + + const error = cleanError(errMsg) + const isHandleError = error.toLowerCase().includes('handle') + + dispatch({type: 'setIsLoading', value: false}) + dispatch({type: 'setError', value: cleanError(errMsg)}) + dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) + } finally { + dispatch({type: 'setIsLoading', value: false}) + } + }, + [ + state.email, + state.password, + state.handle, + state.serviceDescription?.phoneVerificationRequired, + state.serviceUrl, + state.userDomain, + state.inviteCode, + state.dateOfBirth, + dispatch, + _, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + ], + ) +} |