Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
95f70a9a 89f41050

+694 -332
+2 -1
__e2e__/mock-server.ts
··· 14 14 await server?.close() 15 15 console.log('Starting new server') 16 16 const inviteRequired = url?.query && 'invite' in url.query 17 - server = await createServer({inviteRequired}) 17 + const phoneRequired = url?.query && 'phone' in url.query 18 + server = await createServer({inviteRequired, phoneRequired}) 18 19 console.log('Listening at', server.pdsUrl) 19 20 if (url?.query) { 20 21 if ('users' in url.query) {
+5 -7
__e2e__/tests/create-account.test.ts
··· 16 16 17 17 await element(by.id('createAccountButton')).tap() 18 18 await device.takeScreenshot('1- opened create account screen') 19 - await element(by.id('otherServerBtn')).tap() 19 + await element(by.id('selectServiceButton')).tap() 20 20 await device.takeScreenshot('2- selected other server') 21 - await element(by.id('customServerInput')).clearText() 22 - await element(by.id('customServerInput')).typeText(service) 21 + await element(by.id('customServerTextInput')).typeText(service) 22 + await element(by.id('customServerTextInput')).tapReturnKey() 23 + await element(by.id('customServerSelectBtn')).tap() 23 24 await device.takeScreenshot('3- input test server URL') 24 - 25 - await element(by.id('nextBtn')).tap() 26 - 27 25 await element(by.id('emailInput')).typeText('example@test.com') 28 26 await element(by.id('passwordInput')).typeText('hunter2') 29 27 await device.takeScreenshot('4- entered account details') ··· 31 29 await element(by.id('nextBtn')).tap() 32 30 33 31 await element(by.id('handleInput')).typeText('e2e-test') 34 - await device.takeScreenshot('4- entered handle') 32 + await device.takeScreenshot('5- entered handle') 35 33 36 34 await element(by.id('nextBtn')).tap() 37 35
+4 -9
__e2e__/tests/invite-codes.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - /** 4 - * This test is being skipped until we can resolve the detox crash issue 5 - * with the side drawer. 6 - */ 7 - 8 3 import {describe, beforeAll, it} from '@jest/globals' 9 4 import {expect} from 'detox' 10 5 import {openApp, loginAsAlice, createServer} from '../util' ··· 31 26 await element(by.id('e2eOpenLoggedOutView')).tap() 32 27 await element(by.id('createAccountButton')).tap() 33 28 await device.takeScreenshot('1- opened create account screen') 34 - await element(by.id('otherServerBtn')).tap() 29 + await element(by.id('selectServiceButton')).tap() 35 30 await device.takeScreenshot('2- selected other server') 36 - await element(by.id('customServerInput')).clearText() 37 - await element(by.id('customServerInput')).typeText(service) 31 + await element(by.id('customServerTextInput')).typeText(service) 32 + await element(by.id('customServerTextInput')).tapReturnKey() 33 + await element(by.id('customServerSelectBtn')).tap() 38 34 await device.takeScreenshot('3- input test server URL') 39 - await element(by.id('nextBtn')).tap() 40 35 await element(by.id('inviteCodeInput')).typeText(inviteCode) 41 36 await element(by.id('emailInput')).typeText('example@test.com') 42 37 await element(by.id('passwordInput')).typeText('hunter2')
+57
__e2e__/tests/invites-and-text-verification.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {describe, beforeAll, it} from '@jest/globals' 4 + import {expect} from 'detox' 5 + import {openApp, loginAsAlice, createServer} from '../util' 6 + 7 + describe('invite-codes', () => { 8 + let service: string 9 + let inviteCode = '' 10 + beforeAll(async () => { 11 + service = await createServer('?users&invite&phone') 12 + await openApp({permissions: {notifications: 'YES'}}) 13 + }) 14 + 15 + it('I can fetch invite codes', async () => { 16 + await loginAsAlice() 17 + await element(by.id('e2eOpenInviteCodesModal')).tap() 18 + await expect(element(by.id('inviteCodesModal'))).toBeVisible() 19 + const attrs = await element(by.id('inviteCode-0-code')).getAttributes() 20 + inviteCode = attrs.text 21 + await element(by.id('closeBtn')).tap() 22 + await element(by.id('e2eSignOut')).tap() 23 + }) 24 + 25 + it('I can create a new account with the invite code', async () => { 26 + await element(by.id('e2eOpenLoggedOutView')).tap() 27 + await element(by.id('createAccountButton')).tap() 28 + await device.takeScreenshot('1- opened create account screen') 29 + await element(by.id('selectServiceButton')).tap() 30 + await device.takeScreenshot('2- selected other server') 31 + await element(by.id('customServerTextInput')).typeText(service) 32 + await element(by.id('customServerTextInput')).tapReturnKey() 33 + await element(by.id('customServerSelectBtn')).tap() 34 + await device.takeScreenshot('3- input test server URL') 35 + await element(by.id('inviteCodeInput')).typeText(inviteCode) 36 + await element(by.id('emailInput')).typeText('example@test.com') 37 + await element(by.id('passwordInput')).typeText('hunter2') 38 + await device.takeScreenshot('4- entered account details') 39 + await element(by.id('nextBtn')).tap() 40 + await element(by.id('phoneInput')).typeText('5558675309') 41 + await element(by.id('requestCodeBtn')).tap() 42 + await device.takeScreenshot('5- requested code') 43 + await element(by.id('codeInput')).typeText('000000') 44 + await device.takeScreenshot('6- entered code') 45 + await element(by.id('nextBtn')).tap() 46 + await element(by.id('handleInput')).typeText('e2e-test') 47 + await device.takeScreenshot('7- entered handle') 48 + await element(by.id('nextBtn')).tap() 49 + await expect(element(by.id('welcomeOnboarding'))).toBeVisible() 50 + await element(by.id('continueBtn')).tap() 51 + await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() 52 + await element(by.id('continueBtn')).tap() 53 + await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible() 54 + await element(by.id('continueBtn')).tap() 55 + await expect(element(by.id('homeScreen'))).toBeVisible() 56 + }) 57 + })
+85
__e2e__/tests/text-verification.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {describe, beforeAll, it} from '@jest/globals' 4 + import {expect} from 'detox' 5 + import {openApp, createServer} from '../util' 6 + 7 + describe('Create account', () => { 8 + let service: string 9 + beforeAll(async () => { 10 + service = await createServer('?phone') 11 + await openApp({permissions: {notifications: 'YES'}}) 12 + }) 13 + 14 + it('I can create a new account with text verification', async () => { 15 + await element(by.id('e2eOpenLoggedOutView')).tap() 16 + 17 + await element(by.id('createAccountButton')).tap() 18 + await device.takeScreenshot('1- opened create account screen') 19 + await element(by.id('selectServiceButton')).tap() 20 + await device.takeScreenshot('2- selected other server') 21 + await element(by.id('customServerTextInput')).typeText(service) 22 + await element(by.id('customServerTextInput')).tapReturnKey() 23 + await element(by.id('customServerSelectBtn')).tap() 24 + await device.takeScreenshot('3- input test server URL') 25 + await element(by.id('emailInput')).typeText('text-verification@test.com') 26 + await element(by.id('passwordInput')).typeText('hunter2') 27 + await device.takeScreenshot('4- entered account details') 28 + await element(by.id('nextBtn')).tap() 29 + 30 + await element(by.id('phoneInput')).typeText('1234567890') 31 + await element(by.id('requestCodeBtn')).tap() 32 + await device.takeScreenshot('5- requested code') 33 + 34 + await element(by.id('codeInput')).typeText('000000') 35 + await device.takeScreenshot('6- entered code') 36 + await element(by.id('nextBtn')).tap() 37 + 38 + await element(by.id('handleInput')).typeText('text-verification-test') 39 + await device.takeScreenshot('7- entered handle') 40 + 41 + await element(by.id('nextBtn')).tap() 42 + 43 + await expect(element(by.id('welcomeOnboarding'))).toBeVisible() 44 + await element(by.id('continueBtn')).tap() 45 + await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() 46 + await element(by.id('continueBtn')).tap() 47 + await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible() 48 + await element(by.id('continueBtn')).tap() 49 + await expect(element(by.id('homeScreen'))).toBeVisible() 50 + }) 51 + 52 + it('failed text verification correctly goes back to the code input screen', async () => { 53 + await element(by.id('e2eSignOut')).tap() 54 + await element(by.id('e2eOpenLoggedOutView')).tap() 55 + 56 + await element(by.id('createAccountButton')).tap() 57 + await device.takeScreenshot('1- opened create account screen') 58 + await element(by.id('selectServiceButton')).tap() 59 + await device.takeScreenshot('2- selected other server') 60 + await element(by.id('customServerTextInput')).typeText(service) 61 + await element(by.id('customServerTextInput')).tapReturnKey() 62 + await element(by.id('customServerSelectBtn')).tap() 63 + await device.takeScreenshot('3- input test server URL') 64 + await element(by.id('emailInput')).typeText('text-verification2@test.com') 65 + await element(by.id('passwordInput')).typeText('hunter2') 66 + await device.takeScreenshot('4- entered account details') 67 + await element(by.id('nextBtn')).tap() 68 + 69 + await element(by.id('phoneInput')).typeText('1234567890') 70 + await element(by.id('requestCodeBtn')).tap() 71 + await device.takeScreenshot('5- requested code') 72 + 73 + await element(by.id('codeInput')).typeText('111111') 74 + await device.takeScreenshot('6- entered code') 75 + await element(by.id('nextBtn')).tap() 76 + 77 + await element(by.id('handleInput')).typeText('text-verification-test2') 78 + await device.takeScreenshot('7- entered handle') 79 + 80 + await element(by.id('nextBtn')).tap() 81 + 82 + await expect(element(by.id('codeInput'))).toBeVisible() 83 + await device.takeScreenshot('8- got error') 84 + }) 85 + })
+1 -1
__e2e__/util.ts
··· 105 105 await sleep(3000) 106 106 } 107 107 108 - export async function createServer(path = '') { 108 + export async function createServer(path = ''): Promise<string> { 109 109 return new Promise(function (resolve, reject) { 110 110 var req = http.request( 111 111 {
+35 -3
jest/test-pds.ts
··· 1 1 import net from 'net' 2 2 import path from 'path' 3 3 import fs from 'fs' 4 - import {TestNetwork} from '@atproto/dev-env' 4 + import {TestNetwork, TestPds} from '@atproto/dev-env' 5 5 import {AtUri, BskyAgent} from '@atproto/api' 6 6 7 7 export interface TestUser { ··· 55 55 const ids = new StringIdGenerator() 56 56 57 57 export async function createServer( 58 - {inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false}, 58 + { 59 + inviteRequired, 60 + phoneRequired, 61 + }: {inviteRequired: boolean; phoneRequired: boolean} = { 62 + inviteRequired: false, 63 + phoneRequired: false, 64 + }, 59 65 ): Promise<TestPDS> { 60 - const port = await getPort() 66 + const port = 3000 61 67 const port2 = await getPort(port + 1) 62 68 const port3 = await getPort(port2 + 1) 63 69 const pdsUrl = `http://localhost:${port}` 64 70 const id = ids.next() 65 71 72 + const phoneParams = phoneRequired 73 + ? { 74 + phoneVerificationRequired: true, 75 + twilioAccountSid: 'ACXXXXXXX', 76 + twilioAuthToken: 'AUTH', 77 + twilioServiceSid: 'VAXXXXXXXX', 78 + } 79 + : {} 80 + 66 81 const testNet = await TestNetwork.create({ 67 82 pds: { 68 83 port, 69 84 hostname: 'localhost', 85 + dbPostgresSchema: `pds_${id}`, 70 86 inviteRequired, 87 + ...phoneParams, 71 88 }, 72 89 bsky: { 73 90 dbPostgresSchema: `bsky_${id}`, ··· 76 93 }, 77 94 plc: {port: port2}, 78 95 }) 96 + mockTwilio(testNet.pds) 79 97 80 98 const pic = fs.readFileSync( 81 99 path.join(__dirname, '..', 'assets', 'default-avatar.png'), ··· 144 162 email, 145 163 handle: name + '.test', 146 164 password: 'hunter2', 165 + verificationPhone: '1234567890', 166 + verificationCode: '000000', 147 167 }) 148 168 await agent.upsertProfile(async () => { 149 169 const blob = await agent.uploadBlob(this.pic, { ··· 430 450 } 431 451 throw new Error('Unable to find an available port') 432 452 } 453 + 454 + export const mockTwilio = (pds: TestPds) => { 455 + if (!pds.ctx.twilio) return 456 + 457 + pds.ctx.twilio.sendCode = async (_number: string) => { 458 + // do nothing 459 + } 460 + 461 + pds.ctx.twilio.verifyCode = async (_number: string, code: string) => { 462 + return code === '000000' 463 + } 464 + }
+2 -1
package.json
··· 39 39 "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" 40 40 }, 41 41 "dependencies": { 42 - "@atproto/api": "^0.8.0", 42 + "@atproto/api": "^0.9.1", 43 43 "@bam.tech/react-native-image-resizer": "^3.0.4", 44 44 "@braintree/sanitize-url": "^6.0.2", 45 45 "@emoji-mart/react": "^1.1.1", ··· 120 120 "js-sha256": "^0.9.0", 121 121 "jwt-decode": "^4.0.0", 122 122 "lande": "^1.0.10", 123 + "libphonenumber-js": "^1.10.53", 123 124 "lodash.chunk": "^4.2.0", 124 125 "lodash.debounce": "^4.0.8", 125 126 "lodash.isequal": "^4.5.0",
+13 -1
src/state/session/index.tsx
··· 44 44 password: string 45 45 handle: string 46 46 inviteCode?: string 47 + verificationPhone?: string 48 + verificationCode?: string 47 49 }) => Promise<void> 48 50 login: (props: { 49 51 service: string ··· 203 205 }, [setStateAndPersist, queryClient]) 204 206 205 207 const createAccount = React.useCallback<ApiContext['createAccount']>( 206 - async ({service, email, password, handle, inviteCode}: any) => { 208 + async ({ 209 + service, 210 + email, 211 + password, 212 + handle, 213 + inviteCode, 214 + verificationPhone, 215 + verificationCode, 216 + }: any) => { 207 217 logger.info(`session: creating account`, { 208 218 service, 209 219 handle, ··· 217 227 password, 218 228 email, 219 229 inviteCode, 230 + verificationPhone, 231 + verificationCode, 220 232 }) 221 233 222 234 if (!agent.session) {
+24 -2
src/view/com/auth/create/CreateAccount.tsx
··· 22 22 useSetSaveFeedsMutation, 23 23 DEFAULT_PROD_FEEDS, 24 24 } from '#/state/queries/preferences' 25 - import {IS_PROD} from '#/lib/constants' 25 + import {FEEDBACK_FORM_URL, IS_PROD} from '#/lib/constants' 26 26 27 27 import {Step1} from './Step1' 28 28 import {Step2} from './Step2' 29 29 import {Step3} from './Step3' 30 30 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 31 + import {TextLink} from '../../util/Link' 31 32 32 33 export function CreateAccount({onPressBack}: {onPressBack: () => void}) { 33 34 const {screen} = useAnalytics() ··· 117 118 118 119 return ( 119 120 <LoggedOutLayout 120 - leadin={`Step ${uiState.step}`} 121 + leadin="" 121 122 title={_(msg`Create Account`)} 122 123 description={_(msg`We're so excited to have you join us!`)}> 123 124 <ScrollView testID="createAccount" style={pal.view}> ··· 176 177 </> 177 178 ) : undefined} 178 179 </View> 180 + 181 + <View style={styles.stepContainer}> 182 + <View 183 + style={[ 184 + s.flexRow, 185 + s.alignCenter, 186 + pal.viewLight, 187 + {borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12}, 188 + ]}> 189 + <Text type="md" style={pal.textLight}> 190 + <Trans>Having trouble?</Trans>{' '} 191 + </Text> 192 + <TextLink 193 + type="md" 194 + style={pal.link} 195 + text={_(msg`Contact support`)} 196 + href={FEEDBACK_FORM_URL({email: uiState.email})} 197 + /> 198 + </View> 199 + </View> 200 + 179 201 <View style={{height: isTabletOrDesktop ? 50 : 400}} /> 180 202 </ScrollView> 181 203 </LoggedOutLayout>
+189 -156
src/view/com/auth/create/Step1.tsx
··· 1 1 import React from 'react' 2 - import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 2 + import { 3 + ActivityIndicator, 4 + Keyboard, 5 + StyleSheet, 6 + TouchableWithoutFeedback, 7 + View, 8 + } from 'react-native' 9 + import {CreateAccountState, CreateAccountDispatch, is18} from './state' 3 10 import {Text} from 'view/com/util/text/Text' 11 + import {DateInput} from 'view/com/util/forms/DateInput' 4 12 import {StepHeader} from './StepHeader' 5 - import {CreateAccountState, CreateAccountDispatch} from './state' 6 - import {useTheme} from 'lib/ThemeContext' 7 - import {usePalette} from 'lib/hooks/usePalette' 8 13 import {s} from 'lib/styles' 9 - import {HelpTip} from '../util/HelpTip' 14 + import {usePalette} from 'lib/hooks/usePalette' 10 15 import {TextInput} from '../util/TextInput' 11 - import {Button} from 'view/com/util/forms/Button' 16 + import {Button} from '../../util/forms/Button' 17 + import {Policies} from './Policies' 12 18 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 13 - import {msg, Trans} from '@lingui/macro' 19 + import {isWeb} from 'platform/detection' 20 + import {Trans, msg} from '@lingui/macro' 14 21 import {useLingui} from '@lingui/react' 22 + import {useModalControls} from '#/state/modals' 23 + import {logger} from '#/logger' 24 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 15 25 16 - import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' 17 - import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' 26 + function sanitizeDate(date: Date): Date { 27 + if (!date || date.toString() === 'Invalid Date') { 28 + logger.error(`Create account: handled invalid date for birthDate`, { 29 + hasDate: !!date, 30 + }) 31 + return new Date() 32 + } 33 + return date 34 + } 18 35 19 - /** STEP 1: Your hosting provider 20 - * @field Bluesky (default) 21 - * @field Other (staging, local dev, your own PDS, etc.) 22 - */ 23 36 export function Step1({ 24 37 uiState, 25 38 uiDispatch, ··· 28 41 uiDispatch: CreateAccountDispatch 29 42 }) { 30 43 const pal = usePalette('default') 31 - const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) 32 44 const {_} = useLingui() 45 + const {openModal} = useModalControls() 33 46 34 - const onPressDefault = React.useCallback(() => { 35 - setIsDefaultSelected(true) 36 - uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) 37 - }, [setIsDefaultSelected, uiDispatch]) 47 + const onPressSelectService = React.useCallback(() => { 48 + openModal({ 49 + name: 'server-input', 50 + initialService: uiState.serviceUrl, 51 + onSelect: (url: string) => 52 + uiDispatch({type: 'set-service-url', value: url}), 53 + }) 54 + Keyboard.dismiss() 55 + }, [uiDispatch, uiState.serviceUrl, openModal]) 38 56 39 - const onPressOther = React.useCallback(() => { 40 - setIsDefaultSelected(false) 41 - uiDispatch({type: 'set-service-url', value: 'https://'}) 42 - }, [setIsDefaultSelected, uiDispatch]) 57 + const onPressWaitlist = React.useCallback(() => { 58 + openModal({name: 'waitlist'}) 59 + }, [openModal]) 43 60 44 - const onChangeServiceUrl = React.useCallback( 45 - (v: string) => { 46 - uiDispatch({type: 'set-service-url', value: v}) 47 - }, 48 - [uiDispatch], 49 - ) 61 + const birthDate = React.useMemo(() => { 62 + return sanitizeDate(uiState.birthDate) 63 + }, [uiState.birthDate]) 50 64 51 65 return ( 52 66 <View> 53 - <StepHeader step="1" title={_(msg`Your hosting provider`)} /> 54 - <Text style={[pal.text, s.mb10]}> 55 - <Trans>This is the service that keeps you online.</Trans> 56 - </Text> 57 - <Option 58 - testID="blueskyServerBtn" 59 - isSelected={isDefaultSelected} 60 - label="Bluesky" 61 - help="&nbsp;(default)" 62 - onPress={onPressDefault} 63 - /> 64 - <Option 65 - testID="otherServerBtn" 66 - isSelected={!isDefaultSelected} 67 - label="Other" 68 - onPress={onPressOther}> 69 - <View style={styles.otherForm}> 70 - <Text nativeID="addressProvider" style={[pal.text, s.mb5]}> 71 - <Trans>Enter the address of your provider:</Trans> 72 - </Text> 73 - <TextInput 74 - testID="customServerInput" 75 - icon="globe" 76 - placeholder={_(msg`Hosting provider address`)} 77 - value={uiState.serviceUrl} 78 - editable 79 - onChange={onChangeServiceUrl} 80 - accessibilityHint={_(msg`Input hosting provider address`)} 81 - accessibilityLabel={_(msg`Hosting provider address`)} 82 - accessibilityLabelledBy="addressProvider" 83 - /> 84 - {LOGIN_INCLUDE_DEV_SERVERS && ( 85 - <View style={[s.flexRow, s.mt10]}> 86 - <Button 87 - testID="stagingServerBtn" 88 - type="default" 89 - style={s.mr5} 90 - label={_(msg`Staging`)} 91 - onPress={() => onChangeServiceUrl(STAGING_SERVICE)} 92 - /> 93 - <Button 94 - testID="localDevServerBtn" 95 - type="default" 96 - label={_(msg`Dev Server`)} 97 - onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} 67 + <StepHeader uiState={uiState} title={_(msg`Your account`)}> 68 + <View> 69 + <Button 70 + testID="selectServiceButton" 71 + type="default" 72 + style={{ 73 + aspectRatio: 1, 74 + justifyContent: 'center', 75 + alignItems: 'center', 76 + }} 77 + accessibilityLabel={_(msg`Select service`)} 78 + accessibilityHint={_(msg`Sets server for the Bluesky client`)} 79 + onPress={onPressSelectService}> 80 + <FontAwesomeIcon icon="server" size={21} /> 81 + </Button> 82 + </View> 83 + </StepHeader> 84 + 85 + {!uiState.serviceDescription ? ( 86 + <ActivityIndicator /> 87 + ) : ( 88 + <> 89 + {uiState.isInviteCodeRequired && ( 90 + <View style={s.pb20}> 91 + <Text type="md-medium" style={[pal.text, s.mb2]}> 92 + <Trans>Invite code</Trans> 93 + </Text> 94 + <TextInput 95 + testID="inviteCodeInput" 96 + icon="ticket" 97 + placeholder={_(msg`Required for this provider`)} 98 + value={uiState.inviteCode} 99 + editable 100 + onChange={value => uiDispatch({type: 'set-invite-code', value})} 101 + accessibilityLabel={_(msg`Invite code`)} 102 + accessibilityHint={_(msg`Input invite code to proceed`)} 103 + autoCapitalize="none" 104 + autoComplete="off" 105 + autoCorrect={false} 106 + autoFocus={true} 98 107 /> 99 108 </View> 100 109 )} 101 - </View> 102 - </Option> 103 - {uiState.error ? ( 104 - <ErrorMessage message={uiState.error} style={styles.error} /> 105 - ) : ( 106 - <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> 107 - )} 108 - </View> 109 - ) 110 - } 110 + 111 + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( 112 + <View style={[s.flexRow, s.alignCenter]}> 113 + <Text style={pal.text}> 114 + <Trans>Don't have an invite code?</Trans>{' '} 115 + </Text> 116 + <TouchableWithoutFeedback 117 + onPress={onPressWaitlist} 118 + accessibilityLabel={_(msg`Join the waitlist.`)} 119 + accessibilityHint=""> 120 + <View style={styles.touchable}> 121 + <Text style={pal.link}> 122 + <Trans>Join the waitlist.</Trans> 123 + </Text> 124 + </View> 125 + </TouchableWithoutFeedback> 126 + </View> 127 + ) : ( 128 + <> 129 + <View style={s.pb20}> 130 + <Text 131 + type="md-medium" 132 + style={[pal.text, s.mb2]} 133 + nativeID="email"> 134 + <Trans>Email address</Trans> 135 + </Text> 136 + <TextInput 137 + testID="emailInput" 138 + icon="envelope" 139 + placeholder={_(msg`Enter your email address`)} 140 + value={uiState.email} 141 + editable 142 + onChange={value => uiDispatch({type: 'set-email', value})} 143 + accessibilityLabel={_(msg`Email`)} 144 + accessibilityHint={_(msg`Input email for Bluesky account`)} 145 + accessibilityLabelledBy="email" 146 + autoCapitalize="none" 147 + autoComplete="off" 148 + autoCorrect={false} 149 + autoFocus={!uiState.isInviteCodeRequired} 150 + /> 151 + </View> 111 152 112 - function Option({ 113 - children, 114 - isSelected, 115 - label, 116 - help, 117 - onPress, 118 - testID, 119 - }: React.PropsWithChildren<{ 120 - isSelected: boolean 121 - label: string 122 - help?: string 123 - onPress: () => void 124 - testID?: string 125 - }>) { 126 - const theme = useTheme() 127 - const pal = usePalette('default') 128 - const {_} = useLingui() 129 - const circleFillStyle = React.useMemo( 130 - () => ({ 131 - backgroundColor: theme.palette.primary.background, 132 - }), 133 - [theme], 134 - ) 153 + <View style={s.pb20}> 154 + <Text 155 + type="md-medium" 156 + style={[pal.text, s.mb2]} 157 + nativeID="password"> 158 + <Trans>Password</Trans> 159 + </Text> 160 + <TextInput 161 + testID="passwordInput" 162 + icon="lock" 163 + placeholder={_(msg`Choose your password`)} 164 + value={uiState.password} 165 + editable 166 + secureTextEntry 167 + onChange={value => uiDispatch({type: 'set-password', value})} 168 + accessibilityLabel={_(msg`Password`)} 169 + accessibilityHint={_(msg`Set password`)} 170 + accessibilityLabelledBy="password" 171 + autoCapitalize="none" 172 + autoComplete="off" 173 + autoCorrect={false} 174 + /> 175 + </View> 135 176 136 - return ( 137 - <View style={[styles.option, pal.border]}> 138 - <TouchableWithoutFeedback 139 - onPress={onPress} 140 - testID={testID} 141 - accessibilityRole="button" 142 - accessibilityLabel={label} 143 - accessibilityHint={_(msg`Sets hosting provider to ${label}`)}> 144 - <View style={styles.optionHeading}> 145 - <View style={[styles.circle, pal.border]}> 146 - {isSelected ? ( 147 - <View style={[circleFillStyle, styles.circleFill]} /> 148 - ) : undefined} 149 - </View> 150 - <Text type="xl" style={pal.text}> 151 - {label} 152 - {help ? ( 153 - <Text type="xl" style={pal.textLight}> 154 - {help} 155 - </Text> 156 - ) : undefined} 157 - </Text> 158 - </View> 159 - </TouchableWithoutFeedback> 160 - {isSelected && children} 177 + <View style={s.pb20}> 178 + <Text 179 + type="md-medium" 180 + style={[pal.text, s.mb2]} 181 + nativeID="birthDate"> 182 + <Trans>Your birth date</Trans> 183 + </Text> 184 + <DateInput 185 + handleAsUTC 186 + testID="birthdayInput" 187 + value={birthDate} 188 + onChange={value => 189 + uiDispatch({type: 'set-birth-date', value}) 190 + } 191 + buttonType="default-light" 192 + buttonStyle={[pal.border, styles.dateInputButton]} 193 + buttonLabelType="lg" 194 + accessibilityLabel={_(msg`Birthday`)} 195 + accessibilityHint={_(msg`Enter your birth date`)} 196 + accessibilityLabelledBy="birthDate" 197 + /> 198 + </View> 199 + 200 + {uiState.serviceDescription && ( 201 + <Policies 202 + serviceDescription={uiState.serviceDescription} 203 + needsGuardian={!is18(uiState)} 204 + /> 205 + )} 206 + </> 207 + )} 208 + </> 209 + )} 210 + {uiState.error ? ( 211 + <ErrorMessage message={uiState.error} style={styles.error} /> 212 + ) : undefined} 161 213 </View> 162 214 ) 163 215 } ··· 165 217 const styles = StyleSheet.create({ 166 218 error: { 167 219 borderRadius: 6, 220 + marginTop: 10, 168 221 }, 169 - 170 - option: { 222 + dateInputButton: { 171 223 borderWidth: 1, 172 224 borderRadius: 6, 173 - marginBottom: 10, 225 + paddingVertical: 14, 174 226 }, 175 - optionHeading: { 176 - flexDirection: 'row', 177 - alignItems: 'center', 178 - padding: 10, 179 - }, 180 - circle: { 181 - width: 26, 182 - height: 26, 183 - borderRadius: 15, 184 - padding: 4, 185 - borderWidth: 1, 186 - marginRight: 10, 187 - }, 188 - circleFill: { 189 - width: 16, 190 - height: 16, 191 - borderRadius: 10, 192 - }, 193 - 194 - otherForm: { 195 - paddingBottom: 10, 196 - paddingHorizontal: 12, 227 + // @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. 228 + touchable: { 229 + ...(isWeb && {cursor: 'pointer'}), 197 230 }, 198 231 })
+136 -127
src/view/com/auth/create/Step2.tsx
··· 1 1 import React from 'react' 2 - import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 - import {CreateAccountState, CreateAccountDispatch, is18} from './state' 2 + import { 3 + ActivityIndicator, 4 + StyleSheet, 5 + TouchableWithoutFeedback, 6 + View, 7 + } from 'react-native' 8 + import { 9 + CreateAccountState, 10 + CreateAccountDispatch, 11 + requestVerificationCode, 12 + } from './state' 4 13 import {Text} from 'view/com/util/text/Text' 5 - import {DateInput} from 'view/com/util/forms/DateInput' 6 14 import {StepHeader} from './StepHeader' 7 15 import {s} from 'lib/styles' 8 16 import {usePalette} from 'lib/hooks/usePalette' 9 17 import {TextInput} from '../util/TextInput' 10 - import {Policies} from './Policies' 18 + import {Button} from '../../util/forms/Button' 11 19 import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 12 20 import {isWeb} from 'platform/detection' 13 21 import {Trans, msg} from '@lingui/macro' 14 22 import {useLingui} from '@lingui/react' 15 - import {useModalControls} from '#/state/modals' 16 - import {logger} from '#/logger' 17 - 18 - function sanitizeDate(date: Date): Date { 19 - if (!date || date.toString() === 'Invalid Date') { 20 - logger.error(`Create account: handled invalid date for birthDate`, { 21 - hasDate: !!date, 22 - }) 23 - return new Date() 24 - } 25 - return date 26 - } 23 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 24 + import parsePhoneNumber from 'libphonenumber-js' 27 25 28 - /** STEP 2: Your account 29 - * @field Invite code or waitlist 30 - * @field Email address 31 - * @field Email address 32 - * @field Email address 33 - * @field Password 34 - * @field Birth date 35 - * @readonly Terms of service & privacy policy 36 - */ 37 26 export function Step2({ 38 27 uiState, 39 28 uiDispatch, ··· 43 32 }) { 44 33 const pal = usePalette('default') 45 34 const {_} = useLingui() 46 - const {openModal} = useModalControls() 35 + const {isMobile} = useWebMediaQueries() 47 36 48 - const onPressWaitlist = React.useCallback(() => { 49 - openModal({name: 'waitlist'}) 50 - }, [openModal]) 37 + const onPressRequest = React.useCallback(() => { 38 + if ( 39 + uiState.verificationPhone.length >= 9 && 40 + parsePhoneNumber(uiState.verificationPhone, 'US') 41 + ) { 42 + requestVerificationCode({uiState, uiDispatch, _}) 43 + } else { 44 + uiDispatch({ 45 + type: 'set-error', 46 + value: _( 47 + msg`There's something wrong with this number. Please include your country and/or area code!`, 48 + ), 49 + }) 50 + } 51 + }, [uiState, uiDispatch, _]) 51 52 52 - const birthDate = React.useMemo(() => { 53 - return sanitizeDate(uiState.birthDate) 54 - }, [uiState.birthDate]) 53 + const onPressRetry = React.useCallback(() => { 54 + uiDispatch({type: 'set-has-requested-verification-code', value: false}) 55 + }, [uiDispatch]) 56 + 57 + const phoneNumberFormatted = React.useMemo( 58 + () => 59 + uiState.hasRequestedVerificationCode 60 + ? parsePhoneNumber( 61 + uiState.verificationPhone, 62 + 'US', 63 + )?.formatInternational() 64 + : '', 65 + [uiState.hasRequestedVerificationCode, uiState.verificationPhone], 66 + ) 55 67 56 68 return ( 57 69 <View> 58 - <StepHeader step="2" title={_(msg`Your account`)} /> 59 - 60 - {uiState.isInviteCodeRequired && ( 61 - <View style={s.pb20}> 62 - <Text type="md-medium" style={[pal.text, s.mb2]}> 63 - <Trans>Invite code</Trans> 64 - </Text> 65 - <TextInput 66 - testID="inviteCodeInput" 67 - icon="ticket" 68 - placeholder={_(msg`Required for this provider`)} 69 - value={uiState.inviteCode} 70 - editable 71 - onChange={value => uiDispatch({type: 'set-invite-code', value})} 72 - accessibilityLabel={_(msg`Invite code`)} 73 - accessibilityHint={_(msg`Input invite code to proceed`)} 74 - autoCapitalize="none" 75 - autoComplete="off" 76 - autoCorrect={false} 77 - /> 78 - </View> 79 - )} 70 + <StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> 80 71 81 - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( 82 - <Text style={[s.alignBaseline, pal.text]}> 83 - <Trans>Don't have an invite code?</Trans>{' '} 84 - <TouchableWithoutFeedback 85 - onPress={onPressWaitlist} 86 - accessibilityLabel={_(msg`Join the waitlist.`)} 87 - accessibilityHint=""> 88 - <View style={styles.touchable}> 89 - <Text style={pal.link}> 90 - <Trans>Join the waitlist.</Trans> 91 - </Text> 92 - </View> 93 - </TouchableWithoutFeedback> 94 - </Text> 95 - ) : ( 72 + {!uiState.hasRequestedVerificationCode ? ( 96 73 <> 97 74 <View style={s.pb20}> 98 - <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> 99 - <Trans>Email address</Trans> 75 + <Text 76 + type="md-medium" 77 + style={[pal.text, s.mb2]} 78 + nativeID="phoneNumber"> 79 + <Trans>Phone number</Trans> 100 80 </Text> 101 81 <TextInput 102 - testID="emailInput" 103 - icon="envelope" 104 - placeholder={_(msg`Enter your email address`)} 105 - value={uiState.email} 82 + testID="phoneInput" 83 + icon="phone" 84 + placeholder={_(msg`Enter your phone number`)} 85 + value={uiState.verificationPhone} 106 86 editable 107 - onChange={value => uiDispatch({type: 'set-email', value})} 87 + onChange={value => 88 + uiDispatch({type: 'set-verification-phone', value}) 89 + } 108 90 accessibilityLabel={_(msg`Email`)} 109 - accessibilityHint={_(msg`Input email for Bluesky waitlist`)} 110 - accessibilityLabelledBy="email" 91 + accessibilityHint={_( 92 + msg`Input phone number for SMS verification`, 93 + )} 94 + accessibilityLabelledBy="phoneNumber" 95 + keyboardType="phone-pad" 111 96 autoCapitalize="none" 112 - autoComplete="off" 97 + autoComplete="tel" 113 98 autoCorrect={false} 99 + autoFocus={true} 114 100 /> 101 + <Text type="sm" style={[pal.textLight, s.mt5]}> 102 + <Trans> 103 + Please enter a phone number that can receive SMS text messages. 104 + </Trans> 105 + </Text> 115 106 </View> 116 107 108 + <View style={isMobile ? {} : {flexDirection: 'row'}}> 109 + {uiState.isProcessing ? ( 110 + <ActivityIndicator /> 111 + ) : ( 112 + <Button 113 + testID="requestCodeBtn" 114 + type="primary" 115 + label={_(msg`Request code`)} 116 + labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []} 117 + style={ 118 + isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {} 119 + } 120 + onPress={onPressRequest} 121 + /> 122 + )} 123 + </View> 124 + </> 125 + ) : ( 126 + <> 117 127 <View style={s.pb20}> 118 - <Text 119 - type="md-medium" 120 - style={[pal.text, s.mb2]} 121 - nativeID="password"> 122 - <Trans>Password</Trans> 123 - </Text> 128 + <View 129 + style={[ 130 + s.flexRow, 131 + s.mb5, 132 + s.alignCenter, 133 + {justifyContent: 'space-between'}, 134 + ]}> 135 + <Text 136 + type="md-medium" 137 + style={pal.text} 138 + nativeID="verificationCode"> 139 + <Trans>Verification code</Trans>{' '} 140 + </Text> 141 + <TouchableWithoutFeedback 142 + onPress={onPressRetry} 143 + accessibilityLabel={_(msg`Retry.`)} 144 + accessibilityHint=""> 145 + <View style={styles.touchable}> 146 + <Text 147 + type="md-medium" 148 + style={pal.link} 149 + nativeID="verificationCode"> 150 + <Trans>Retry</Trans> 151 + </Text> 152 + </View> 153 + </TouchableWithoutFeedback> 154 + </View> 124 155 <TextInput 125 - testID="passwordInput" 126 - icon="lock" 127 - placeholder={_(msg`Choose your password`)} 128 - value={uiState.password} 156 + testID="codeInput" 157 + icon="hashtag" 158 + placeholder={_(msg`XXXXXX`)} 159 + value={uiState.verificationCode} 129 160 editable 130 - secureTextEntry 131 - onChange={value => uiDispatch({type: 'set-password', value})} 132 - accessibilityLabel={_(msg`Password`)} 133 - accessibilityHint={_(msg`Set password`)} 134 - accessibilityLabelledBy="password" 161 + onChange={value => 162 + uiDispatch({type: 'set-verification-code', value}) 163 + } 164 + accessibilityLabel={_(msg`Email`)} 165 + accessibilityHint={_( 166 + msg`Input the verification code we have texted to you`, 167 + )} 168 + accessibilityLabelledBy="verificationCode" 169 + keyboardType="phone-pad" 135 170 autoCapitalize="none" 136 - autoComplete="off" 171 + autoComplete="one-time-code" 172 + textContentType="oneTimeCode" 137 173 autoCorrect={false} 174 + autoFocus={true} 138 175 /> 139 - </View> 140 - 141 - <View style={s.pb20}> 142 - <Text 143 - type="md-medium" 144 - style={[pal.text, s.mb2]} 145 - nativeID="birthDate"> 146 - <Trans>Your birth date</Trans> 176 + <Text type="sm" style={[pal.textLight, s.mt5]}> 177 + <Trans>Please enter the verification code sent to</Trans>{' '} 178 + {phoneNumberFormatted}. 147 179 </Text> 148 - <DateInput 149 - handleAsUTC 150 - testID="birthdayInput" 151 - value={birthDate} 152 - onChange={value => uiDispatch({type: 'set-birth-date', value})} 153 - buttonType="default-light" 154 - buttonStyle={[pal.border, styles.dateInputButton]} 155 - buttonLabelType="lg" 156 - accessibilityLabel={_(msg`Birthday`)} 157 - accessibilityHint={_(msg`Enter your birth date`)} 158 - accessibilityLabelledBy="birthDate" 159 - /> 160 180 </View> 161 - 162 - {uiState.serviceDescription && ( 163 - <Policies 164 - serviceDescription={uiState.serviceDescription} 165 - needsGuardian={!is18(uiState)} 166 - /> 167 - )} 168 181 </> 169 182 )} 183 + 170 184 {uiState.error ? ( 171 185 <ErrorMessage message={uiState.error} style={styles.error} /> 172 186 ) : undefined} ··· 178 192 error: { 179 193 borderRadius: 6, 180 194 marginTop: 10, 181 - }, 182 - dateInputButton: { 183 - borderWidth: 1, 184 - borderRadius: 6, 185 - paddingVertical: 14, 186 195 }, 187 196 // @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. 188 197 touchable: {
+1 -1
src/view/com/auth/create/Step3.tsx
··· 25 25 const {_} = useLingui() 26 26 return ( 27 27 <View> 28 - <StepHeader step="3" title={_(msg`Your user handle`)} /> 28 + <StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> 29 29 <View style={s.pb10}> 30 30 <TextInput 31 31 testID="handleInput"
+26 -11
src/view/com/auth/create/StepHeader.tsx
··· 3 3 import {Text} from 'view/com/util/text/Text' 4 4 import {usePalette} from 'lib/hooks/usePalette' 5 5 import {Trans} from '@lingui/macro' 6 + import {CreateAccountState} from './state' 6 7 7 - export function StepHeader({step, title}: {step: string; title: string}) { 8 + export function StepHeader({ 9 + uiState, 10 + title, 11 + children, 12 + }: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) { 8 13 const pal = usePalette('default') 14 + const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2 9 15 return ( 10 16 <View style={styles.container}> 11 - <Text type="lg" style={[pal.textLight]}> 12 - {step === '3' ? ( 13 - <Trans>Last step!</Trans> 14 - ) : ( 15 - <Trans>Step {step} of 3</Trans> 16 - )} 17 - </Text> 18 - <Text style={[pal.text]} type="title-xl"> 19 - {title} 20 - </Text> 17 + <View> 18 + <Text type="lg" style={[pal.textLight]}> 19 + {uiState.step === 3 ? ( 20 + <Trans>Last step!</Trans> 21 + ) : ( 22 + <Trans> 23 + Step {uiState.step} of {numSteps} 24 + </Trans> 25 + )} 26 + </Text> 27 + 28 + <Text style={[pal.text]} type="title-xl"> 29 + {title} 30 + </Text> 31 + </View> 32 + {children} 21 33 </View> 22 34 ) 23 35 } 24 36 25 37 const styles = StyleSheet.create({ 26 38 container: { 39 + flexDirection: 'row', 40 + justifyContent: 'space-between', 41 + alignItems: 'center', 27 42 marginBottom: 20, 28 43 }, 29 44 })
+99 -8
src/view/com/auth/create/state.ts
··· 2 2 import { 3 3 ComAtprotoServerDescribeServer, 4 4 ComAtprotoServerCreateAccount, 5 + BskyAgent, 5 6 } from '@atproto/api' 6 7 import {I18nContext, useLingui} from '@lingui/react' 7 8 import {msg} from '@lingui/macro' ··· 13 14 import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' 14 15 import {ApiContext as SessionApiContext} from '#/state/session' 15 16 import {DEFAULT_SERVICE} from '#/lib/constants' 17 + import parsePhoneNumber from 'libphonenumber-js' 16 18 17 19 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 18 20 const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago ··· 27 29 | {type: 'set-invite-code'; value: string} 28 30 | {type: 'set-email'; value: string} 29 31 | {type: 'set-password'; value: string} 32 + | {type: 'set-verification-phone'; value: string} 33 + | {type: 'set-verification-code'; value: string} 34 + | {type: 'set-has-requested-verification-code'; value: boolean} 30 35 | {type: 'set-handle'; value: string} 31 36 | {type: 'set-birth-date'; value: Date} 32 37 | {type: 'next'} ··· 43 48 inviteCode: string 44 49 email: string 45 50 password: string 51 + verificationPhone: string 52 + verificationCode: string 53 + hasRequestedVerificationCode: boolean 46 54 handle: string 47 55 birthDate: Date 48 56 ··· 50 58 canBack: boolean 51 59 canNext: boolean 52 60 isInviteCodeRequired: boolean 61 + isPhoneVerificationRequired: boolean 53 62 } 54 63 55 64 export type CreateAccountDispatch = (action: CreateAccountAction) => void ··· 66 75 inviteCode: '', 67 76 email: '', 68 77 password: '', 78 + verificationPhone: '', 79 + verificationCode: '', 80 + hasRequestedVerificationCode: false, 69 81 handle: '', 70 82 birthDate: DEFAULT_DATE, 71 83 72 84 canBack: false, 73 85 canNext: false, 74 86 isInviteCodeRequired: false, 87 + isPhoneVerificationRequired: false, 75 88 }) 76 89 } 77 90 91 + export async function requestVerificationCode({ 92 + uiState, 93 + uiDispatch, 94 + _, 95 + }: { 96 + uiState: CreateAccountState 97 + uiDispatch: CreateAccountDispatch 98 + _: I18nContext['_'] 99 + }) { 100 + const phoneNumber = parsePhoneNumber(uiState.verificationPhone, 'US')?.number 101 + if (!phoneNumber) { 102 + return 103 + } 104 + uiDispatch({type: 'set-error', value: ''}) 105 + uiDispatch({type: 'set-processing', value: true}) 106 + uiDispatch({type: 'set-verification-phone', value: phoneNumber}) 107 + try { 108 + const agent = new BskyAgent({service: uiState.serviceUrl}) 109 + await agent.com.atproto.temp.requestPhoneVerification({ 110 + phoneNumber, 111 + }) 112 + uiDispatch({type: 'set-has-requested-verification-code', value: true}) 113 + } catch (e: any) { 114 + logger.error( 115 + `Failed to request sms verification code (${e.status} status)`, 116 + {error: e}, 117 + ) 118 + uiDispatch({type: 'set-error', value: cleanError(e.toString())}) 119 + } 120 + uiDispatch({type: 'set-processing', value: false}) 121 + } 122 + 78 123 export async function submit({ 79 124 createAccount, 80 125 onboardingDispatch, ··· 89 134 _: I18nContext['_'] 90 135 }) { 91 136 if (!uiState.email) { 92 - uiDispatch({type: 'set-step', value: 2}) 137 + uiDispatch({type: 'set-step', value: 1}) 93 138 return uiDispatch({ 94 139 type: 'set-error', 95 140 value: _(msg`Please enter your email.`), 96 141 }) 97 142 } 98 143 if (!EmailValidator.validate(uiState.email)) { 99 - uiDispatch({type: 'set-step', value: 2}) 144 + uiDispatch({type: 'set-step', value: 1}) 100 145 return uiDispatch({ 101 146 type: 'set-error', 102 147 value: _(msg`Your email appears to be invalid.`), 103 148 }) 104 149 } 105 150 if (!uiState.password) { 151 + uiDispatch({type: 'set-step', value: 1}) 152 + return uiDispatch({ 153 + type: 'set-error', 154 + value: _(msg`Please choose your password.`), 155 + }) 156 + } 157 + if ( 158 + uiState.isPhoneVerificationRequired && 159 + (!uiState.verificationPhone || !uiState.verificationCode) 160 + ) { 106 161 uiDispatch({type: 'set-step', value: 2}) 107 162 return uiDispatch({ 108 163 type: 'set-error', 109 - value: _(msg`Please choose your password.`), 164 + value: _(msg`Please enter the code you received by SMS.`), 110 165 }) 111 166 } 112 167 if (!uiState.handle) { ··· 127 182 handle: createFullHandle(uiState.handle, uiState.userDomain), 128 183 password: uiState.password, 129 184 inviteCode: uiState.inviteCode.trim(), 185 + verificationPhone: uiState.verificationPhone.trim(), 186 + verificationCode: uiState.verificationCode.trim(), 130 187 }) 131 188 } catch (e: any) { 132 189 onboardingDispatch({type: 'skip'}) // undo starting the onboard ··· 135 192 errMsg = _( 136 193 msg`Invite code not accepted. Check that you input it correctly and try again.`, 137 194 ) 195 + uiDispatch({type: 'set-step', value: 1}) 196 + } else if (e.error === 'InvalidPhoneVerification') { 197 + uiDispatch({type: 'set-step', value: 2}) 138 198 } 139 199 140 200 if ([400, 429].includes(e.status)) { ··· 201 261 case 'set-password': { 202 262 return compute({...state, password: action.value}) 203 263 } 264 + case 'set-verification-phone': { 265 + return compute({ 266 + ...state, 267 + verificationPhone: action.value, 268 + hasRequestedVerificationCode: false, 269 + }) 270 + } 271 + case 'set-verification-code': { 272 + return compute({...state, verificationCode: action.value.trim()}) 273 + } 274 + case 'set-has-requested-verification-code': { 275 + return compute({...state, hasRequestedVerificationCode: action.value}) 276 + } 204 277 case 'set-handle': { 205 278 return compute({...state, handle: action.value}) 206 279 } ··· 208 281 return compute({...state, birthDate: action.value}) 209 282 } 210 283 case 'next': { 211 - if (state.step === 2) { 284 + if (state.step === 1) { 212 285 if (!is13(state)) { 213 286 return compute({ 214 287 ...state, ··· 218 291 }) 219 292 } 220 293 } 221 - return compute({...state, error: '', step: state.step + 1}) 294 + let increment = 1 295 + if (state.step === 1 && !state.isPhoneVerificationRequired) { 296 + increment = 2 297 + } 298 + return compute({...state, error: '', step: state.step + increment}) 222 299 } 223 300 case 'back': { 224 - return compute({...state, error: '', step: state.step - 1}) 301 + let decrement = 1 302 + if (state.step === 3 && !state.isPhoneVerificationRequired) { 303 + decrement = 2 304 + } 305 + return compute({...state, error: '', step: state.step - decrement}) 225 306 } 226 307 } 227 308 } ··· 230 311 function compute(state: CreateAccountState): CreateAccountState { 231 312 let canNext = true 232 313 if (state.step === 1) { 233 - canNext = !!state.serviceDescription 234 - } else if (state.step === 2) { 235 314 canNext = 315 + !!state.serviceDescription && 236 316 (!state.isInviteCodeRequired || !!state.inviteCode) && 237 317 !!state.email && 238 318 !!state.password 319 + } else if (state.step === 2) { 320 + canNext = 321 + !state.isPhoneVerificationRequired || 322 + (!!state.verificationPhone && 323 + isValidVerificationCode(state.verificationCode)) 239 324 } else if (state.step === 3) { 240 325 canNext = !!state.handle 241 326 } ··· 244 329 canBack: state.step > 1, 245 330 canNext, 246 331 isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, 332 + isPhoneVerificationRequired: 333 + !!state.serviceDescription?.phoneVerificationRequired, 247 334 } 248 335 } 336 + 337 + function isValidVerificationCode(str: string): boolean { 338 + return /[0-9]{6}/.test(str) 339 + }
+6
src/view/icons/index.tsx
··· 52 52 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' 53 53 import {faHand} from '@fortawesome/free-solid-svg-icons/faHand' 54 54 import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand' 55 + import {faHashtag} from '@fortawesome/free-solid-svg-icons/faHashtag' 55 56 import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' 56 57 import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart' 57 58 import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' ··· 71 72 import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' 72 73 import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' 73 74 import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' 75 + import {faPhone} from '@fortawesome/free-solid-svg-icons/faPhone' 74 76 import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' 75 77 import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' 76 78 import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft' ··· 78 80 import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet' 79 81 import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' 80 82 import {faSatelliteDish} from '@fortawesome/free-solid-svg-icons/faSatelliteDish' 83 + import {faServer} from '@fortawesome/free-solid-svg-icons/faServer' 81 84 import {faShare} from '@fortawesome/free-solid-svg-icons/faShare' 82 85 import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' 83 86 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield' ··· 153 156 faGlobe, 154 157 faHand, 155 158 farHand, 159 + faHashtag, 156 160 faHeart, 157 161 fasHeart, 158 162 faHouse, ··· 172 176 faPen, 173 177 faPenNib, 174 178 faPenToSquare, 179 + faPhone, 175 180 faPlay, 176 181 faPlus, 177 182 faQuoteLeft, ··· 179 184 faRetweet, 180 185 faRss, 181 186 faSatelliteDish, 187 + faServer, 182 188 faShare, 183 189 faShareFromSquare, 184 190 faShield,
+9 -4
yarn.lock
··· 48 48 typed-emitter "^2.1.0" 49 49 zod "^3.21.4" 50 50 51 - "@atproto/api@^0.8.0": 52 - version "0.8.0" 53 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.8.0.tgz#57ef1f6292d05ba851e3acec575139cfc4fd7a7a" 54 - integrity sha512-FgPOoij/PAEa0YoLKqj5NFYBvysdyb13gtS2XpJOdIvUZ2KehMlTrtj7g0AR78pRfME2jJjIgmAw6qpmSsjSTw== 51 + "@atproto/api@^0.9.1": 52 + version "0.9.1" 53 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.1.tgz#0b28baefa4af32bc4c05715b8641656f332546c6" 54 + integrity sha512-DHPc/dGgpf8sgPlfR9meIAk7s4YMll0g7HTq/W/LeaaaY0T6d3ZAtrgvjIU1aKCp5WNzTfzrmz0LIHIX46FHHw== 55 55 dependencies: 56 56 "@atproto/common-web" "^0.2.3" 57 57 "@atproto/lexicon" "^0.3.1" ··· 15074 15074 dependencies: 15075 15075 prelude-ls "^1.2.1" 15076 15076 type-check "~0.4.0" 15077 + 15078 + libphonenumber-js@^1.10.53: 15079 + version "1.10.53" 15080 + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.53.tgz#8dbfe1355ef1a3d8e13b8d92849f7db7ebddc98f" 15081 + integrity sha512-sDTnnqlWK4vH4AlDQuswz3n4Hx7bIQWTpIcScJX+Sp7St3LXHmfiax/ZFfyYxHmkdCvydOLSuvtAO/XpXiSySw== 15077 15082 15078 15083 lie@3.1.1: 15079 15084 version "3.1.1"