diff options
Diffstat (limited to 'src/view/com/auth/create')
-rw-r--r-- | src/view/com/auth/create/Backup.tsx | 584 | ||||
-rw-r--r-- | src/view/com/auth/create/CreateAccount.tsx | 241 | ||||
-rw-r--r-- | src/view/com/auth/create/Policies.tsx | 101 | ||||
-rw-r--r-- | src/view/com/auth/create/Step1.tsx | 187 | ||||
-rw-r--r-- | src/view/com/auth/create/Step2.tsx | 275 | ||||
-rw-r--r-- | src/view/com/auth/create/Step3.tsx | 44 | ||||
-rw-r--r-- | src/view/com/auth/create/StepHeader.tsx | 22 |
7 files changed, 1454 insertions, 0 deletions
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<boolean>(false) + const [serviceUrl, setServiceUrl] = React.useState<string>(DEFAULT_SERVICE) + const [error, setError] = React.useState<string>('') + const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>( + {}, + ) + const [serviceDescription, setServiceDescription] = React.useState< + ServiceDescription | undefined + >(undefined) + const [userDomain, setUserDomain] = React.useState<string>('') + const [inviteCode, setInviteCode] = React.useState<string>('') + const [email, setEmail] = React.useState<string>('') + const [password, setPassword] = React.useState<string>('') + const [handle, setHandle] = React.useState<string>('') + const [is13, setIs13] = React.useState<boolean>(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 ( + <ScrollView testID="createAccount" style={pal.view}> + <KeyboardAvoidingView behavior="padding"> + <LogoTextHero /> + {error ? ( + <View style={[styles.error, styles.errorFloating]}> + <View style={[styles.errorIcon]}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={styles.groupLabel}> + <Text type="sm-bold" style={pal.text}> + Service provider + </Text> + </View> + <View style={[pal.borderDark, styles.group]}> + <View + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TouchableOpacity + testID="registerSelectServiceButton" + style={styles.textBtn} + onPress={onPressSelectService}> + <Text type="xl" style={[pal.text, styles.textBtnLabel]}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={[pal.textLight, styles.textBtnFakeInnerBtnIcon]} + /> + <Text style={[pal.textLight]}>Change</Text> + </View> + </TouchableOpacity> + </View> + </View> + {serviceDescription ? ( + <> + <View style={styles.groupLabel}> + <Text type="sm-bold" style={pal.text}> + Account details + </Text> + </View> + <View style={[pal.borderDark, styles.group]}> + {serviceDescription?.inviteCodeRequired ? ( + <View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + style={[pal.text, styles.textInput]} + placeholder="Invite code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + autoFocus + keyboardAppearance={theme.colorScheme} + value={inviteCode} + onChangeText={setInviteCode} + onBlur={onBlurInviteCode} + editable={!isProcessing} + /> + </View> + ) : undefined} + <View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerEmailInput" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + /> + </View> + <View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="Choose your password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + /> + </View> + </View> + </> + ) : undefined} + {serviceDescription ? ( + <> + <View style={styles.groupLabel}> + <Text type="sm-bold" style={pal.text}> + Choose your username + </Text> + </View> + <View style={[pal.border, styles.group]}> + <View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerHandleInput" + style={[pal.text, styles.textInput]} + placeholder="eg alice" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + value={handle} + onChangeText={v => setHandle(makeValidHandle(v))} + editable={!isProcessing} + /> + </View> + {serviceDescription.availableUserDomains.length > 1 && ( + <View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="globe" + style={styles.groupContentIcon} + /> + <Picker + style={[pal.text, styles.picker]} + labelStyle={styles.pickerLabel} + iconStyle={pal.textLight as FontAwesomeIconStyle} + value={userDomain} + items={serviceDescription.availableUserDomains.map(d => ({ + label: `.${d}`, + value: d, + }))} + onChange={itemValue => setUserDomain(itemValue)} + enabled={!isProcessing} + /> + </View> + )} + <View style={[pal.border, styles.groupContent]}> + <Text style={[pal.textLight, s.p10]}> + Your full username will be{' '} + <Text type="md-bold" style={pal.textLight}> + @{createFullHandle(handle, userDomain)} + </Text> + </Text> + </View> + </View> + <View style={styles.groupLabel}> + <Text type="sm-bold" style={pal.text}> + Legal + </Text> + </View> + <View style={[pal.border, styles.group]}> + <View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <TouchableOpacity + testID="registerIs13Input" + style={styles.textBtn} + onPress={() => setIs13(!is13)}> + <View + style={[ + pal.border, + is13 ? styles.checkboxFilled : styles.checkbox, + ]}> + {is13 && ( + <FontAwesomeIcon icon="check" style={s.blue3} size={14} /> + )} + </View> + <Text style={[pal.text, styles.textBtnLabel]}> + I am 13 years old or older + </Text> + </TouchableOpacity> + </View> + </View> + <Policies serviceDescription={serviceDescription} /> + </> + ) : undefined} + <View style={[s.flexRow, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack}> + <Text type="xl" style={pal.link}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isReady ? ( + <TouchableOpacity + testID="createAccountButton" + onPress={onPressNext}> + {isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Next + </Text> + )} + </TouchableOpacity> + ) : !serviceDescription && error ? ( + <TouchableOpacity + testID="registerRetryButton" + onPress={onPressRetryConnect}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Retry + </Text> + </TouchableOpacity> + ) : !serviceDescription ? ( + <> + <ActivityIndicator color="#fff" /> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Connecting... + </Text> + </> + ) : undefined} + </View> + <View style={s.footerSpacer} /> + </KeyboardAvoidingView> + </ScrollView> + ) +} + +const Policies = ({ + serviceDescription, +}: { + serviceDescription: ServiceDescription +}) => { + const pal = usePalette('default') + if (!serviceDescription) { + return <View /> + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + <View style={styles.policies}> + <View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}> + <FontAwesomeIcon + icon="exclamation" + style={pal.textLight as FontAwesomeIconStyle} + size={10} + /> + </View> + <Text style={[pal.textLight, s.pl5, s.flex1]}> + This service has not provided terms of service or a privacy policy. + </Text> + </View> + ) + } + const els = [] + if (tos) { + els.push( + <TextLink + key="tos" + href={tos} + text="Terms of Service" + style={[pal.link, s.underline]} + />, + ) + } + if (pp) { + els.push( + <TextLink + key="pp" + href={pp} + text="Privacy Policy" + style={[pal.link, s.underline]} + />, + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + <Text key="and" style={pal.textLight}> + {' '} + and{' '} + </Text>, + ) + } + return ( + <View style={styles.policies}> + <Text style={pal.textLight}> + By creating an account you agree to the {els}. + </Text> + </View> + ) +} + +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 ( + <ScrollView testID="createAccount" style={pal.view}> + <KeyboardAvoidingView behavior="padding"> + <View style={styles.stepContainer}> + {model.step === 1 && <Step1 model={model} />} + {model.step === 2 && <Step2 model={model} />} + {model.step === 3 && <Step3 model={model} />} + </View> + <View style={[s.flexRow, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBackInner}> + <Text type="xl" style={pal.link}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {model.canNext ? ( + <TouchableOpacity + testID="createAccountButton" + onPress={onPressNext}> + {model.isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Next + </Text> + )} + </TouchableOpacity> + ) : model.didServiceDescriptionFetchFail ? ( + <TouchableOpacity + testID="registerRetryButton" + onPress={onPressRetryConnect}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Retry + </Text> + </TouchableOpacity> + ) : model.isFetchingServiceDescription ? ( + <> + <ActivityIndicator color="#fff" /> + <Text type="xl" style={[pal.text, s.pr5]}> + Connecting... + </Text> + </> + ) : undefined} + </View> + <View style={s.footerSpacer} /> + </KeyboardAvoidingView> + </ScrollView> + ) + }, +) + +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 <View /> + } + const tos = validWebLink(serviceDescription.links?.termsOfService) + const pp = validWebLink(serviceDescription.links?.privacyPolicy) + if (!tos && !pp) { + return ( + <View style={styles.policies}> + <View style={[styles.errorIcon, {borderColor: pal.colors.text}, s.mt2]}> + <FontAwesomeIcon + icon="exclamation" + style={pal.textLight as FontAwesomeIconStyle} + size={10} + /> + </View> + <Text style={[pal.textLight, s.pl5, s.flex1]}> + This service has not provided terms of service or a privacy policy. + </Text> + </View> + ) + } + const els = [] + if (tos) { + els.push( + <TextLink + key="tos" + href={tos} + text="Terms of Service" + style={[pal.link, s.underline]} + />, + ) + } + if (pp) { + els.push( + <TextLink + key="pp" + href={pp} + text="Privacy Policy" + style={[pal.link, s.underline]} + />, + ) + } + if (els.length === 2) { + els.splice( + 1, + 0, + <Text key="and" style={pal.textLight}> + {' '} + and{' '} + </Text>, + ) + } + return ( + <View style={styles.policies}> + <Text style={pal.textLight}> + By creating an account you agree to the {els}. + </Text> + </View> + ) +} + +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 ( + <View> + <StepHeader step="1" title="Your hosting provider" /> + <Text style={[pal.text, s.mb10]}> + This is the company that keeps you online. + </Text> + <Option + isSelected={isDefaultSelected} + label="Bluesky" + help=" (default)" + onPress={onPressDefault} + /> + <Option + isSelected={!isDefaultSelected} + label="Other" + onPress={onPressOther}> + <View style={styles.otherForm}> + <Text style={[pal.text, s.mb5]}> + Enter the address of your provider: + </Text> + <TextInput + icon="globe" + placeholder="Hosting provider address" + value={model.serviceUrl} + editable + onChange={onChangeServiceUrl} + /> + {LOGIN_INCLUDE_DEV_SERVERS && ( + <View style={[s.flexRow, s.mt10]}> + <Button + type="default" + style={s.mr5} + label="Staging" + onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} + /> + <Button + type="default" + label="Dev Server" + onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} + /> + </View> + )} + </View> + </Option> + {model.error ? ( + <ErrorMessage message={model.error} style={styles.error} /> + ) : ( + <HelpTip text="You can change hosting providers at any time." /> + )} + </View> + ) +}) + +function Option({ + children, + isSelected, + label, + help, + onPress, +}: React.PropsWithChildren<{ + isSelected: boolean + label: string + help?: string + onPress: () => void +}>) { + const theme = useTheme() + const pal = usePalette('default') + const circleFillStyle = React.useMemo( + () => ({ + backgroundColor: theme.palette.primary.background, + }), + [theme], + ) + + return ( + <View style={[styles.option, pal.border]}> + <TouchableWithoutFeedback onPress={onPress}> + <View style={styles.optionHeading}> + <View style={[styles.circle, pal.border]}> + {isSelected ? ( + <View style={[circleFillStyle, styles.circleFill]} /> + ) : undefined} + </View> + <Text type="xl" style={pal.text}> + {label} + {help ? ( + <Text type="xl" style={pal.textLight}> + {help} + </Text> + ) : undefined} + </Text> + </View> + </TouchableWithoutFeedback> + {isSelected && children} + </View> + ) +} + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + }, + + option: { + borderWidth: 1, + borderRadius: 6, + marginBottom: 10, + }, + optionHeading: { + flexDirection: 'row', + alignItems: 'center', + padding: 10, + }, + circle: { + width: 26, + height: 26, + borderRadius: 15, + padding: 4, + borderWidth: 1, + marginRight: 10, + }, + circleFill: { + width: 16, + height: 16, + borderRadius: 10, + }, + + otherForm: { + paddingBottom: 10, + paddingHorizontal: 12, + }, +}) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx new file mode 100644 index 000000000..1f3162880 --- /dev/null +++ b/src/view/com/auth/create/Step2.tsx @@ -0,0 +1,275 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {Text} from 'view/com/util/text/Text' +import {TextLink} from 'view/com/util/Link' +import {StepHeader} from './StepHeader' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {TextInput} from '../util/TextInput' +import {Policies} from './Policies' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' + +export const Step2 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + return ( + <View> + <StepHeader step="2" title="Your account" /> + + {model.isInviteCodeRequired && ( + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Invite code + </Text> + <TextInput + icon="ticket" + placeholder="Required for this provider" + value={model.inviteCode} + editable + onChange={model.setInviteCode} + /> + </View> + )} + + {!model.inviteCode && model.isInviteCodeRequired ? ( + <Text> + Don't have an invite code?{' '} + <TextLink text="Join the waitlist" href="#" style={pal.link} /> to try + the beta before it's publicly available. + </Text> + ) : ( + <> + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Email address + </Text> + <TextInput + icon="envelope" + placeholder="Enter your email address" + value={model.email} + editable + onChange={model.setEmail} + /> + </View> + + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Password + </Text> + <TextInput + icon="lock" + placeholder="Choose your password" + value={model.password} + editable + secureTextEntry + onChange={model.setPassword} + /> + </View> + + <View style={s.pb20}> + <Text type="md-medium" style={[pal.text, s.mb2]}> + Legal check + </Text> + <TouchableOpacity + testID="registerIs13Input" + style={[styles.toggleBtn, pal.border]} + onPress={() => model.setIs13(!model.is13)}> + <View style={[pal.borderDark, styles.checkbox]}> + {model.is13 && ( + <FontAwesomeIcon icon="check" style={s.blue3} size={16} /> + )} + </View> + <Text type="md" style={[pal.text, styles.toggleBtnLabel]}> + I am 13 years old or older + </Text> + </TouchableOpacity> + </View> + + {model.serviceDescription && ( + <Policies serviceDescription={model.serviceDescription} /> + )} + </> + )} + {model.error ? ( + <ErrorMessage message={model.error} style={styles.error} /> + ) : undefined} + </View> + ) +}) + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + marginTop: 10, + }, + + toggleBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + borderWidth: 1, + paddingHorizontal: 10, + paddingVertical: 10, + borderRadius: 6, + }, + toggleBtnLabel: { + flex: 1, + paddingHorizontal: 10, + }, + + checkbox: { + borderWidth: 1, + borderRadius: 2, + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, +}) + +/* + +<View style={[pal.borderDark, styles.group]}> +{serviceDescription?.inviteCodeRequired ? ( + <View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + style={[pal.text, styles.textInput]} + placeholder="Invite code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + autoFocus + keyboardAppearance={theme.colorScheme} + value={inviteCode} + onChangeText={setInviteCode} + onBlur={onBlurInviteCode} + editable={!isProcessing} + /> + </View> +) : undefined} +<View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerEmailInput" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + /> +</View> +<View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="Choose your password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + /> +</View> +</View> +</> +) : undefined} +{serviceDescription ? ( +<> +<View style={styles.groupLabel}> +<Text type="sm-bold" style={pal.text}> + Choose your username +</Text> +</View> +<View style={[pal.border, styles.group]}> +<View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="registerHandleInput" + style={[pal.text, styles.textInput]} + placeholder="eg alice" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + value={handle} + onChangeText={v => setHandle(makeValidHandle(v))} + editable={!isProcessing} + /> +</View> +{serviceDescription.availableUserDomains.length > 1 && ( + <View style={[pal.border, styles.groupContent]}> + <FontAwesomeIcon + icon="globe" + style={styles.groupContentIcon} + /> + <Picker + style={[pal.text, styles.picker]} + labelStyle={styles.pickerLabel} + iconStyle={pal.textLight as FontAwesomeIconStyle} + value={userDomain} + items={serviceDescription.availableUserDomains.map(d => ({ + label: `.${d}`, + value: d, + }))} + onChange={itemValue => setUserDomain(itemValue)} + enabled={!isProcessing} + /> + </View> +)} +<View style={[pal.border, styles.groupContent]}> + <Text style={[pal.textLight, s.p10]}> + Your full username will be{' '} + <Text type="md-bold" style={pal.textLight}> + @{createFullHandle(handle, userDomain)} + </Text> + </Text> +</View> +</View> +<View style={styles.groupLabel}> +<Text type="sm-bold" style={pal.text}> + Legal +</Text> +</View> +<View style={[pal.border, styles.group]}> +<View + style={[pal.border, styles.groupContent, styles.noTopBorder]}> + <TouchableOpacity + testID="registerIs13Input" + style={styles.textBtn} + onPress={() => setIs13(!is13)}> + <View + style={[ + pal.border, + is13 ? styles.checkboxFilled : styles.checkbox, + ]}> + {is13 && ( + <FontAwesomeIcon icon="check" style={s.blue3} size={14} /> + )} + </View> + <Text style={[pal.text, styles.textBtnLabel]}> + I am 13 years old or older + </Text> + </TouchableOpacity> +</View> +</View>*/ diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx new file mode 100644 index 000000000..652591171 --- /dev/null +++ b/src/view/com/auth/create/Step3.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {CreateAccountModel} from 'state/models/ui/create-account' +import {Text} from 'view/com/util/text/Text' +import {StepHeader} from './StepHeader' +import {s} from 'lib/styles' +import {TextInput} from '../util/TextInput' +import {createFullHandle} from 'lib/strings/handles' +import {usePalette} from 'lib/hooks/usePalette' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' + +export const Step3 = observer(({model}: {model: CreateAccountModel}) => { + const pal = usePalette('default') + return ( + <View> + <StepHeader step="3" title="Your user handle" /> + <View style={s.pb10}> + <TextInput + icon="at" + placeholder="eg alice" + value={model.handle} + editable + onChange={model.setHandle} + /> + <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> + Your full handle will be{' '} + <Text type="lg-bold" style={pal.text}> + @{createFullHandle(model.handle, model.userDomain)} + </Text> + </Text> + </View> + {model.error ? ( + <ErrorMessage message={model.error} style={styles.error} /> + ) : undefined} + </View> + ) +}) + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + }, +}) diff --git a/src/view/com/auth/create/StepHeader.tsx b/src/view/com/auth/create/StepHeader.tsx new file mode 100644 index 000000000..8c852b640 --- /dev/null +++ b/src/view/com/auth/create/StepHeader.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {Text} from 'view/com/util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +export function StepHeader({step, title}: {step: string; title: string}) { + const pal = usePalette('default') + return ( + <View style={styles.container}> + <Text type="lg" style={pal.textLight}> + {step === '3' ? 'Last step!' : <>Step {step} of 3</>} + </Text> + <Text type="title-xl">{title}</Text> + </View> + ) +} + +const styles = StyleSheet.create({ + container: { + marginBottom: 20, + }, +}) |