Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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

authored by

Paul Frazee and committed by
GitHub
e637798e 9f7a162a

+383 -337
+7 -1
src/lib/constants.ts
··· 1 - import {Insets} from 'react-native' 1 + import {Insets, Platform} from 'react-native' 2 + 3 + export const LOCAL_DEV_SERVICE = 4 + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' 5 + export const STAGING_SERVICE = 'https://staging.bsky.dev' 6 + export const PROD_SERVICE = 'https://bsky.social' 7 + export const DEFAULT_SERVICE = PROD_SERVICE 2 8 3 9 const HELP_DESK_LANG = 'en-us' 4 10 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
-223
src/state/models/ui/create-account.ts
··· 1 - import {makeAutoObservable} from 'mobx' 2 - import {RootStoreModel} from '../root-store' 3 - import {ServiceDescription} from '../session' 4 - import {DEFAULT_SERVICE} from 'state/index' 5 - import {ComAtprotoServerCreateAccount} from '@atproto/api' 6 - import * as EmailValidator from 'email-validator' 7 - import {createFullHandle} from 'lib/strings/handles' 8 - import {cleanError} from 'lib/strings/errors' 9 - import {getAge} from 'lib/strings/time' 10 - import {track} from 'lib/analytics/analytics' 11 - import {logger} from '#/logger' 12 - import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' 13 - import {ApiContext as SessionApiContext} from '#/state/session' 14 - 15 - const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago 16 - 17 - export class CreateAccountModel { 18 - step: number = 1 19 - isProcessing = false 20 - isFetchingServiceDescription = false 21 - didServiceDescriptionFetchFail = false 22 - error = '' 23 - 24 - serviceUrl = DEFAULT_SERVICE 25 - serviceDescription: ServiceDescription | undefined = undefined 26 - userDomain = '' 27 - inviteCode = '' 28 - email = '' 29 - password = '' 30 - handle = '' 31 - birthDate = DEFAULT_DATE 32 - 33 - constructor(public rootStore: RootStoreModel) { 34 - makeAutoObservable(this, {}, {autoBind: true}) 35 - } 36 - 37 - get isAge13() { 38 - return getAge(this.birthDate) >= 13 39 - } 40 - 41 - get isAge18() { 42 - return getAge(this.birthDate) >= 18 43 - } 44 - 45 - // form state controls 46 - // = 47 - 48 - next() { 49 - this.error = '' 50 - if (this.step === 2) { 51 - if (!this.isAge13) { 52 - this.error = 53 - 'Unfortunately, you do not meet the requirements to create an account.' 54 - return 55 - } 56 - } 57 - this.step++ 58 - } 59 - 60 - back() { 61 - this.error = '' 62 - this.step-- 63 - } 64 - 65 - setStep(v: number) { 66 - this.step = v 67 - } 68 - 69 - async fetchServiceDescription() { 70 - this.setError('') 71 - this.setIsFetchingServiceDescription(true) 72 - this.setDidServiceDescriptionFetchFail(false) 73 - this.setServiceDescription(undefined) 74 - if (!this.serviceUrl) { 75 - return 76 - } 77 - try { 78 - const desc = await this.rootStore.session.describeService(this.serviceUrl) 79 - this.setServiceDescription(desc) 80 - this.setUserDomain(desc.availableUserDomains[0]) 81 - } catch (err: any) { 82 - logger.warn( 83 - `Failed to fetch service description for ${this.serviceUrl}`, 84 - {error: err}, 85 - ) 86 - this.setError( 87 - 'Unable to contact your service. Please check your Internet connection.', 88 - ) 89 - this.setDidServiceDescriptionFetchFail(true) 90 - } finally { 91 - this.setIsFetchingServiceDescription(false) 92 - } 93 - } 94 - 95 - async submit({ 96 - createAccount, 97 - onboardingDispatch, 98 - }: { 99 - createAccount: SessionApiContext['createAccount'] 100 - onboardingDispatch: OnboardingDispatchContext 101 - }) { 102 - if (!this.email) { 103 - this.setStep(2) 104 - return this.setError('Please enter your email.') 105 - } 106 - if (!EmailValidator.validate(this.email)) { 107 - this.setStep(2) 108 - return this.setError('Your email appears to be invalid.') 109 - } 110 - if (!this.password) { 111 - this.setStep(2) 112 - return this.setError('Please choose your password.') 113 - } 114 - if (!this.handle) { 115 - this.setStep(3) 116 - return this.setError('Please choose your handle.') 117 - } 118 - this.setError('') 119 - this.setIsProcessing(true) 120 - 121 - try { 122 - onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view 123 - await createAccount({ 124 - service: this.serviceUrl, 125 - email: this.email, 126 - handle: createFullHandle(this.handle, this.userDomain), 127 - password: this.password, 128 - inviteCode: this.inviteCode.trim(), 129 - }) 130 - track('Create Account') 131 - } catch (e: any) { 132 - onboardingDispatch({type: 'skip'}) // undo starting the onboard 133 - let errMsg = e.toString() 134 - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { 135 - errMsg = 136 - 'Invite code not accepted. Check that you input it correctly and try again.' 137 - } 138 - logger.error('Failed to create account', {error: e}) 139 - this.setIsProcessing(false) 140 - this.setError(cleanError(errMsg)) 141 - throw e 142 - } 143 - } 144 - 145 - // form state accessors 146 - // = 147 - 148 - get canBack() { 149 - return this.step > 1 150 - } 151 - 152 - get canNext() { 153 - if (this.step === 1) { 154 - return !!this.serviceDescription 155 - } else if (this.step === 2) { 156 - return ( 157 - (!this.isInviteCodeRequired || this.inviteCode) && 158 - !!this.email && 159 - !!this.password 160 - ) 161 - } 162 - return !!this.handle 163 - } 164 - 165 - get isServiceDescribed() { 166 - return !!this.serviceDescription 167 - } 168 - 169 - get isInviteCodeRequired() { 170 - return this.serviceDescription?.inviteCodeRequired 171 - } 172 - 173 - // setters 174 - // = 175 - 176 - setIsProcessing(v: boolean) { 177 - this.isProcessing = v 178 - } 179 - 180 - setIsFetchingServiceDescription(v: boolean) { 181 - this.isFetchingServiceDescription = v 182 - } 183 - 184 - setDidServiceDescriptionFetchFail(v: boolean) { 185 - this.didServiceDescriptionFetchFail = v 186 - } 187 - 188 - setError(v: string) { 189 - this.error = v 190 - } 191 - 192 - setServiceUrl(v: string) { 193 - this.serviceUrl = v 194 - } 195 - 196 - setServiceDescription(v: ServiceDescription | undefined) { 197 - this.serviceDescription = v 198 - } 199 - 200 - setUserDomain(v: string) { 201 - this.userDomain = v 202 - } 203 - 204 - setInviteCode(v: string) { 205 - this.inviteCode = v 206 - } 207 - 208 - setEmail(v: string) { 209 - this.email = v 210 - } 211 - 212 - setPassword(v: string) { 213 - this.password = v 214 - } 215 - 216 - setHandle(v: string) { 217 - this.handle = v 218 - } 219 - 220 - setBirthDate(v: Date) { 221 - this.birthDate = v 222 - } 223 - }
+15 -5
src/state/queries/service.ts
··· 1 + import {BskyAgent} from '@atproto/api' 1 2 import {useQuery} from '@tanstack/react-query' 2 - 3 - import {useSession} from '#/state/session' 4 3 5 4 export const RQKEY = (serviceUrl: string) => ['service', serviceUrl] 6 5 7 - export function useServiceQuery() { 8 - const {agent} = useSession() 6 + export function useServiceQuery(serviceUrl: string) { 9 7 return useQuery({ 10 - queryKey: RQKEY(agent.service.toString()), 8 + queryKey: RQKEY(serviceUrl), 11 9 queryFn: async () => { 10 + const agent = new BskyAgent({service: serviceUrl}) 12 11 const res = await agent.com.atproto.server.describeServer() 13 12 return res.data 14 13 }, 14 + enabled: isValidUrl(serviceUrl), 15 15 }) 16 16 } 17 + 18 + function isValidUrl(url: string) { 19 + try { 20 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 + const urlp = new URL(url) 22 + return true 23 + } catch { 24 + return false 25 + } 26 + }
+64 -39
src/view/com/auth/create/CreateAccount.tsx
··· 7 7 TouchableOpacity, 8 8 View, 9 9 } from 'react-native' 10 - import {observer} from 'mobx-react-lite' 11 10 import {useAnalytics} from 'lib/analytics/analytics' 12 11 import {Text} from '../../util/text/Text' 13 12 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' 14 13 import {s} from 'lib/styles' 15 - import {useStores} from 'state/index' 16 - import {CreateAccountModel} from 'state/models/ui/create-account' 17 14 import {usePalette} from 'lib/hooks/usePalette' 18 15 import {msg, Trans} from '@lingui/macro' 19 16 import {useLingui} from '@lingui/react' 20 17 import {useOnboardingDispatch} from '#/state/shell' 21 18 import {useSessionApi} from '#/state/session' 19 + import {useCreateAccount, submit} from './state' 20 + import {useServiceQuery} from '#/state/queries/service' 22 21 import { 23 22 usePreferencesSetBirthDateMutation, 24 23 useSetSaveFeedsMutation, ··· 30 29 import {Step2} from './Step2' 31 30 import {Step3} from './Step3' 32 31 33 - export const CreateAccount = observer(function CreateAccountImpl({ 34 - onPressBack, 35 - }: { 36 - onPressBack: () => void 37 - }) { 32 + export function CreateAccount({onPressBack}: {onPressBack: () => void}) { 38 33 const {track, screen} = useAnalytics() 39 34 const pal = usePalette('default') 40 - const store = useStores() 41 - const model = React.useMemo(() => new CreateAccountModel(store), [store]) 42 35 const {_} = useLingui() 36 + const [uiState, uiDispatch] = useCreateAccount() 43 37 const onboardingDispatch = useOnboardingDispatch() 44 38 const {createAccount} = useSessionApi() 45 39 const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() ··· 49 43 screen('CreateAccount') 50 44 }, [screen]) 51 45 46 + // fetch service info 47 + // = 48 + 49 + const { 50 + data: serviceInfo, 51 + isFetching: serviceInfoIsFetching, 52 + error: serviceInfoError, 53 + refetch: refetchServiceInfo, 54 + } = useServiceQuery(uiState.serviceUrl) 55 + 52 56 React.useEffect(() => { 53 - model.fetchServiceDescription() 54 - }, [model]) 57 + if (serviceInfo) { 58 + uiDispatch({type: 'set-service-description', value: serviceInfo}) 59 + uiDispatch({type: 'set-error', value: ''}) 60 + } else if (serviceInfoError) { 61 + uiDispatch({ 62 + type: 'set-error', 63 + value: _( 64 + msg`Unable to contact your service. Please check your Internet connection.`, 65 + ), 66 + }) 67 + } 68 + }, [_, uiDispatch, serviceInfo, serviceInfoError]) 55 69 56 - const onPressRetryConnect = React.useCallback( 57 - () => model.fetchServiceDescription(), 58 - [model], 59 - ) 70 + // event handlers 71 + // = 60 72 61 73 const onPressBackInner = React.useCallback(() => { 62 - if (model.canBack) { 63 - model.back() 74 + if (uiState.canBack) { 75 + uiDispatch({type: 'back'}) 64 76 } else { 65 77 onPressBack() 66 78 } 67 - }, [model, onPressBack]) 79 + }, [uiState, uiDispatch, onPressBack]) 68 80 69 81 const onPressNext = React.useCallback(async () => { 70 - if (!model.canNext) { 82 + if (!uiState.canNext) { 71 83 return 72 84 } 73 - if (model.step < 3) { 74 - model.next() 85 + if (uiState.step < 3) { 86 + uiDispatch({type: 'next'}) 75 87 } else { 76 88 try { 77 - await model.submit({ 89 + await submit({ 78 90 onboardingDispatch, 79 91 createAccount, 92 + uiState, 93 + uiDispatch, 94 + _, 80 95 }) 81 - 82 - setBirthDate({birthDate: model.birthDate}) 83 - 84 - if (IS_PROD(model.serviceUrl)) { 96 + track('Create Account') 97 + setBirthDate({birthDate: uiState.birthDate}) 98 + if (IS_PROD(uiState.serviceUrl)) { 85 99 setSavedFeeds(DEFAULT_PROD_FEEDS) 86 100 } 87 101 } catch { ··· 91 105 } 92 106 } 93 107 }, [ 94 - model, 108 + uiState, 109 + uiDispatch, 95 110 track, 96 111 onboardingDispatch, 97 112 createAccount, 98 113 setBirthDate, 99 114 setSavedFeeds, 115 + _, 100 116 ]) 101 117 118 + // rendering 119 + // = 120 + 102 121 return ( 103 122 <LoggedOutLayout 104 - leadin={`Step ${model.step}`} 123 + leadin={`Step ${uiState.step}`} 105 124 title={_(msg`Create Account`)} 106 125 description={_(msg`We're so excited to have you join us!`)}> 107 126 <ScrollView testID="createAccount" style={pal.view}> 108 127 <KeyboardAvoidingView behavior="padding"> 109 128 <View style={styles.stepContainer}> 110 - {model.step === 1 && <Step1 model={model} />} 111 - {model.step === 2 && <Step2 model={model} />} 112 - {model.step === 3 && <Step3 model={model} />} 129 + {uiState.step === 1 && ( 130 + <Step1 uiState={uiState} uiDispatch={uiDispatch} /> 131 + )} 132 + {uiState.step === 2 && ( 133 + <Step2 uiState={uiState} uiDispatch={uiDispatch} /> 134 + )} 135 + {uiState.step === 3 && ( 136 + <Step3 uiState={uiState} uiDispatch={uiDispatch} /> 137 + )} 113 138 </View> 114 139 <View style={[s.flexRow, s.pl20, s.pr20]}> 115 140 <TouchableOpacity ··· 121 146 </Text> 122 147 </TouchableOpacity> 123 148 <View style={s.flex1} /> 124 - {model.canNext ? ( 149 + {uiState.canNext ? ( 125 150 <TouchableOpacity 126 151 testID="nextBtn" 127 152 onPress={onPressNext} 128 153 accessibilityRole="button"> 129 - {model.isProcessing ? ( 154 + {uiState.isProcessing ? ( 130 155 <ActivityIndicator /> 131 156 ) : ( 132 157 <Text type="xl-bold" style={[pal.link, s.pr5]}> ··· 134 159 </Text> 135 160 )} 136 161 </TouchableOpacity> 137 - ) : model.didServiceDescriptionFetchFail ? ( 162 + ) : serviceInfoError ? ( 138 163 <TouchableOpacity 139 164 testID="retryConnectBtn" 140 - onPress={onPressRetryConnect} 165 + onPress={() => refetchServiceInfo()} 141 166 accessibilityRole="button" 142 167 accessibilityLabel={_(msg`Retry`)} 143 - accessibilityHint="Retries account creation" 168 + accessibilityHint="" 144 169 accessibilityLiveRegion="polite"> 145 170 <Text type="xl-bold" style={[pal.link, s.pr5]}> 146 171 <Trans>Retry</Trans> 147 172 </Text> 148 173 </TouchableOpacity> 149 - ) : model.isFetchingServiceDescription ? ( 174 + ) : serviceInfoIsFetching ? ( 150 175 <> 151 176 <ActivityIndicator color="#fff" /> 152 177 <Text type="xl" style={[pal.text, s.pr5]}> ··· 160 185 </ScrollView> 161 186 </LoggedOutLayout> 162 187 ) 163 - }) 188 + } 164 189 165 190 const styles = StyleSheet.create({ 166 191 stepContainer: {
+1 -1
src/view/com/auth/create/Policies.tsx
··· 93 93 94 94 const styles = StyleSheet.create({ 95 95 policies: { 96 - flexDirection: 'row', 96 + flexDirection: 'column', 97 97 gap: 8, 98 98 }, 99 99 errorIcon: {
+18 -34
src/view/com/auth/create/Step1.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import debounce from 'lodash.debounce' 5 3 import {Text} from 'view/com/util/text/Text' 6 4 import {StepHeader} from './StepHeader' 7 - import {CreateAccountModel} from 'state/models/ui/create-account' 5 + import {CreateAccountState, CreateAccountDispatch} from './state' 8 6 import {useTheme} from 'lib/ThemeContext' 9 7 import {usePalette} from 'lib/hooks/usePalette' 10 8 import {s} from 'lib/styles' ··· 22 20 * @field Bluesky (default) 23 21 * @field Other (staging, local dev, your own PDS, etc.) 24 22 */ 25 - export const Step1 = observer(function Step1Impl({ 26 - model, 23 + export function Step1({ 24 + uiState, 25 + uiDispatch, 27 26 }: { 28 - model: CreateAccountModel 27 + uiState: CreateAccountState 28 + uiDispatch: CreateAccountDispatch 29 29 }) { 30 30 const pal = usePalette('default') 31 31 const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) ··· 33 33 34 34 const onPressDefault = React.useCallback(() => { 35 35 setIsDefaultSelected(true) 36 - model.setServiceUrl(PROD_SERVICE) 37 - model.fetchServiceDescription() 38 - }, [setIsDefaultSelected, model]) 36 + uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) 37 + }, [setIsDefaultSelected, uiDispatch]) 39 38 40 39 const onPressOther = React.useCallback(() => { 41 40 setIsDefaultSelected(false) 42 - model.setServiceUrl('https://') 43 - model.setServiceDescription(undefined) 44 - }, [setIsDefaultSelected, model]) 45 - 46 - const fetchServiceDescription = React.useMemo( 47 - () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms) 48 - [model], 49 - ) 41 + uiDispatch({type: 'set-service-url', value: 'https://'}) 42 + }, [setIsDefaultSelected, uiDispatch]) 50 43 51 44 const onChangeServiceUrl = React.useCallback( 52 45 (v: string) => { 53 - model.setServiceUrl(v) 54 - fetchServiceDescription() 46 + uiDispatch({type: 'set-service-url', value: v}) 55 47 }, 56 - [model, fetchServiceDescription], 57 - ) 58 - 59 - const onDebugChangeServiceUrl = React.useCallback( 60 - (v: string) => { 61 - model.setServiceUrl(v) 62 - model.fetchServiceDescription() 63 - }, 64 - [model], 48 + [uiDispatch], 65 49 ) 66 50 67 51 return ( ··· 90 74 testID="customServerInput" 91 75 icon="globe" 92 76 placeholder={_(msg`Hosting provider address`)} 93 - value={model.serviceUrl} 77 + value={uiState.serviceUrl} 94 78 editable 95 79 onChange={onChangeServiceUrl} 96 80 accessibilityHint="Input hosting provider address" ··· 104 88 type="default" 105 89 style={s.mr5} 106 90 label={_(msg`Staging`)} 107 - onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} 91 + onPress={() => onChangeServiceUrl(STAGING_SERVICE)} 108 92 /> 109 93 <Button 110 94 testID="localDevServerBtn" 111 95 type="default" 112 96 label={_(msg`Dev Server`)} 113 - onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} 97 + onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} 114 98 /> 115 99 </View> 116 100 )} 117 101 </View> 118 102 </Option> 119 - {model.error ? ( 120 - <ErrorMessage message={model.error} style={styles.error} /> 103 + {uiState.error ? ( 104 + <ErrorMessage message={uiState.error} style={styles.error} /> 121 105 ) : ( 122 106 <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> 123 107 )} 124 108 </View> 125 109 ) 126 - }) 110 + } 127 111 128 112 function Option({ 129 113 children,
+22 -21
src/view/com/auth/create/Step2.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {CreateAccountModel} from 'state/models/ui/create-account' 3 + import {CreateAccountState, CreateAccountDispatch, is18} from './state' 5 4 import {Text} from 'view/com/util/text/Text' 6 5 import {DateInput} from 'view/com/util/forms/DateInput' 7 6 import {StepHeader} from './StepHeader' ··· 24 23 * @field Birth date 25 24 * @readonly Terms of service & privacy policy 26 25 */ 27 - export const Step2 = observer(function Step2Impl({ 28 - model, 26 + export function Step2({ 27 + uiState, 28 + uiDispatch, 29 29 }: { 30 - model: CreateAccountModel 30 + uiState: CreateAccountState 31 + uiDispatch: CreateAccountDispatch 31 32 }) { 32 33 const pal = usePalette('default') 33 34 const {_} = useLingui() ··· 41 42 <View> 42 43 <StepHeader step="2" title={_(msg`Your account`)} /> 43 44 44 - {model.isInviteCodeRequired && ( 45 + {uiState.isInviteCodeRequired && ( 45 46 <View style={s.pb20}> 46 47 <Text type="md-medium" style={[pal.text, s.mb2]}> 47 48 Invite code ··· 50 51 testID="inviteCodeInput" 51 52 icon="ticket" 52 53 placeholder={_(msg`Required for this provider`)} 53 - value={model.inviteCode} 54 + value={uiState.inviteCode} 54 55 editable 55 - onChange={model.setInviteCode} 56 + onChange={value => uiDispatch({type: 'set-invite-code', value})} 56 57 accessibilityLabel={_(msg`Invite code`)} 57 58 accessibilityHint="Input invite code to proceed" 58 59 /> 59 60 </View> 60 61 )} 61 62 62 - {!model.inviteCode && model.isInviteCodeRequired ? ( 63 + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( 63 64 <Text style={[s.alignBaseline, pal.text]}> 64 65 Don't have an invite code?{' '} 65 66 <TouchableWithoutFeedback ··· 83 84 testID="emailInput" 84 85 icon="envelope" 85 86 placeholder={_(msg`Enter your email address`)} 86 - value={model.email} 87 + value={uiState.email} 87 88 editable 88 - onChange={model.setEmail} 89 + onChange={value => uiDispatch({type: 'set-email', value})} 89 90 accessibilityLabel={_(msg`Email`)} 90 91 accessibilityHint="Input email for Bluesky waitlist" 91 92 accessibilityLabelledBy="email" ··· 103 104 testID="passwordInput" 104 105 icon="lock" 105 106 placeholder={_(msg`Choose your password`)} 106 - value={model.password} 107 + value={uiState.password} 107 108 editable 108 109 secureTextEntry 109 - onChange={model.setPassword} 110 + onChange={value => uiDispatch({type: 'set-password', value})} 110 111 accessibilityLabel={_(msg`Password`)} 111 112 accessibilityHint="Set password" 112 113 accessibilityLabelledBy="password" ··· 122 123 </Text> 123 124 <DateInput 124 125 testID="birthdayInput" 125 - value={model.birthDate} 126 - onChange={model.setBirthDate} 126 + value={uiState.birthDate} 127 + onChange={value => uiDispatch({type: 'set-birth-date', value})} 127 128 buttonType="default-light" 128 129 buttonStyle={[pal.border, styles.dateInputButton]} 129 130 buttonLabelType="lg" ··· 133 134 /> 134 135 </View> 135 136 136 - {model.serviceDescription && ( 137 + {uiState.serviceDescription && ( 137 138 <Policies 138 - serviceDescription={model.serviceDescription} 139 - needsGuardian={!model.isAge18} 139 + serviceDescription={uiState.serviceDescription} 140 + needsGuardian={!is18(uiState)} 140 141 /> 141 142 )} 142 143 </> 143 144 )} 144 - {model.error ? ( 145 - <ErrorMessage message={model.error} style={styles.error} /> 145 + {uiState.error ? ( 146 + <ErrorMessage message={uiState.error} style={styles.error} /> 146 147 ) : undefined} 147 148 </View> 148 149 ) 149 - }) 150 + } 150 151 151 152 const styles = StyleSheet.create({ 152 153 error: {
+12 -11
src/view/com/auth/create/Step3.tsx
··· 1 1 import React from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 - import {CreateAccountModel} from 'state/models/ui/create-account' 3 + import {CreateAccountState, CreateAccountDispatch} from './state' 5 4 import {Text} from 'view/com/util/text/Text' 6 5 import {StepHeader} from './StepHeader' 7 6 import {s} from 'lib/styles' ··· 15 14 /** STEP 3: Your user handle 16 15 * @field User handle 17 16 */ 18 - export const Step3 = observer(function Step3Impl({ 19 - model, 17 + export function Step3({ 18 + uiState, 19 + uiDispatch, 20 20 }: { 21 - model: CreateAccountModel 21 + uiState: CreateAccountState 22 + uiDispatch: CreateAccountDispatch 22 23 }) { 23 24 const pal = usePalette('default') 24 25 const {_} = useLingui() ··· 30 31 testID="handleInput" 31 32 icon="at" 32 33 placeholder="e.g. alice" 33 - value={model.handle} 34 + value={uiState.handle} 34 35 editable 35 - onChange={model.setHandle} 36 + onChange={value => uiDispatch({type: 'set-handle', value})} 36 37 // TODO: Add explicit text label 37 38 accessibilityLabel={_(msg`User handle`)} 38 39 accessibilityHint="Input your user handle" ··· 40 41 <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> 41 42 <Trans>Your full handle will be</Trans> 42 43 <Text type="lg-bold" style={[pal.text, s.ml5]}> 43 - @{createFullHandle(model.handle, model.userDomain)} 44 + @{createFullHandle(uiState.handle, uiState.userDomain)} 44 45 </Text> 45 46 </Text> 46 47 </View> 47 - {model.error ? ( 48 - <ErrorMessage message={model.error} style={styles.error} /> 48 + {uiState.error ? ( 49 + <ErrorMessage message={uiState.error} style={styles.error} /> 49 50 ) : undefined} 50 51 </View> 51 52 ) 52 - }) 53 + } 53 54 54 55 const styles = StyleSheet.create({ 55 56 error: {
+242
src/view/com/auth/create/state.ts
··· 1 + import {useReducer} from 'react' 2 + import { 3 + ComAtprotoServerDescribeServer, 4 + ComAtprotoServerCreateAccount, 5 + } from '@atproto/api' 6 + import {I18nContext, useLingui} from '@lingui/react' 7 + import {msg} from '@lingui/macro' 8 + import * as EmailValidator from 'email-validator' 9 + import {getAge} from 'lib/strings/time' 10 + import {logger} from '#/logger' 11 + import {createFullHandle} from '#/lib/strings/handles' 12 + import {cleanError} from '#/lib/strings/errors' 13 + import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' 14 + import {ApiContext as SessionApiContext} from '#/state/session' 15 + import {DEFAULT_SERVICE} from '#/lib/constants' 16 + 17 + export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 18 + const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago 19 + 20 + export type CreateAccountAction = 21 + | {type: 'set-step'; value: number} 22 + | {type: 'set-error'; value: string | undefined} 23 + | {type: 'set-processing'; value: boolean} 24 + | {type: 'set-service-url'; value: string} 25 + | {type: 'set-service-description'; value: ServiceDescription | undefined} 26 + | {type: 'set-user-domain'; value: string} 27 + | {type: 'set-invite-code'; value: string} 28 + | {type: 'set-email'; value: string} 29 + | {type: 'set-password'; value: string} 30 + | {type: 'set-handle'; value: string} 31 + | {type: 'set-birth-date'; value: Date} 32 + | {type: 'next'} 33 + | {type: 'back'} 34 + 35 + export interface CreateAccountState { 36 + // state 37 + step: number 38 + error: string | undefined 39 + isProcessing: boolean 40 + serviceUrl: string 41 + serviceDescription: ServiceDescription | undefined 42 + userDomain: string 43 + inviteCode: string 44 + email: string 45 + password: string 46 + handle: string 47 + birthDate: Date 48 + 49 + // computed 50 + canBack: boolean 51 + canNext: boolean 52 + isInviteCodeRequired: boolean 53 + } 54 + 55 + export type CreateAccountDispatch = (action: CreateAccountAction) => void 56 + 57 + export function useCreateAccount() { 58 + const {_} = useLingui() 59 + return useReducer(createReducer({_}), { 60 + step: 1, 61 + error: undefined, 62 + isProcessing: false, 63 + serviceUrl: DEFAULT_SERVICE, 64 + serviceDescription: undefined, 65 + userDomain: '', 66 + inviteCode: '', 67 + email: '', 68 + password: '', 69 + handle: '', 70 + birthDate: DEFAULT_DATE, 71 + 72 + canBack: false, 73 + canNext: false, 74 + isInviteCodeRequired: false, 75 + }) 76 + } 77 + 78 + export async function submit({ 79 + createAccount, 80 + onboardingDispatch, 81 + uiState, 82 + uiDispatch, 83 + _, 84 + }: { 85 + createAccount: SessionApiContext['createAccount'] 86 + onboardingDispatch: OnboardingDispatchContext 87 + uiState: CreateAccountState 88 + uiDispatch: CreateAccountDispatch 89 + _: I18nContext['_'] 90 + }) { 91 + if (!uiState.email) { 92 + uiDispatch({type: 'set-step', value: 2}) 93 + return uiDispatch({ 94 + type: 'set-error', 95 + value: _(msg`Please enter your email.`), 96 + }) 97 + } 98 + if (!EmailValidator.validate(uiState.email)) { 99 + uiDispatch({type: 'set-step', value: 2}) 100 + return uiDispatch({ 101 + type: 'set-error', 102 + value: _(msg`Your email appears to be invalid.`), 103 + }) 104 + } 105 + if (!uiState.password) { 106 + uiDispatch({type: 'set-step', value: 2}) 107 + return uiDispatch({ 108 + type: 'set-error', 109 + value: _(msg`Please choose your password.`), 110 + }) 111 + } 112 + if (!uiState.handle) { 113 + uiDispatch({type: 'set-step', value: 3}) 114 + return uiDispatch({ 115 + type: 'set-error', 116 + value: _(msg`Please choose your handle.`), 117 + }) 118 + } 119 + uiDispatch({type: 'set-error', value: ''}) 120 + uiDispatch({type: 'set-processing', value: true}) 121 + 122 + try { 123 + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view 124 + await createAccount({ 125 + service: uiState.serviceUrl, 126 + email: uiState.email, 127 + handle: createFullHandle(uiState.handle, uiState.userDomain), 128 + password: uiState.password, 129 + inviteCode: uiState.inviteCode.trim(), 130 + }) 131 + } catch (e: any) { 132 + onboardingDispatch({type: 'skip'}) // undo starting the onboard 133 + let errMsg = e.toString() 134 + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { 135 + errMsg = _( 136 + msg`Invite code not accepted. Check that you input it correctly and try again.`, 137 + ) 138 + } 139 + logger.error('Failed to create account', {error: e}) 140 + uiDispatch({type: 'set-processing', value: false}) 141 + uiDispatch({type: 'set-error', value: cleanError(errMsg)}) 142 + throw e 143 + } 144 + } 145 + 146 + export function is13(state: CreateAccountState) { 147 + return getAge(state.birthDate) >= 18 148 + } 149 + 150 + export function is18(state: CreateAccountState) { 151 + return getAge(state.birthDate) >= 18 152 + } 153 + 154 + function createReducer({_}: {_: I18nContext['_']}) { 155 + return function reducer( 156 + state: CreateAccountState, 157 + action: CreateAccountAction, 158 + ): CreateAccountState { 159 + switch (action.type) { 160 + case 'set-step': { 161 + return compute({...state, step: action.value}) 162 + } 163 + case 'set-error': { 164 + return compute({...state, error: action.value}) 165 + } 166 + case 'set-processing': { 167 + return compute({...state, isProcessing: action.value}) 168 + } 169 + case 'set-service-url': { 170 + return compute({ 171 + ...state, 172 + serviceUrl: action.value, 173 + serviceDescription: 174 + state.serviceUrl !== action.value 175 + ? undefined 176 + : state.serviceDescription, 177 + }) 178 + } 179 + case 'set-service-description': { 180 + return compute({ 181 + ...state, 182 + serviceDescription: action.value, 183 + userDomain: action.value?.availableUserDomains[0] || '', 184 + }) 185 + } 186 + case 'set-user-domain': { 187 + return compute({...state, userDomain: action.value}) 188 + } 189 + case 'set-invite-code': { 190 + return compute({...state, inviteCode: action.value}) 191 + } 192 + case 'set-email': { 193 + return compute({...state, email: action.value}) 194 + } 195 + case 'set-password': { 196 + return compute({...state, password: action.value}) 197 + } 198 + case 'set-handle': { 199 + return compute({...state, handle: action.value}) 200 + } 201 + case 'set-birth-date': { 202 + return compute({...state, birthDate: action.value}) 203 + } 204 + case 'next': { 205 + if (state.step === 2) { 206 + if (!is13(state)) { 207 + return compute({ 208 + ...state, 209 + error: _( 210 + msg`Unfortunately, you do not meet the requirements to create an account.`, 211 + ), 212 + }) 213 + } 214 + } 215 + return compute({...state, error: '', step: state.step + 1}) 216 + } 217 + case 'back': { 218 + return compute({...state, error: '', step: state.step - 1}) 219 + } 220 + } 221 + } 222 + } 223 + 224 + function compute(state: CreateAccountState): CreateAccountState { 225 + let canNext = true 226 + if (state.step === 1) { 227 + canNext = !!state.serviceDescription 228 + } else if (state.step === 2) { 229 + canNext = 230 + (!state.isInviteCodeRequired || !!state.inviteCode) && 231 + !!state.email && 232 + !!state.password 233 + } else if (state.step === 3) { 234 + canNext = !!state.handle 235 + } 236 + return { 237 + ...state, 238 + canBack: state.step > 1, 239 + canNext, 240 + isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, 241 + } 242 + }
+2 -2
src/view/com/modals/ChangeHandle.tsx
··· 33 33 export type Props = {onChanged: () => void} 34 34 35 35 export function Component(props: Props) { 36 - const {currentAccount} = useSession() 36 + const {agent, currentAccount} = useSession() 37 37 const { 38 38 isLoading, 39 39 data: serviceInfo, 40 40 error: serviceInfoError, 41 - } = useServiceQuery() 41 + } = useServiceQuery(agent.service.toString()) 42 42 43 43 return isLoading || !currentAccount ? ( 44 44 <View style={{padding: 18}}>