diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 89c441e981..f8f651305d 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 60f4fc1844..0000000000 --- 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 df12d6cbc8..5f7e10778b 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 0f3ff41af8..ab6d34584a 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]) - React.useEffect(() => { - model.fetchServiceDescription() - }, [model]) + // fetch service info + // = - const onPressRetryConnect = React.useCallback( - () => model.fetchServiceDescription(), - [model], - ) + const { + data: serviceInfo, + isFetching: serviceInfoIsFetching, + error: serviceInfoError, + refetch: refetchServiceInfo, + } = useServiceQuery(uiState.serviceUrl) + + React.useEffect(() => { + if (serviceInfo) { + uiDispatch({type: 'set-service-description', value: serviceInfo}) + uiDispatch({type: 'set-error', value: ''}) + } else if (serviceInfoError) { + uiDispatch({ + type: 'set-error', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } + }, [_, uiDispatch, serviceInfo, serviceInfoError]) + + // event handlers + // = const onPressBackInner = React.useCallback(() => { - if (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 ( - {model.step === 1 && } - {model.step === 2 && } - {model.step === 3 && } + {uiState.step === 1 && ( + + )} + {uiState.step === 2 && ( + + )} + {uiState.step === 3 && ( + + )} - {model.canNext ? ( + {uiState.canNext ? ( - {model.isProcessing ? ( + {uiState.isProcessing ? ( ) : ( @@ -134,19 +159,19 @@ export const CreateAccount = observer(function CreateAccountImpl({ )} - ) : model.didServiceDescriptionFetchFail ? ( + ) : serviceInfoError ? ( refetchServiceInfo()} accessibilityRole="button" accessibilityLabel={_(msg`Retry`)} - accessibilityHint="Retries account creation" + accessibilityHint="" accessibilityLiveRegion="polite"> Retry - ) : model.isFetchingServiceDescription ? ( + ) : serviceInfoIsFetching ? ( <> @@ -160,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ ) -}) +} const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx index 8eb669bcf6..7d10f32fc1 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 7e3ea062dc..ab47b411f1 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() + uiDispatch({type: 'set-service-url', value: v}) }, - [model, fetchServiceDescription], - ) - - const onDebugChangeServiceUrl = React.useCallback( - (v: string) => { - model.setServiceUrl(v) - model.fetchServiceDescription() - }, - [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)} />