diff options
Diffstat (limited to 'src/view/com/auth/create')
-rw-r--r-- | src/view/com/auth/create/CreateAccount.tsx | 219 | ||||
-rw-r--r-- | src/view/com/auth/create/Policies.tsx | 114 | ||||
-rw-r--r-- | src/view/com/auth/create/Step1.tsx | 283 | ||||
-rw-r--r-- | src/view/com/auth/create/Step2.tsx | 307 | ||||
-rw-r--r-- | src/view/com/auth/create/Step3.tsx | 62 | ||||
-rw-r--r-- | src/view/com/auth/create/StepHeader.tsx | 44 | ||||
-rw-r--r-- | src/view/com/auth/create/state.ts | 350 |
7 files changed, 0 insertions, 1379 deletions
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx deleted file mode 100644 index 5d452736a..000000000 --- a/src/view/com/auth/create/CreateAccount.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - ScrollView, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {useAnalytics} from 'lib/analytics/analytics' -import {Text} from '../../util/text/Text' -import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useOnboardingDispatch} from '#/state/shell' -import {useSessionApi} from '#/state/session' -import {useCreateAccount, submit} from './state' -import {useServiceQuery} from '#/state/queries/service' -import { - usePreferencesSetBirthDateMutation, - useSetSaveFeedsMutation, - DEFAULT_PROD_FEEDS, -} from '#/state/queries/preferences' -import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants' - -import {Step1} from './Step1' -import {Step2} from './Step2' -import {Step3} from './Step3' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {TextLink} from '../../util/Link' - -export function CreateAccount({onPressBack}: {onPressBack: () => void}) { - const {screen} = useAnalytics() - const pal = usePalette('default') - const {_} = useLingui() - const [uiState, uiDispatch] = useCreateAccount() - const onboardingDispatch = useOnboardingDispatch() - const {createAccount} = useSessionApi() - const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() - const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() - const {isTabletOrDesktop} = useWebMediaQueries() - - React.useEffect(() => { - screen('CreateAccount') - }, [screen]) - - // fetch service info - // = - - const { - data: serviceInfo, - isFetching: serviceInfoIsFetching, - error: serviceInfoError, - refetch: refetchServiceInfo, - } = useServiceQuery(uiState.serviceUrl) - - React.useEffect(() => { - if (serviceInfo) { - uiDispatch({type: 'set-service-description', value: serviceInfo}) - uiDispatch({type: 'set-error', value: ''}) - } else if (serviceInfoError) { - uiDispatch({ - type: 'set-error', - value: _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - }) - } - }, [_, uiDispatch, serviceInfo, serviceInfoError]) - - // event handlers - // = - - const onPressBackInner = React.useCallback(() => { - if (uiState.canBack) { - uiDispatch({type: 'back'}) - } else { - onPressBack() - } - }, [uiState, uiDispatch, onPressBack]) - - const onPressNext = React.useCallback(async () => { - if (!uiState.canNext) { - return - } - if (uiState.step < 3) { - uiDispatch({type: 'next'}) - } else { - try { - await submit({ - onboardingDispatch, - createAccount, - uiState, - uiDispatch, - _, - }) - setBirthDate({birthDate: uiState.birthDate}) - if (IS_PROD(uiState.serviceUrl)) { - setSavedFeeds(DEFAULT_PROD_FEEDS) - } - } catch { - // dont need to handle here - } - } - }, [ - uiState, - uiDispatch, - onboardingDispatch, - createAccount, - setBirthDate, - setSavedFeeds, - _, - ]) - - // rendering - // = - - return ( - <LoggedOutLayout - leadin="" - title={_(msg`Create Account`)} - description={_(msg`We're so excited to have you join us!`)}> - <ScrollView - testID="createAccount" - style={pal.view} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag"> - <View style={styles.stepContainer}> - {uiState.step === 1 && ( - <Step1 uiState={uiState} uiDispatch={uiDispatch} /> - )} - {uiState.step === 2 && ( - <Step2 uiState={uiState} uiDispatch={uiDispatch} /> - )} - {uiState.step === 3 && ( - <Step3 uiState={uiState} uiDispatch={uiDispatch} /> - )} - </View> - <View style={[s.flexRow, s.pl20, s.pr20]}> - <TouchableOpacity - onPress={onPressBackInner} - testID="backBtn" - accessibilityRole="button" - hitSlop={HITSLOP_10}> - <Text type="xl" style={pal.link}> - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {uiState.canNext ? ( - <TouchableOpacity - testID="nextBtn" - onPress={onPressNext} - accessibilityRole="button" - hitSlop={HITSLOP_10}> - {uiState.isProcessing ? ( - <ActivityIndicator /> - ) : ( - <Text type="xl-bold" style={[pal.link, s.pr5]}> - <Trans>Next</Trans> - </Text> - )} - </TouchableOpacity> - ) : serviceInfoError ? ( - <TouchableOpacity - testID="retryConnectBtn" - onPress={() => refetchServiceInfo()} - accessibilityRole="button" - accessibilityLabel={_(msg`Retry`)} - accessibilityHint="" - accessibilityLiveRegion="polite" - hitSlop={HITSLOP_10}> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - <Trans>Retry</Trans> - </Text> - </TouchableOpacity> - ) : serviceInfoIsFetching ? ( - <> - <ActivityIndicator color="#fff" /> - <Text type="xl" style={[pal.text, s.pr5]}> - <Trans>Connecting...</Trans> - </Text> - </> - ) : undefined} - </View> - - <View style={styles.stepContainer}> - <View - style={[ - s.flexRow, - s.alignCenter, - pal.viewLight, - {borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12}, - ]}> - <Text type="md" style={pal.textLight}> - <Trans>Having trouble?</Trans>{' '} - </Text> - <TextLink - type="md" - style={pal.link} - text={_(msg`Contact support`)} - href={FEEDBACK_FORM_URL({email: uiState.email})} - /> - </View> - </View> - - <View style={{height: isTabletOrDesktop ? 50 : 400}} /> - </ScrollView> - </LoggedOutLayout> - ) -} - -const styles = StyleSheet.create({ - stepContainer: { - paddingHorizontal: 20, - paddingVertical: 20, - }, -}) diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx deleted file mode 100644 index 2c7d60818..000000000 --- a/src/view/com/auth/create/Policies.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ComAtprotoServerDescribeServer} from '@atproto/api' -import {TextLink} from '../../util/Link' -import {Text} from '../../util/text/Text' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' - -type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema - -export const Policies = ({ - serviceDescription, - needsGuardian, -}: { - serviceDescription: ServiceDescription - needsGuardian: boolean -}) => { - const pal = usePalette('default') - if (!serviceDescription) { - return <View /> - } - const tos = validWebLink(serviceDescription.links?.termsOfService) - const pp = validWebLink(serviceDescription.links?.privacyPolicy) - if (!tos && !pp) { - return ( - <View style={[styles.policies, {flexDirection: 'row'}]}> - <View - style={[ - styles.errorIcon, - {borderColor: pal.colors.text, marginTop: 1}, - ]}> - <FontAwesomeIcon - icon="exclamation" - style={pal.textLight as FontAwesomeIconStyle} - size={10} - /> - </View> - <Text style={[pal.textLight, s.pl5, s.flex1]}> - This service has not provided terms of service or a privacy policy. - </Text> - </View> - ) - } - const els = [] - if (tos) { - els.push( - <TextLink - key="tos" - href={tos} - text="Terms of Service" - style={[pal.link, s.underline]} - />, - ) - } - if (pp) { - els.push( - <TextLink - key="pp" - href={pp} - text="Privacy Policy" - style={[pal.link, s.underline]} - />, - ) - } - if (els.length === 2) { - els.splice( - 1, - 0, - <Text key="and" style={pal.textLight}> - {' '} - and{' '} - </Text>, - ) - } - return ( - <View style={styles.policies}> - <Text style={pal.textLight}> - By creating an account you agree to the {els}. - </Text> - {needsGuardian && ( - <Text style={[pal.textLight, s.bold]}> - 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. - </Text> - )} - </View> - ) -} - -function validWebLink(url?: string): string | undefined { - return url && (url.startsWith('http://') || url.startsWith('https://')) - ? url - : undefined -} - -const styles = StyleSheet.create({ - policies: { - flexDirection: 'column', - gap: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - }, -}) diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx deleted file mode 100644 index a7abbfaa8..000000000 --- a/src/view/com/auth/create/Step1.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - Keyboard, - StyleSheet, - TouchableOpacity, - TouchableWithoutFeedback, - View, -} from 'react-native' -import {CreateAccountState, CreateAccountDispatch, is18} from './state' -import {Text} from 'view/com/util/text/Text' -import {DateInput} from 'view/com/util/forms/DateInput' -import {StepHeader} from './StepHeader' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {TextInput} from '../util/TextInput' -import {Policies} from './Policies' -import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {isWeb} from 'platform/detection' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' -import {logger} from '#/logger' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useDialogControl} from '#/components/Dialog' - -import {ServerInputDialog} from '../server-input' -import {toNiceDomain} from '#/lib/strings/url-helpers' - -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 Step1({ - uiState, - uiDispatch, -}: { - uiState: CreateAccountState - uiDispatch: CreateAccountDispatch -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {openModal} = useModalControls() - const serverInputControl = useDialogControl() - - const onPressSelectService = React.useCallback(() => { - serverInputControl.open() - Keyboard.dismiss() - }, [serverInputControl]) - - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) - - const birthDate = React.useMemo(() => { - return sanitizeDate(uiState.birthDate) - }, [uiState.birthDate]) - - return ( - <View> - <ServerInputDialog - control={serverInputControl} - onSelect={url => uiDispatch({type: 'set-service-url', value: url})} - /> - <StepHeader uiState={uiState} title={_(msg`Your account`)} /> - - <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]}> - <Trans>Hosting provider</Trans> - </Text> - <View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}> - <View - style={[ - pal.borderDark, - {flexDirection: 'row', alignItems: 'center'}, - ]}> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, {marginLeft: 14}]} - /> - <TouchableOpacity - testID="selectServiceButton" - style={{ - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel={_(msg`Select service`)} - accessibilityHint={_(msg`Sets server for the Bluesky client`)}> - <Text - type="xl" - style={[ - pal.text, - { - flex: 1, - paddingVertical: 10, - paddingRight: 12, - paddingLeft: 10, - }, - ]}> - {toNiceDomain(uiState.serviceUrl)} - </Text> - <View - style={[ - pal.btn, - { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - ]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.textLight as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - </View> - </View> - </View> - - {!uiState.serviceDescription ? ( - <ActivityIndicator /> - ) : ( - <> - {uiState.isInviteCodeRequired && ( - <View style={s.pb20}> - <Text type="md-medium" style={[pal.text, s.mb2]}> - <Trans>Invite code</Trans> - </Text> - <TextInput - testID="inviteCodeInput" - icon="ticket" - placeholder={_(msg`Required for this provider`)} - value={uiState.inviteCode} - editable - onChange={value => uiDispatch({type: 'set-invite-code', value})} - accessibilityLabel={_(msg`Invite code`)} - accessibilityHint={_(msg`Input invite code to proceed`)} - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - autoFocus={true} - /> - </View> - )} - - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( - <View style={[s.flexRow, s.alignCenter]}> - <Text style={pal.text}> - <Trans>Don't have an invite code?</Trans>{' '} - </Text> - <TouchableWithoutFeedback - onPress={onPressWaitlist} - accessibilityLabel={_(msg`Join the waitlist.`)} - accessibilityHint=""> - <View style={styles.touchable}> - <Text style={pal.link}> - <Trans>Join the waitlist.</Trans> - </Text> - </View> - </TouchableWithoutFeedback> - </View> - ) : ( - <> - <View style={s.pb20}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="email"> - <Trans>Email address</Trans> - </Text> - <TextInput - testID="emailInput" - icon="envelope" - placeholder={_(msg`Enter your email address`)} - value={uiState.email} - editable - onChange={value => uiDispatch({type: 'set-email', value})} - accessibilityLabel={_(msg`Email`)} - accessibilityHint={_(msg`Input email for Bluesky account`)} - accessibilityLabelledBy="email" - autoCapitalize="none" - autoComplete="email" - autoCorrect={false} - autoFocus={!uiState.isInviteCodeRequired} - /> - </View> - - <View style={s.pb20}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="password"> - <Trans>Password</Trans> - </Text> - <TextInput - testID="passwordInput" - icon="lock" - placeholder={_(msg`Choose your password`)} - value={uiState.password} - editable - secureTextEntry - onChange={value => uiDispatch({type: 'set-password', value})} - accessibilityLabel={_(msg`Password`)} - accessibilityHint={_(msg`Set password`)} - accessibilityLabelledBy="password" - autoCapitalize="none" - autoComplete="new-password" - autoCorrect={false} - /> - </View> - - <View style={s.pb20}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="birthDate"> - <Trans>Your birth date</Trans> - </Text> - <DateInput - handleAsUTC - testID="birthdayInput" - value={birthDate} - onChange={value => - uiDispatch({type: 'set-birth-date', value}) - } - buttonType="default-light" - buttonStyle={[pal.border, styles.dateInputButton]} - buttonLabelType="lg" - accessibilityLabel={_(msg`Birthday`)} - accessibilityHint={_(msg`Enter your birth date`)} - accessibilityLabelledBy="birthDate" - /> - </View> - - {uiState.serviceDescription && ( - <Policies - serviceDescription={uiState.serviceDescription} - needsGuardian={!is18(uiState)} - /> - )} - </> - )} - </> - )} - {uiState.error ? ( - <ErrorMessage message={uiState.error} style={styles.error} /> - ) : undefined} - </View> - ) -} - -const styles = StyleSheet.create({ - error: { - borderRadius: 6, - marginTop: 10, - }, - dateInputButton: { - borderWidth: 1, - borderRadius: 6, - paddingVertical: 14, - }, - // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. - touchable: { - ...(isWeb && {cursor: 'pointer'}), - }, -}) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx deleted file mode 100644 index 2e16b13bb..000000000 --- a/src/view/com/auth/create/Step2.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableWithoutFeedback, - View, -} from 'react-native' -import RNPickerSelect from 'react-native-picker-select' -import { - CreateAccountState, - CreateAccountDispatch, - requestVerificationCode, -} from './state' -import {Text} from 'view/com/util/text/Text' -import {StepHeader} from './StepHeader' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {TextInput} from '../util/TextInput' -import {Button} from '../../util/forms/Button' -import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {isAndroid, isWeb} from 'platform/detection' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import parsePhoneNumber from 'libphonenumber-js' -import {COUNTRY_CODES} from '#/lib/country-codes' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {HITSLOP_10} from '#/lib/constants' - -export function Step2({ - uiState, - uiDispatch, -}: { - uiState: CreateAccountState - uiDispatch: CreateAccountDispatch -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {isMobile} = useWebMediaQueries() - - const onPressRequest = React.useCallback(() => { - const phoneNumber = parsePhoneNumber( - uiState.verificationPhone, - uiState.phoneCountry, - ) - if (phoneNumber && phoneNumber.isValid()) { - requestVerificationCode({uiState, uiDispatch, _}) - } else { - uiDispatch({ - type: 'set-error', - value: _( - msg`There's something wrong with this number. Please choose your country and enter your full phone number!`, - ), - }) - } - }, [uiState, uiDispatch, _]) - - const onPressRetry = React.useCallback(() => { - uiDispatch({type: 'set-has-requested-verification-code', value: false}) - }, [uiDispatch]) - - const phoneNumberFormatted = React.useMemo( - () => - uiState.hasRequestedVerificationCode - ? parsePhoneNumber( - uiState.verificationPhone, - uiState.phoneCountry, - )?.formatInternational() - : '', - [ - uiState.hasRequestedVerificationCode, - uiState.verificationPhone, - uiState.phoneCountry, - ], - ) - - return ( - <View> - <StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> - - {!uiState.hasRequestedVerificationCode ? ( - <> - <View style={s.pb10}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="phoneCountry"> - <Trans>Country</Trans> - </Text> - <View - style={[ - {position: 'relative'}, - isAndroid && { - borderWidth: 1, - borderColor: pal.border.borderColor, - borderRadius: 4, - }, - ]}> - <RNPickerSelect - placeholder={{}} - value={uiState.phoneCountry} - onValueChange={value => - uiDispatch({type: 'set-phone-country', value}) - } - items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({ - label: l.name, - value: l.code2, - key: l.code2, - }))} - style={{ - inputAndroid: { - backgroundColor: pal.view.backgroundColor, - color: pal.text.color, - fontSize: 21, - letterSpacing: 0.5, - fontWeight: '500', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 4, - }, - inputIOS: { - backgroundColor: pal.view.backgroundColor, - color: pal.text.color, - fontSize: 14, - letterSpacing: 0.5, - fontWeight: '500', - paddingHorizontal: 14, - paddingVertical: 8, - borderWidth: 1, - borderColor: pal.border.borderColor, - borderRadius: 4, - }, - inputWeb: { - // @ts-ignore web only - cursor: 'pointer', - '-moz-appearance': 'none', - '-webkit-appearance': 'none', - appearance: 'none', - outline: 0, - borderWidth: 1, - borderColor: pal.border.borderColor, - backgroundColor: pal.view.backgroundColor, - color: pal.text.color, - fontSize: 14, - letterSpacing: 0.5, - fontWeight: '500', - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 4, - }, - }} - accessibilityLabel={_(msg`Select your phone's country`)} - accessibilityHint="" - accessibilityLabelledBy="phoneCountry" - /> - <View - style={{ - position: 'absolute', - top: 1, - right: 1, - bottom: 1, - width: 40, - pointerEvents: 'none', - alignItems: 'center', - justifyContent: 'center', - }}> - <FontAwesomeIcon - icon="chevron-down" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - </View> - </View> - - <View style={s.pb20}> - <Text - type="md-medium" - style={[pal.text, s.mb2]} - nativeID="phoneNumber"> - <Trans>Phone number</Trans> - </Text> - <TextInput - testID="phoneInput" - icon="phone" - placeholder={_(msg`Enter your phone number`)} - value={uiState.verificationPhone} - editable - onChange={value => - uiDispatch({type: 'set-verification-phone', value}) - } - accessibilityLabel={_(msg`Email`)} - accessibilityHint={_( - msg`Input phone number for SMS verification`, - )} - accessibilityLabelledBy="phoneNumber" - keyboardType="phone-pad" - autoCapitalize="none" - autoComplete="tel" - autoCorrect={false} - autoFocus={true} - /> - <Text type="sm" style={[pal.textLight, s.mt5]}> - <Trans> - Please enter a phone number that can receive SMS text messages. - </Trans> - </Text> - </View> - - <View style={isMobile ? {} : {flexDirection: 'row'}}> - {uiState.isProcessing ? ( - <ActivityIndicator /> - ) : ( - <Button - testID="requestCodeBtn" - type="primary" - label={_(msg`Request code`)} - labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []} - style={ - isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {} - } - onPress={onPressRequest} - /> - )} - </View> - </> - ) : ( - <> - <View style={s.pb20}> - <View - style={[ - s.flexRow, - s.mb5, - s.alignCenter, - {justifyContent: 'space-between'}, - ]}> - <Text - type="md-medium" - style={pal.text} - nativeID="verificationCode"> - <Trans>Verification code</Trans>{' '} - </Text> - <TouchableWithoutFeedback - onPress={onPressRetry} - accessibilityLabel={_(msg`Retry.`)} - accessibilityHint="" - hitSlop={HITSLOP_10}> - <View style={styles.touchable}> - <Text - type="md-medium" - style={pal.link} - nativeID="verificationCode"> - <Trans>Retry</Trans> - </Text> - </View> - </TouchableWithoutFeedback> - </View> - <TextInput - testID="codeInput" - icon="hashtag" - placeholder={_(msg`XXXXXX`)} - value={uiState.verificationCode} - editable - onChange={value => - uiDispatch({type: 'set-verification-code', value}) - } - accessibilityLabel={_(msg`Email`)} - accessibilityHint={_( - msg`Input the verification code we have texted to you`, - )} - accessibilityLabelledBy="verificationCode" - keyboardType="phone-pad" - autoCapitalize="none" - autoComplete="one-time-code" - textContentType="oneTimeCode" - autoCorrect={false} - autoFocus={true} - /> - <Text type="sm" style={[pal.textLight, s.mt5]}> - <Trans> - Please enter the verification code sent to{' '} - {phoneNumberFormatted}. - </Trans> - </Text> - </View> - </> - )} - - {uiState.error ? ( - <ErrorMessage message={uiState.error} style={styles.error} /> - ) : undefined} - </View> - ) -} - -const styles = StyleSheet.create({ - error: { - borderRadius: 6, - marginTop: 10, - }, - // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. - touchable: { - ...(isWeb && {cursor: 'pointer'}), - }, -}) diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx deleted file mode 100644 index 3a52abf80..000000000 --- a/src/view/com/auth/create/Step3.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {CreateAccountState, CreateAccountDispatch} from './state' -import {Text} from 'view/com/util/text/Text' -import {StepHeader} from './StepHeader' -import {s} from 'lib/styles' -import {TextInput} from '../util/TextInput' -import {createFullHandle} from 'lib/strings/handles' -import {usePalette} from 'lib/hooks/usePalette' -import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -/** STEP 3: Your user handle - * @field User handle - */ -export function Step3({ - uiState, - uiDispatch, -}: { - uiState: CreateAccountState - uiDispatch: CreateAccountDispatch -}) { - const pal = usePalette('default') - const {_} = useLingui() - return ( - <View> - <StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> - <View style={s.pb10}> - <TextInput - testID="handleInput" - icon="at" - placeholder="e.g. alice" - value={uiState.handle} - editable - autoFocus - autoComplete="off" - autoCorrect={false} - onChange={value => uiDispatch({type: 'set-handle', value})} - // TODO: Add explicit text label - accessibilityLabel={_(msg`User handle`)} - accessibilityHint={_(msg`Input your user handle`)} - /> - <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> - <Trans>Your full handle will be</Trans>{' '} - <Text type="lg-bold" style={pal.text}> - @{createFullHandle(uiState.handle, uiState.userDomain)} - </Text> - </Text> - </View> - {uiState.error ? ( - <ErrorMessage message={uiState.error} style={styles.error} /> - ) : undefined} - </View> - ) -} - -const styles = StyleSheet.create({ - error: { - borderRadius: 6, - }, -}) diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx deleted file mode 100644 index af6bf5478..000000000 --- a/src/view/com/auth/create/StepHeader.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {Text} from 'view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {Trans} from '@lingui/macro' -import {CreateAccountState} from './state' - -export function StepHeader({ - uiState, - title, - children, -}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) { - const pal = usePalette('default') - const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2 - return ( - <View style={styles.container}> - <View> - <Text type="lg" style={[pal.textLight]}> - {uiState.step === 3 ? ( - <Trans>Last step!</Trans> - ) : ( - <Trans> - Step {uiState.step} of {numSteps} - </Trans> - )} - </Text> - - <Text style={[pal.text]} type="title-xl"> - {title} - </Text> - </View> - {children} - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 20, - }, -}) diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts deleted file mode 100644 index e8a7cd4ed..000000000 --- a/src/view/com/auth/create/state.ts +++ /dev/null @@ -1,350 +0,0 @@ -import {useReducer} from 'react' -import { - ComAtprotoServerDescribeServer, - ComAtprotoServerCreateAccount, - BskyAgent, -} from '@atproto/api' -import {I18nContext, useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' -import * as EmailValidator from 'email-validator' -import {getAge} from 'lib/strings/time' -import {logger} from '#/logger' -import {createFullHandle} from '#/lib/strings/handles' -import {cleanError} from '#/lib/strings/errors' -import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' -import {ApiContext as SessionApiContext} from '#/state/session' -import {DEFAULT_SERVICE} from '#/lib/constants' -import parsePhoneNumber, {CountryCode} from 'libphonenumber-js' - -export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema -const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago - -export type CreateAccountAction = - | {type: 'set-step'; value: number} - | {type: 'set-error'; value: string | undefined} - | {type: 'set-processing'; value: boolean} - | {type: 'set-service-url'; value: string} - | {type: 'set-service-description'; value: ServiceDescription | undefined} - | {type: 'set-user-domain'; value: string} - | {type: 'set-invite-code'; value: string} - | {type: 'set-email'; value: string} - | {type: 'set-password'; value: string} - | {type: 'set-phone-country'; value: CountryCode} - | {type: 'set-verification-phone'; value: string} - | {type: 'set-verification-code'; value: string} - | {type: 'set-has-requested-verification-code'; value: boolean} - | {type: 'set-handle'; value: string} - | {type: 'set-birth-date'; value: Date} - | {type: 'next'} - | {type: 'back'} - -export interface CreateAccountState { - // state - step: number - error: string | undefined - isProcessing: boolean - serviceUrl: string - serviceDescription: ServiceDescription | undefined - userDomain: string - inviteCode: string - email: string - password: string - phoneCountry: CountryCode - verificationPhone: string - verificationCode: string - hasRequestedVerificationCode: boolean - handle: string - birthDate: Date - - // computed - canBack: boolean - canNext: boolean - isInviteCodeRequired: boolean - isPhoneVerificationRequired: boolean -} - -export type CreateAccountDispatch = (action: CreateAccountAction) => void - -export function useCreateAccount() { - const {_} = useLingui() - return useReducer(createReducer({_}), { - step: 1, - error: undefined, - isProcessing: false, - serviceUrl: DEFAULT_SERVICE, - serviceDescription: undefined, - userDomain: '', - inviteCode: '', - email: '', - password: '', - phoneCountry: 'US', - verificationPhone: '', - verificationCode: '', - hasRequestedVerificationCode: false, - handle: '', - birthDate: DEFAULT_DATE, - - canBack: false, - canNext: false, - isInviteCodeRequired: false, - isPhoneVerificationRequired: false, - }) -} - -export async function requestVerificationCode({ - uiState, - uiDispatch, - _, -}: { - uiState: CreateAccountState - uiDispatch: CreateAccountDispatch - _: I18nContext['_'] -}) { - const phoneNumber = parsePhoneNumber( - uiState.verificationPhone, - uiState.phoneCountry, - )?.number - if (!phoneNumber) { - return - } - uiDispatch({type: 'set-error', value: ''}) - uiDispatch({type: 'set-processing', value: true}) - uiDispatch({type: 'set-verification-phone', value: phoneNumber}) - try { - const agent = new BskyAgent({service: uiState.serviceUrl}) - await agent.com.atproto.temp.requestPhoneVerification({ - phoneNumber, - }) - uiDispatch({type: 'set-has-requested-verification-code', value: true}) - } catch (e: any) { - logger.error( - `Failed to request sms verification code (${e.status} status)`, - {message: e}, - ) - uiDispatch({type: 'set-error', value: cleanError(e.toString())}) - } - uiDispatch({type: 'set-processing', value: false}) -} - -export async function submit({ - createAccount, - onboardingDispatch, - uiState, - uiDispatch, - _, -}: { - createAccount: SessionApiContext['createAccount'] - onboardingDispatch: OnboardingDispatchContext - uiState: CreateAccountState - uiDispatch: CreateAccountDispatch - _: I18nContext['_'] -}) { - if (!uiState.email) { - uiDispatch({type: 'set-step', value: 1}) - return uiDispatch({ - type: 'set-error', - value: _(msg`Please enter your email.`), - }) - } - if (!EmailValidator.validate(uiState.email)) { - uiDispatch({type: 'set-step', value: 1}) - return uiDispatch({ - type: 'set-error', - value: _(msg`Your email appears to be invalid.`), - }) - } - if (!uiState.password) { - uiDispatch({type: 'set-step', value: 1}) - return uiDispatch({ - type: 'set-error', - value: _(msg`Please choose your password.`), - }) - } - if ( - uiState.isPhoneVerificationRequired && - (!uiState.verificationPhone || !uiState.verificationCode) - ) { - uiDispatch({type: 'set-step', value: 2}) - return uiDispatch({ - type: 'set-error', - value: _(msg`Please enter the code you received by SMS.`), - }) - } - if (!uiState.handle) { - uiDispatch({type: 'set-step', value: 3}) - return uiDispatch({ - type: 'set-error', - value: _(msg`Please choose your handle.`), - }) - } - uiDispatch({type: 'set-error', value: ''}) - uiDispatch({type: 'set-processing', value: true}) - - try { - onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view - await createAccount({ - service: uiState.serviceUrl, - email: uiState.email, - handle: createFullHandle(uiState.handle, uiState.userDomain), - password: uiState.password, - inviteCode: uiState.inviteCode.trim(), - verificationPhone: uiState.verificationPhone.trim(), - verificationCode: uiState.verificationCode.trim(), - }) - } catch (e: any) { - onboardingDispatch({type: 'skip'}) // undo starting the onboard - let errMsg = e.toString() - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { - errMsg = _( - msg`Invite code not accepted. Check that you input it correctly and try again.`, - ) - uiDispatch({type: 'set-step', value: 1}) - } else if (e.error === 'InvalidPhoneVerification') { - uiDispatch({type: 'set-step', value: 2}) - } - - 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, - }) - } - - uiDispatch({type: 'set-processing', value: false}) - uiDispatch({type: 'set-error', value: cleanError(errMsg)}) - throw e - } -} - -export function is13(state: CreateAccountState) { - return getAge(state.birthDate) >= 13 -} - -export function is18(state: CreateAccountState) { - return getAge(state.birthDate) >= 18 -} - -function createReducer({_}: {_: I18nContext['_']}) { - return function reducer( - state: CreateAccountState, - action: CreateAccountAction, - ): CreateAccountState { - switch (action.type) { - case 'set-step': { - return compute({...state, step: action.value}) - } - case 'set-error': { - return compute({...state, error: action.value}) - } - case 'set-processing': { - return compute({...state, isProcessing: action.value}) - } - case 'set-service-url': { - return compute({ - ...state, - serviceUrl: action.value, - serviceDescription: - state.serviceUrl !== action.value - ? undefined - : state.serviceDescription, - }) - } - case 'set-service-description': { - return compute({ - ...state, - serviceDescription: action.value, - userDomain: action.value?.availableUserDomains[0] || '', - }) - } - case 'set-user-domain': { - return compute({...state, userDomain: action.value}) - } - case 'set-invite-code': { - return compute({...state, inviteCode: action.value}) - } - case 'set-email': { - return compute({...state, email: action.value}) - } - case 'set-password': { - return compute({...state, password: action.value}) - } - case 'set-phone-country': { - return compute({...state, phoneCountry: action.value}) - } - case 'set-verification-phone': { - return compute({ - ...state, - verificationPhone: action.value, - hasRequestedVerificationCode: false, - }) - } - case 'set-verification-code': { - return compute({...state, verificationCode: action.value.trim()}) - } - case 'set-has-requested-verification-code': { - return compute({...state, hasRequestedVerificationCode: action.value}) - } - case 'set-handle': { - return compute({...state, handle: action.value}) - } - case 'set-birth-date': { - return compute({...state, birthDate: action.value}) - } - case 'next': { - if (state.step === 1) { - if (!is13(state)) { - return compute({ - ...state, - error: _( - msg`Unfortunately, you do not meet the requirements to create an account.`, - ), - }) - } - } - let increment = 1 - if (state.step === 1 && !state.isPhoneVerificationRequired) { - increment = 2 - } - return compute({...state, error: '', step: state.step + increment}) - } - case 'back': { - let decrement = 1 - if (state.step === 3 && !state.isPhoneVerificationRequired) { - decrement = 2 - } - return compute({...state, error: '', step: state.step - decrement}) - } - } - } -} - -function compute(state: CreateAccountState): CreateAccountState { - let canNext = true - if (state.step === 1) { - canNext = - !!state.serviceDescription && - (!state.isInviteCodeRequired || !!state.inviteCode) && - !!state.email && - !!state.password - } else if (state.step === 2) { - canNext = - !state.isPhoneVerificationRequired || - (!!state.verificationPhone && - isValidVerificationCode(state.verificationCode)) - } else if (state.step === 3) { - canNext = !!state.handle - } - return { - ...state, - canBack: state.step > 1, - canNext, - isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, - isPhoneVerificationRequired: - !!state.serviceDescription?.phoneVerificationRequired, - } -} - -function isValidVerificationCode(str: string): boolean { - return /[0-9]{6}/.test(str) -} |