diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/login/CreateAccount.tsx | 443 | ||||
-rw-r--r-- | src/view/com/login/Logo.tsx | 42 | ||||
-rw-r--r-- | src/view/com/login/Signin.tsx | 262 |
3 files changed, 747 insertions, 0 deletions
diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx new file mode 100644 index 000000000..37dcba2fd --- /dev/null +++ b/src/view/com/login/CreateAccount.tsx @@ -0,0 +1,443 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import * as EmailValidator from 'email-validator' +import {Logo} from './Logo' +import {Picker} from '../util/Picker' +import {TextLink} from '../util/Link' +import {s, colors} from '../../lib/styles' +import { + makeValidHandle, + createFullHandle, + toNiceDomain, +} from '../../../lib/strings' +import {useStores, DEFAULT_SERVICE} from '../../../state' +import {ServiceDescription} from '../../../state/models/session' +import {ServerInputModal} from '../../../state/models/shell-ui' +import {ComAtprotoAccountCreate} from '../../../third-party/api/index' + +export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => { + const store = useStores() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) + const [error, setError] = useState<string>('') + const [serviceDescription, setServiceDescription] = useState< + ServiceDescription | undefined + >(undefined) + const [userDomain, setUserDomain] = useState<string>('') + const [inviteCode, setInviteCode] = useState<string>('') + const [email, setEmail] = useState<string>('') + const [password, setPassword] = useState<string>('') + const [handle, setHandle] = useState<string>('') + + useEffect(() => { + let aborted = false + setError('') + setServiceDescription(undefined) + console.log('Fetching service description', serviceUrl) + store.session.describeService(serviceUrl).then( + desc => { + if (aborted) return + setServiceDescription(desc) + setUserDomain(desc.availableUserDomains[0]) + }, + err => { + if (aborted) return + console.error(err) + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + }, + ) + return () => { + aborted = true + } + }, [serviceUrl, store.session]) + + const onPressSelectService = () => { + store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) + } + + const onPressNext = 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, + }) + } 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.' + } + console.log(e) + setIsProcessing(false) + setError(errMsg.replace(/^Error:/, '')) + } + } + + const Policies = () => { + 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, s.mt2]}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <Text style={[s.white, 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 + href={tos} + text="Terms of Service" + style={[s.white, s.underline]} + />, + ) + } + if (pp) { + els.push( + <TextLink + href={pp} + text="Privacy Policy" + style={[s.white, s.underline]} + />, + ) + } + if (els.length === 2) { + els.splice(1, 0, <Text style={s.white}> and </Text>) + } + return ( + <View style={styles.policies}> + <Text style={s.white}> + By creating an account you agree to the {els}. + </Text> + </View> + ) + } + + return ( + <ScrollView style={{flex: 1}}> + <KeyboardAvoidingView behavior="padding" style={{flex: 1}}> + <View style={styles.logoHero}> + <Logo /> + </View> + {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.group]}> + <View style={styles.groupTitle}> + <Text style={[s.white, s.f18, s.bold]}>Create a new account</Text> + </View> + <View style={styles.groupContent}> + <FontAwesomeIcon icon="globe" style={styles.groupContentIcon} /> + <TouchableOpacity + style={styles.textBtn} + onPress={onPressSelectService}> + <Text style={styles.textBtnLabel}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={styles.textBtnFakeInnerBtn}> + <FontAwesomeIcon + icon="pen" + size={12} + style={styles.textBtnFakeInnerBtnIcon} + /> + <Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text> + </View> + </TouchableOpacity> + </View> + {serviceDescription ? ( + <> + {serviceDescription?.inviteCodeRequired ? ( + <View style={styles.groupContent}> + <FontAwesomeIcon + icon="ticket" + style={styles.groupContentIcon} + /> + <TextInput + style={[styles.textInput]} + placeholder="Invite code" + placeholderTextColor={colors.blue0} + autoCapitalize="none" + autoCorrect={false} + autoFocus + value={inviteCode} + onChangeText={setInviteCode} + editable={!isProcessing} + /> + </View> + ) : undefined} + <View style={styles.groupContent}> + <FontAwesomeIcon + icon="envelope" + style={styles.groupContentIcon} + /> + <TextInput + style={[styles.textInput]} + placeholder="Email address" + placeholderTextColor={colors.blue0} + autoCapitalize="none" + autoCorrect={false} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + /> + </View> + <View style={styles.groupContent}> + <FontAwesomeIcon icon="lock" style={styles.groupContentIcon} /> + <TextInput + style={[styles.textInput]} + placeholder="Choose your password" + placeholderTextColor={colors.blue0} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + /> + </View> + </> + ) : undefined} + </View> + {serviceDescription ? ( + <> + <View style={styles.group}> + <View style={styles.groupTitle}> + <Text style={[s.white, s.f18, s.bold]}> + Choose your username + </Text> + </View> + <View style={styles.groupContent}> + <FontAwesomeIcon icon="at" style={styles.groupContentIcon} /> + <TextInput + style={[styles.textInput]} + placeholder="eg alice" + placeholderTextColor={colors.blue0} + autoCapitalize="none" + value={handle} + onChangeText={v => setHandle(makeValidHandle(v))} + editable={!isProcessing} + /> + </View> + {serviceDescription.availableUserDomains.length > 1 && ( + <View style={styles.groupContent}> + <FontAwesomeIcon + icon="globe" + style={styles.groupContentIcon} + /> + <Picker + style={styles.picker} + labelStyle={styles.pickerLabel} + iconStyle={styles.pickerIcon} + value={userDomain} + items={serviceDescription.availableUserDomains.map(d => ({ + label: `.${d}`, + value: d, + }))} + onChange={itemValue => setUserDomain(itemValue)} + enabled={!isProcessing} + /> + </View> + )} + <View style={styles.groupContent}> + <Text style={[s.white, s.p10]}> + Your full username will be{' '} + <Text style={s.bold}> + @{createFullHandle(handle, userDomain)} + </Text> + </Text> + </View> + </View> + <Policies /> + </> + ) : undefined} + <View style={[s.flexRow, s.pl20, s.pr20, {paddingBottom: 200}]}> + <TouchableOpacity onPress={onPressBack}> + <Text style={[s.white, s.f18, s.pl5]}>Back</Text> + </TouchableOpacity> + <View style={s.flex1} /> + {serviceDescription ? ( + <TouchableOpacity onPress={onPressNext}> + {isProcessing ? ( + <ActivityIndicator color="#fff" /> + ) : ( + <Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text> + )} + </TouchableOpacity> + ) : undefined} + </View> + </KeyboardAvoidingView> + </ScrollView> + ) +} + +function validWebLink(url?: string): string | undefined { + return url && (url.startsWith('http://') || url.startsWith('https://')) + ? url + : undefined +} + +const styles = StyleSheet.create({ + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + backgroundColor: colors.blue3, + }, + groupTitle: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + }, + groupContent: { + borderTopWidth: 1, + borderTopColor: colors.blue1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + color: 'white', + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + backgroundColor: colors.blue3, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + borderRadius: 10, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.blue2, + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + color: colors.white, + marginRight: 4, + }, + textBtnFakeInnerBtnLabel: { + color: colors.white, + }, + picker: { + flex: 1, + width: '100%', + backgroundColor: colors.blue3, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + borderRadius: 10, + }, + pickerLabel: { + color: colors.white, + fontSize: 18, + }, + pickerIcon: { + color: colors.white, + }, + policies: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: 20, + paddingBottom: 20, + }, + error: { + borderWidth: 1, + borderColor: colors.red5, + 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, + color: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, +}) diff --git a/src/view/com/login/Logo.tsx b/src/view/com/login/Logo.tsx new file mode 100644 index 000000000..d1dc9c671 --- /dev/null +++ b/src/view/com/login/Logo.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Svg, {Circle, Line, Text as SvgText} from 'react-native-svg' + +export const Logo = () => { + return ( + <View style={styles.logo}> + <Svg width="100" height="100"> + <Circle + cx="50" + cy="50" + r="46" + fill="none" + stroke="white" + strokeWidth={2} + /> + <Line stroke="white" strokeWidth={1} x1="30" x2="30" y1="0" y2="100" /> + <Line stroke="white" strokeWidth={1} x1="74" x2="74" y1="0" y2="100" /> + <Line stroke="white" strokeWidth={1} x1="0" x2="100" y1="22" y2="22" /> + <Line stroke="white" strokeWidth={1} x1="0" x2="100" y1="74" y2="74" /> + <SvgText + fill="none" + stroke="white" + strokeWidth={2} + fontSize="60" + fontWeight="bold" + x="52" + y="70" + textAnchor="middle"> + B + </SvgText> + </Svg> + </View> + ) +} + +const styles = StyleSheet.create({ + logo: { + flexDirection: 'row', + justifyContent: 'center', + }, +}) diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx new file mode 100644 index 000000000..f11a4c6ca --- /dev/null +++ b/src/view/com/login/Signin.tsx @@ -0,0 +1,262 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + KeyboardAvoidingView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Logo} from './Logo' +import {s, colors} from '../../lib/styles' +import {createFullHandle, toNiceDomain} from '../../../lib/strings' +import {useStores, DEFAULT_SERVICE} from '../../../state' +import {ServiceDescription} from '../../../state/models/session' +import {ServerInputModal} from '../../../state/models/shell-ui' +import {isNetworkError} from '../../../lib/errors' + +export const Signin = ({onPressBack}: {onPressBack: () => void}) => { + const store = useStores() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) + const [serviceDescription, setServiceDescription] = useState< + ServiceDescription | undefined + >(undefined) + const [error, setError] = useState<string>('') + const [handle, setHandle] = useState<string>('') + const [password, setPassword] = useState<string>('') + + useEffect(() => { + let aborted = false + setError('') + console.log('Fetching service description', serviceUrl) + store.session.describeService(serviceUrl).then( + desc => { + if (aborted) return + setServiceDescription(desc) + }, + err => { + if (aborted) return + console.error(err) + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + }, + ) + return () => { + aborted = true + } + }, [store.session, serviceUrl]) + + const onPressSelectService = () => { + store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl)) + } + + const onPressNext = async () => { + setError('') + setIsProcessing(true) + + // try to guess the handle if the user just gave their own username + try { + let fullHandle = handle + if ( + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullHandle.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullHandle = createFullHandle( + handle, + serviceDescription.availableUserDomains[0], + ) + } + } + + await store.session.login({ + service: serviceUrl, + handle: fullHandle, + password, + }) + } catch (e: any) { + const errMsg = e.toString() + console.log(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(errMsg.replace(/^Error:/, '')) + } + } + } + + return ( + <KeyboardAvoidingView behavior="padding" style={{flex: 1}}> + <View style={styles.logoHero}> + <Logo /> + </View> + <View style={styles.group}> + <TouchableOpacity + style={[styles.groupTitle, {paddingRight: 0, paddingVertical: 6}]} + onPress={onPressSelectService}> + <Text style={[s.flex1, s.white, s.f18, s.bold]} numberOfLines={1}> + Sign in to {toNiceDomain(serviceUrl)} + </Text> + <View style={styles.textBtnFakeInnerBtn}> + <FontAwesomeIcon + icon="pen" + size={12} + style={styles.textBtnFakeInnerBtnIcon} + /> + <Text style={styles.textBtnFakeInnerBtnLabel}>Change</Text> + </View> + </TouchableOpacity> + <View style={styles.groupContent}> + <FontAwesomeIcon icon="at" style={styles.groupContentIcon} /> + <TextInput + style={styles.textInput} + placeholder="Username" + placeholderTextColor={colors.blue0} + autoCapitalize="none" + autoFocus + autoCorrect={false} + value={handle} + onChangeText={str => setHandle((str || '').toLowerCase())} + editable={!isProcessing} + /> + </View> + <View style={styles.groupContent}> + <FontAwesomeIcon icon="lock" style={styles.groupContentIcon} /> + <TextInput + style={styles.textInput} + placeholder="Password" + placeholderTextColor={colors.blue0} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <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={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack}> + <Text style={[s.white, s.f18, s.pl5]}>Back</Text> + </TouchableOpacity> + <View style={s.flex1} /> + <TouchableOpacity onPress={onPressNext}> + {!serviceDescription || isProcessing ? ( + <ActivityIndicator color="#fff" /> + ) : ( + <Text style={[s.white, s.f18, s.bold, s.pr5]}>Next</Text> + )} + </TouchableOpacity> + {!serviceDescription || isProcessing ? ( + <Text style={[s.white, s.f18, s.pl10]}>Connecting...</Text> + ) : undefined} + </View> + </KeyboardAvoidingView> + ) +} + +const styles = StyleSheet.create({ + logoHero: { + paddingTop: 30, + paddingBottom: 40, + }, + group: { + borderWidth: 1, + borderColor: colors.white, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + backgroundColor: colors.blue3, + }, + groupTitle: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 12, + }, + groupContent: { + borderTopWidth: 1, + borderTopColor: colors.blue1, + flexDirection: 'row', + alignItems: 'center', + }, + groupContentIcon: { + color: 'white', + marginLeft: 10, + }, + textInput: { + flex: 1, + width: '100%', + backgroundColor: colors.blue3, + color: colors.white, + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 18, + borderRadius: 10, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.blue2, + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtnFakeInnerBtnIcon: { + color: colors.white, + marginRight: 4, + }, + textBtnFakeInnerBtnLabel: { + color: colors.white, + }, + error: { + borderWidth: 1, + borderColor: colors.red5, + 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, + }, +}) |