diff options
Diffstat (limited to 'src/view/com/auth')
-rw-r--r-- | src/view/com/auth/CreateAccount.tsx | 584 | ||||
-rw-r--r-- | src/view/com/auth/LoggedOut.tsx | 67 | ||||
-rw-r--r-- | src/view/com/auth/Logo.tsx | 26 | ||||
-rw-r--r-- | src/view/com/auth/Signin.tsx | 895 | ||||
-rw-r--r-- | src/view/com/auth/SplashScreen.tsx | 92 | ||||
-rw-r--r-- | src/view/com/auth/SplashScreen.web.tsx | 102 | ||||
-rw-r--r-- | src/view/com/auth/withAuthRequired.tsx | 47 |
7 files changed, 1813 insertions, 0 deletions
diff --git a/src/view/com/auth/CreateAccount.tsx b/src/view/com/auth/CreateAccount.tsx new file mode 100644 index 000000000..a24dc4e35 --- /dev/null +++ b/src/view/com/auth/CreateAccount.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/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx new file mode 100644 index 000000000..47dd51d9c --- /dev/null +++ b/src/view/com/auth/LoggedOut.tsx @@ -0,0 +1,67 @@ +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 {ErrorBoundary} from 'view/com/util/ErrorBoundary' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {useAnalytics} from 'lib/analytics' +import {SplashScreen} from './SplashScreen' +import {CenteredView} from '../util/Views' + +enum ScreenState { + S_SigninOrCreateAccount, + S_Signin, + S_CreateAccount, +} + +export const LoggedOut = observer(() => { + const pal = usePalette('default') + const store = useStores() + const {screen} = useAnalytics() + const [screenState, setScreenState] = React.useState<ScreenState>( + ScreenState.S_SigninOrCreateAccount, + ) + + React.useEffect(() => { + screen('Login') + store.shell.setMinimalShellMode(true) + }, [store, screen]) + + if ( + store.session.isResumingSession || + screenState === ScreenState.S_SigninOrCreateAccount + ) { + return ( + <SplashScreen + onPressSignin={() => setScreenState(ScreenState.S_Signin)} + onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} + /> + ) + } + + return ( + <CenteredView style={[s.hContentRegion, pal.view]}> + <SafeAreaView testID="noSessionView" style={s.hContentRegion}> + <ErrorBoundary> + {screenState === ScreenState.S_Signin ? ( + <Signin + onPressBack={() => + setScreenState(ScreenState.S_SigninOrCreateAccount) + } + /> + ) : undefined} + {screenState === ScreenState.S_CreateAccount ? ( + <CreateAccount + onPressBack={() => + setScreenState(ScreenState.S_SigninOrCreateAccount) + } + /> + ) : undefined} + </ErrorBoundary> + </SafeAreaView> + </CenteredView> + ) +}) diff --git a/src/view/com/auth/Logo.tsx b/src/view/com/auth/Logo.tsx new file mode 100644 index 000000000..ac408cd2f --- /dev/null +++ b/src/view/com/auth/Logo.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {s, colors} from 'lib/styles' +import {Text} from '../util/text/Text' + +export const LogoTextHero = () => { + return ( + <View style={[styles.textHero]}> + <Text type="title-lg" style={[s.white, s.bold]}> + Bluesky + </Text> + </View> + ) +} + +const styles = StyleSheet.create({ + textHero: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingRight: 20, + paddingVertical: 15, + marginBottom: 20, + backgroundColor: colors.blue3, + }, +}) diff --git a/src/view/com/auth/Signin.tsx b/src/view/com/auth/Signin.tsx new file mode 100644 index 000000000..6faf5ff12 --- /dev/null +++ b/src/view/com/auth/Signin.tsx @@ -0,0 +1,895 @@ +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<string>('') + const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) + const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) + const [serviceDescription, setServiceDescription] = useState< + ServiceDescription | undefined + >(undefined) + const [initialHandle, setInitialHandle] = useState<string>('') + const [currentForm, setCurrentForm] = useState<Forms>( + 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 ( + <KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}> + {currentForm === Forms.Login ? ( + <LoginForm + store={store} + error={error} + serviceUrl={serviceUrl} + serviceDescription={serviceDescription} + initialHandle={initialHandle} + setError={setError} + setServiceUrl={setServiceUrl} + onPressBack={onPressBack} + onPressForgotPassword={onPressForgotPassword} + onPressRetryConnect={onPressRetryConnect} + /> + ) : undefined} + {currentForm === Forms.ChooseAccount ? ( + <ChooseAccountForm + store={store} + onSelectAccount={onSelectAccount} + onPressBack={onPressBack} + /> + ) : undefined} + {currentForm === Forms.ForgotPassword ? ( + <ForgotPasswordForm + store={store} + error={error} + serviceUrl={serviceUrl} + serviceDescription={serviceDescription} + setError={setError} + setServiceUrl={setServiceUrl} + onPressBack={gotoForm(Forms.Login)} + onEmailSent={gotoForm(Forms.SetNewPassword)} + /> + ) : undefined} + {currentForm === Forms.SetNewPassword ? ( + <SetNewPasswordForm + store={store} + error={error} + serviceUrl={serviceUrl} + setError={setError} + onPressBack={gotoForm(Forms.ForgotPassword)} + onPasswordSet={gotoForm(Forms.PasswordUpdated)} + /> + ) : undefined} + {currentForm === Forms.PasswordUpdated ? ( + <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + ) : undefined} + </KeyboardAvoidingView> + ) +} + +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 ( + <View testID="chooseAccountForm"> + <LogoTextHero /> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + Sign in as... + </Text> + {store.session.accounts.map(account => ( + <TouchableOpacity + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[pal.borderDark, styles.group, s.mb5]} + onPress={() => onTryAccount(account)}> + <View + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <View style={s.p10}> + <UserAvatar avatar={account.aviUrl} size={30} /> + </View> + <Text style={styles.accountText}> + <Text type="lg-bold" style={pal.text}> + {account.displayName || account.handle}{' '} + </Text> + <Text type="lg" style={[pal.textLight]}> + {account.handle} + </Text> + </Text> + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + </View> + </TouchableOpacity> + ))} + <TouchableOpacity + testID="chooseNewAccountBtn" + style={[pal.borderDark, styles.group]} + onPress={() => onSelectAccount(undefined)}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <Text style={[styles.accountText, styles.accountTextOther]}> + <Text type="lg" style={pal.text}> + Other account + </Text> + </Text> + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + </View> + </TouchableOpacity> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack}> + <Text type="xl" style={[pal.link, s.pl5]}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing && <ActivityIndicator />} + </View> + </View> + ) +} + +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<boolean>(false) + const [identifier, setIdentifier] = useState<string>(initialHandle) + const [password, setPassword] = useState<string>('') + + 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 ( + <View testID="loginForm"> + <LogoTextHero /> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + Sign into + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TouchableOpacity + testID="loginSelectServiceButton" + 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 as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + </View> + </View> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + Account + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginUsernameInput" + style={[pal.text, styles.textInput]} + placeholder="Username or email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + value={identifier} + onChangeText={str => setIdentifier((str || '').toLowerCase())} + editable={!isProcessing} + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="Password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + /> + <TouchableOpacity + testID="forgotPasswordButton" + style={styles.textInputInnerBtn} + onPress={onPressForgotPassword}> + <Text style={pal.link}>Forgot</Text> + </TouchableOpacity> + </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 type="xl" style={[pal.link, s.pl5]}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription && error ? ( + <TouchableOpacity + testID="loginRetryButton" + onPress={onPressRetryConnect}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Retry + </Text> + </TouchableOpacity> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text type="xl" style={[pal.textLight, s.pl10]}> + Connecting... + </Text> + </> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : isReady ? ( + <TouchableOpacity testID="loginNextButton" onPress={onPressNext}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Next + </Text> + </TouchableOpacity> + ) : undefined} + </View> + </View> + ) +} + +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<boolean>(false) + const [email, setEmail] = useState<string>('') + 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 ( + <> + <LogoTextHero /> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + Reset password + </Text> + <Text type="md" style={[pal.text, styles.instructions]}> + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + </Text> + <View + testID="forgotPasswordView" + style={[pal.borderDark, pal.view, styles.group]}> + <TouchableOpacity + testID="forgotPasswordSelectServiceButton" + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} + onPress={onPressSelectService}> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <Text style={[pal.text, styles.textInput]} numberOfLines={1}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="forgotPasswordEmail" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + value={email} + onChangeText={setEmail} + 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 type="xl" style={[pal.link, s.pl5]}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription || isProcessing ? ( + <ActivityIndicator /> + ) : !email ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + Next + </Text> + ) : ( + <TouchableOpacity testID="newPasswordButton" onPress={onPressNext}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Next + </Text> + </TouchableOpacity> + )} + {!serviceDescription || isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + Processing... + </Text> + ) : undefined} + </View> + </View> + </> + ) +} + +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<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [password, setPassword] = useState<string>('') + + 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 ( + <> + <LogoTextHero /> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + Set new password + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + </Text> + <View + testID="newPasswordView" + style={[pal.view, pal.borderDark, styles.group]}> + <View + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="resetCodeInput" + style={[pal.text, styles.textInput]} + placeholder="Reset code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + autoFocus + value={resetCode} + onChangeText={setResetCode} + editable={!isProcessing} + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="newPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="New password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + 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 type="xl" style={[pal.link, s.pl5]}> + Back + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing ? ( + <ActivityIndicator /> + ) : !resetCode || !password ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + Next + </Text> + ) : ( + <TouchableOpacity + testID="setNewPasswordButton" + onPress={onPressNext}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Next + </Text> + </TouchableOpacity> + )} + {isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + Updating... + </Text> + ) : undefined} + </View> + </View> + </> + ) +} + +const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { + const {screen} = useAnalytics() + + // useEffect(() => { + screen('Signin:PasswordUpdatedForm') + // }, [screen]) + + const pal = usePalette('default') + return ( + <> + <LogoTextHero /> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + Password updated! + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + You can now sign in with your new password. + </Text> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <View style={s.flex1} /> + <TouchableOpacity onPress={onPressNext}> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + Okay + </Text> + </TouchableOpacity> + </View> + </View> + </> + ) +} + +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 new file mode 100644 index 000000000..27943f64d --- /dev/null +++ b/src/view/com/auth/SplashScreen.tsx @@ -0,0 +1,92 @@ +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 {usePalette} from 'lib/hooks/usePalette' +import {CLOUD_SPLASH} from 'lib/assets' +import {CenteredView} from '../util/Views' + +export const SplashScreen = ({ + onPressSignin, + onPressCreateAccount, +}: { + onPressSignin: () => void + onPressCreateAccount: () => void +}) => { + const pal = usePalette('default') + return ( + <CenteredView style={styles.container}> + <Image source={CLOUD_SPLASH as ImageSource} style={styles.bgImg} /> + <SafeAreaView testID="noSessionView" style={styles.container}> + <ErrorBoundary> + <View style={styles.hero}> + <View style={styles.heroText}> + <Text style={styles.title}>Bluesky</Text> + </View> + </View> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[pal.view, styles.btn]} + onPress={onPressCreateAccount}> + <Text style={[pal.link, styles.btnLabel]}> + Create a new account + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[pal.view, styles.btn]} + onPress={onPressSignin}> + <Text style={[pal.link, styles.btnLabel]}>Sign in</Text> + </TouchableOpacity> + </View> + </ErrorBoundary> + </SafeAreaView> + </CenteredView> + ) +} + +const styles = StyleSheet.create({ + container: { + height: '100%', + }, + hero: { + 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', + }, + btn: { + borderRadius: 4, + paddingVertical: 16, + marginBottom: 20, + marginHorizontal: 20, + backgroundColor: colors.blue3, + }, + btnLabel: { + textAlign: 'center', + fontSize: 21, + color: colors.white, + }, +}) diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx new file mode 100644 index 000000000..05d0355d9 --- /dev/null +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {Text} from 'view/com/util/text/Text' +import {TextLink} from '../util/Link' +import {ErrorBoundary} from 'view/com/util/ErrorBoundary' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {CenteredView} from '../util/Views' + +export const SplashScreen = ({ + onPressSignin, + onPressCreateAccount, +}: { + onPressSignin: () => void + onPressCreateAccount: () => void +}) => { + const pal = usePalette('default') + return ( + <CenteredView style={styles.container}> + <View testID="noSessionView" style={styles.containerInner}> + <ErrorBoundary> + <Text style={styles.title}>Bluesky</Text> + <Text style={styles.subtitle}>See what's next</Text> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount}> + <Text style={[s.white, styles.btnLabel]}> + Create a new account + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin}> + <Text style={[pal.link, styles.btnLabel]}>Sign in</Text> + </TouchableOpacity> + </View> + <Text + type="xl" + style={[styles.notice, pal.textLight]} + lineHeight={1.3}> + Bluesky will launch soon.{' '} + <TextLink + type="xl" + text="Join the waitlist" + href="#" + style={pal.link} + />{' '} + to try the beta before it's publicly available. + </Text> + </ErrorBoundary> + </View> + </CenteredView> + ) +} + +const styles = StyleSheet.create({ + container: { + height: '100%', + backgroundColor: colors.gray1, + }, + containerInner: { + backgroundColor: colors.white, + paddingVertical: 40, + paddingBottom: 50, + paddingHorizontal: 20, + }, + title: { + textAlign: 'center', + color: colors.blue3, + fontSize: 68, + fontWeight: 'bold', + paddingBottom: 10, + }, + subtitle: { + textAlign: 'center', + color: colors.gray5, + fontSize: 52, + fontWeight: 'bold', + paddingBottom: 30, + }, + btns: { + flexDirection: 'row', + paddingBottom: 40, + }, + btn: { + flex: 1, + borderRadius: 30, + paddingVertical: 12, + marginHorizontal: 10, + }, + btnLabel: { + textAlign: 'center', + fontSize: 18, + }, + notice: { + paddingHorizontal: 40, + textAlign: 'center', + }, +}) diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx new file mode 100644 index 000000000..11b67f383 --- /dev/null +++ b/src/view/com/auth/withAuthRequired.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {LoggedOut} from './LoggedOut' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' + +export const withAuthRequired = <P extends object>( + Component: React.ComponentType<P>, +): React.FC<P> => + observer((props: P) => { + const store = useStores() + if (store.session.isResumingSession) { + return <Loading /> + } + if (!store.session.hasSession) { + return <LoggedOut /> + } + return <Component {...props} /> + }) + +function Loading() { + const pal = usePalette('default') + return ( + <View style={[styles.loading, pal.view]}> + <ActivityIndicator size="large" /> + <Text type="2xl" style={[styles.loadingText, pal.textLight]}> + Firing up the grill... + </Text> + </View> + ) +} + +const styles = StyleSheet.create({ + loading: { + height: '100%', + alignContent: 'center', + justifyContent: 'center', + paddingBottom: 100, + }, + loadingText: { + paddingVertical: 20, + paddingHorizontal: 20, + textAlign: 'center', + }, +}) |