From acf0f80de2a7a96ad8ee58dbf6aa8cb59859c9e8 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 14 Mar 2023 13:03:43 -0500 Subject: Rework account creation and login views --- src/lib/icons.tsx | 27 + src/lib/styles.ts | 1 + src/state/index.ts | 2 +- src/state/models/ui/create-account.ts | 192 ++++++ src/view/com/auth/CreateAccount.tsx | 584 ------------------- src/view/com/auth/LoggedOut.tsx | 22 +- src/view/com/auth/Signin.tsx | 895 ---------------------------- src/view/com/auth/SplashScreen.tsx | 44 +- src/view/com/auth/create/Backup.tsx | 584 +++++++++++++++++++ src/view/com/auth/create/CreateAccount.tsx | 241 ++++++++ src/view/com/auth/create/Policies.tsx | 101 ++++ src/view/com/auth/create/Step1.tsx | 187 ++++++ src/view/com/auth/create/Step2.tsx | 275 +++++++++ src/view/com/auth/create/Step3.tsx | 44 ++ src/view/com/auth/create/StepHeader.tsx | 22 + src/view/com/auth/login/Login.tsx | 904 +++++++++++++++++++++++++++++ src/view/com/auth/util/HelpTip.tsx | 32 + src/view/com/auth/util/TextInput.tsx | 68 +++ src/view/com/modals/DeleteAccount.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 3 + src/view/com/util/WelcomeBanner.tsx | 5 +- src/view/com/util/error/ErrorMessage.tsx | 2 +- 22 files changed, 2716 insertions(+), 1525 deletions(-) create mode 100644 src/state/models/ui/create-account.ts delete mode 100644 src/view/com/auth/CreateAccount.tsx delete mode 100644 src/view/com/auth/Signin.tsx create mode 100644 src/view/com/auth/create/Backup.tsx create mode 100644 src/view/com/auth/create/CreateAccount.tsx create mode 100644 src/view/com/auth/create/Policies.tsx create mode 100644 src/view/com/auth/create/Step1.tsx create mode 100644 src/view/com/auth/create/Step2.tsx create mode 100644 src/view/com/auth/create/Step3.tsx create mode 100644 src/view/com/auth/create/StepHeader.tsx create mode 100644 src/view/com/auth/login/Login.tsx create mode 100644 src/view/com/auth/util/HelpTip.tsx create mode 100644 src/view/com/auth/util/TextInput.tsx (limited to 'src') diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index e194e7a87..fd233f99c 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -801,3 +801,30 @@ export function SquarePlusIcon({ ) } + +export function InfoCircleIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 328229f46..5d7f7f82d 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -64,6 +64,7 @@ export const s = StyleSheet.create({ footerSpacer: {height: 100}, contentContainer: {paddingBottom: 200}, contentContainerExtra: {paddingBottom: 300}, + border0: {borderWidth: 0}, border1: {borderWidth: 1}, borderTop1: {borderTopWidth: 1}, borderRight1: {borderRightWidth: 1}, diff --git a/src/state/index.ts b/src/state/index.ts index 61b85e51d..f0713efeb 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -6,7 +6,7 @@ import * as apiPolyfill from 'lib/api/api-polyfill' import * as storage from 'lib/storage' export const LOCAL_DEV_SERVICE = - Platform.OS === 'ios' ? 'http://localhost:2583' : 'http://10.0.2.2:2583' + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' export const STAGING_SERVICE = 'https://pds.staging.bsky.dev' export const PROD_SERVICE = 'https://bsky.social' export const DEFAULT_SERVICE = PROD_SERVICE diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts new file mode 100644 index 000000000..a212fe05e --- /dev/null +++ b/src/state/models/ui/create-account.ts @@ -0,0 +1,192 @@ +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from '../root-store' +import {ServiceDescription} from '../session' +import {DEFAULT_SERVICE} from 'state/index' +import {ComAtprotoAccountCreate} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {createFullHandle} from 'lib/strings/handles' +import {cleanError} from 'lib/strings/errors' + +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 = '' + is13 = false + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, {}, {autoBind: true}) + } + + // form state controls + // = + + next() { + this.error = '' + 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) { + this.rootStore.log.warn( + `Failed to fetch service description for ${this.serviceUrl}`, + err, + ) + this.setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + this.setDidServiceDescriptionFetchFail(true) + } finally { + this.setIsFetchingServiceDescription(false) + } + } + + async submit() { + 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 { + await this.rootStore.session.createAccount({ + service: this.serviceUrl, + email: this.email, + handle: createFullHandle(this.handle, this.userDomain), + password: this.password, + inviteCode: this.inviteCode, + }) + } catch (e: any) { + let errMsg = e.toString() + if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + errMsg = + 'Invite code not accepted. Check that you input it correctly and try again.' + } + this.rootStore.log.error('Failed to create account', 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 && + this.is13 + ) + } + 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 + } + + setIs13(v: boolean) { + this.is13 = v + } +} diff --git a/src/view/com/auth/CreateAccount.tsx b/src/view/com/auth/CreateAccount.tsx deleted file mode 100644 index a24dc4e35..000000000 --- a/src/view/com/auth/CreateAccount.tsx +++ /dev/null @@ -1,584 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - Keyboard, - KeyboardAvoidingView, - ScrollView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ComAtprotoAccountCreate} from '@atproto/api' -import * as EmailValidator from 'email-validator' -import {sha256} from 'js-sha256' -import {useAnalytics} from 'lib/analytics' -import {LogoTextHero} from './Logo' -import {Picker} from '../util/Picker' -import {TextLink} from '../util/Link' -import {Text} from '../util/text/Text' -import {s, colors} from 'lib/styles' -import {makeValidHandle, createFullHandle} from 'lib/strings/handles' -import {toNiceDomain} from 'lib/strings/url-helpers' -import {useStores, DEFAULT_SERVICE} from 'state/index' -import {ServiceDescription} from 'state/models/session' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {cleanError} from 'lib/strings/errors' - -export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { - const {track, screen, identify} = useAnalytics() - const pal = usePalette('default') - const theme = useTheme() - const store = useStores() - const [isProcessing, setIsProcessing] = React.useState(false) - const [serviceUrl, setServiceUrl] = React.useState(DEFAULT_SERVICE) - const [error, setError] = React.useState('') - const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState( - {}, - ) - const [serviceDescription, setServiceDescription] = React.useState< - ServiceDescription | undefined - >(undefined) - const [userDomain, setUserDomain] = React.useState('') - const [inviteCode, setInviteCode] = React.useState('') - const [email, setEmail] = React.useState('') - const [password, setPassword] = React.useState('') - const [handle, setHandle] = React.useState('') - const [is13, setIs13] = React.useState(false) - - React.useEffect(() => { - screen('CreateAccount') - }, [screen]) - - React.useEffect(() => { - let aborted = false - setError('') - setServiceDescription(undefined) - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - setUserDomain(desc.availableUserDomains[0]) - }, - err => { - if (aborted) { - return - } - store.log.warn( - `Failed to fetch service description for ${serviceUrl}`, - err, - ) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true - } - }, [serviceUrl, store.session, store.log, retryDescribeTrigger]) - - const onPressRetryConnect = React.useCallback( - () => setRetryDescribeTrigger({}), - [setRetryDescribeTrigger], - ) - - const onPressSelectService = React.useCallback(() => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - Keyboard.dismiss() - }, [store, serviceUrl]) - - const onBlurInviteCode = React.useCallback(() => { - setInviteCode(inviteCode.trim()) - }, [setInviteCode, inviteCode]) - - const onPressNext = React.useCallback(async () => { - if (!email) { - return setError('Please enter your email.') - } - if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') - } - if (!password) { - return setError('Please choose your password.') - } - if (!handle) { - return setError('Please choose your username.') - } - setError('') - setIsProcessing(true) - try { - await store.session.createAccount({ - service: serviceUrl, - email, - handle: createFullHandle(handle, userDomain), - password, - inviteCode, - }) - - const email_hashed = sha256(email) - identify(email_hashed, {email_hashed}) - - track('Create Account') - } catch (e: any) { - let errMsg = e.toString() - if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { - errMsg = - 'Invite code not accepted. Check that you input it correctly and try again.' - } - store.log.error('Failed to create account', e) - setIsProcessing(false) - setError(cleanError(errMsg)) - } - }, [ - serviceUrl, - userDomain, - inviteCode, - email, - password, - handle, - setError, - setIsProcessing, - store, - track, - identify, - ]) - - const isReady = !!email && !!password && !!handle && is13 - return ( - - - - {error ? ( - - - - - - {error} - - - ) : undefined} - - - Service provider - - - - - - - - {toNiceDomain(serviceUrl)} - - - - Change - - - - - {serviceDescription ? ( - <> - - - Account details - - - - {serviceDescription?.inviteCodeRequired ? ( - - - - - ) : undefined} - - - - - - - - - - - ) : undefined} - {serviceDescription ? ( - <> - - - Choose your username - - - - - - setHandle(makeValidHandle(v))} - editable={!isProcessing} - /> - - {serviceDescription.availableUserDomains.length > 1 && ( - - - ({ - label: `.${d}`, - value: d, - }))} - onChange={itemValue => setUserDomain(itemValue)} - enabled={!isProcessing} - /> - - )} - - - Your full username will be{' '} - - @{createFullHandle(handle, userDomain)} - - - - - - - Legal - - - - - setIs13(!is13)}> - - {is13 && ( - - )} - - - I am 13 years old or older - - - - - - - ) : undefined} - - - - Back - - - - {isReady ? ( - - {isProcessing ? ( - - ) : ( - - Next - - )} - - ) : !serviceDescription && error ? ( - - - Retry - - - ) : !serviceDescription ? ( - <> - - - Connecting... - - - ) : undefined} - - - - - ) -} - -const Policies = ({ - serviceDescription, -}: { - serviceDescription: ServiceDescription -}) => { - const pal = usePalette('default') - if (!serviceDescription) { - return - } - const tos = validWebLink(serviceDescription.links?.termsOfService) - const pp = validWebLink(serviceDescription.links?.privacyPolicy) - if (!tos && !pp) { - return ( - - - - - - This service has not provided terms of service or a privacy policy. - - - ) - } - const els = [] - if (tos) { - els.push( - , - ) - } - if (pp) { - els.push( - , - ) - } - if (els.length === 2) { - els.splice( - 1, - 0, - - {' '} - and{' '} - , - ) - } - return ( - - - By creating an account you agree to the {els}. - - - ) -} - -function validWebLink(url?: string): string | undefined { - return url && (url.startsWith('http://') || url.startsWith('https://')) - ? url - : undefined -} - -const styles = StyleSheet.create({ - noTopBorder: { - borderTopWidth: 0, - }, - logoHero: { - paddingTop: 30, - paddingBottom: 40, - }, - group: { - borderWidth: 1, - borderRadius: 10, - marginBottom: 20, - marginHorizontal: 20, - }, - groupLabel: { - paddingHorizontal: 20, - paddingBottom: 5, - }, - groupContent: { - borderTopWidth: 1, - flexDirection: 'row', - alignItems: 'center', - }, - groupContentIcon: { - marginLeft: 10, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - textBtn: { - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }, - textBtnLabel: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 12, - }, - textBtnFakeInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - textBtnFakeInnerBtnIcon: { - marginRight: 4, - }, - picker: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 17, - borderRadius: 10, - }, - pickerLabel: { - fontSize: 17, - }, - checkbox: { - borderWidth: 1, - borderRadius: 2, - width: 16, - height: 16, - marginLeft: 16, - }, - checkboxFilled: { - borderWidth: 1, - borderRadius: 2, - width: 16, - height: 16, - marginLeft: 16, - }, - policies: { - flexDirection: 'row', - alignItems: 'flex-start', - paddingHorizontal: 20, - paddingBottom: 20, - }, - error: { - backgroundColor: colors.red4, - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginHorizontal: 20, - marginBottom: 15, - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 8, - }, - errorFloating: { - marginBottom: 20, - marginHorizontal: 20, - borderRadius: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, -}) diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 47dd51d9c..5d4b9451f 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,8 +1,8 @@ import React from 'react' import {SafeAreaView} from 'react-native' import {observer} from 'mobx-react-lite' -import {Signin} from 'view/com/auth/Signin' -import {CreateAccount} from 'view/com/auth/CreateAccount' +import {Login} from 'view/com/auth/login/Login' +import {CreateAccount} from 'view/com/auth/create/CreateAccount' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' @@ -12,8 +12,8 @@ import {SplashScreen} from './SplashScreen' import {CenteredView} from '../util/Views' enum ScreenState { - S_SigninOrCreateAccount, - S_Signin, + S_LoginOrCreateAccount, + S_Login, S_CreateAccount, } @@ -22,7 +22,7 @@ export const LoggedOut = observer(() => { const store = useStores() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState( - ScreenState.S_SigninOrCreateAccount, + ScreenState.S_LoginOrCreateAccount, ) React.useEffect(() => { @@ -32,11 +32,11 @@ export const LoggedOut = observer(() => { if ( store.session.isResumingSession || - screenState === ScreenState.S_SigninOrCreateAccount + screenState === ScreenState.S_LoginOrCreateAccount ) { return ( setScreenState(ScreenState.S_Signin)} + onPressSignin={() => setScreenState(ScreenState.S_Login)} onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} /> ) @@ -46,17 +46,17 @@ export const LoggedOut = observer(() => { - {screenState === ScreenState.S_Signin ? ( - - setScreenState(ScreenState.S_SigninOrCreateAccount) + setScreenState(ScreenState.S_LoginOrCreateAccount) } /> ) : undefined} {screenState === ScreenState.S_CreateAccount ? ( - setScreenState(ScreenState.S_SigninOrCreateAccount) + setScreenState(ScreenState.S_LoginOrCreateAccount) } /> ) : undefined} diff --git a/src/view/com/auth/Signin.tsx b/src/view/com/auth/Signin.tsx deleted file mode 100644 index 6faf5ff12..000000000 --- a/src/view/com/auth/Signin.tsx +++ /dev/null @@ -1,895 +0,0 @@ -import React, {useState, useEffect} from 'react' -import { - ActivityIndicator, - Keyboard, - KeyboardAvoidingView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import * as EmailValidator from 'email-validator' -import AtpAgent from '@atproto/api' -import {useAnalytics} from 'lib/analytics' -import {LogoTextHero} from './Logo' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import {s, colors} from 'lib/styles' -import {createFullHandle} from 'lib/strings/handles' -import {toNiceDomain} from 'lib/strings/url-helpers' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' -import {ServiceDescription} from 'state/models/session' -import {AccountData} from 'state/models/session' -import {isNetworkError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {cleanError} from 'lib/strings/errors' - -enum Forms { - Login, - ChooseAccount, - ForgotPassword, - SetNewPassword, - PasswordUpdated, -} - -export const Signin = ({onPressBack}: {onPressBack: () => void}) => { - const pal = usePalette('default') - const store = useStores() - const {track} = useAnalytics() - const [error, setError] = useState('') - const [retryDescribeTrigger, setRetryDescribeTrigger] = useState({}) - const [serviceUrl, setServiceUrl] = useState(DEFAULT_SERVICE) - const [serviceDescription, setServiceDescription] = useState< - ServiceDescription | undefined - >(undefined) - const [initialHandle, setInitialHandle] = useState('') - const [currentForm, setCurrentForm] = useState( - store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login, - ) - - const onSelectAccount = (account?: AccountData) => { - if (account?.service) { - setServiceUrl(account.service) - } - setInitialHandle(account?.handle || '') - setCurrentForm(Forms.Login) - } - - const gotoForm = (form: Forms) => () => { - setError('') - setCurrentForm(form) - } - - useEffect(() => { - let aborted = false - setError('') - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - }, - err => { - if (aborted) { - return - } - store.log.warn( - `Failed to fetch service description for ${serviceUrl}`, - err, - ) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true - } - }, [store.session, store.log, serviceUrl, retryDescribeTrigger]) - - const onPressRetryConnect = () => setRetryDescribeTrigger({}) - const onPressForgotPassword = () => { - track('Signin:PressedForgotPassword') - setCurrentForm(Forms.ForgotPassword) - } - - return ( - - {currentForm === Forms.Login ? ( - - ) : undefined} - {currentForm === Forms.ChooseAccount ? ( - - ) : undefined} - {currentForm === Forms.ForgotPassword ? ( - - ) : undefined} - {currentForm === Forms.SetNewPassword ? ( - - ) : undefined} - {currentForm === Forms.PasswordUpdated ? ( - - ) : undefined} - - ) -} - -const ChooseAccountForm = ({ - store, - onSelectAccount, - onPressBack, -}: { - store: RootStoreModel - onSelectAccount: (account?: AccountData) => void - onPressBack: () => void -}) => { - const {track, screen} = useAnalytics() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = React.useState(false) - - // React.useEffect(() => { - screen('Choose Account') - // }, [screen]) - - const onTryAccount = async (account: AccountData) => { - if (account.accessJwt && account.refreshJwt) { - setIsProcessing(true) - if (await store.session.resumeSession(account)) { - track('Sign In', {resumedSession: true}) - setIsProcessing(false) - return - } - setIsProcessing(false) - } - onSelectAccount(account) - } - - return ( - - - - Sign in as... - - {store.session.accounts.map(account => ( - onTryAccount(account)}> - - - - - - - {account.displayName || account.handle}{' '} - - - {account.handle} - - - - - - ))} - onSelectAccount(undefined)}> - - - - Other account - - - - - - - - - Back - - - - {isProcessing && } - - - ) -} - -const LoginForm = ({ - store, - error, - serviceUrl, - serviceDescription, - initialHandle, - setError, - setServiceUrl, - onPressRetryConnect, - onPressBack, - onPressForgotPassword, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - initialHandle: string - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressRetryConnect: () => void - onPressBack: () => void - onPressForgotPassword: () => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState(false) - const [identifier, setIdentifier] = useState(initialHandle) - const [password, setPassword] = useState('') - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - Keyboard.dismiss() - track('Signin:PressedSelectService') - } - - const onPressNext = async () => { - setError('') - setIsProcessing(true) - - try { - // try to guess the handle if the user just gave their own username - let fullIdent = identifier - if ( - !identifier.includes('@') && // not an email - !identifier.includes('.') && // not a domain - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullIdent.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullIdent = createFullHandle( - identifier, - serviceDescription.availableUserDomains[0], - ) - } - } - - await store.session.login({ - service: serviceUrl, - identifier: fullIdent, - password, - }) - track('Sign In', {resumedSession: false}) - } catch (e: any) { - const errMsg = e.toString() - store.log.warn('Failed to login', e) - setIsProcessing(false) - if (errMsg.includes('Authentication Required')) { - setError('Invalid username or password') - } else if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - const isReady = !!serviceDescription && !!identifier && !!password - return ( - - - - Sign into - - - - - - - {toNiceDomain(serviceUrl)} - - - - - - - - - Account - - - - - setIdentifier((str || '').toLowerCase())} - editable={!isProcessing} - /> - - - - - - Forgot - - - - {error ? ( - - - - - - {error} - - - ) : undefined} - - - - Back - - - - {!serviceDescription && error ? ( - - - Retry - - - ) : !serviceDescription ? ( - <> - - - Connecting... - - - ) : isProcessing ? ( - - ) : isReady ? ( - - - Next - - - ) : undefined} - - - ) -} - -const ForgotPasswordForm = ({ - store, - error, - serviceUrl, - serviceDescription, - setError, - setServiceUrl, - onPressBack, - onEmailSent, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressBack: () => void - onEmailSent: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState(false) - const [email, setEmail] = useState('') - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:ForgotPassword') - }, [screen]) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - } - - const onPressNext = async () => { - if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') - } - - setError('') - setIsProcessing(true) - - try { - const agent = new AtpAgent({service: serviceUrl}) - await agent.api.com.atproto.account.requestPasswordReset({email}) - onEmailSent() - } catch (e: any) { - const errMsg = e.toString() - store.log.warn('Failed to request password reset', e) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - - - - Reset password - - - Enter the email you used to create your account. We'll send you a - "reset code" so you can set a new password. - - - - - - {toNiceDomain(serviceUrl)} - - - - - - - - - - - {error ? ( - - - - - - {error} - - - ) : undefined} - - - - Back - - - - {!serviceDescription || isProcessing ? ( - - ) : !email ? ( - - Next - - ) : ( - - - Next - - - )} - {!serviceDescription || isProcessing ? ( - - Processing... - - ) : undefined} - - - - ) -} - -const SetNewPasswordForm = ({ - store, - error, - serviceUrl, - setError, - onPressBack, - onPasswordSet, -}: { - store: RootStoreModel - error: string - serviceUrl: string - setError: (v: string) => void - onPressBack: () => void - onPasswordSet: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:SetNewPasswordForm') - }, [screen]) - - const [isProcessing, setIsProcessing] = useState(false) - const [resetCode, setResetCode] = useState('') - const [password, setPassword] = useState('') - - const onPressNext = async () => { - setError('') - setIsProcessing(true) - - try { - const agent = new AtpAgent({service: serviceUrl}) - await agent.api.com.atproto.account.resetPassword({ - token: resetCode, - password, - }) - onPasswordSet() - } catch (e: any) { - const errMsg = e.toString() - store.log.warn('Failed to set new password', e) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - - - - Set new password - - - You will receive an email with a "reset code." Enter that code here, - then enter your new password. - - - - - - - - - - - - {error ? ( - - - - - - {error} - - - ) : undefined} - - - - Back - - - - {isProcessing ? ( - - ) : !resetCode || !password ? ( - - Next - - ) : ( - - - Next - - - )} - {isProcessing ? ( - - Updating... - - ) : undefined} - - - - ) -} - -const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { - const {screen} = useAnalytics() - - // useEffect(() => { - screen('Signin:PasswordUpdatedForm') - // }, [screen]) - - const pal = usePalette('default') - return ( - <> - - - - Password updated! - - - You can now sign in with your new password. - - - - - - Okay - - - - - - ) -} - -const styles = StyleSheet.create({ - screenTitle: { - marginBottom: 10, - marginHorizontal: 20, - }, - instructions: { - marginBottom: 20, - marginHorizontal: 20, - }, - group: { - borderWidth: 1, - borderRadius: 10, - marginBottom: 20, - marginHorizontal: 20, - }, - groupLabel: { - paddingHorizontal: 20, - paddingBottom: 5, - }, - groupContent: { - borderTopWidth: 1, - flexDirection: 'row', - alignItems: 'center', - }, - noTopBorder: { - borderTopWidth: 0, - }, - groupContentIcon: { - marginLeft: 10, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - textInputInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - textBtn: { - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }, - textBtnLabel: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 12, - }, - textBtnFakeInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - accountText: { - flex: 1, - flexDirection: 'row', - alignItems: 'baseline', - paddingVertical: 10, - }, - accountTextOther: { - paddingLeft: 12, - }, - error: { - backgroundColor: colors.red4, - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginHorizontal: 20, - marginBottom: 15, - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - color: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, - dimmed: {opacity: 0.5}, -}) diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 27943f64d..f98bed120 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,11 +1,9 @@ import React from 'react' import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' -import Image, {Source as ImageSource} from 'view/com/util/images/Image' import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {colors} from 'lib/styles' +import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {CLOUD_SPLASH} from 'lib/assets' import {CenteredView} from '../util/Views' export const SplashScreen = ({ @@ -17,29 +15,29 @@ export const SplashScreen = ({ }) => { const pal = usePalette('default') return ( - - + - - Bluesky - + Bluesky + + See what's next + - + Create a new account - Sign in + Sign in @@ -56,37 +54,27 @@ const styles = StyleSheet.create({ flex: 2, justifyContent: 'center', }, - bgImg: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - }, - heroText: { - backgroundColor: colors.white, - paddingTop: 10, - paddingBottom: 20, - }, btns: { paddingBottom: 40, }, title: { textAlign: 'center', - color: colors.blue3, fontSize: 68, fontWeight: 'bold', }, + subtitle: { + textAlign: 'center', + fontSize: 42, + fontWeight: 'bold', + }, btn: { - borderRadius: 4, + borderRadius: 32, paddingVertical: 16, marginBottom: 20, marginHorizontal: 20, - backgroundColor: colors.blue3, }, btnLabel: { textAlign: 'center', fontSize: 21, - color: colors.white, }, }) diff --git a/src/view/com/auth/create/Backup.tsx b/src/view/com/auth/create/Backup.tsx new file mode 100644 index 000000000..c0693605f --- /dev/null +++ b/src/view/com/auth/create/Backup.tsx @@ -0,0 +1,584 @@ +import React from 'react' +import { + ActivityIndicator, + Keyboard, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoAccountCreate} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {sha256} from 'js-sha256' +import {useAnalytics} from 'lib/analytics' +import {LogoTextHero} from '../Logo' +import {Picker} from '../../util/Picker' +import {TextLink} from '../../util/Link' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {makeValidHandle, createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {useStores, DEFAULT_SERVICE} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' + +export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { + const {track, screen, identify} = useAnalytics() + const pal = usePalette('default') + const theme = useTheme() + const store = useStores() + const [isProcessing, setIsProcessing] = React.useState(false) + const [serviceUrl, setServiceUrl] = React.useState(DEFAULT_SERVICE) + const [error, setError] = React.useState('') + const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState( + {}, + ) + const [serviceDescription, setServiceDescription] = React.useState< + ServiceDescription | undefined + >(undefined) + const [userDomain, setUserDomain] = React.useState('') + const [inviteCode, setInviteCode] = React.useState('') + const [email, setEmail] = React.useState('') + const [password, setPassword] = React.useState('') + const [handle, setHandle] = React.useState('') + const [is13, setIs13] = React.useState(false) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + let aborted = false + setError('') + setServiceDescription(undefined) + store.session.describeService(serviceUrl).then( + desc => { + if (aborted) { + return + } + setServiceDescription(desc) + setUserDomain(desc.availableUserDomains[0]) + }, + err => { + if (aborted) { + return + } + store.log.warn( + `Failed to fetch service description for ${serviceUrl}`, + err, + ) + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + }, + ) + return () => { + aborted = true + } + }, [serviceUrl, store.session, store.log, retryDescribeTrigger]) + + const onPressRetryConnect = React.useCallback( + () => setRetryDescribeTrigger({}), + [setRetryDescribeTrigger], + ) + + const onPressSelectService = React.useCallback(() => { + store.shell.openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + Keyboard.dismiss() + }, [store, serviceUrl]) + + const onBlurInviteCode = React.useCallback(() => { + setInviteCode(inviteCode.trim()) + }, [setInviteCode, inviteCode]) + + const onPressNext = React.useCallback(async () => { + if (!email) { + return setError('Please enter your email.') + } + if (!EmailValidator.validate(email)) { + return setError('Your email appears to be invalid.') + } + if (!password) { + return setError('Please choose your password.') + } + if (!handle) { + return setError('Please choose your username.') + } + setError('') + setIsProcessing(true) + try { + await store.session.createAccount({ + service: serviceUrl, + email, + handle: createFullHandle(handle, userDomain), + password, + inviteCode, + }) + + const email_hashed = sha256(email) + identify(email_hashed, {email_hashed}) + + track('Create Account') + } catch (e: any) { + let errMsg = e.toString() + if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) { + errMsg = + 'Invite code not accepted. Check that you input it correctly and try again.' + } + store.log.error('Failed to create account', e) + setIsProcessing(false) + setError(cleanError(errMsg)) + } + }, [ + serviceUrl, + userDomain, + inviteCode, + email, + password, + handle, + setError, + setIsProcessing, + store, + track, + identify, + ]) + + const isReady = !!email && !!password && !!handle && is13 + return ( + + + + {error ? ( + + + + + + {error} + + + ) : undefined} + + + Service provider + + + + + + + + {toNiceDomain(serviceUrl)} + + + + Change + + + + + {serviceDescription ? ( + <> + + + Account details + + + + {serviceDescription?.inviteCodeRequired ? ( + + + + + ) : undefined} + + + + + + + + + + + ) : undefined} + {serviceDescription ? ( + <> + + + Choose your username + + + + + + setHandle(makeValidHandle(v))} + editable={!isProcessing} + /> + + {serviceDescription.availableUserDomains.length > 1 && ( + + + ({ + label: `.${d}`, + value: d, + }))} + onChange={itemValue => setUserDomain(itemValue)} + enabled={!isProcessing} + /> + + )} + + + Your full username will be{' '} + + @{createFullHandle(handle, userDomain)} + + + + + + + Legal + + + + + setIs13(!is13)}> + + {is13 && ( + + )} + + + I am 13 years old or older + + + + + + + ) : undefined} + + + + Back + + + + {isReady ? ( + + {isProcessing ? ( + + ) : ( + + Next + + )} + + ) : !serviceDescription && error ? ( + + + Retry + + + ) : !serviceDescription ? ( + <> + + + Connecting... + + + ) : undefined} + + + + + ) +} + +const Policies = ({ + serviceDescription, +}: { + serviceDescription: ServiceDescription +}) => { + const pal = usePalette('default') + if (!serviceDescription) { + return + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + + + + + + This service has not provided terms of service or a privacy policy. + + + ) + } + const els = [] + if (tos) { + els.push( + , + ) + } + if (pp) { + els.push( + , + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + + {' '} + and{' '} + , + ) + } + return ( + + + By creating an account you agree to the {els}. + + + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} + +const styles = StyleSheet.create({ + noTopBorder: { + borderTopWidth: 0, + }, + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + marginRight: 4, + }, + picker: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + borderRadius: 10, + }, + pickerLabel: { + fontSize: 17, + }, + checkbox: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + checkboxFilled: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 20, + paddingBottom: 20, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorFloating: { + marginBottom: 20, + marginHorizontal: 20, + borderRadius: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx new file mode 100644 index 000000000..93773665d --- /dev/null +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {sha256} from 'js-sha256' +import {useAnalytics} from 'lib/analytics' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {useStores} from 'state/index' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' + +import {Step1} from './Step1' +import {Step2} from './Step2' +import {Step3} from './Step3' + +export const CreateAccount = observer( + ({onPressBack}: {onPressBack: () => void}) => { + const {track, screen, identify} = useAnalytics() + const pal = usePalette('default') + const store = useStores() + const model = React.useMemo(() => new CreateAccountModel(store), [store]) + + React.useEffect(() => { + screen('CreateAccount') + }, [screen]) + + React.useEffect(() => { + model.fetchServiceDescription() + }, [model]) + + const onPressRetryConnect = React.useCallback( + () => model.fetchServiceDescription(), + [model], + ) + + const onPressBackInner = React.useCallback(() => { + if (model.canBack) { + console.log('?') + model.back() + } else { + onPressBack() + } + }, [model, onPressBack]) + + const onPressNext = React.useCallback(async () => { + if (!model.canNext) { + return + } + if (model.step < 3) { + model.next() + } else { + try { + await model.submit() + const email_hashed = sha256(model.email) + identify(email_hashed, {email_hashed}) + track('Create Account') + } catch { + // dont need to handle here + } + } + }, [model, identify, track]) + + return ( + + + + {model.step === 1 && } + {model.step === 2 && } + {model.step === 3 && } + + + + + Back + + + + {model.canNext ? ( + + {model.isProcessing ? ( + + ) : ( + + Next + + )} + + ) : model.didServiceDescriptionFetchFail ? ( + + + Retry + + + ) : model.isFetchingServiceDescription ? ( + <> + + + Connecting... + + + ) : undefined} + + + + + ) + }, +) + +const styles = StyleSheet.create({ + stepContainer: { + paddingHorizontal: 20, + paddingVertical: 20, + }, + + noTopBorder: { + borderTopWidth: 0, + }, + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + marginRight: 4, + }, + picker: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + borderRadius: 10, + }, + pickerLabel: { + fontSize: 17, + }, + checkbox: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + checkboxFilled: { + borderWidth: 1, + borderRadius: 2, + width: 16, + height: 16, + marginLeft: 16, + }, + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 20, + paddingBottom: 20, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorFloating: { + marginBottom: 20, + marginHorizontal: 20, + borderRadius: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx new file mode 100644 index 000000000..4ba6a5406 --- /dev/null +++ b/src/view/com/auth/create/Policies.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {TextLink} from '../../util/Link' +import {Text} from '../../util/text/Text' +import {s, colors} from 'lib/styles' +import {ServiceDescription} from 'state/models/session' +import {usePalette} from 'lib/hooks/usePalette' + +export const Policies = ({ + serviceDescription, +}: { + serviceDescription: ServiceDescription +}) => { + const pal = usePalette('default') + if (!serviceDescription) { + return + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + + + + + + This service has not provided terms of service or a privacy policy. + + + ) + } + const els = [] + if (tos) { + els.push( + , + ) + } + if (pp) { + els.push( + , + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + + {' '} + and{' '} + , + ) + } + return ( + + + By creating an account you agree to the {els}. + + + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} + +const styles = StyleSheet.create({ + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx new file mode 100644 index 000000000..0a628f9d0 --- /dev/null +++ b/src/view/com/auth/create/Step1.tsx @@ -0,0 +1,187 @@ +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 {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {HelpTip} from '../util/HelpTip' +import {TextInput} from '../util/TextInput' +import {Button} from 'view/com/util/forms/Button' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' + +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' + +export const Step1 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + + const onPressDefault = React.useCallback(() => { + setIsDefaultSelected(true) + model.setServiceUrl(PROD_SERVICE) + model.fetchServiceDescription() + }, [setIsDefaultSelected, model]) + + const onPressOther = React.useCallback(() => { + setIsDefaultSelected(false) + model.setServiceUrl('https://') + model.setServiceDescription(undefined) + }, [setIsDefaultSelected, model]) + + const fetchServiceDesription = React.useMemo( + () => debounce(() => model.fetchServiceDescription(), 1e3), + [model], + ) + + const onChangeServiceUrl = React.useCallback( + (v: string) => { + model.setServiceUrl(v) + fetchServiceDesription() + }, + [model, fetchServiceDesription], + ) + + const onDebugChangeServiceUrl = React.useCallback( + (v: string) => { + model.setServiceUrl(v) + model.fetchServiceDescription() + }, + [model], + ) + + return ( + + + + This is the company that keeps you online. + +