diff options
-rw-r--r-- | src/alf/atoms.ts | 6 | ||||
-rw-r--r-- | src/components/forms/TextField.tsx | 8 | ||||
-rw-r--r-- | src/components/icons/Lock.tsx | 5 | ||||
-rw-r--r-- | src/components/icons/Pencil.tsx | 5 | ||||
-rw-r--r-- | src/screens/Login/ChooseAccountForm.tsx | 188 | ||||
-rw-r--r-- | src/screens/Login/LoginForm.tsx | 301 | ||||
-rw-r--r-- | src/screens/Login/index.tsx | 173 | ||||
-rw-r--r-- | src/view/com/auth/LoggedOut.tsx | 16 | ||||
-rw-r--r-- | src/view/com/auth/login/ChooseAccountForm.tsx | 158 | ||||
-rw-r--r-- | src/view/com/auth/login/Login.tsx | 164 | ||||
-rw-r--r-- | src/view/com/auth/login/LoginForm.tsx | 298 | ||||
-rw-r--r-- | src/view/com/auth/server-input/index.tsx | 2 |
12 files changed, 694 insertions, 630 deletions
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 5088e3aac..fef68ecab 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -154,6 +154,12 @@ export const atoms = { align_end: { alignItems: 'flex-end', }, + align_baseline: { + alignItems: 'baseline', + }, + align_stretch: { + alignItems: 'stretch', + }, self_auto: { alignSelf: 'auto', }, diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index b37f4bfae..3cffe5b2b 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -14,6 +14,7 @@ import {useTheme, atoms as a, web, android} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Props as SVGIconProps} from '#/components/icons/common' +import {mergeRefs} from '#/lib/merge-refs' const Context = React.createContext<{ inputRef: React.RefObject<TextInput> | null @@ -128,6 +129,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { value: string onChangeText: (value: string) => void isInvalid?: boolean + inputRef?: React.RefObject<TextInput> } export function createInput(Component: typeof TextInput) { @@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) { value, onChangeText, isInvalid, + inputRef, ...rest }: InputProps) { const t = useTheme() @@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) { ) } + const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) + return ( <> <Component accessibilityHint={undefined} {...rest} accessibilityLabel={label} - ref={ctx.inputRef} + ref={refs} value={value} onChangeText={onChangeText} onFocus={ctx.onFocus} onBlur={ctx.onBlur} placeholder={placeholder || label} placeholderTextColor={t.palette.contrast_500} + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} hitSlop={HITSLOP_20} style={[ a.relative, diff --git a/src/components/icons/Lock.tsx b/src/components/icons/Lock.tsx new file mode 100644 index 000000000..87830b379 --- /dev/null +++ b/src/components/icons/Lock.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/Pencil.tsx b/src/components/icons/Pencil.tsx new file mode 100644 index 000000000..1b7fc17cf --- /dev/null +++ b/src/components/icons/Pencil.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Pencil_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M13.586 1.5a2 2 0 0 1 2.828 0L19.5 4.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 5.086 21H1a1 1 0 0 1-1-1v-4.086A2 2 0 0 1 .586 14.5l13-13ZM15 2.914l-13 13V19h3.086l13-13L15 2.914ZM11 20a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z', +}) diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx new file mode 100644 index 000000000..f5b3c2a86 --- /dev/null +++ b/src/screens/Login/ChooseAccountForm.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import {ScrollView, TouchableOpacity, View} from 'react-native' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {useAnalytics} from 'lib/analytics/analytics' +import {UserAvatar} from '../../view/com/util/UserAvatar' +import {colors} from 'lib/styles' +import {styles} from '../../view/com/auth/login/styles' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' +import {Button} from '#/components/Button' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' + +function Group({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <View + style={[ + a.rounded_md, + a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, + ]}> + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View style={[a.border_b, t.atoms.border_contrast_low]} /> + ) : null} + {React.cloneElement(child, { + // @ts-ignore + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + </React.Fragment> + ) : null + })} + </View> + ) +} + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + <TouchableOpacity + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[a.flex_1]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in as ${account.handle}`)} + accessibilityHint={_(msg`Double tap to sign in`)}> + <View style={[a.flex_1, a.flex_row, a.align_center, {height: 48}]}> + <View style={a.p_md}> + <UserAvatar avatar={profile?.avatar} size={24} /> + </View> + <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}> + <Text style={[a.font_bold]}> + {profile?.displayName || account.handle}{' '} + </Text> + <Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text> + </Text> + {isCurrentAccount ? ( + <Check size="sm" style={[{color: colors.green3}, a.mr_md]} /> + ) : ( + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + )} + </View> + </TouchableOpacity> + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const {_} = useLingui() + const t = useTheme() + const {accounts, currentAccount} = useSession() + const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() + const {gtMobile} = useBreakpoints() + + React.useEffect(() => { + screen('Choose Account') + }, [screen]) + + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(_(msg`Already signed in as @${account.handle}`)) + } else { + await initSession(account) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(_(msg`Signed in as @${account.handle}`)) + }, 100) + } + } else { + onSelectAccount(account) + } + }, + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], + ) + + return ( + <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> + <View style={!gtMobile && a.px_lg}> + <Text + style={[a.mt_md, a.mb_lg, a.font_bold, t.atoms.text_contrast_medium]}> + <Trans>Sign in as...</Trans> + </Text> + <Group> + {accounts.map(account => ( + <AccountItem + key={account.did} + account={account} + onSelect={onSelect} + isCurrentAccount={account.did === currentAccount?.did} + /> + ))} + <TouchableOpacity + testID="chooseNewAccountBtn" + style={[a.flex_1]} + onPress={() => onSelectAccount(undefined)} + accessibilityRole="button" + accessibilityLabel={_(msg`Login to account that is not listed`)} + accessibilityHint=""> + <View + style={[a.flex_row, a.flex_row, a.align_center, {height: 48}]}> + <Text + style={[ + a.align_baseline, + a.flex_1, + a.flex_row, + a.py_sm, + {paddingLeft: 48}, + ]}> + <Trans>Other account</Trans> + </Text> + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> + </View> + </TouchableOpacity> + </Group> + <View style={[a.flex_row, a.mt_lg]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressBack}> + {_(msg`Back`)} + </Button> + <View style={[a.flex_1]} /> + </View> + </View> + </ScrollView> + ) +} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx new file mode 100644 index 000000000..3089b3887 --- /dev/null +++ b/src/screens/Login/LoginForm.tsx @@ -0,0 +1,301 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + ScrollView, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' + +import {useAnalytics} from 'lib/analytics/analytics' +import {s} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {useSessionApi} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {styles} from '../../view/com/auth/login/styles' +import {useLingui} from '@lingui/react' +import {useDialogControl} from '#/components/Dialog' +import {ServerInputDialog} from '../../view/com/auth/server-input' +import {Button, ButtonText} from '#/components/Button' +import {isAndroid} from '#/platform/detection' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as TextField from '#/components/forms/TextField' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' +import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' +import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const LoginForm = ({ + error, + serviceUrl, + serviceDescription, + initialHandle, + setError, + setServiceUrl, + onPressRetryConnect, + onPressBack, + onPressForgotPassword, +}: { + 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 t = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [identifier, setIdentifier] = useState<string>(initialHandle) + const [password, setPassword] = useState<string>('') + const passwordInputRef = useRef<TextInput>(null) + const {_} = useLingui() + const {login} = useSessionApi() + const serverInputControl = useDialogControl() + const {gtMobile} = useBreakpoints() + + const onPressSelectService = () => { + serverInputControl.open() + Keyboard.dismiss() + track('Signin:PressedSelectService') + } + + const onPressNext = async () => { + Keyboard.dismiss() + 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], + ) + } + } + + // TODO remove double login + await login({ + service: serviceUrl, + identifier: fullIdent, + password, + }) + } catch (e: any) { + const errMsg = e.toString() + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + logger.debug('Failed to login due to invalid credentials', { + error: errMsg, + }) + setError(_(msg`Invalid username or password`)) + } else if (isNetworkError(e)) { + logger.warn('Failed to login due to network error', {error: errMsg}) + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + logger.warn('Failed to login', {error: errMsg}) + setError(cleanError(errMsg)) + } + } + } + + const isReady = !!serviceDescription && !!identifier && !!password + return ( + <ScrollView testID="loginForm" style={a.h_full}> + <View style={[a.gap_lg, !gtMobile && a.px_lg, a.flex_1]}> + <ServerInputDialog + control={serverInputControl} + onSelect={setServiceUrl} + /> + + <View> + <TextField.Label> + <Trans>Hosting provider</Trans> + </TextField.Label> + <TouchableOpacity + accessibilityRole="button" + style={[ + a.w_full, + a.flex_row, + a.align_center, + a.rounded_sm, + a.px_md, + a.gap_xs, + {paddingVertical: isAndroid ? 14 : 9}, + t.atoms.bg_contrast_25, + ]} + onPress={onPressSelectService}> + <TextField.Icon icon={Globe} /> + <Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text> + <View + style={[ + a.rounded_sm, + t.atoms.bg_contrast_100, + {marginLeft: 'auto', left: 6, padding: 6}, + ]}> + <Pencil + style={{color: t.palette.contrast_500}} + height={18} + width={18} + /> + </View> + </TouchableOpacity> + </View> + <View> + <TextField.Label> + <Trans>Account</Trans> + </TextField.Label> + <TextField.Root> + <TextField.Icon icon={At} /> + <TextField.Input + testID="loginUsernameInput" + label={_(msg`Username or email address`)} + autoCapitalize="none" + autoFocus + autoCorrect={false} + autoComplete="username" + returnKeyType="next" + textContentType="username" + onSubmitEditing={() => { + passwordInputRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + value={identifier} + onChangeText={str => + setIdentifier((str || '').toLowerCase().trim()) + } + editable={!isProcessing} + accessibilityHint={_( + msg`Input the username or email address you used at signup`, + )} + /> + </TextField.Root> + </View> + <View> + <TextField.Root> + <TextField.Icon icon={Lock} /> + <TextField.Input + testID="loginPasswordInput" + inputRef={passwordInputRef} + label={_(msg`Password`)} + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + enablesReturnKeyAutomatically={true} + secureTextEntry={true} + textContentType="password" + clearButtonMode="while-editing" + value={password} + onChangeText={setPassword} + onSubmitEditing={onPressNext} + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing + editable={!isProcessing} + accessibilityHint={ + identifier === '' + ? _(msg`Input your password`) + : _(msg`Input the password tied to ${identifier}`) + } + /> + <TouchableOpacity + testID="forgotPasswordButton" + onPress={onPressForgotPassword} + accessibilityRole="button" + accessibilityLabel={_(msg`Forgot password`)} + accessibilityHint={_(msg`Opens password reset form`)} + style={[ + a.rounded_sm, + t.atoms.bg_contrast_100, + {marginLeft: 'auto', left: 6, padding: 6}, + a.z_10, + ]}> + <ButtonText style={t.atoms.text_contrast_medium}> + <Trans>Forgot?</Trans> + </ButtonText> + </TouchableOpacity> + </TextField.Root> + </View> + {error ? ( + <View style={[styles.error, {marginHorizontal: 0}]}> + <Warning style={s.white} size="sm" /> + <View style={(a.flex_1, a.ml_sm)}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[a.flex_row, a.align_center]}> + <Button + label={_(msg`Back`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressBack}> + {_(msg`Back`)} + </Button> + <View style={s.flex1} /> + {!serviceDescription && error ? ( + <Button + testID="loginRetryButton" + label={_(msg`Retry`)} + accessibilityHint={_(msg`Retries login`)} + variant="solid" + color="secondary" + size="small" + onPress={onPressRetryConnect}> + {_(msg`Retry`)} + </Button> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : isReady ? ( + <Button + label={_(msg`Next`)} + accessibilityHint={_(msg`Navigates to the next screen`)} + variant="solid" + color="primary" + size="small" + onPress={onPressNext}> + {_(msg`Next`)} + </Button> + ) : undefined} + </View> + </View> + </ScrollView> + ) +} diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx new file mode 100644 index 000000000..028a497d2 --- /dev/null +++ b/src/screens/Login/index.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import {KeyboardAvoidingView} from 'react-native' +import {useAnalytics} from '#/lib/analytics/analytics' +import {useLingui} from '@lingui/react' +import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' + +import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' +import {SessionAccount, useSession} from '#/state/session' +import {DEFAULT_SERVICE} from '#/lib/constants' +import {useLoggedOutView} from '#/state/shell/logged-out' +import {useServiceQuery} from '#/state/queries/service' +import {msg} from '@lingui/macro' +import {logger} from '#/logger' +import {atoms as a} from '#/alf' +import {ChooseAccountForm} from './ChooseAccountForm' +import {ForgotPasswordForm} from '#/view/com/auth/login/ForgotPasswordForm' +import {SetNewPasswordForm} from '#/view/com/auth/login/SetNewPasswordForm' +import {PasswordUpdatedForm} from '#/view/com/auth/login/PasswordUpdatedForm' +import {LoginForm} from '#/screens/Login/LoginForm' + +enum Forms { + Login, + ChooseAccount, + ForgotPassword, + SetNewPassword, + PasswordUpdated, +} + +export const Login = ({onPressBack}: {onPressBack: () => void}) => { + const {_} = useLingui() + + const {accounts} = useSession() + const {track} = useAnalytics() + const {requestedAccountSwitchTo} = useLoggedOutView() + const requestedAccount = accounts.find( + acc => acc.did === requestedAccountSwitchTo, + ) + + const [error, setError] = React.useState<string>('') + const [serviceUrl, setServiceUrl] = React.useState<string>( + requestedAccount?.service || DEFAULT_SERVICE, + ) + const [initialHandle, setInitialHandle] = React.useState<string>( + requestedAccount?.handle || '', + ) + const [currentForm, setCurrentForm] = React.useState<Forms>( + requestedAccount + ? Forms.Login + : accounts.length + ? Forms.ChooseAccount + : Forms.Login, + ) + + const { + data: serviceDescription, + error: serviceError, + refetch: refetchService, + } = useServiceQuery(serviceUrl) + + const onSelectAccount = (account?: SessionAccount) => { + if (account?.service) { + setServiceUrl(account.service) + } + setInitialHandle(account?.handle || '') + setCurrentForm(Forms.Login) + } + + const gotoForm = (form: Forms) => () => { + setError('') + setCurrentForm(form) + } + + React.useEffect(() => { + if (serviceError) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { + error: String(serviceError), + }) + } else { + setError('') + } + }, [serviceError, serviceUrl, _]) + + const onPressRetryConnect = () => refetchService() + const onPressForgotPassword = () => { + track('Signin:PressedForgotPassword') + setCurrentForm(Forms.ForgotPassword) + } + + let content = null + let title = '' + let description = '' + + switch (currentForm) { + case Forms.Login: + title = _(msg`Sign in`) + description = _(msg`Enter your username and password`) + content = ( + <LoginForm + error={error} + serviceUrl={serviceUrl} + serviceDescription={serviceDescription} + initialHandle={initialHandle} + setError={setError} + setServiceUrl={setServiceUrl} + onPressBack={onPressBack} + onPressForgotPassword={onPressForgotPassword} + onPressRetryConnect={onPressRetryConnect} + /> + ) + break + case Forms.ChooseAccount: + title = _(msg`Sign in`) + description = _(msg`Select from an existing account`) + content = ( + <ChooseAccountForm + onSelectAccount={onSelectAccount} + onPressBack={onPressBack} + /> + ) + break + case Forms.ForgotPassword: + title = _(msg`Forgot Password`) + description = _(msg`Let's get your password reset!`) + content = ( + <ForgotPasswordForm + error={error} + serviceUrl={serviceUrl} + serviceDescription={serviceDescription} + setError={setError} + setServiceUrl={setServiceUrl} + onPressBack={gotoForm(Forms.Login)} + onEmailSent={gotoForm(Forms.SetNewPassword)} + /> + ) + break + case Forms.SetNewPassword: + title = _(msg`Forgot Password`) + description = _(msg`Let's get your password reset!`) + content = ( + <SetNewPasswordForm + error={error} + serviceUrl={serviceUrl} + setError={setError} + onPressBack={gotoForm(Forms.ForgotPassword)} + onPasswordSet={gotoForm(Forms.PasswordUpdated)} + /> + ) + break + case Forms.PasswordUpdated: + title = _(msg`Password updated`) + description = _(msg`You can now sign in with your new password.`) + content = <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + break + } + + return ( + <KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}> + <LoggedOutLayout leadin="" title={title} description={description}> + <Animated.View + entering={FadeInRight} + exiting={FadeOutLeft} + key={currentForm}> + {content} + </Animated.View> + </LoggedOutLayout> + </KeyboardAvoidingView> + ) +} diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 603abbab2..58604ec9e 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -5,16 +5,16 @@ import {useLingui} from '@lingui/react' import {Trans, msg} from '@lingui/macro' import {useNavigation} from '@react-navigation/native' -import {isIOS, isNative} from 'platform/detection' -import {Login} from 'view/com/auth/login/Login' -import {CreateAccount} from 'view/com/auth/create/CreateAccount' -import {ErrorBoundary} from 'view/com/util/ErrorBoundary' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' +import {isIOS, isNative} from '#/platform/detection' +import {Login} from '#/screens/Login' +import {CreateAccount} from '#/view/com/auth/create/CreateAccount' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {s} from '#/lib/styles' +import {usePalette} from '#/lib/hooks/usePalette' +import {useAnalytics} from '#/lib/analytics/analytics' import {SplashScreen} from './SplashScreen' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import { useLoggedOutView, useLoggedOutViewControls, diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx deleted file mode 100644 index 32cd8315d..000000000 --- a/src/view/com/auth/login/ChooseAccountForm.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react' -import {ScrollView, TouchableOpacity, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useAnalytics} from 'lib/analytics/analytics' -import {Text} from '../../util/text/Text' -import {UserAvatar} from '../../util/UserAvatar' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {styles} from './styles' -import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useProfileQuery} from '#/state/queries/profile' -import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import * as Toast from '#/view/com/util/Toast' - -function AccountItem({ - account, - onSelect, - isCurrentAccount, -}: { - account: SessionAccount - onSelect: (account: SessionAccount) => void - isCurrentAccount: boolean -}) { - const pal = usePalette('default') - const {_} = useLingui() - const {data: profile} = useProfileQuery({did: account.did}) - - const onPress = React.useCallback(() => { - onSelect(account) - }, [account, onSelect]) - - return ( - <TouchableOpacity - testID={`chooseAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, pal.border, styles.account]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={_(msg`Sign in as ${account.handle}`)} - accessibilityHint={_(msg`Double tap to sign in`)}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <View style={s.p10}> - <UserAvatar avatar={profile?.avatar} size={30} /> - </View> - <Text style={styles.accountText}> - <Text type="lg-bold" style={pal.text}> - {profile?.displayName || account.handle}{' '} - </Text> - <Text type="lg" style={[pal.textLight]}> - {account.handle} - </Text> - </Text> - {isCurrentAccount ? ( - <FontAwesomeIcon - icon="check" - size={16} - style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]} - /> - ) : ( - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - )} - </View> - </TouchableOpacity> - ) -} -export const ChooseAccountForm = ({ - onSelectAccount, - onPressBack, -}: { - onSelectAccount: (account?: SessionAccount) => void - onPressBack: () => void -}) => { - const {track, screen} = useAnalytics() - const pal = usePalette('default') - const {_} = useLingui() - const {accounts, currentAccount} = useSession() - const {initSession} = useSessionApi() - const {setShowLoggedOut} = useLoggedOutViewControls() - - React.useEffect(() => { - screen('Choose Account') - }, [screen]) - - const onSelect = React.useCallback( - async (account: SessionAccount) => { - if (account.accessJwt) { - if (account.did === currentAccount?.did) { - setShowLoggedOut(false) - Toast.show(_(msg`Already signed in as @${account.handle}`)) - } else { - await initSession(account) - track('Sign In', {resumedSession: true}) - setTimeout(() => { - Toast.show(_(msg`Signed in as @${account.handle}`)) - }, 100) - } - } else { - onSelectAccount(account) - } - }, - [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], - ) - - return ( - <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> - <Text - type="2xl-medium" - style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> - <Trans>Sign in as...</Trans> - </Text> - {accounts.map(account => ( - <AccountItem - key={account.did} - account={account} - onSelect={onSelect} - isCurrentAccount={account.did === currentAccount?.did} - /> - ))} - <TouchableOpacity - testID="chooseNewAccountBtn" - style={[pal.view, pal.border, styles.account, styles.accountLast]} - onPress={() => onSelectAccount(undefined)} - accessibilityRole="button" - accessibilityLabel={_(msg`Login to account that is not listed`)} - accessibilityHint=""> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <Text style={[styles.accountText, styles.accountTextOther]}> - <Text type="lg" style={pal.text}> - <Trans>Other account</Trans> - </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} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - </View> - </ScrollView> - ) -} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx deleted file mode 100644 index bc931ac04..000000000 --- a/src/view/com/auth/login/Login.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React, {useState, useEffect} from 'react' -import {KeyboardAvoidingView} from 'react-native' -import {useAnalytics} from 'lib/analytics/analytics' -import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' -import {DEFAULT_SERVICE} from '#/lib/constants' -import {usePalette} from 'lib/hooks/usePalette' -import {logger} from '#/logger' -import {ChooseAccountForm} from './ChooseAccountForm' -import {LoginForm} from './LoginForm' -import {ForgotPasswordForm} from './ForgotPasswordForm' -import {SetNewPasswordForm} from './SetNewPasswordForm' -import {PasswordUpdatedForm} from './PasswordUpdatedForm' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' -import {useSession, SessionAccount} from '#/state/session' -import {useServiceQuery} from '#/state/queries/service' -import {useLoggedOutView} from '#/state/shell/logged-out' - -enum Forms { - Login, - ChooseAccount, - ForgotPassword, - SetNewPassword, - PasswordUpdated, -} - -export const Login = ({onPressBack}: {onPressBack: () => void}) => { - const {_} = useLingui() - const pal = usePalette('default') - - const {accounts} = useSession() - const {track} = useAnalytics() - const {requestedAccountSwitchTo} = useLoggedOutView() - const requestedAccount = accounts.find( - a => a.did === requestedAccountSwitchTo, - ) - - const [error, setError] = useState<string>('') - const [serviceUrl, setServiceUrl] = useState<string>( - requestedAccount?.service || DEFAULT_SERVICE, - ) - const [initialHandle, setInitialHandle] = useState<string>( - requestedAccount?.handle || '', - ) - const [currentForm, setCurrentForm] = useState<Forms>( - requestedAccount - ? Forms.Login - : accounts.length - ? Forms.ChooseAccount - : Forms.Login, - ) - - const { - data: serviceDescription, - error: serviceError, - refetch: refetchService, - } = useServiceQuery(serviceUrl) - - const onSelectAccount = (account?: SessionAccount) => { - if (account?.service) { - setServiceUrl(account.service) - } - setInitialHandle(account?.handle || '') - setCurrentForm(Forms.Login) - } - - const gotoForm = (form: Forms) => () => { - setError('') - setCurrentForm(form) - } - - useEffect(() => { - if (serviceError) { - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - logger.warn(`Failed to fetch service description for ${serviceUrl}`, { - error: String(serviceError), - }) - } else { - setError('') - } - }, [serviceError, serviceUrl, _]) - - const onPressRetryConnect = () => refetchService() - const onPressForgotPassword = () => { - track('Signin:PressedForgotPassword') - setCurrentForm(Forms.ForgotPassword) - } - - return ( - <KeyboardAvoidingView testID="signIn" behavior="padding" style={pal.view}> - {currentForm === Forms.Login ? ( - <LoggedOutLayout - leadin="" - title={_(msg`Sign in`)} - description={_(msg`Enter your username and password`)}> - <LoginForm - error={error} - serviceUrl={serviceUrl} - serviceDescription={serviceDescription} - initialHandle={initialHandle} - setError={setError} - setServiceUrl={setServiceUrl} - onPressBack={onPressBack} - onPressForgotPassword={onPressForgotPassword} - onPressRetryConnect={onPressRetryConnect} - /> - </LoggedOutLayout> - ) : undefined} - {currentForm === Forms.ChooseAccount ? ( - <LoggedOutLayout - leadin="" - title={_(msg`Sign in as...`)} - description={_(msg`Select from an existing account`)}> - <ChooseAccountForm - onSelectAccount={onSelectAccount} - onPressBack={onPressBack} - /> - </LoggedOutLayout> - ) : undefined} - {currentForm === Forms.ForgotPassword ? ( - <LoggedOutLayout - leadin="" - title={_(msg`Forgot Password`)} - description={_(msg`Let's get your password reset!`)}> - <ForgotPasswordForm - error={error} - serviceUrl={serviceUrl} - serviceDescription={serviceDescription} - setError={setError} - setServiceUrl={setServiceUrl} - onPressBack={gotoForm(Forms.Login)} - onEmailSent={gotoForm(Forms.SetNewPassword)} - /> - </LoggedOutLayout> - ) : undefined} - {currentForm === Forms.SetNewPassword ? ( - <LoggedOutLayout - leadin="" - title={_(msg`Forgot Password`)} - description={_(msg`Let's get your password reset!`)}> - <SetNewPasswordForm - error={error} - serviceUrl={serviceUrl} - setError={setError} - onPressBack={gotoForm(Forms.ForgotPassword)} - onPasswordSet={gotoForm(Forms.PasswordUpdated)} - /> - </LoggedOutLayout> - ) : undefined} - {currentForm === Forms.PasswordUpdated ? ( - <LoggedOutLayout - leadin="" - title={_(msg`Password updated`)} - description={_(msg`You can now sign in with your new password.`)}> - <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> - </LoggedOutLayout> - ) : undefined} - </KeyboardAvoidingView> - ) -} diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx deleted file mode 100644 index fdba9f203..000000000 --- a/src/view/com/auth/login/LoginForm.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import React, {useState, useRef} from 'react' -import { - ActivityIndicator, - Keyboard, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ComAtprotoServerDescribeServer} from '@atproto/api' -import {useAnalytics} from 'lib/analytics/analytics' -import {Text} from '../../util/text/Text' -import {s} from 'lib/styles' -import {createFullHandle} from 'lib/strings/handles' -import {toNiceDomain} from 'lib/strings/url-helpers' -import {isNetworkError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {useSessionApi} from '#/state/session' -import {cleanError} from 'lib/strings/errors' -import {logger} from '#/logger' -import {Trans, msg} from '@lingui/macro' -import {styles} from './styles' -import {useLingui} from '@lingui/react' -import {useDialogControl} from '#/components/Dialog' - -import {ServerInputDialog} from '../server-input' - -type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema - -export const LoginForm = ({ - error, - serviceUrl, - serviceDescription, - initialHandle, - setError, - setServiceUrl, - onPressRetryConnect, - onPressBack, - onPressForgotPassword, -}: { - 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 passwordInputRef = useRef<TextInput>(null) - const {_} = useLingui() - const {login} = useSessionApi() - const serverInputControl = useDialogControl() - - const onPressSelectService = () => { - serverInputControl.open() - Keyboard.dismiss() - track('Signin:PressedSelectService') - } - - const onPressNext = async () => { - Keyboard.dismiss() - 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], - ) - } - } - - // TODO remove double login - await login({ - service: serviceUrl, - identifier: fullIdent, - password, - }) - } catch (e: any) { - const errMsg = e.toString() - setIsProcessing(false) - if (errMsg.includes('Authentication Required')) { - logger.debug('Failed to login due to invalid credentials', { - error: errMsg, - }) - setError(_(msg`Invalid username or password`)) - } else if (isNetworkError(e)) { - logger.warn('Failed to login due to network error', {error: errMsg}) - setError( - _( - msg`Unable to contact your service. Please check your Internet connection.`, - ), - ) - } else { - logger.warn('Failed to login', {error: errMsg}) - setError(cleanError(errMsg)) - } - } - } - - const isReady = !!serviceDescription && !!identifier && !!password - return ( - <View testID="loginForm"> - <ServerInputDialog - control={serverInputControl} - onSelect={setServiceUrl} - /> - - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - <Trans>Sign into</Trans> - </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} - accessibilityRole="button" - accessibilityLabel={_(msg`Select service`)} - accessibilityHint={_(msg`Sets server for the Bluesky client`)}> - <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]}> - <Trans>Account</Trans> - </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={_(msg`Username or email address`)} - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - autoComplete="username" - returnKeyType="next" - textContentType="username" - onSubmitEditing={() => { - passwordInputRef.current?.focus() - }} - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field - keyboardAppearance={theme.colorScheme} - value={identifier} - onChangeText={str => - setIdentifier((str || '').toLowerCase().trim()) - } - editable={!isProcessing} - accessibilityLabel={_(msg`Username or email address`)} - accessibilityHint={_( - msg`Input the username or email address you used at signup`, - )} - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginPasswordInput" - ref={passwordInputRef} - style={[pal.text, styles.textInput]} - placeholder="Password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - autoComplete="password" - returnKeyType="done" - enablesReturnKeyAutomatically={true} - keyboardAppearance={theme.colorScheme} - secureTextEntry={true} - textContentType="password" - clearButtonMode="while-editing" - value={password} - onChangeText={setPassword} - onSubmitEditing={onPressNext} - blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing - editable={!isProcessing} - accessibilityLabel={_(msg`Password`)} - accessibilityHint={ - identifier === '' - ? _(msg`Input your password`) - : _(msg`Input the password tied to ${identifier}`) - } - /> - <TouchableOpacity - testID="forgotPasswordButton" - style={styles.textInputInnerBtn} - onPress={onPressForgotPassword} - accessibilityRole="button" - accessibilityLabel={_(msg`Forgot password`)} - accessibilityHint={_(msg`Opens password reset form`)}> - <Text style={pal.link}> - <Trans>Forgot</Trans> - </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} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - <Trans>Back</Trans> - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription && error ? ( - <TouchableOpacity - testID="loginRetryButton" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel={_(msg`Retry`)} - accessibilityHint={_(msg`Retries login`)}> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - <Trans>Retry</Trans> - </Text> - </TouchableOpacity> - ) : !serviceDescription ? ( - <> - <ActivityIndicator /> - <Text type="xl" style={[pal.textLight, s.pl10]}> - <Trans>Connecting...</Trans> - </Text> - </> - ) : isProcessing ? ( - <ActivityIndicator /> - ) : isReady ? ( - <TouchableOpacity - testID="loginNextButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel={_(msg`Go to next`)} - accessibilityHint={_(msg`Navigates to the next screen`)}> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - <Trans>Next</Trans> - </Text> - </TouchableOpacity> - ) : undefined} - </View> - </View> - ) -} diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx index 32b5a3141..81f4bdf93 100644 --- a/src/view/com/auth/server-input/index.tsx +++ b/src/view/com/auth/server-input/index.tsx @@ -67,7 +67,7 @@ export function ServerInputDialog({ return ( <Dialog.Outer control={control} - nativeOptions={{sheet: {snapPoints: ['100%']}}} + nativeOptions={{sheet: {snapPoints: ['80', '100%']}}} onClose={onClose}> <Dialog.Handle /> |