diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-11-16 11:16:31 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-16 11:16:31 -0800 |
commit | e637798e05ba3bfc1c78be1b0f70e8b0ac22554d (patch) | |
tree | 61d60e597d406744125c4dcf71a6c63cebc3a47b | |
parent | 9f7a162a96200aaca0512765eff938a88c84d6d6 (diff) | |
download | voidsky-e637798e05ba3bfc1c78be1b0f70e8b0ac22554d.tar.zst |
Refactor account-creation to use react-query and a reducer (react-query refactor) (#1931)
* Refactor account-creation to use react-query and a reducer * Add translations * Missing translate
-rw-r--r-- | src/lib/constants.ts | 8 | ||||
-rw-r--r-- | src/state/models/ui/create-account.ts | 223 | ||||
-rw-r--r-- | src/state/queries/service.ts | 20 | ||||
-rw-r--r-- | src/view/com/auth/create/CreateAccount.tsx | 103 | ||||
-rw-r--r-- | src/view/com/auth/create/Policies.tsx | 2 | ||||
-rw-r--r-- | src/view/com/auth/create/Step1.tsx | 52 | ||||
-rw-r--r-- | src/view/com/auth/create/Step2.tsx | 43 | ||||
-rw-r--r-- | src/view/com/auth/create/Step3.tsx | 23 | ||||
-rw-r--r-- | src/view/com/auth/create/state.ts | 242 | ||||
-rw-r--r-- | src/view/com/modals/ChangeHandle.tsx | 4 |
10 files changed, 383 insertions, 337 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 89c441e98..f8f651305 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,10 @@ -import {Insets} from 'react-native' +import {Insets, Platform} from 'react-native' + +export const LOCAL_DEV_SERVICE = + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' +export const STAGING_SERVICE = 'https://staging.bsky.dev' +export const PROD_SERVICE = 'https://bsky.social' +export const DEFAULT_SERVICE = PROD_SERVICE const HELP_DESK_LANG = 'en-us' export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts deleted file mode 100644 index 60f4fc184..000000000 --- a/src/state/models/ui/create-account.ts +++ /dev/null @@ -1,223 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {ServiceDescription} from '../session' -import {DEFAULT_SERVICE} from 'state/index' -import {ComAtprotoServerCreateAccount} from '@atproto/api' -import * as EmailValidator from 'email-validator' -import {createFullHandle} from 'lib/strings/handles' -import {cleanError} from 'lib/strings/errors' -import {getAge} from 'lib/strings/time' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' -import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' -import {ApiContext as SessionApiContext} from '#/state/session' - -const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago - -export class CreateAccountModel { - step: number = 1 - isProcessing = false - isFetchingServiceDescription = false - didServiceDescriptionFetchFail = false - error = '' - - serviceUrl = DEFAULT_SERVICE - serviceDescription: ServiceDescription | undefined = undefined - userDomain = '' - inviteCode = '' - email = '' - password = '' - handle = '' - birthDate = DEFAULT_DATE - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {}, {autoBind: true}) - } - - get isAge13() { - return getAge(this.birthDate) >= 13 - } - - get isAge18() { - return getAge(this.birthDate) >= 18 - } - - // form state controls - // = - - next() { - this.error = '' - if (this.step === 2) { - if (!this.isAge13) { - this.error = - 'Unfortunately, you do not meet the requirements to create an account.' - return - } - } - this.step++ - } - - back() { - this.error = '' - this.step-- - } - - setStep(v: number) { - this.step = v - } - - async fetchServiceDescription() { - this.setError('') - this.setIsFetchingServiceDescription(true) - this.setDidServiceDescriptionFetchFail(false) - this.setServiceDescription(undefined) - if (!this.serviceUrl) { - return - } - try { - const desc = await this.rootStore.session.describeService(this.serviceUrl) - this.setServiceDescription(desc) - this.setUserDomain(desc.availableUserDomains[0]) - } catch (err: any) { - logger.warn( - `Failed to fetch service description for ${this.serviceUrl}`, - {error: err}, - ) - this.setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - this.setDidServiceDescriptionFetchFail(true) - } finally { - this.setIsFetchingServiceDescription(false) - } - } - - async submit({ - createAccount, - onboardingDispatch, - }: { - createAccount: SessionApiContext['createAccount'] - onboardingDispatch: OnboardingDispatchContext - }) { - if (!this.email) { - this.setStep(2) - return this.setError('Please enter your email.') - } - if (!EmailValidator.validate(this.email)) { - this.setStep(2) - return this.setError('Your email appears to be invalid.') - } - if (!this.password) { - this.setStep(2) - return this.setError('Please choose your password.') - } - if (!this.handle) { - this.setStep(3) - return this.setError('Please choose your handle.') - } - this.setError('') - this.setIsProcessing(true) - - try { - onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view - await createAccount({ - service: this.serviceUrl, - email: this.email, - handle: createFullHandle(this.handle, this.userDomain), - password: this.password, - inviteCode: this.inviteCode.trim(), - }) - track('Create Account') - } catch (e: any) { - onboardingDispatch({type: 'skip'}) // undo starting the onboard - let errMsg = e.toString() - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { - errMsg = - 'Invite code not accepted. Check that you input it correctly and try again.' - } - logger.error('Failed to create account', {error: e}) - this.setIsProcessing(false) - this.setError(cleanError(errMsg)) - throw e - } - } - - // form state accessors - // = - - get canBack() { - return this.step > 1 - } - - get canNext() { - if (this.step === 1) { - return !!this.serviceDescription - } else if (this.step === 2) { - return ( - (!this.isInviteCodeRequired || this.inviteCode) && - !!this.email && - !!this.password - ) - } - return !!this.handle - } - - get isServiceDescribed() { - return !!this.serviceDescription - } - - get isInviteCodeRequired() { - return this.serviceDescription?.inviteCodeRequired - } - - // setters - // = - - setIsProcessing(v: boolean) { - this.isProcessing = v - } - - setIsFetchingServiceDescription(v: boolean) { - this.isFetchingServiceDescription = v - } - - setDidServiceDescriptionFetchFail(v: boolean) { - this.didServiceDescriptionFetchFail = v - } - - setError(v: string) { - this.error = v - } - - setServiceUrl(v: string) { - this.serviceUrl = v - } - - setServiceDescription(v: ServiceDescription | undefined) { - this.serviceDescription = v - } - - setUserDomain(v: string) { - this.userDomain = v - } - - setInviteCode(v: string) { - this.inviteCode = v - } - - setEmail(v: string) { - this.email = v - } - - setPassword(v: string) { - this.password = v - } - - setHandle(v: string) { - this.handle = v - } - - setBirthDate(v: Date) { - this.birthDate = v - } -} diff --git a/src/state/queries/service.ts b/src/state/queries/service.ts index df12d6cbc..5f7e10778 100644 --- a/src/state/queries/service.ts +++ b/src/state/queries/service.ts @@ -1,16 +1,26 @@ +import {BskyAgent} from '@atproto/api' import {useQuery} from '@tanstack/react-query' -import {useSession} from '#/state/session' - export const RQKEY = (serviceUrl: string) => ['service', serviceUrl] -export function useServiceQuery() { - const {agent} = useSession() +export function useServiceQuery(serviceUrl: string) { return useQuery({ - queryKey: RQKEY(agent.service.toString()), + queryKey: RQKEY(serviceUrl), queryFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) const res = await agent.com.atproto.server.describeServer() return res.data }, + enabled: isValidUrl(serviceUrl), }) } + +function isValidUrl(url: string) { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const urlp = new URL(url) + return true + } catch { + return false + } +} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 0f3ff41af..ab6d34584 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -7,18 +7,17 @@ import { TouchableOpacity, View, } from 'react-native' -import {observer} from 'mobx-react-lite' 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 {useStores} from 'state/index' -import {CreateAccountModel} from 'state/models/ui/create-account' 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, @@ -30,16 +29,11 @@ import {Step1} from './Step1' import {Step2} from './Step2' import {Step3} from './Step3' -export const CreateAccount = observer(function CreateAccountImpl({ - onPressBack, -}: { - onPressBack: () => void -}) { +export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {track, screen} = useAnalytics() const pal = usePalette('default') - const store = useStores() - const model = React.useMemo(() => new CreateAccountModel(store), [store]) const {_} = useLingui() + const [uiState, uiDispatch] = useCreateAccount() const onboardingDispatch = useOnboardingDispatch() const {createAccount} = useSessionApi() const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() @@ -49,39 +43,59 @@ export const CreateAccount = observer(function CreateAccountImpl({ screen('CreateAccount') }, [screen]) + // fetch service info + // = + + const { + data: serviceInfo, + isFetching: serviceInfoIsFetching, + error: serviceInfoError, + refetch: refetchServiceInfo, + } = useServiceQuery(uiState.serviceUrl) + React.useEffect(() => { - model.fetchServiceDescription() - }, [model]) + 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]) - const onPressRetryConnect = React.useCallback( - () => model.fetchServiceDescription(), - [model], - ) + // event handlers + // = const onPressBackInner = React.useCallback(() => { - if (model.canBack) { - model.back() + if (uiState.canBack) { + uiDispatch({type: 'back'}) } else { onPressBack() } - }, [model, onPressBack]) + }, [uiState, uiDispatch, onPressBack]) const onPressNext = React.useCallback(async () => { - if (!model.canNext) { + if (!uiState.canNext) { return } - if (model.step < 3) { - model.next() + if (uiState.step < 3) { + uiDispatch({type: 'next'}) } else { try { - await model.submit({ + await submit({ onboardingDispatch, createAccount, + uiState, + uiDispatch, + _, }) - - setBirthDate({birthDate: model.birthDate}) - - if (IS_PROD(model.serviceUrl)) { + track('Create Account') + setBirthDate({birthDate: uiState.birthDate}) + if (IS_PROD(uiState.serviceUrl)) { setSavedFeeds(DEFAULT_PROD_FEEDS) } } catch { @@ -91,25 +105,36 @@ export const CreateAccount = observer(function CreateAccountImpl({ } } }, [ - model, + uiState, + uiDispatch, track, onboardingDispatch, createAccount, setBirthDate, setSavedFeeds, + _, ]) + // rendering + // = + return ( <LoggedOutLayout - leadin={`Step ${model.step}`} + leadin={`Step ${uiState.step}`} title={_(msg`Create Account`)} description={_(msg`We're so excited to have you join us!`)}> <ScrollView testID="createAccount" style={pal.view}> <KeyboardAvoidingView behavior="padding"> <View style={styles.stepContainer}> - {model.step === 1 && <Step1 model={model} />} - {model.step === 2 && <Step2 model={model} />} - {model.step === 3 && <Step3 model={model} />} + {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 @@ -121,12 +146,12 @@ export const CreateAccount = observer(function CreateAccountImpl({ </Text> </TouchableOpacity> <View style={s.flex1} /> - {model.canNext ? ( + {uiState.canNext ? ( <TouchableOpacity testID="nextBtn" onPress={onPressNext} accessibilityRole="button"> - {model.isProcessing ? ( + {uiState.isProcessing ? ( <ActivityIndicator /> ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> @@ -134,19 +159,19 @@ export const CreateAccount = observer(function CreateAccountImpl({ </Text> )} </TouchableOpacity> - ) : model.didServiceDescriptionFetchFail ? ( + ) : serviceInfoError ? ( <TouchableOpacity testID="retryConnectBtn" - onPress={onPressRetryConnect} + onPress={() => refetchServiceInfo()} accessibilityRole="button" accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries account creation" + accessibilityHint="" accessibilityLiveRegion="polite"> <Text type="xl-bold" style={[pal.link, s.pr5]}> <Trans>Retry</Trans> </Text> </TouchableOpacity> - ) : model.isFetchingServiceDescription ? ( + ) : serviceInfoIsFetching ? ( <> <ActivityIndicator color="#fff" /> <Text type="xl" style={[pal.text, s.pr5]}> @@ -160,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ </ScrollView> </LoggedOutLayout> ) -}) +} const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx index 8eb669bcf..7d10f32fc 100644 --- a/src/view/com/auth/create/Policies.tsx +++ b/src/view/com/auth/create/Policies.tsx @@ -93,7 +93,7 @@ function validWebLink(url?: string): string | undefined { const styles = StyleSheet.create({ policies: { - flexDirection: 'row', + flexDirection: 'column', gap: 8, }, errorIcon: { diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 7e3ea062d..ab47b411f 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -1,10 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' @@ -22,10 +20,12 @@ import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' * @field Bluesky (default) * @field Other (staging, local dev, your own PDS, etc.) */ -export const Step1 = observer(function Step1Impl({ - model, +export function Step1({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) @@ -33,35 +33,19 @@ export const Step1 = observer(function Step1Impl({ const onPressDefault = React.useCallback(() => { setIsDefaultSelected(true) - model.setServiceUrl(PROD_SERVICE) - model.fetchServiceDescription() - }, [setIsDefaultSelected, model]) + uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) + }, [setIsDefaultSelected, uiDispatch]) const onPressOther = React.useCallback(() => { setIsDefaultSelected(false) - model.setServiceUrl('https://') - model.setServiceDescription(undefined) - }, [setIsDefaultSelected, model]) - - const fetchServiceDescription = React.useMemo( - () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms) - [model], - ) + uiDispatch({type: 'set-service-url', value: 'https://'}) + }, [setIsDefaultSelected, uiDispatch]) const onChangeServiceUrl = React.useCallback( (v: string) => { - model.setServiceUrl(v) - fetchServiceDescription() - }, - [model, fetchServiceDescription], - ) - - const onDebugChangeServiceUrl = React.useCallback( - (v: string) => { - model.setServiceUrl(v) - model.fetchServiceDescription() + uiDispatch({type: 'set-service-url', value: v}) }, - [model], + [uiDispatch], ) return ( @@ -90,7 +74,7 @@ export const Step1 = observer(function Step1Impl({ testID="customServerInput" icon="globe" placeholder={_(msg`Hosting provider address`)} - value={model.serviceUrl} + value={uiState.serviceUrl} editable onChange={onChangeServiceUrl} accessibilityHint="Input hosting provider address" @@ -104,26 +88,26 @@ export const Step1 = observer(function Step1Impl({ type="default" style={s.mr5} label={_(msg`Staging`)} - onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} + onPress={() => onChangeServiceUrl(STAGING_SERVICE)} /> <Button testID="localDevServerBtn" type="default" label={_(msg`Dev Server`)} - onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} + onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} /> </View> )} </View> </Option> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : ( <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> )} </View> ) -}) +} function Option({ children, diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 3cc8ae934..89fd070ad 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +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' @@ -24,10 +23,12 @@ import {useModalControls} from '#/state/modals' * @field Birth date * @readonly Terms of service & privacy policy */ -export const Step2 = observer(function Step2Impl({ - model, +export function Step2({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') const {_} = useLingui() @@ -41,7 +42,7 @@ export const Step2 = observer(function Step2Impl({ <View> <StepHeader step="2" title={_(msg`Your account`)} /> - {model.isInviteCodeRequired && ( + {uiState.isInviteCodeRequired && ( <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]}> Invite code @@ -50,16 +51,16 @@ export const Step2 = observer(function Step2Impl({ testID="inviteCodeInput" icon="ticket" placeholder={_(msg`Required for this provider`)} - value={model.inviteCode} + value={uiState.inviteCode} editable - onChange={model.setInviteCode} + onChange={value => uiDispatch({type: 'set-invite-code', value})} accessibilityLabel={_(msg`Invite code`)} accessibilityHint="Input invite code to proceed" /> </View> )} - {!model.inviteCode && model.isInviteCodeRequired ? ( + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( <Text style={[s.alignBaseline, pal.text]}> Don't have an invite code?{' '} <TouchableWithoutFeedback @@ -83,9 +84,9 @@ export const Step2 = observer(function Step2Impl({ testID="emailInput" icon="envelope" placeholder={_(msg`Enter your email address`)} - value={model.email} + value={uiState.email} editable - onChange={model.setEmail} + onChange={value => uiDispatch({type: 'set-email', value})} accessibilityLabel={_(msg`Email`)} accessibilityHint="Input email for Bluesky waitlist" accessibilityLabelledBy="email" @@ -103,10 +104,10 @@ export const Step2 = observer(function Step2Impl({ testID="passwordInput" icon="lock" placeholder={_(msg`Choose your password`)} - value={model.password} + value={uiState.password} editable secureTextEntry - onChange={model.setPassword} + onChange={value => uiDispatch({type: 'set-password', value})} accessibilityLabel={_(msg`Password`)} accessibilityHint="Set password" accessibilityLabelledBy="password" @@ -122,8 +123,8 @@ export const Step2 = observer(function Step2Impl({ </Text> <DateInput testID="birthdayInput" - value={model.birthDate} - onChange={model.setBirthDate} + value={uiState.birthDate} + onChange={value => uiDispatch({type: 'set-birth-date', value})} buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" @@ -133,20 +134,20 @@ export const Step2 = observer(function Step2Impl({ /> </View> - {model.serviceDescription && ( + {uiState.serviceDescription && ( <Policies - serviceDescription={model.serviceDescription} - needsGuardian={!model.isAge18} + serviceDescription={uiState.serviceDescription} + needsGuardian={!is18(uiState)} /> )} </> )} - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index 09fba0714..3b628b6b6 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' import {s} from 'lib/styles' @@ -15,10 +14,12 @@ import {useLingui} from '@lingui/react' /** STEP 3: Your user handle * @field User handle */ -export const Step3 = observer(function Step3Impl({ - model, +export function Step3({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') const {_} = useLingui() @@ -30,9 +31,9 @@ export const Step3 = observer(function Step3Impl({ testID="handleInput" icon="at" placeholder="e.g. alice" - value={model.handle} + value={uiState.handle} editable - onChange={model.setHandle} + onChange={value => uiDispatch({type: 'set-handle', value})} // TODO: Add explicit text label accessibilityLabel={_(msg`User handle`)} accessibilityHint="Input your user handle" @@ -40,16 +41,16 @@ export const Step3 = observer(function Step3Impl({ <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> <Trans>Your full handle will be</Trans> <Text type="lg-bold" style={[pal.text, s.ml5]}> - @{createFullHandle(model.handle, model.userDomain)} + @{createFullHandle(uiState.handle, uiState.userDomain)} </Text> </Text> </View> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts new file mode 100644 index 000000000..4df82f8fc --- /dev/null +++ b/src/view/com/auth/create/state.ts @@ -0,0 +1,242 @@ +import {useReducer} from 'react' +import { + ComAtprotoServerDescribeServer, + ComAtprotoServerCreateAccount, +} 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' + +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-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 + handle: string + birthDate: Date + + // computed + canBack: boolean + canNext: boolean + isInviteCodeRequired: 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: '', + handle: '', + birthDate: DEFAULT_DATE, + + canBack: false, + canNext: false, + isInviteCodeRequired: 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: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(uiState.email)) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!uiState.password) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your password.`), + }) + } + 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(), + }) + } 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.`, + ) + } + logger.error('Failed to create account', {error: 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) >= 18 +} + +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-handle': { + return compute({...state, handle: action.value}) + } + case 'set-birth-date': { + return compute({...state, birthDate: action.value}) + } + case 'next': { + if (state.step === 2) { + if (!is13(state)) { + return compute({ + ...state, + error: _( + msg`Unfortunately, you do not meet the requirements to create an account.`, + ), + }) + } + } + return compute({...state, error: '', step: state.step + 1}) + } + case 'back': { + return compute({...state, error: '', step: state.step - 1}) + } + } + } +} + +function compute(state: CreateAccountState): CreateAccountState { + let canNext = true + if (state.step === 1) { + canNext = !!state.serviceDescription + } else if (state.step === 2) { + canNext = + (!state.isInviteCodeRequired || !!state.inviteCode) && + !!state.email && + !!state.password + } else if (state.step === 3) { + canNext = !!state.handle + } + return { + ...state, + canBack: state.step > 1, + canNext, + isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, + } +} diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index 1a259b85e..da814b3d4 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -33,12 +33,12 @@ export const snapPoints = ['100%'] export type Props = {onChanged: () => void} export function Component(props: Props) { - const {currentAccount} = useSession() + const {agent, currentAccount} = useSession() const { isLoading, data: serviceInfo, error: serviceInfoError, - } = useServiceQuery() + } = useServiceQuery(agent.service.toString()) return isLoading || !currentAccount ? ( <View style={{padding: 18}}> |