From 95f70a9a6aec3a4c1b23f837a26bc5c0d4266554 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 Jan 2024 20:48:51 -0800 Subject: [PATCH] Phone number verification in account creation (#2564) * Add optional sms verification * Add support link to account creation * Add e2e tests * Bump api@0.9.0 * Update lockfile * Bump api@0.9.1 * Include the phone number in the ui * Add phone number validation and normalization --- __e2e__/mock-server.ts | 3 +- __e2e__/tests/create-account.test.ts | 12 +- __e2e__/tests/invite-codes.test.ts | 13 +- .../invites-and-text-verification.test.ts | 57 +++ __e2e__/tests/text-verification.test.ts | 85 +++++ __e2e__/util.ts | 2 +- jest/test-pds.ts | 38 +- package.json | 3 +- src/state/session/index.tsx | 14 +- src/view/com/auth/create/CreateAccount.tsx | 26 +- src/view/com/auth/create/Step1.tsx | 345 ++++++++++-------- src/view/com/auth/create/Step2.tsx | 277 +++++++------- src/view/com/auth/create/Step3.tsx | 2 +- src/view/com/auth/create/StepHeader.tsx | 37 +- src/view/com/auth/create/state.ts | 107 +++++- src/view/icons/index.tsx | 6 + yarn.lock | 13 +- 17 files changed, 701 insertions(+), 339 deletions(-) create mode 100644 __e2e__/tests/invites-and-text-verification.test.ts create mode 100644 __e2e__/tests/text-verification.test.ts diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 482df6ef..1bf240cc 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -14,7 +14,8 @@ async function main() { await server?.close() console.log('Starting new server') const inviteRequired = url?.query && 'invite' in url.query - server = await createServer({inviteRequired}) + const phoneRequired = url?.query && 'phone' in url.query + server = await createServer({inviteRequired, phoneRequired}) console.log('Listening at', server.pdsUrl) if (url?.query) { if ('users' in url.query) { diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts index eab3e538..a6724e8e 100644 --- a/__e2e__/tests/create-account.test.ts +++ b/__e2e__/tests/create-account.test.ts @@ -16,14 +16,12 @@ describe('Create account', () => { await element(by.id('createAccountButton')).tap() await device.takeScreenshot('1- opened create account screen') - await element(by.id('otherServerBtn')).tap() + await element(by.id('selectServiceButton')).tap() await device.takeScreenshot('2- selected other server') - await element(by.id('customServerInput')).clearText() - await element(by.id('customServerInput')).typeText(service) + await element(by.id('customServerTextInput')).typeText(service) + await element(by.id('customServerTextInput')).tapReturnKey() + await element(by.id('customServerSelectBtn')).tap() await device.takeScreenshot('3- input test server URL') - - await element(by.id('nextBtn')).tap() - await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('passwordInput')).typeText('hunter2') await device.takeScreenshot('4- entered account details') @@ -31,7 +29,7 @@ describe('Create account', () => { await element(by.id('nextBtn')).tap() await element(by.id('handleInput')).typeText('e2e-test') - await device.takeScreenshot('4- entered handle') + await device.takeScreenshot('5- entered handle') await element(by.id('nextBtn')).tap() diff --git a/__e2e__/tests/invite-codes.test.ts b/__e2e__/tests/invite-codes.test.ts index 7db7c595..7ab5b147 100644 --- a/__e2e__/tests/invite-codes.test.ts +++ b/__e2e__/tests/invite-codes.test.ts @@ -1,10 +1,5 @@ /* eslint-env detox/detox */ -/** - * This test is being skipped until we can resolve the detox crash issue - * with the side drawer. - */ - import {describe, beforeAll, it} from '@jest/globals' import {expect} from 'detox' import {openApp, loginAsAlice, createServer} from '../util' @@ -31,12 +26,12 @@ describe('invite-codes', () => { await element(by.id('e2eOpenLoggedOutView')).tap() await element(by.id('createAccountButton')).tap() await device.takeScreenshot('1- opened create account screen') - await element(by.id('otherServerBtn')).tap() + await element(by.id('selectServiceButton')).tap() await device.takeScreenshot('2- selected other server') - await element(by.id('customServerInput')).clearText() - await element(by.id('customServerInput')).typeText(service) + await element(by.id('customServerTextInput')).typeText(service) + await element(by.id('customServerTextInput')).tapReturnKey() + await element(by.id('customServerSelectBtn')).tap() await device.takeScreenshot('3- input test server URL') - await element(by.id('nextBtn')).tap() await element(by.id('inviteCodeInput')).typeText(inviteCode) await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('passwordInput')).typeText('hunter2') diff --git a/__e2e__/tests/invites-and-text-verification.test.ts b/__e2e__/tests/invites-and-text-verification.test.ts new file mode 100644 index 00000000..850ca6d5 --- /dev/null +++ b/__e2e__/tests/invites-and-text-verification.test.ts @@ -0,0 +1,57 @@ +/* eslint-env detox/detox */ + +import {describe, beforeAll, it} from '@jest/globals' +import {expect} from 'detox' +import {openApp, loginAsAlice, createServer} from '../util' + +describe('invite-codes', () => { + let service: string + let inviteCode = '' + beforeAll(async () => { + service = await createServer('?users&invite&phone') + await openApp({permissions: {notifications: 'YES'}}) + }) + + it('I can fetch invite codes', async () => { + await loginAsAlice() + await element(by.id('e2eOpenInviteCodesModal')).tap() + await expect(element(by.id('inviteCodesModal'))).toBeVisible() + const attrs = await element(by.id('inviteCode-0-code')).getAttributes() + inviteCode = attrs.text + await element(by.id('closeBtn')).tap() + await element(by.id('e2eSignOut')).tap() + }) + + it('I can create a new account with the invite code', async () => { + await element(by.id('e2eOpenLoggedOutView')).tap() + await element(by.id('createAccountButton')).tap() + await device.takeScreenshot('1- opened create account screen') + await element(by.id('selectServiceButton')).tap() + await device.takeScreenshot('2- selected other server') + await element(by.id('customServerTextInput')).typeText(service) + await element(by.id('customServerTextInput')).tapReturnKey() + await element(by.id('customServerSelectBtn')).tap() + await device.takeScreenshot('3- input test server URL') + await element(by.id('inviteCodeInput')).typeText(inviteCode) + await element(by.id('emailInput')).typeText('example@test.com') + await element(by.id('passwordInput')).typeText('hunter2') + await device.takeScreenshot('4- entered account details') + await element(by.id('nextBtn')).tap() + await element(by.id('phoneInput')).typeText('5558675309') + await element(by.id('requestCodeBtn')).tap() + await device.takeScreenshot('5- requested code') + await element(by.id('codeInput')).typeText('000000') + await device.takeScreenshot('6- entered code') + await element(by.id('nextBtn')).tap() + await element(by.id('handleInput')).typeText('e2e-test') + await device.takeScreenshot('7- entered handle') + await element(by.id('nextBtn')).tap() + await expect(element(by.id('welcomeOnboarding'))).toBeVisible() + await element(by.id('continueBtn')).tap() + await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() + await element(by.id('continueBtn')).tap() + await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible() + await element(by.id('continueBtn')).tap() + await expect(element(by.id('homeScreen'))).toBeVisible() + }) +}) diff --git a/__e2e__/tests/text-verification.test.ts b/__e2e__/tests/text-verification.test.ts new file mode 100644 index 00000000..a307a95f --- /dev/null +++ b/__e2e__/tests/text-verification.test.ts @@ -0,0 +1,85 @@ +/* eslint-env detox/detox */ + +import {describe, beforeAll, it} from '@jest/globals' +import {expect} from 'detox' +import {openApp, createServer} from '../util' + +describe('Create account', () => { + let service: string + beforeAll(async () => { + service = await createServer('?phone') + await openApp({permissions: {notifications: 'YES'}}) + }) + + it('I can create a new account with text verification', async () => { + await element(by.id('e2eOpenLoggedOutView')).tap() + + await element(by.id('createAccountButton')).tap() + await device.takeScreenshot('1- opened create account screen') + await element(by.id('selectServiceButton')).tap() + await device.takeScreenshot('2- selected other server') + await element(by.id('customServerTextInput')).typeText(service) + await element(by.id('customServerTextInput')).tapReturnKey() + await element(by.id('customServerSelectBtn')).tap() + await device.takeScreenshot('3- input test server URL') + await element(by.id('emailInput')).typeText('text-verification@test.com') + await element(by.id('passwordInput')).typeText('hunter2') + await device.takeScreenshot('4- entered account details') + await element(by.id('nextBtn')).tap() + + await element(by.id('phoneInput')).typeText('1234567890') + await element(by.id('requestCodeBtn')).tap() + await device.takeScreenshot('5- requested code') + + await element(by.id('codeInput')).typeText('000000') + await device.takeScreenshot('6- entered code') + await element(by.id('nextBtn')).tap() + + await element(by.id('handleInput')).typeText('text-verification-test') + await device.takeScreenshot('7- entered handle') + + await element(by.id('nextBtn')).tap() + + await expect(element(by.id('welcomeOnboarding'))).toBeVisible() + await element(by.id('continueBtn')).tap() + await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() + await element(by.id('continueBtn')).tap() + await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible() + await element(by.id('continueBtn')).tap() + await expect(element(by.id('homeScreen'))).toBeVisible() + }) + + it('failed text verification correctly goes back to the code input screen', async () => { + await element(by.id('e2eSignOut')).tap() + await element(by.id('e2eOpenLoggedOutView')).tap() + + await element(by.id('createAccountButton')).tap() + await device.takeScreenshot('1- opened create account screen') + await element(by.id('selectServiceButton')).tap() + await device.takeScreenshot('2- selected other server') + await element(by.id('customServerTextInput')).typeText(service) + await element(by.id('customServerTextInput')).tapReturnKey() + await element(by.id('customServerSelectBtn')).tap() + await device.takeScreenshot('3- input test server URL') + await element(by.id('emailInput')).typeText('text-verification2@test.com') + await element(by.id('passwordInput')).typeText('hunter2') + await device.takeScreenshot('4- entered account details') + await element(by.id('nextBtn')).tap() + + await element(by.id('phoneInput')).typeText('1234567890') + await element(by.id('requestCodeBtn')).tap() + await device.takeScreenshot('5- requested code') + + await element(by.id('codeInput')).typeText('111111') + await device.takeScreenshot('6- entered code') + await element(by.id('nextBtn')).tap() + + await element(by.id('handleInput')).typeText('text-verification-test2') + await device.takeScreenshot('7- entered handle') + + await element(by.id('nextBtn')).tap() + + await expect(element(by.id('codeInput'))).toBeVisible() + await device.takeScreenshot('8- got error') + }) +}) diff --git a/__e2e__/util.ts b/__e2e__/util.ts index c5668d04..8c47406c 100644 --- a/__e2e__/util.ts +++ b/__e2e__/util.ts @@ -105,7 +105,7 @@ async function openAppForDebugBuild(platform: string, opts: any) { await sleep(3000) } -export async function createServer(path = '') { +export async function createServer(path = ''): Promise { return new Promise(function (resolve, reject) { var req = http.request( { diff --git a/jest/test-pds.ts b/jest/test-pds.ts index d86ebd78..1912ffc6 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -1,7 +1,7 @@ import net from 'net' import path from 'path' import fs from 'fs' -import {TestNetwork} from '@atproto/dev-env' +import {TestNetwork, TestPds} from '@atproto/dev-env' import {AtUri, BskyAgent} from '@atproto/api' export interface TestUser { @@ -55,19 +55,36 @@ class StringIdGenerator { const ids = new StringIdGenerator() export async function createServer( - {inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false}, + { + inviteRequired, + phoneRequired, + }: {inviteRequired: boolean; phoneRequired: boolean} = { + inviteRequired: false, + phoneRequired: false, + }, ): Promise { - const port = await getPort() + const port = 3000 const port2 = await getPort(port + 1) const port3 = await getPort(port2 + 1) const pdsUrl = `http://localhost:${port}` const id = ids.next() + const phoneParams = phoneRequired + ? { + phoneVerificationRequired: true, + twilioAccountSid: 'ACXXXXXXX', + twilioAuthToken: 'AUTH', + twilioServiceSid: 'VAXXXXXXXX', + } + : {} + const testNet = await TestNetwork.create({ pds: { port, hostname: 'localhost', + dbPostgresSchema: `pds_${id}`, inviteRequired, + ...phoneParams, }, bsky: { dbPostgresSchema: `bsky_${id}`, @@ -76,6 +93,7 @@ export async function createServer( }, plc: {port: port2}, }) + mockTwilio(testNet.pds) const pic = fs.readFileSync( path.join(__dirname, '..', 'assets', 'default-avatar.png'), @@ -144,6 +162,8 @@ class Mocker { email, handle: name + '.test', password: 'hunter2', + verificationPhone: '1234567890', + verificationCode: '000000', }) await agent.upsertProfile(async () => { const blob = await agent.uploadBlob(this.pic, { @@ -430,3 +450,15 @@ async function getPort(start = 3000) { } throw new Error('Unable to find an available port') } + +export const mockTwilio = (pds: TestPds) => { + if (!pds.ctx.twilio) return + + pds.ctx.twilio.sendCode = async (_number: string) => { + // do nothing + } + + pds.ctx.twilio.verifyCode = async (_number: string, code: string) => { + return code === '000000' + } +} diff --git a/package.json b/package.json index ff0ddd3e..120e653f 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" }, "dependencies": { - "@atproto/api": "^0.8.0", + "@atproto/api": "^0.9.1", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", @@ -120,6 +120,7 @@ "js-sha256": "^0.9.0", "jwt-decode": "^4.0.0", "lande": "^1.0.10", + "libphonenumber-js": "^1.10.53", "lodash.chunk": "^4.2.0", "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 0a565c97..e49bc2b3 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -44,6 +44,8 @@ export type ApiContext = { password: string handle: string inviteCode?: string + verificationPhone?: string + verificationCode?: string }) => Promise login: (props: { service: string @@ -203,7 +205,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }, [setStateAndPersist, queryClient]) const createAccount = React.useCallback( - async ({service, email, password, handle, inviteCode}: any) => { + async ({ + service, + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }: any) => { logger.info(`session: creating account`, { service, handle, @@ -217,6 +227,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { password, email, inviteCode, + verificationPhone, + verificationCode, }) if (!agent.session) { diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 74307a63..449afb0d 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -22,12 +22,13 @@ import { useSetSaveFeedsMutation, DEFAULT_PROD_FEEDS, } from '#/state/queries/preferences' -import {IS_PROD} from '#/lib/constants' +import {FEEDBACK_FORM_URL, 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() @@ -117,7 +118,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) { return ( @@ -176,6 +177,27 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) { ) : undefined} + + + + + Having trouble?{' '} + + + + + diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 0f8581c0..2ce77cf5 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -1,25 +1,38 @@ import React from 'react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import { + ActivityIndicator, + Keyboard, + StyleSheet, + 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 {CreateAccountState, CreateAccountDispatch} from './state' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' -import {HelpTip} from '../util/HelpTip' +import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' -import {Button} from 'view/com/util/forms/Button' +import {Button} from '../../util/forms/Button' +import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {msg, Trans} from '@lingui/macro' +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} from '@fortawesome/react-native-fontawesome' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' -import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' +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 +} -/** STEP 1: Your hosting provider - * @field Bluesky (default) - * @field Other (staging, local dev, your own PDS, etc.) - */ export function Step1({ uiState, uiDispatch, @@ -28,136 +41,175 @@ export function Step1({ uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') - const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) const {_} = useLingui() + const {openModal} = useModalControls() - const onPressDefault = React.useCallback(() => { - setIsDefaultSelected(true) - uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) - }, [setIsDefaultSelected, uiDispatch]) + const onPressSelectService = React.useCallback(() => { + openModal({ + name: 'server-input', + initialService: uiState.serviceUrl, + onSelect: (url: string) => + uiDispatch({type: 'set-service-url', value: url}), + }) + Keyboard.dismiss() + }, [uiDispatch, uiState.serviceUrl, openModal]) - const onPressOther = React.useCallback(() => { - setIsDefaultSelected(false) - uiDispatch({type: 'set-service-url', value: 'https://'}) - }, [setIsDefaultSelected, uiDispatch]) + const onPressWaitlist = React.useCallback(() => { + openModal({name: 'waitlist'}) + }, [openModal]) - const onChangeServiceUrl = React.useCallback( - (v: string) => { - uiDispatch({type: 'set-service-url', value: v}) - }, - [uiDispatch], - ) + const birthDate = React.useMemo(() => { + return sanitizeDate(uiState.birthDate) + }, [uiState.birthDate]) return ( - - - This is the service that keeps you online. - - + + {!uiState.serviceDescription ? ( + + ) : ( + <> + {uiState.isInviteCodeRequired && ( + + + Invite code + + 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} /> )} - - + + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( + + + Don't have an invite code?{' '} + + + + + Join the waitlist. + + + + + ) : ( + <> + + + Email address + + uiDispatch({type: 'set-email', value})} + accessibilityLabel={_(msg`Email`)} + accessibilityHint={_(msg`Input email for Bluesky account`)} + accessibilityLabelledBy="email" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + autoFocus={!uiState.isInviteCodeRequired} + /> + + + + + Password + + uiDispatch({type: 'set-password', value})} + accessibilityLabel={_(msg`Password`)} + accessibilityHint={_(msg`Set password`)} + accessibilityLabelledBy="password" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + + + + + Your birth date + + + 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" + /> + + + {uiState.serviceDescription && ( + + )} + + )} + + )} {uiState.error ? ( - ) : ( - - )} - - ) -} - -function Option({ - children, - isSelected, - label, - help, - onPress, - testID, -}: React.PropsWithChildren<{ - isSelected: boolean - label: string - help?: string - onPress: () => void - testID?: string -}>) { - const theme = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const circleFillStyle = React.useMemo( - () => ({ - backgroundColor: theme.palette.primary.background, - }), - [theme], - ) - - return ( - - - - - {isSelected ? ( - - ) : undefined} - - - {label} - {help ? ( - - {help} - - ) : undefined} - - - - {isSelected && children} + ) : undefined} ) } @@ -165,34 +217,15 @@ function Option({ const styles = StyleSheet.create({ error: { borderRadius: 6, + marginTop: 10, }, - - option: { + dateInputButton: { borderWidth: 1, borderRadius: 6, - marginBottom: 10, + paddingVertical: 14, }, - optionHeading: { - flexDirection: 'row', - alignItems: 'center', - padding: 10, - }, - circle: { - width: 26, - height: 26, - borderRadius: 15, - padding: 4, - borderWidth: 1, - marginRight: 10, - }, - circleFill: { - width: 16, - height: 16, - borderRadius: 10, - }, - - otherForm: { - paddingBottom: 10, - paddingHorizontal: 12, + // @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 index 53e1e02c..f938bb9c 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,39 +1,28 @@ import React from 'react' -import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {CreateAccountState, CreateAccountDispatch, is18} from './state' +import { + ActivityIndicator, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native' +import { + CreateAccountState, + CreateAccountDispatch, + requestVerificationCode, +} 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 {Button} from '../../util/forms/Button' 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 {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import parsePhoneNumber from 'libphonenumber-js' -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 -} - -/** STEP 2: Your account - * @field Invite code or waitlist - * @field Email address - * @field Email address - * @field Email address - * @field Password - * @field Birth date - * @readonly Terms of service & privacy policy - */ export function Step2({ uiState, uiDispatch, @@ -43,130 +32,155 @@ export function Step2({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() + const {isMobile} = useWebMediaQueries() - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) + const onPressRequest = React.useCallback(() => { + if ( + uiState.verificationPhone.length >= 9 && + parsePhoneNumber(uiState.verificationPhone, 'US') + ) { + requestVerificationCode({uiState, uiDispatch, _}) + } else { + uiDispatch({ + type: 'set-error', + value: _( + msg`There's something wrong with this number. Please include your country and/or area code!`, + ), + }) + } + }, [uiState, uiDispatch, _]) - const birthDate = React.useMemo(() => { - return sanitizeDate(uiState.birthDate) - }, [uiState.birthDate]) + const onPressRetry = React.useCallback(() => { + uiDispatch({type: 'set-has-requested-verification-code', value: false}) + }, [uiDispatch]) + + const phoneNumberFormatted = React.useMemo( + () => + uiState.hasRequestedVerificationCode + ? parsePhoneNumber( + uiState.verificationPhone, + 'US', + )?.formatInternational() + : '', + [uiState.hasRequestedVerificationCode, uiState.verificationPhone], + ) return ( - + - {uiState.isInviteCodeRequired && ( - - - Invite code - - uiDispatch({type: 'set-invite-code', value})} - accessibilityLabel={_(msg`Invite code`)} - accessibilityHint={_(msg`Input invite code to proceed`)} - autoCapitalize="none" - autoComplete="off" - autoCorrect={false} - /> - - )} + {!uiState.hasRequestedVerificationCode ? ( + <> + + + Phone number + + + 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} + /> + + + Please enter a phone number that can receive SMS text messages. + + + - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( - - Don't have an invite code?{' '} - - - - Join the waitlist. - - - - + + {uiState.isProcessing ? ( + + ) : ( +