diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx index 696ee0e5d4..0013f92bee 100644 --- a/src/components/dialogs/SwitchAccount.tsx +++ b/src/components/dialogs/SwitchAccount.tsx @@ -43,10 +43,10 @@ export function SwitchAccountDialog({ return ( - + - Switch Account + Switch account void onPressBack: () => void }) => { - const [pendingDid, setPendingDid] = React.useState(null) + const [pendingDid, setPendingDid] = useState(null) const {_} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const {resumeSession} = useSessionApi() const {setShowLoggedOut} = useLoggedOutViewControls() - const onSelect = React.useCallback( + const onSelect = useCallback( async (account: SessionAccount) => { if (pendingDid) { // The session API isn't resilient to race conditions so let's just ignore this. @@ -54,7 +55,7 @@ export const ChooseAccountForm = ({ Toast.show(_(msg`Signed in as @${account.handle}`)) } catch (e: any) { logger.error('choose account: initSession failed', { - message: e.message, + message: e instanceof Error ? e.message : 'Unknown error', }) // Move to login form. onSelectAccount(account) @@ -69,6 +70,7 @@ export const ChooseAccountForm = ({ onSelectAccount, setShowLoggedOut, _, + ax, ], ) @@ -78,26 +80,29 @@ export const ChooseAccountForm = ({ titleText={Select account} style={web([a.py_2xl])}> - - Sign in as... - + {IS_WEB && ( + + Sign in as... + + )} onSelectAccount()} pendingDid={pendingDid} /> - - - - + {IS_WEB && ( + + + + + )} ) } diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx index d3b5a4f101..8a7ddc628c 100644 --- a/src/screens/Login/ForgotPasswordForm.tsx +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -1,21 +1,22 @@ import React, {useState} from 'react' -import {ActivityIndicator, Keyboard, View} from 'react-native' +import {Keyboard, View} from 'react-native' import {type ComAtprotoServerDescribeServer} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import * as EmailValidator from 'email-validator' -import {isNetworkError} from '#/lib/strings/errors' -import {cleanError} from '#/lib/strings/errors' +import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {Agent} from '#/state/session/agent' -import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {atoms as a, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' import * as TextField from '#/components/forms/TextField' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {IS_WEB} from '#/env' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -118,43 +119,50 @@ export const ForgotPasswordForm = ({ - - - - {!serviceDescription || isProcessing ? ( - + + {IS_WEB && ( + <> + + + + )} + {!serviceDescription ? ( + ) : ( )} - {!serviceDescription || isProcessing ? ( - - Processing... - - ) : undefined} }) { const {gtMobile} = useBreakpoints() - const t = useTheme() + const gutter = useGutters([0, 'wide']) + return ( + style={[a.gap_md, a.flex_1, !gtMobile && gutter, style]}> {titleText && !gtMobile && ( - - {titleText} - + {titleText} )} {children} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 779e273740..f7ccb5c997 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -1,11 +1,5 @@ -import React, {useRef, useState} from 'react' -import { - ActivityIndicator, - Keyboard, - LayoutAnimation, - type TextInput, - View, -} from 'react-native' +import {useCallback, useRef, useState} from 'react' +import {Keyboard, type TextInput, View} from 'react-native' import { ComAtprotoServerCreateSession, type ComAtprotoServerDescribeServer, @@ -14,14 +8,13 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' -import {isNetworkError} from '#/lib/strings/errors' -import {cleanError} from '#/lib/strings/errors' +import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {atoms as a, ios, useTheme} from '#/alf' +import {atoms as a, ios, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' import {HostingProvider} from '#/components/forms/HostingProvider' @@ -31,7 +24,7 @@ import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {IS_IOS} from '#/env' +import {IS_IOS, IS_WEB} from '#/env' import {FormContainer} from './FormContainer' type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -62,9 +55,11 @@ export const LoginForm = ({ onAttemptFailed: () => void }) => { const t = useTheme() - const [isProcessing, setIsProcessing] = useState(false) - const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = - useState(false) + const [isProcessing, setIsProcessing] = useState(false) + const [errorField, setErrorField] = useState< + 'none' | 'identifier' | 'password' | '2fa' + >('none') + const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) const identifierValueRef = useRef(initialHandle || '') const passwordValueRef = useRef('') const [authFactorToken, setAuthFactorToken] = useState('') @@ -77,26 +72,28 @@ export const LoginForm = ({ const {setShowLoggedOut} = useLoggedOutViewControls() const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() - const onPressSelectService = React.useCallback(() => { + const onPressSelectService = useCallback(() => { Keyboard.dismiss() }, []) const onPressNext = async () => { if (isProcessing) return Keyboard.dismiss() - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) setError('') + setErrorField('none') const identifier = identifierValueRef.current.toLowerCase().trim() const password = passwordValueRef.current if (!identifier) { setError(_(msg`Please enter your username`)) + setErrorField('identifier') return } if (!password) { setError(_(msg`Please enter your password`)) + setErrorField('password') return } @@ -141,7 +138,6 @@ export const LoginForm = ({ requestNotificationsPermission('Login') } catch (e: any) { const errMsg = e.toString() - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) setIsProcessing(false) if ( e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError @@ -154,6 +150,7 @@ export const LoginForm = ({ error: errMsg, }) setError(_(msg`Invalid 2FA confirmation code.`)) + setErrorField('2fa') } else if ( errMsg.includes('Authentication Required') || errMsg.includes('Invalid identifier or password') @@ -178,7 +175,7 @@ export const LoginForm = ({ } return ( - Sign in}> + Log in}> Hosting provider @@ -194,7 +191,7 @@ export const LoginForm = ({ Account - + { identifierValueRef.current = v + if (errorField) setErrorField('none') }} onSubmitEditing={() => { passwordRef.current?.focus() @@ -221,7 +219,7 @@ export const LoginForm = ({ /> - + { passwordValueRef.current = v + if (errorField) setErrorField('none') }} onSubmitEditing={onPressNext} blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing @@ -277,7 +276,7 @@ export const LoginForm = ({ 2FA Confirmation - + { + setAuthFactorToken(text) + if (errorField) setErrorField('none') + }} onSubmitEditing={onPressNext} editable={!isProcessing} accessibilityHint={_( @@ -308,25 +310,24 @@ export const LoginForm = ({ )} - - - + + {IS_WEB && ( + + )} {!serviceDescription && error ? ( ) : !serviceDescription ? ( - <> - - - Connecting... - - + ) : ( diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx index 7418b6d53e..63c80eec52 100644 --- a/src/screens/Login/PasswordUpdatedForm.tsx +++ b/src/screens/Login/PasswordUpdatedForm.tsx @@ -2,7 +2,7 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {atoms as a, useBreakpoints} from '#/alf' +import {atoms as a, useBreakpoints, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' import {FormContainer} from './FormContainer' @@ -19,18 +19,17 @@ export const PasswordUpdatedForm = ({ - + Password updated! You can now sign in with your new password. - + - - {isProcessing ? ( - - ) : ( - + + {IS_WEB && ( + <> + + + )} - {isProcessing ? ( - - Updating... - - ) : undefined} + + ) diff --git a/src/screens/Login/components/AuthLayout/Header/index.tsx b/src/screens/Login/components/AuthLayout/Header/index.tsx new file mode 100644 index 0000000000..ac7716508a --- /dev/null +++ b/src/screens/Login/components/AuthLayout/Header/index.tsx @@ -0,0 +1,92 @@ +import {useContext} from 'react' +import {type GestureResponderEvent, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_30} from '#/lib/constants' +import {Logomark} from '#/view/icons/Logomark' +import {atoms as a, platform, useGutters, useTheme} from '#/alf' +import {Button, ButtonIcon, type ButtonProps} from '#/components/Button' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' +import { + BUTTON_VISUAL_ALIGNMENT_OFFSET, + Header, + HEADER_SLOT_SIZE, +} from '#/components/Layout' +import {IS_WEB} from '#/env' +import {AuthLayoutNavigationContext} from '../context' + +/** + * This is a simplified version of `Layout.Header` for the auth screens. + */ + +export const Slot = Header.Slot + +export function Outer({children}: {children: React.ReactNode}) { + const t = useTheme() + const gutters = useGutters([0, 'wide']) + + return ( + + {children} + + ) +} + +export function Content({children}: {children?: React.ReactNode}) { + return ( + + {IS_WEB ? children : } + + ) +} + +export function Logo() { + const t = useTheme() + + return +} + +export function BackButton({onPress, style, ...props}: Partial) { + const {_} = useLingui() + const navigation = useContext(AuthLayoutNavigationContext) + + const onPressBack = (evt: GestureResponderEvent) => { + onPress?.(evt) + if (evt.defaultPrevented) return + navigation?.goBack() + } + + return ( + + + + ) +} diff --git a/src/screens/Login/components/AuthLayout/Header/index.web.tsx b/src/screens/Login/components/AuthLayout/Header/index.web.tsx new file mode 100644 index 0000000000..08e9eb0eb3 --- /dev/null +++ b/src/screens/Login/components/AuthLayout/Header/index.web.tsx @@ -0,0 +1,23 @@ +import {type ButtonProps} from '#/components/Button' +import {Header} from '#/components/Layout' + +// TEMP: Web needs a much more comprehensive layout change to support the new design +// so to do this incrementally, we'll do native only and migrate to web later + +export const Slot = Header.Slot + +export function Outer({}: {children: React.ReactNode}) { + return null +} + +export function Content({}: {children?: React.ReactNode}) { + return null +} + +export function Logo() { + return null +} + +export function BackButton({}: Partial) { + return null +} diff --git a/src/screens/Login/components/AuthLayout/context.ts b/src/screens/Login/components/AuthLayout/context.ts new file mode 100644 index 0000000000..871113fcdf --- /dev/null +++ b/src/screens/Login/components/AuthLayout/context.ts @@ -0,0 +1,5 @@ +import {createContext} from 'react' + +export const AuthLayoutNavigationContext = createContext<{ + goBack: () => void +} | null>(null) diff --git a/src/screens/Login/components/AuthLayout/index.tsx b/src/screens/Login/components/AuthLayout/index.tsx new file mode 100644 index 0000000000..2b3629bbd8 --- /dev/null +++ b/src/screens/Login/components/AuthLayout/index.tsx @@ -0,0 +1 @@ +export * as Header from './Header' diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx index 17dfeb4262..2004317734 100644 --- a/src/screens/Login/index.tsx +++ b/src/screens/Login/index.tsx @@ -18,6 +18,8 @@ import {atoms as a, native} from '#/alf' import {ScreenTransition} from '#/components/ScreenTransition' import {useAnalytics} from '#/analytics' import {ChooseAccountForm} from './ChooseAccountForm' +import * as AuthLayout from './components/AuthLayout' +import {AuthLayoutNavigationContext} from './components/AuthLayout/context' enum Forms { Login, @@ -131,11 +133,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { let content = null let title = '' let description = '' + let goBack = null switch (currentForm) { case Forms.Login: title = _(msg`Sign in`) description = _(msg`Enter your username and password`) + goBack = () => + accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() content = ( void}) => { onAttemptFailed={onAttemptFailed} onAttemptSuccess={onAttemptSuccess} setServiceUrl={setServiceUrl} - onPressBack={() => - accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() - } + onPressBack={goBack} onPressForgotPassword={onPressForgotPassword} onPressRetryConnect={refetchService} /> @@ -157,16 +160,18 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { case Forms.ChooseAccount: title = _(msg`Sign in`) description = _(msg`Select from an existing account`) + goBack = handlePressBack content = ( ) break case Forms.ForgotPassword: title = _(msg`Forgot Password`) description = _(msg`Let's get your password reset!`) + goBack = () => gotoForm(Forms.Login) content = ( void}) => { serviceDescription={serviceDescription} setError={setError} setServiceUrl={setServiceUrl} - onPressBack={() => gotoForm(Forms.Login)} + onPressBack={goBack} onEmailSent={() => gotoForm(Forms.SetNewPassword)} /> ) @@ -182,12 +187,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { case Forms.SetNewPassword: title = _(msg`Forgot Password`) description = _(msg`Let's get your password reset!`) + goBack = () => gotoForm(Forms.ForgotPassword) content = ( gotoForm(Forms.ForgotPassword)} + onPressBack={goBack} onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} /> ) @@ -201,23 +207,35 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { break } + const navigation = goBack ? {goBack} : null + return ( - - - - - - {content} - - - - - + + + + + + + + + + + + {content} + + + + + + ) } diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 088ae6679d..b3cf382db2 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,10 +1,14 @@ -import React from 'react' +import {useCallback, useEffect, useState} from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' import {PressableScale} from '#/lib/custom-animations/PressableScale' +import {STALE} from '#/state/queries' +import {profilesQueryKey} from '#/state/queries/profile' +import {useAgent, useSession} from '#/state/session' import { useLoggedOutView, useLoggedOutViewControls, @@ -35,7 +39,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const insets = useSafeAreaInsets() const setMinimalShellMode = useSetMinimalShellMode() const {requestedAccountSwitchTo} = useLoggedOutView() - const [screenState, setScreenState] = React.useState(() => { + const [screenState, setScreenState] = useState(() => { if (requestedAccountSwitchTo === 'new') { return ScreenState.S_CreateAccount } else if (requestedAccountSwitchTo === 'starterpack') { @@ -48,11 +52,26 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }) const {clearRequestedAccount} = useLoggedOutViewControls() - React.useEffect(() => { + const queryClient = useQueryClient() + const {accounts} = useSession() + const agent = useAgent() + useEffect(() => { + const actors = accounts.map(acc => acc.did) + void queryClient.prefetchQuery({ + queryKey: profilesQueryKey(actors), + staleTime: STALE.MINUTES.FIVE, + queryFn: async () => { + const res = await agent.getProfiles({actors}) + return res.data + }, + }) + }, [accounts, agent, queryClient]) + + useEffect(() => { setMinimalShellMode(true) }, [setMinimalShellMode]) - const onPressDismiss = React.useCallback(() => { + const onPressDismiss = useCallback(() => { if (onDismiss) { onDismiss() } diff --git a/src/view/com/util/layouts/LoggedOutLayout.tsx b/src/view/com/util/layouts/LoggedOutLayout.tsx index 334bf07561..b6a0e8682d 100644 --- a/src/view/com/util/layouts/LoggedOutLayout.tsx +++ b/src/view/com/util/layouts/LoggedOutLayout.tsx @@ -42,11 +42,11 @@ export const LoggedOutLayout = ({ contentContainerStyle={[ {paddingBottom: isKeyboardVisible ? 300 : 0}, ]}> - {children} + {children} ) } else { - return {children} + return {children} } } return (