Bluesky app fork with some witchin' additions 💫

[Auth rework] Add a header to login flow (#9368)

authored by samuel.fm and committed by

GitHub 2e34f965 9cdfb40c

+332 -163
+2 -2
src/components/dialogs/SwitchAccount.tsx
··· 43 43 return ( 44 44 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 45 45 <Dialog.Handle /> 46 - <Dialog.ScrollableInner label={_(msg`Switch Account`)}> 46 + <Dialog.ScrollableInner label={_(msg`Switch account`)}> 47 47 <View style={[a.gap_lg]}> 48 48 <Text style={[a.text_2xl, a.font_semi_bold]}> 49 - <Trans>Switch Account</Trans> 49 + <Trans>Switch account</Trans> 50 50 </Text> 51 51 52 52 <AccountList
+23 -18
src/screens/Login/ChooseAccountForm.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' ··· 12 12 import {Button, ButtonText} from '#/components/Button' 13 13 import * as TextField from '#/components/forms/TextField' 14 14 import {useAnalytics} from '#/analytics' 15 + import {IS_WEB} from '#/env' 15 16 import {FormContainer} from './FormContainer' 16 17 17 18 export const ChooseAccountForm = ({ ··· 21 22 onSelectAccount: (account?: SessionAccount) => void 22 23 onPressBack: () => void 23 24 }) => { 24 - const [pendingDid, setPendingDid] = React.useState<string | null>(null) 25 + const [pendingDid, setPendingDid] = useState<string | null>(null) 25 26 const {_} = useLingui() 26 27 const ax = useAnalytics() 27 28 const {currentAccount} = useSession() 28 29 const {resumeSession} = useSessionApi() 29 30 const {setShowLoggedOut} = useLoggedOutViewControls() 30 31 31 - const onSelect = React.useCallback( 32 + const onSelect = useCallback( 32 33 async (account: SessionAccount) => { 33 34 if (pendingDid) { 34 35 // The session API isn't resilient to race conditions so let's just ignore this. ··· 54 55 Toast.show(_(msg`Signed in as @${account.handle}`)) 55 56 } catch (e: any) { 56 57 logger.error('choose account: initSession failed', { 57 - message: e.message, 58 + message: e instanceof Error ? e.message : 'Unknown error', 58 59 }) 59 60 // Move to login form. 60 61 onSelectAccount(account) ··· 69 70 onSelectAccount, 70 71 setShowLoggedOut, 71 72 _, 73 + ax, 72 74 ], 73 75 ) 74 76 ··· 78 80 titleText={<Trans>Select account</Trans>} 79 81 style={web([a.py_2xl])}> 80 82 <View> 81 - <TextField.LabelText> 82 - <Trans>Sign in as...</Trans> 83 - </TextField.LabelText> 83 + {IS_WEB && ( 84 + <TextField.LabelText> 85 + <Trans>Sign in as...</Trans> 86 + </TextField.LabelText> 87 + )} 84 88 <AccountList 85 89 onSelectAccount={onSelect} 86 90 onSelectOther={() => onSelectAccount()} 87 91 pendingDid={pendingDid} 88 92 /> 89 93 </View> 90 - <View style={[a.flex_row]}> 91 - <Button 92 - label={_(msg`Back`)} 93 - variant="solid" 94 - color="secondary" 95 - size="large" 96 - onPress={onPressBack}> 97 - <ButtonText>{_(msg`Back`)}</ButtonText> 98 - </Button> 99 - <View style={[a.flex_1]} /> 100 - </View> 94 + {IS_WEB && ( 95 + <View style={[a.flex_row]}> 96 + <Button 97 + label={_(msg`Back`)} 98 + color="secondary" 99 + size="large" 100 + onPress={onPressBack}> 101 + <ButtonText>{_(msg`Back`)}</ButtonText> 102 + </Button> 103 + <View style={[a.flex_1]} /> 104 + </View> 105 + )} 101 106 </FormContainer> 102 107 ) 103 108 }
+36 -28
src/screens/Login/ForgotPasswordForm.tsx
··· 1 1 import React, {useState} from 'react' 2 - import {ActivityIndicator, Keyboard, View} from 'react-native' 2 + import {Keyboard, View} from 'react-native' 3 3 import {type ComAtprotoServerDescribeServer} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import * as EmailValidator from 'email-validator' 7 7 8 - import {isNetworkError} from '#/lib/strings/errors' 9 - import {cleanError} from '#/lib/strings/errors' 8 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 10 9 import {logger} from '#/logger' 11 10 import {Agent} from '#/state/session/agent' 12 - import {atoms as a, useTheme} from '#/alf' 13 - import {Button, ButtonText} from '#/components/Button' 11 + import {atoms as a, useTheme, web} from '#/alf' 12 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 13 import {FormError} from '#/components/forms/FormError' 15 14 import {HostingProvider} from '#/components/forms/HostingProvider' 16 15 import * as TextField from '#/components/forms/TextField' 17 16 import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 17 + import {Loader} from '#/components/Loader' 18 18 import {Text} from '#/components/Typography' 19 + import {IS_WEB} from '#/env' 19 20 import {FormContainer} from './FormContainer' 20 21 21 22 type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema ··· 118 119 119 120 <FormError error={error} /> 120 121 121 - <View style={[a.flex_row, a.align_center, a.pt_md]}> 122 - <Button 123 - label={_(msg`Back`)} 124 - variant="solid" 125 - color="secondary" 126 - size="large" 127 - onPress={onPressBack}> 128 - <ButtonText> 129 - <Trans>Back</Trans> 130 - </ButtonText> 131 - </Button> 132 - <View style={a.flex_1} /> 133 - {!serviceDescription || isProcessing ? ( 134 - <ActivityIndicator /> 122 + <View style={[web([a.flex_row, a.align_center]), a.pt_md]}> 123 + {IS_WEB && ( 124 + <> 125 + <Button 126 + label={_(msg`Back`)} 127 + color="secondary" 128 + size="large" 129 + onPress={onPressBack}> 130 + <ButtonText> 131 + <Trans>Back</Trans> 132 + </ButtonText> 133 + </Button> 134 + <View style={a.flex_1} /> 135 + </> 136 + )} 137 + {!serviceDescription ? ( 138 + <Button 139 + label={_(msg`Connecting to service...`)} 140 + size="large" 141 + color="secondary" 142 + disabled> 143 + <ButtonIcon icon={Loader} /> 144 + <ButtonText>Connecting...</ButtonText> 145 + </Button> 135 146 ) : ( 136 147 <Button 137 148 label={_(msg`Next`)} 138 - variant="solid" 139 - color={'primary'} 149 + accessibilityHint={_(msg`Navigates to the next screen`)} 150 + color="primary" 140 151 size="large" 141 - onPress={onPressNext}> 152 + onPress={onPressNext} 153 + disabled={isProcessing}> 142 154 <ButtonText> 143 155 <Trans>Next</Trans> 144 156 </ButtonText> 157 + {isProcessing && <ButtonIcon icon={Loader} />} 145 158 </Button> 146 159 )} 147 - {!serviceDescription || isProcessing ? ( 148 - <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 149 - <Trans>Processing...</Trans> 150 - </Text> 151 - ) : undefined} 152 160 </View> 153 161 <View 154 162 style={[ 155 163 t.atoms.border_contrast_medium, 156 164 a.border_t, 157 - a.pt_2xl, 165 + a.pt_xl, 158 166 a.mt_md, 159 167 a.flex_row, 160 168 a.justify_center,
+5 -6
src/screens/Login/FormContainer.tsx
··· 1 1 import {type StyleProp, View, type ViewStyle} from 'react-native' 2 2 import type React from 'react' 3 3 4 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 4 + import {atoms as a, useBreakpoints, useGutters} from '#/alf' 5 5 import {Text} from '#/components/Typography' 6 6 7 7 export function FormContainer({ ··· 16 16 style?: StyleProp<ViewStyle> 17 17 }) { 18 18 const {gtMobile} = useBreakpoints() 19 - const t = useTheme() 19 + const gutter = useGutters([0, 'wide']) 20 + 20 21 return ( 21 22 <View 22 23 testID={testID} 23 - style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.py_md], style]}> 24 + style={[a.gap_md, a.flex_1, !gtMobile && gutter, style]}> 24 25 {titleText && !gtMobile && ( 25 - <Text style={[a.text_xl, a.font_semi_bold, t.atoms.text_contrast_high]}> 26 - {titleText} 27 - </Text> 26 + <Text style={[a.text_3xl, a.font_bold]}>{titleText}</Text> 28 27 )} 29 28 {children} 30 29 </View>
+48 -46
src/screens/Login/LoginForm.tsx
··· 1 - import React, {useRef, useState} from 'react' 2 - import { 3 - ActivityIndicator, 4 - Keyboard, 5 - LayoutAnimation, 6 - type TextInput, 7 - View, 8 - } from 'react-native' 1 + import {useCallback, useRef, useState} from 'react' 2 + import {Keyboard, type TextInput, View} from 'react-native' 9 3 import { 10 4 ComAtprotoServerCreateSession, 11 5 type ComAtprotoServerDescribeServer, ··· 14 8 import {useLingui} from '@lingui/react' 15 9 16 10 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 17 - import {isNetworkError} from '#/lib/strings/errors' 18 - import {cleanError} from '#/lib/strings/errors' 11 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 19 12 import {createFullHandle} from '#/lib/strings/handles' 20 13 import {logger} from '#/logger' 21 14 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 22 15 import {useSessionApi} from '#/state/session' 23 16 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 24 - import {atoms as a, ios, useTheme} from '#/alf' 17 + import {atoms as a, ios, useTheme, web} from '#/alf' 25 18 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26 19 import {FormError} from '#/components/forms/FormError' 27 20 import {HostingProvider} from '#/components/forms/HostingProvider' ··· 31 24 import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 32 25 import {Loader} from '#/components/Loader' 33 26 import {Text} from '#/components/Typography' 34 - import {IS_IOS} from '#/env' 27 + import {IS_IOS, IS_WEB} from '#/env' 35 28 import {FormContainer} from './FormContainer' 36 29 37 30 type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema ··· 62 55 onAttemptFailed: () => void 63 56 }) => { 64 57 const t = useTheme() 65 - const [isProcessing, setIsProcessing] = useState<boolean>(false) 66 - const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = 67 - useState<boolean>(false) 58 + const [isProcessing, setIsProcessing] = useState(false) 59 + const [errorField, setErrorField] = useState< 60 + 'none' | 'identifier' | 'password' | '2fa' 61 + >('none') 62 + const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) 68 63 const identifierValueRef = useRef<string>(initialHandle || '') 69 64 const passwordValueRef = useRef<string>('') 70 65 const [authFactorToken, setAuthFactorToken] = useState('') ··· 77 72 const {setShowLoggedOut} = useLoggedOutViewControls() 78 73 const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 79 74 80 - const onPressSelectService = React.useCallback(() => { 75 + const onPressSelectService = useCallback(() => { 81 76 Keyboard.dismiss() 82 77 }, []) 83 78 84 79 const onPressNext = async () => { 85 80 if (isProcessing) return 86 81 Keyboard.dismiss() 87 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 88 82 setError('') 83 + setErrorField('none') 89 84 90 85 const identifier = identifierValueRef.current.toLowerCase().trim() 91 86 const password = passwordValueRef.current 92 87 93 88 if (!identifier) { 94 89 setError(_(msg`Please enter your username`)) 90 + setErrorField('identifier') 95 91 return 96 92 } 97 93 98 94 if (!password) { 99 95 setError(_(msg`Please enter your password`)) 96 + setErrorField('password') 100 97 return 101 98 } 102 99 ··· 141 138 requestNotificationsPermission('Login') 142 139 } catch (e: any) { 143 140 const errMsg = e.toString() 144 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 145 141 setIsProcessing(false) 146 142 if ( 147 143 e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError ··· 154 150 error: errMsg, 155 151 }) 156 152 setError(_(msg`Invalid 2FA confirmation code.`)) 153 + setErrorField('2fa') 157 154 } else if ( 158 155 errMsg.includes('Authentication Required') || 159 156 errMsg.includes('Invalid identifier or password') ··· 178 175 } 179 176 180 177 return ( 181 - <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 178 + <FormContainer testID="loginForm" titleText={<Trans>Log in</Trans>}> 182 179 <View> 183 180 <TextField.LabelText> 184 181 <Trans>Hosting provider</Trans> ··· 194 191 <Trans>Account</Trans> 195 192 </TextField.LabelText> 196 193 <View style={[a.gap_sm]}> 197 - <TextField.Root> 194 + <TextField.Root isInvalid={errorField === 'identifier'}> 198 195 <TextField.Icon icon={At} /> 199 196 <TextField.Input 200 197 testID="loginUsernameInput" ··· 209 206 defaultValue={initialHandle || ''} 210 207 onChangeText={v => { 211 208 identifierValueRef.current = v 209 + if (errorField) setErrorField('none') 212 210 }} 213 211 onSubmitEditing={() => { 214 212 passwordRef.current?.focus() ··· 221 219 /> 222 220 </TextField.Root> 223 221 224 - <TextField.Root> 222 + <TextField.Root isInvalid={errorField === 'password'}> 225 223 <TextField.Icon icon={Lock} /> 226 224 <TextField.Input 227 225 testID="loginPasswordInput" ··· 236 234 clearButtonMode="while-editing" 237 235 onChangeText={v => { 238 236 passwordValueRef.current = v 237 + if (errorField) setErrorField('none') 239 238 }} 240 239 onSubmitEditing={onPressNext} 241 240 blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing ··· 277 276 <TextField.LabelText> 278 277 <Trans>2FA Confirmation</Trans> 279 278 </TextField.LabelText> 280 - <TextField.Root> 279 + <TextField.Root isInvalid={errorField === '2fa'}> 281 280 <TextField.Icon icon={Ticket} /> 282 281 <TextField.Input 283 282 testID="loginAuthFactorTokenInput" ··· 288 287 autoComplete="one-time-code" 289 288 returnKeyType="done" 290 289 blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 291 - onChangeText={setAuthFactorToken} 292 290 value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly 291 + onChangeText={text => { 292 + setAuthFactorToken(text) 293 + if (errorField) setErrorField('none') 294 + }} 293 295 onSubmitEditing={onPressNext} 294 296 editable={!isProcessing} 295 297 accessibilityHint={_( ··· 308 310 </View> 309 311 )} 310 312 <FormError error={error} /> 311 - <View style={[a.flex_row, a.align_center, a.pt_md]}> 312 - <Button 313 - label={_(msg`Back`)} 314 - variant="solid" 315 - color="secondary" 316 - size="large" 317 - onPress={onPressBack}> 318 - <ButtonText> 319 - <Trans>Back</Trans> 320 - </ButtonText> 321 - </Button> 322 - <View style={a.flex_1} /> 313 + <View style={[a.pt_md, web([a.justify_between, a.flex_row])]}> 314 + {IS_WEB && ( 315 + <Button 316 + label={_(msg`Back`)} 317 + color="secondary" 318 + size="large" 319 + onPress={onPressBack}> 320 + <ButtonText> 321 + <Trans>Back</Trans> 322 + </ButtonText> 323 + </Button> 324 + )} 323 325 {!serviceDescription && error ? ( 324 326 <Button 325 327 testID="loginRetryButton" 326 328 label={_(msg`Retry`)} 327 329 accessibilityHint={_(msg`Retries signing in`)} 328 - variant="solid" 329 - color="secondary" 330 + color="primary_subtle" 330 331 size="large" 331 332 onPress={onPressRetryConnect}> 332 333 <ButtonText> ··· 334 335 </ButtonText> 335 336 </Button> 336 337 ) : !serviceDescription ? ( 337 - <> 338 - <ActivityIndicator /> 339 - <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 340 - <Trans>Connecting...</Trans> 341 - </Text> 342 - </> 338 + <Button 339 + label={_(msg`Connecting to service...`)} 340 + size="large" 341 + color="secondary" 342 + disabled> 343 + <ButtonIcon icon={Loader} /> 344 + <ButtonText>Connecting...</ButtonText> 345 + </Button> 343 346 ) : ( 344 347 <Button 345 348 testID="loginNextButton" 346 - label={_(msg`Next`)} 349 + label={_(msg`Log in`)} 347 350 accessibilityHint={_(msg`Navigates to the next screen`)} 348 - variant="solid" 349 351 color="primary" 350 352 size="large" 351 353 onPress={onPressNext}> 352 354 <ButtonText> 353 - <Trans>Next</Trans> 355 + <Trans>Log in</Trans> 354 356 </ButtonText> 355 357 {isProcessing && <ButtonIcon icon={Loader} />} 356 358 </Button>
+3 -4
src/screens/Login/PasswordUpdatedForm.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {atoms as a, useBreakpoints} from '#/alf' 5 + import {atoms as a, useBreakpoints, web} from '#/alf' 6 6 import {Button, ButtonText} from '#/components/Button' 7 7 import {Text} from '#/components/Typography' 8 8 import {FormContainer} from './FormContainer' ··· 19 19 <FormContainer 20 20 testID="passwordUpdatedForm" 21 21 style={[a.gap_2xl, !gtMobile && a.mt_5xl]}> 22 - <Text style={[a.text_3xl, a.font_semi_bold, a.text_center]}> 22 + <Text style={[a.text_3xl, a.font_bold, a.text_center]}> 23 23 <Trans>Password updated!</Trans> 24 24 </Text> 25 25 <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}> 26 26 <Trans>You can now sign in with your new password.</Trans> 27 27 </Text> 28 - <View style={[a.flex_row, a.justify_center]}> 28 + <View style={web([a.flex_row, a.justify_center])}> 29 29 <Button 30 30 onPress={onPressNext} 31 31 label={_(msg`Close alert`)} 32 32 accessibilityHint={_(msg`Closes password update alert`)} 33 - variant="solid" 34 33 color="primary" 35 34 size="large"> 36 35 <ButtonText>
+28 -30
src/screens/Login/SetNewPasswordForm.tsx
··· 1 1 import {useState} from 'react' 2 - import {ActivityIndicator, View} from 'react-native' 2 + import {View} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 ··· 7 7 import {checkAndFormatResetCode} from '#/lib/strings/password' 8 8 import {logger} from '#/logger' 9 9 import {Agent} from '#/state/session/agent' 10 - import {atoms as a, useTheme} from '#/alf' 11 - import {Button, ButtonText} from '#/components/Button' 10 + import {atoms as a, web} from '#/alf' 11 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 12 import {FormError} from '#/components/forms/FormError' 13 13 import * as TextField from '#/components/forms/TextField' 14 14 import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 15 15 import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 16 + import {Loader} from '#/components/Loader' 16 17 import {Text} from '#/components/Typography' 17 18 import {useAnalytics} from '#/analytics' 19 + import {IS_WEB} from '#/env' 18 20 import {FormContainer} from './FormContainer' 19 21 20 22 export const SetNewPasswordForm = ({ ··· 31 33 onPasswordSet: () => void 32 34 }) => { 33 35 const {_} = useLingui() 34 - const t = useTheme() 35 36 const ax = useAnalytics() 36 37 37 38 const [isProcessing, setIsProcessing] = useState<boolean>(false) ··· 163 164 164 165 <FormError error={error} /> 165 166 166 - <View style={[a.flex_row, a.align_center, a.pt_lg]}> 167 + <View style={[web([a.flex_row, a.align_center]), a.pt_lg]}> 168 + {IS_WEB && ( 169 + <> 170 + <Button 171 + label={_(msg`Back`)} 172 + variant="solid" 173 + color="secondary" 174 + size="large" 175 + onPress={onPressBack}> 176 + <ButtonText> 177 + <Trans>Back</Trans> 178 + </ButtonText> 179 + </Button> 180 + <View style={a.flex_1} /> 181 + </> 182 + )} 183 + 167 184 <Button 168 - label={_(msg`Back`)} 169 - variant="solid" 170 - color="secondary" 185 + label={_(msg`Next`)} 186 + color="primary" 171 187 size="large" 172 - onPress={onPressBack}> 188 + onPress={onPressNext} 189 + disabled={isProcessing}> 173 190 <ButtonText> 174 - <Trans>Back</Trans> 191 + <Trans>Next</Trans> 175 192 </ButtonText> 193 + {isProcessing && <ButtonIcon icon={Loader} />} 176 194 </Button> 177 - <View style={a.flex_1} /> 178 - {isProcessing ? ( 179 - <ActivityIndicator /> 180 - ) : ( 181 - <Button 182 - label={_(msg`Next`)} 183 - variant="solid" 184 - color="primary" 185 - size="large" 186 - onPress={onPressNext}> 187 - <ButtonText> 188 - <Trans>Next</Trans> 189 - </ButtonText> 190 - </Button> 191 - )} 192 - {isProcessing ? ( 193 - <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 194 - <Trans>Updating...</Trans> 195 - </Text> 196 - ) : undefined} 197 195 </View> 198 196 </FormContainer> 199 197 )
+92
src/screens/Login/components/AuthLayout/Header/index.tsx
··· 1 + import {useContext} from 'react' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {HITSLOP_30} from '#/lib/constants' 7 + import {Logomark} from '#/view/icons/Logomark' 8 + import {atoms as a, platform, useGutters, useTheme} from '#/alf' 9 + import {Button, ButtonIcon, type ButtonProps} from '#/components/Button' 10 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' 11 + import { 12 + BUTTON_VISUAL_ALIGNMENT_OFFSET, 13 + Header, 14 + HEADER_SLOT_SIZE, 15 + } from '#/components/Layout' 16 + import {IS_WEB} from '#/env' 17 + import {AuthLayoutNavigationContext} from '../context' 18 + 19 + /** 20 + * This is a simplified version of `Layout.Header` for the auth screens. 21 + */ 22 + 23 + export const Slot = Header.Slot 24 + 25 + export function Outer({children}: {children: React.ReactNode}) { 26 + const t = useTheme() 27 + const gutters = useGutters([0, 'wide']) 28 + 29 + return ( 30 + <View 31 + style={[ 32 + a.w_full, 33 + a.flex_row, 34 + a.align_center, 35 + a.gap_sm, 36 + gutters, 37 + platform({ 38 + native: [a.pb_xs, {minHeight: 48}], 39 + web: [a.py_xs, {minHeight: 52}], 40 + }), 41 + t.atoms.border_contrast_low, 42 + ]}> 43 + {children} 44 + </View> 45 + ) 46 + } 47 + 48 + export function Content({children}: {children?: React.ReactNode}) { 49 + return ( 50 + <View style={[a.flex_1, a.justify_center, {minHeight: HEADER_SLOT_SIZE}]}> 51 + {IS_WEB ? children : <Logo />} 52 + </View> 53 + ) 54 + } 55 + 56 + export function Logo() { 57 + const t = useTheme() 58 + 59 + return <Logomark fill={t.palette.primary_500} style={[a.mx_auto]} /> 60 + } 61 + 62 + export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) { 63 + const {_} = useLingui() 64 + const navigation = useContext(AuthLayoutNavigationContext) 65 + 66 + const onPressBack = (evt: GestureResponderEvent) => { 67 + onPress?.(evt) 68 + if (evt.defaultPrevented) return 69 + navigation?.goBack() 70 + } 71 + 72 + return ( 73 + <Slot> 74 + <Button 75 + label={_(msg`Go back`)} 76 + onPress={onPressBack} 77 + size="small" 78 + variant="ghost" 79 + color="secondary" 80 + shape="round" 81 + hitSlop={HITSLOP_30} 82 + style={[ 83 + {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, 84 + a.bg_transparent, 85 + style, 86 + ]} 87 + {...props}> 88 + <ButtonIcon icon={ArrowLeft} size="lg" /> 89 + </Button> 90 + </Slot> 91 + ) 92 + }
+23
src/screens/Login/components/AuthLayout/Header/index.web.tsx
··· 1 + import {type ButtonProps} from '#/components/Button' 2 + import {Header} from '#/components/Layout' 3 + 4 + // TEMP: Web needs a much more comprehensive layout change to support the new design 5 + // so to do this incrementally, we'll do native only and migrate to web later 6 + 7 + export const Slot = Header.Slot 8 + 9 + export function Outer({}: {children: React.ReactNode}) { 10 + return null 11 + } 12 + 13 + export function Content({}: {children?: React.ReactNode}) { 14 + return null 15 + } 16 + 17 + export function Logo() { 18 + return null 19 + } 20 + 21 + export function BackButton({}: Partial<ButtonProps>) { 22 + return null 23 + }
+5
src/screens/Login/components/AuthLayout/context.ts
··· 1 + import {createContext} from 'react' 2 + 3 + export const AuthLayoutNavigationContext = createContext<{ 4 + goBack: () => void 5 + } | null>(null)
+1
src/screens/Login/components/AuthLayout/index.tsx
··· 1 + export * as Header from './Header'
+41 -23
src/screens/Login/index.tsx
··· 18 18 import {ScreenTransition} from '#/components/ScreenTransition' 19 19 import {useAnalytics} from '#/analytics' 20 20 import {ChooseAccountForm} from './ChooseAccountForm' 21 + import * as AuthLayout from './components/AuthLayout' 22 + import {AuthLayoutNavigationContext} from './components/AuthLayout/context' 21 23 22 24 enum Forms { 23 25 Login, ··· 131 133 let content = null 132 134 let title = '' 133 135 let description = '' 136 + let goBack = null 134 137 135 138 switch (currentForm) { 136 139 case Forms.Login: 137 140 title = _(msg`Sign in`) 138 141 description = _(msg`Enter your username and password`) 142 + goBack = () => 143 + accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() 139 144 content = ( 140 145 <LoginForm 141 146 error={error} ··· 146 151 onAttemptFailed={onAttemptFailed} 147 152 onAttemptSuccess={onAttemptSuccess} 148 153 setServiceUrl={setServiceUrl} 149 - onPressBack={() => 150 - accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack() 151 - } 154 + onPressBack={goBack} 152 155 onPressForgotPassword={onPressForgotPassword} 153 156 onPressRetryConnect={refetchService} 154 157 /> ··· 157 160 case Forms.ChooseAccount: 158 161 title = _(msg`Sign in`) 159 162 description = _(msg`Select from an existing account`) 163 + goBack = handlePressBack 160 164 content = ( 161 165 <ChooseAccountForm 162 166 onSelectAccount={onSelectAccount} 163 - onPressBack={handlePressBack} 167 + onPressBack={goBack} 164 168 /> 165 169 ) 166 170 break 167 171 case Forms.ForgotPassword: 168 172 title = _(msg`Forgot Password`) 169 173 description = _(msg`Let's get your password reset!`) 174 + goBack = () => gotoForm(Forms.Login) 170 175 content = ( 171 176 <ForgotPasswordForm 172 177 error={error} ··· 174 179 serviceDescription={serviceDescription} 175 180 setError={setError} 176 181 setServiceUrl={setServiceUrl} 177 - onPressBack={() => gotoForm(Forms.Login)} 182 + onPressBack={goBack} 178 183 onEmailSent={() => gotoForm(Forms.SetNewPassword)} 179 184 /> 180 185 ) ··· 182 187 case Forms.SetNewPassword: 183 188 title = _(msg`Forgot Password`) 184 189 description = _(msg`Let's get your password reset!`) 190 + goBack = () => gotoForm(Forms.ForgotPassword) 185 191 content = ( 186 192 <SetNewPasswordForm 187 193 error={error} 188 194 serviceUrl={serviceUrl} 189 195 setError={setError} 190 - onPressBack={() => gotoForm(Forms.ForgotPassword)} 196 + onPressBack={goBack} 191 197 onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} 192 198 /> 193 199 ) ··· 201 207 break 202 208 } 203 209 210 + const navigation = goBack ? {goBack} : null 211 + 204 212 return ( 205 - <Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}> 206 - <KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}> 207 - <LoggedOutLayout 208 - leadin="" 209 - title={title} 210 - description={description} 211 - scrollable> 212 - <LayoutAnimationConfig skipEntering> 213 - <ScreenTransition 214 - key={currentForm} 215 - direction={screenTransitionDirection}> 216 - {content} 217 - </ScreenTransition> 218 - </LayoutAnimationConfig> 219 - </LoggedOutLayout> 220 - </KeyboardAvoidingView> 221 - </Animated.View> 213 + <AuthLayoutNavigationContext value={navigation}> 214 + <Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}> 215 + <KeyboardAvoidingView 216 + testID="signIn" 217 + behavior="padding" 218 + style={a.flex_1}> 219 + <AuthLayout.Header.Outer> 220 + <AuthLayout.Header.BackButton /> 221 + <AuthLayout.Header.Content /> 222 + <AuthLayout.Header.Slot /> 223 + </AuthLayout.Header.Outer> 224 + <LoggedOutLayout 225 + leadin="" 226 + title={title} 227 + description={description} 228 + scrollable> 229 + <LayoutAnimationConfig skipEntering> 230 + <ScreenTransition 231 + key={currentForm} 232 + direction={screenTransitionDirection}> 233 + {content} 234 + </ScreenTransition> 235 + </LayoutAnimationConfig> 236 + </LoggedOutLayout> 237 + </KeyboardAvoidingView> 238 + </Animated.View> 239 + </AuthLayoutNavigationContext> 222 240 ) 223 241 }
+23 -4
src/view/com/auth/LoggedOut.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useEffect, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 + import {useQueryClient} from '@tanstack/react-query' 6 7 7 8 import {PressableScale} from '#/lib/custom-animations/PressableScale' 9 + import {STALE} from '#/state/queries' 10 + import {profilesQueryKey} from '#/state/queries/profile' 11 + import {useAgent, useSession} from '#/state/session' 8 12 import { 9 13 useLoggedOutView, 10 14 useLoggedOutViewControls, ··· 35 39 const insets = useSafeAreaInsets() 36 40 const setMinimalShellMode = useSetMinimalShellMode() 37 41 const {requestedAccountSwitchTo} = useLoggedOutView() 38 - const [screenState, setScreenState] = React.useState<ScreenState>(() => { 42 + const [screenState, setScreenState] = useState<ScreenState>(() => { 39 43 if (requestedAccountSwitchTo === 'new') { 40 44 return ScreenState.S_CreateAccount 41 45 } else if (requestedAccountSwitchTo === 'starterpack') { ··· 48 52 }) 49 53 const {clearRequestedAccount} = useLoggedOutViewControls() 50 54 51 - React.useEffect(() => { 55 + const queryClient = useQueryClient() 56 + const {accounts} = useSession() 57 + const agent = useAgent() 58 + useEffect(() => { 59 + const actors = accounts.map(acc => acc.did) 60 + void queryClient.prefetchQuery({ 61 + queryKey: profilesQueryKey(actors), 62 + staleTime: STALE.MINUTES.FIVE, 63 + queryFn: async () => { 64 + const res = await agent.getProfiles({actors}) 65 + return res.data 66 + }, 67 + }) 68 + }, [accounts, agent, queryClient]) 69 + 70 + useEffect(() => { 52 71 setMinimalShellMode(true) 53 72 }, [setMinimalShellMode]) 54 73 55 - const onPressDismiss = React.useCallback(() => { 74 + const onPressDismiss = useCallback(() => { 56 75 if (onDismiss) { 57 76 onDismiss() 58 77 }
+2 -2
src/view/com/util/layouts/LoggedOutLayout.tsx
··· 42 42 contentContainerStyle={[ 43 43 {paddingBottom: isKeyboardVisible ? 300 : 0}, 44 44 ]}> 45 - <View style={a.pt_md}>{children}</View> 45 + <View style={a.pt_lg}>{children}</View> 46 46 </ScrollView> 47 47 ) 48 48 } else { 49 - return <View style={a.pt_md}>{children}</View> 49 + return <View style={a.pt_lg}>{children}</View> 50 50 } 51 51 } 52 52 return (