alf the login form

zio/stable
Samuel Newman 2024-03-13 23:34:01 +00:00
parent f5b39f2755
commit 9f5289a101
7 changed files with 169 additions and 154 deletions

View File

@ -14,6 +14,7 @@ import {useTheme, atoms as a, web, android} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
import {mergeRefs} from '#/lib/merge-refs'
const Context = React.createContext<{ const Context = React.createContext<{
inputRef: React.RefObject<TextInput> | null inputRef: React.RefObject<TextInput> | null
@ -128,6 +129,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
value: string value: string
onChangeText: (value: string) => void onChangeText: (value: string) => void
isInvalid?: boolean isInvalid?: boolean
inputRef?: React.RefObject<TextInput>
} }
export function createInput(Component: typeof TextInput) { export function createInput(Component: typeof TextInput) {
@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
value, value,
onChangeText, onChangeText,
isInvalid, isInvalid,
inputRef,
...rest ...rest
}: InputProps) { }: InputProps) {
const t = useTheme() const t = useTheme()
@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
) )
} }
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
return ( return (
<> <>
<Component <Component
accessibilityHint={undefined} accessibilityHint={undefined}
{...rest} {...rest}
accessibilityLabel={label} accessibilityLabel={label}
ref={ctx.inputRef} ref={refs}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
onFocus={ctx.onFocus} onFocus={ctx.onFocus}
onBlur={ctx.onBlur} onBlur={ctx.onBlur}
placeholder={placeholder || label} placeholder={placeholder || label}
placeholderTextColor={t.palette.contrast_500} placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
hitSlop={HITSLOP_20} hitSlop={HITSLOP_20}
style={[ style={[
a.relative, a.relative,

View File

@ -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',
})

View File

@ -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',
})

View File

@ -13,7 +13,7 @@ import {useProfileQuery} from '#/state/queries/profile'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@ -106,6 +106,7 @@ export const ChooseAccountForm = ({
const {accounts, currentAccount} = useSession() const {accounts, currentAccount} = useSession()
const {initSession} = useSessionApi() const {initSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const {gtMobile} = useBreakpoints()
React.useEffect(() => { React.useEffect(() => {
screen('Choose Account') screen('Choose Account')
@ -133,7 +134,9 @@ export const ChooseAccountForm = ({
return ( return (
<ScrollView testID="chooseAccountForm" style={styles.maxHeight}> <ScrollView testID="chooseAccountForm" style={styles.maxHeight}>
<Text style={[a.mt_md, a.mb_lg, a.font_bold]}> <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> <Trans>Sign in as...</Trans>
</Text> </Text>
<Group> <Group>
@ -152,7 +155,8 @@ export const ChooseAccountForm = ({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Login to account that is not listed`)} accessibilityLabel={_(msg`Login to account that is not listed`)}
accessibilityHint=""> accessibilityHint="">
<View style={[a.flex_row, a.flex_row, a.align_center, {height: 48}]}> <View
style={[a.flex_row, a.flex_row, a.align_center, {height: 48}]}>
<Text <Text
style={[ style={[
a.align_baseline, a.align_baseline,
@ -174,10 +178,11 @@ export const ChooseAccountForm = ({
color="secondary" color="secondary"
size="small" size="small"
onPress={onPressBack}> onPress={onPressBack}>
<Trans>Back</Trans> {_(msg`Back`)}
</Button> </Button>
<View style={[a.flex_1]} /> <View style={[a.flex_1]} />
</View> </View>
</View>
</ScrollView> </ScrollView>
) )
} }

View File

@ -6,28 +6,31 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoServerDescribeServer} from '@atproto/api' import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {Text} from '../../util/text/Text'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {createFullHandle} from 'lib/strings/handles' import {createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers' import {toNiceDomain} from 'lib/strings/url-helpers'
import {isNetworkError} from 'lib/strings/errors' import {isNetworkError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {styles} from '../../view/com/auth/login/styles'
import {styles} from './styles'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../../view/com/auth/server-input'
import {ServerInputDialog} from '../server-input' import {Button} 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 type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@ -40,7 +43,6 @@ export const LoginForm = ({
setServiceUrl, setServiceUrl,
onPressRetryConnect, onPressRetryConnect,
onPressBack, onPressBack,
onPressForgotPassword,
}: { }: {
error: string error: string
serviceUrl: string serviceUrl: string
@ -53,8 +55,7 @@ export const LoginForm = ({
onPressForgotPassword: () => void onPressForgotPassword: () => void
}) => { }) => {
const {track} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const t = useTheme()
const theme = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [identifier, setIdentifier] = useState<string>(initialHandle) const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('') const [password, setPassword] = useState<string>('')
@ -62,6 +63,7 @@ export const LoginForm = ({
const {_} = useLingui() const {_} = useLingui()
const {login} = useSessionApi() const {login} = useSessionApi()
const serverInputControl = useDialogControl() const serverInputControl = useDialogControl()
const {gtMobile} = useBreakpoints()
const onPressSelectService = () => { const onPressSelectService = () => {
serverInputControl.open() serverInputControl.open()
@ -127,55 +129,54 @@ export const LoginForm = ({
const isReady = !!serviceDescription && !!identifier && !!password const isReady = !!serviceDescription && !!identifier && !!password
return ( return (
<View testID="loginForm"> <View testID="loginForm" style={[a.gap_lg, !gtMobile && a.px_lg]}>
<ServerInputDialog <ServerInputDialog
control={serverInputControl} control={serverInputControl}
onSelect={setServiceUrl} onSelect={setServiceUrl}
/> />
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}> <View>
<Trans>Sign into</Trans> <TextField.Label>
</Text> <Trans>Hosting provider</Trans>
<View style={[pal.borderDark, styles.group]}> </TextField.Label>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TouchableOpacity <TouchableOpacity
testID="loginSelectServiceButton"
style={styles.textBtn}
onPress={onPressSelectService}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Select service`)} style={[
accessibilityHint={_(msg`Sets server for the Bluesky client`)}> a.w_full,
<Text type="xl" style={[pal.text, styles.textBtnLabel]}> a.flex_row,
{toNiceDomain(serviceUrl)} a.align_center,
</Text> a.rounded_sm,
<View style={[pal.btn, styles.textBtnFakeInnerBtn]}> a.px_md,
<FontAwesomeIcon a.gap_xs,
icon="pen" {paddingVertical: isAndroid ? 14 : 9},
size={12} t.atoms.bg_contrast_25,
style={pal.textLight as FontAwesomeIconStyle} ]}
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> </View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> <View>
<Text type="sm-bold" style={[pal.text, styles.groupLabel]}> <TextField.Label>
<Trans>Account</Trans> <Trans>Account</Trans>
</Text> </TextField.Label>
<View style={[pal.borderDark, styles.group]}> <TextField.Root>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> <TextField.Icon icon={At} />
<FontAwesomeIcon <TextField.Input
icon="at"
style={[pal.textLight, styles.groupContentIcon]}
/>
<TextInput
testID="loginUsernameInput" testID="loginUsernameInput"
style={[pal.text, styles.textInput]} label={_(msg`Username or email address`)}
placeholder={_(msg`Username or email address`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none" autoCapitalize="none"
autoFocus autoFocus
autoCorrect={false} autoCorrect={false}
@ -186,35 +187,29 @@ export const LoginForm = ({
passwordInputRef.current?.focus() passwordInputRef.current?.focus()
}} }}
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
keyboardAppearance={theme.colorScheme}
value={identifier} value={identifier}
onChangeText={str => onChangeText={str =>
setIdentifier((str || '').toLowerCase().trim()) setIdentifier((str || '').toLowerCase().trim())
} }
editable={!isProcessing} editable={!isProcessing}
accessibilityLabel={_(msg`Username or email address`)}
accessibilityHint={_( accessibilityHint={_(
msg`Input the username or email address you used at signup`, msg`Input the username or email address you used at signup`,
)} )}
/> />
</TextField.Root>
</View> </View>
<View style={[pal.borderDark, styles.groupContent]}> <View>
<FontAwesomeIcon <TextField.Root>
icon="lock" <TextField.Icon icon={Lock} />
style={[pal.textLight, styles.groupContentIcon]} <TextField.Input
/>
<TextInput
testID="loginPasswordInput" testID="loginPasswordInput"
ref={passwordInputRef} inputRef={passwordInputRef}
style={[pal.text, styles.textInput]} label={_(msg`Password`)}
placeholder="Password"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoComplete="password" autoComplete="password"
returnKeyType="done" returnKeyType="done"
enablesReturnKeyAutomatically={true} enablesReturnKeyAutomatically={true}
keyboardAppearance={theme.colorScheme}
secureTextEntry={true} secureTextEntry={true}
textContentType="password" textContentType="password"
clearButtonMode="while-editing" clearButtonMode="while-editing"
@ -223,14 +218,13 @@ export const LoginForm = ({
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
editable={!isProcessing} editable={!isProcessing}
accessibilityLabel={_(msg`Password`)}
accessibilityHint={ accessibilityHint={
identifier === '' identifier === ''
? _(msg`Input your password`) ? _(msg`Input your password`)
: _(msg`Input the password tied to ${identifier}`) : _(msg`Input the password tied to ${identifier}`)
} }
/> />
<TouchableOpacity {/* <TouchableOpacity
testID="forgotPasswordButton" testID="forgotPasswordButton"
style={styles.textInputInnerBtn} style={styles.textInputInnerBtn}
onPress={onPressForgotPassword} onPress={onPressForgotPassword}
@ -240,57 +234,57 @@ export const LoginForm = ({
<Text style={pal.link}> <Text style={pal.link}>
<Trans>Forgot</Trans> <Trans>Forgot</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity> */}
</View> </TextField.Root>
</View> </View>
{error ? ( {error ? (
<View style={styles.error}> <View style={[styles.error, {marginHorizontal: 0}]}>
<View style={styles.errorIcon}> <Warning style={s.white} size="sm" />
<FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> <View style={(a.flex_1, a.ml_sm)}>
</View>
<View style={s.flex1}>
<Text style={[s.white, s.bold]}>{error}</Text> <Text style={[s.white, s.bold]}>{error}</Text>
</View> </View>
</View> </View>
) : undefined} ) : undefined}
<View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> <View style={[a.flex_row, a.align_center]}>
<TouchableOpacity onPress={onPressBack} accessibilityRole="button"> <Button
<Text type="xl" style={[pal.link, s.pl5]}> label={_(msg`Back`)}
<Trans>Back</Trans> variant="solid"
</Text> color="secondary"
</TouchableOpacity> size="small"
onPress={onPressBack}>
{_(msg`Back`)}
</Button>
<View style={s.flex1} /> <View style={s.flex1} />
{!serviceDescription && error ? ( {!serviceDescription && error ? (
<TouchableOpacity <Button
testID="loginRetryButton" testID="loginRetryButton"
onPress={onPressRetryConnect} label={_(msg`Retry`)}
accessibilityRole="button" accessibilityHint={_(msg`Retries login`)}
accessibilityLabel={_(msg`Retry`)} variant="solid"
accessibilityHint={_(msg`Retries login`)}> color="secondary"
<Text type="xl-bold" style={[pal.link, s.pr5]}> size="small"
<Trans>Retry</Trans> onPress={onPressRetryConnect}>
</Text> {_(msg`Retry`)}
</TouchableOpacity> </Button>
) : !serviceDescription ? ( ) : !serviceDescription ? (
<> <>
<ActivityIndicator /> <ActivityIndicator />
<Text type="xl" style={[pal.textLight, s.pl10]}> <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Connecting...</Trans> <Trans>Connecting...</Trans>
</Text> </Text>
</> </>
) : isProcessing ? ( ) : isProcessing ? (
<ActivityIndicator /> <ActivityIndicator />
) : isReady ? ( ) : isReady ? (
<TouchableOpacity <Button
testID="loginNextButton" label={_(msg`Next`)}
onPress={onPressNext} accessibilityHint={_(msg`Navigates to the next screen`)}
accessibilityRole="button" variant="solid"
accessibilityLabel={_(msg`Go to next`)} color="primary"
accessibilityHint={_(msg`Navigates to the next screen`)}> size="small"
<Text type="xl-bold" style={[pal.link, s.pr5]}> onPress={onPressNext}>
<Trans>Next</Trans> {_(msg`Next`)}
</Text> </Button>
</TouchableOpacity>
) : undefined} ) : undefined}
</View> </View>
</View> </View>

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {KeyboardAvoidingView} from 'react-native'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
@ -9,12 +10,11 @@ import {useServiceQuery} from '#/state/queries/service'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {logger} from '#/logger' import {logger} from '#/logger'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {KeyboardAvoidingView} from 'react-native'
import {ChooseAccountForm} from './ChooseAccountForm' import {ChooseAccountForm} from './ChooseAccountForm'
import {ForgotPasswordForm} from '#/view/com/auth/login/ForgotPasswordForm' import {ForgotPasswordForm} from '#/view/com/auth/login/ForgotPasswordForm'
import {SetNewPasswordForm} from '#/view/com/auth/login/SetNewPasswordForm' import {SetNewPasswordForm} from '#/view/com/auth/login/SetNewPasswordForm'
import {PasswordUpdatedForm} from '#/view/com/auth/login/PasswordUpdatedForm' import {PasswordUpdatedForm} from '#/view/com/auth/login/PasswordUpdatedForm'
import {LoginForm} from '#/view/com/auth/login/LoginForm' import {LoginForm} from '#/screens/Login/LoginForm'
enum Forms { enum Forms {
Login, Login,

View File

@ -67,7 +67,7 @@ export function ServerInputDialog({
return ( return (
<Dialog.Outer <Dialog.Outer
control={control} control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}} nativeOptions={{sheet: {snapPoints: ['80', '100%']}}}
onClose={onClose}> onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />