Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at c906fea77adb2daad28a521f06e68d5bbc4bce4d 363 lines 13 kB view raw
1import {useCallback, useRef, useState} from 'react' 2import {Keyboard, type TextInput, View} from 'react-native' 3import { 4 ComAtprotoServerCreateSession, 5 type ComAtprotoServerDescribeServer, 6} from '@atproto/api' 7import {msg, Trans} from '@lingui/macro' 8import {useLingui} from '@lingui/react' 9 10import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 11import {cleanError, isNetworkError} from '#/lib/strings/errors' 12import {createFullHandle} from '#/lib/strings/handles' 13import {logger} from '#/logger' 14import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 15import {useSessionApi} from '#/state/session' 16import {useLoggedOutViewControls} from '#/state/shell/logged-out' 17import {atoms as a, ios, useTheme, web} from '#/alf' 18import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19import {FormError} from '#/components/forms/FormError' 20import {HostingProvider} from '#/components/forms/HostingProvider' 21import * as TextField from '#/components/forms/TextField' 22import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 23import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 24import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 25import {Loader} from '#/components/Loader' 26import {Text} from '#/components/Typography' 27import {IS_IOS, IS_WEB} from '#/env' 28import {FormContainer} from './FormContainer' 29 30type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 31 32export const LoginForm = ({ 33 error, 34 serviceUrl, 35 serviceDescription, 36 initialHandle, 37 setError, 38 setServiceUrl, 39 onPressRetryConnect, 40 onPressBack, 41 onPressForgotPassword, 42 onAttemptSuccess, 43 onAttemptFailed, 44}: { 45 error: string 46 serviceUrl: string 47 serviceDescription: ServiceDescription | undefined 48 initialHandle: string 49 setError: (v: string) => void 50 setServiceUrl: (v: string) => void 51 onPressRetryConnect: () => void 52 onPressBack: () => void 53 onPressForgotPassword: () => void 54 onAttemptSuccess: () => void 55 onAttemptFailed: () => void 56}) => { 57 const t = useTheme() 58 const [isProcessing, setIsProcessing] = useState(false) 59 const [errorField, setErrorField] = useState< 60 'none' | 'identifier' | 'password' | '2fa' 61 >('none') 62 const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) 63 const identifierValueRef = useRef<string>(initialHandle || '') 64 const passwordValueRef = useRef<string>('') 65 const [authFactorToken, setAuthFactorToken] = useState('') 66 const identifierRef = useRef<TextInput>(null) 67 const passwordRef = useRef<TextInput>(null) 68 const hasFocusedOnce = useRef<boolean>(false) 69 const {_} = useLingui() 70 const {login} = useSessionApi() 71 const requestNotificationsPermission = useRequestNotificationsPermission() 72 const {setShowLoggedOut} = useLoggedOutViewControls() 73 const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 74 75 const onPressSelectService = useCallback(() => { 76 Keyboard.dismiss() 77 }, []) 78 79 const onPressNext = async () => { 80 if (isProcessing) return 81 Keyboard.dismiss() 82 setError('') 83 setErrorField('none') 84 85 const identifier = identifierValueRef.current.toLowerCase().trim() 86 const password = passwordValueRef.current 87 88 if (!identifier) { 89 setError(_(msg`Please enter your username`)) 90 setErrorField('identifier') 91 return 92 } 93 94 if (!password) { 95 setError(_(msg`Please enter your password`)) 96 setErrorField('password') 97 return 98 } 99 100 setIsProcessing(true) 101 102 try { 103 // try to guess the handle if the user just gave their own username 104 let fullIdent = identifier 105 if ( 106 !identifier.includes('@') && // not an email 107 !identifier.includes('.') && // not a domain 108 serviceDescription && 109 serviceDescription.availableUserDomains.length > 0 110 ) { 111 let matched = false 112 for (const domain of serviceDescription.availableUserDomains) { 113 if (fullIdent.endsWith(domain)) { 114 matched = true 115 } 116 } 117 if (!matched) { 118 fullIdent = createFullHandle( 119 identifier, 120 serviceDescription.availableUserDomains[0], 121 ) 122 } 123 } 124 125 // TODO remove double login 126 await login( 127 { 128 service: serviceUrl, 129 identifier: fullIdent, 130 password, 131 authFactorToken: authFactorToken.trim(), 132 }, 133 'LoginForm', 134 ) 135 onAttemptSuccess() 136 setShowLoggedOut(false) 137 setHasCheckedForStarterPack(true) 138 requestNotificationsPermission('Login') 139 } catch (e: any) { 140 const errMsg = e.toString() 141 setIsProcessing(false) 142 if ( 143 e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError 144 ) { 145 setIsAuthFactorTokenNeeded(true) 146 } else { 147 onAttemptFailed() 148 if (errMsg.includes('Token is invalid')) { 149 logger.debug('Failed to login due to invalid 2fa token', { 150 error: errMsg, 151 }) 152 setError(_(msg`Invalid 2FA confirmation code.`)) 153 setErrorField('2fa') 154 } else if ( 155 errMsg.includes('Authentication Required') || 156 errMsg.includes('Invalid identifier or password') 157 ) { 158 logger.debug('Failed to login due to invalid credentials', { 159 error: errMsg, 160 }) 161 setError(_(msg`Incorrect username or password`)) 162 } else if (isNetworkError(e)) { 163 logger.warn('Failed to login due to network error', {error: errMsg}) 164 setError( 165 _( 166 msg`Unable to contact your service. Please check your Internet connection.`, 167 ), 168 ) 169 } else { 170 logger.warn('Failed to login', {error: errMsg}) 171 setError(cleanError(errMsg)) 172 } 173 } 174 } 175 } 176 177 return ( 178 <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 179 <View> 180 <TextField.LabelText> 181 <Trans>Hosting provider</Trans> 182 </TextField.LabelText> 183 <HostingProvider 184 serviceUrl={serviceUrl} 185 onSelectServiceUrl={setServiceUrl} 186 onOpenDialog={onPressSelectService} 187 /> 188 </View> 189 <View> 190 <TextField.LabelText> 191 <Trans>Account</Trans> 192 </TextField.LabelText> 193 <View style={[a.gap_sm]}> 194 <TextField.Root isInvalid={errorField === 'identifier'}> 195 <TextField.Icon icon={At} /> 196 <TextField.Input 197 testID="loginUsernameInput" 198 inputRef={identifierRef} 199 label={_(msg`Username or email address`)} 200 autoCapitalize="none" 201 autoFocus={!IS_IOS} 202 autoCorrect={false} 203 autoComplete="username" 204 returnKeyType="next" 205 textContentType="username" 206 defaultValue={initialHandle || ''} 207 onChangeText={v => { 208 identifierValueRef.current = v 209 if (errorField) setErrorField('none') 210 }} 211 onSubmitEditing={() => { 212 passwordRef.current?.focus() 213 }} 214 blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 215 editable={!isProcessing} 216 accessibilityHint={_( 217 msg`Enter the username or email address you used when you created your account`, 218 )} 219 /> 220 </TextField.Root> 221 222 <TextField.Root isInvalid={errorField === 'password'}> 223 <TextField.Icon icon={Lock} /> 224 <TextField.Input 225 testID="loginPasswordInput" 226 inputRef={passwordRef} 227 label={_(msg`Password`)} 228 autoCapitalize="none" 229 autoCorrect={false} 230 autoComplete="current-password" 231 returnKeyType="done" 232 enablesReturnKeyAutomatically={true} 233 secureTextEntry={true} 234 clearButtonMode="while-editing" 235 onChangeText={v => { 236 passwordValueRef.current = v 237 if (errorField) setErrorField('none') 238 }} 239 onSubmitEditing={onPressNext} 240 blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing 241 editable={!isProcessing} 242 accessibilityHint={_(msg`Enter your password`)} 243 onLayout={ios(() => { 244 if (hasFocusedOnce.current) return 245 hasFocusedOnce.current = true 246 // kinda dumb, but if we use `autoFocus` to focus 247 // the username input, it happens before the password 248 // input gets rendered. this breaks the password autofill 249 // on iOS (it only does the username part). delaying 250 // it until both inputs are rendered fixes the autofill -sfn 251 identifierRef.current?.focus() 252 })} 253 /> 254 <Button 255 testID="forgotPasswordButton" 256 onPress={onPressForgotPassword} 257 label={_(msg`Forgot password?`)} 258 accessibilityHint={_(msg`Opens password reset form`)} 259 variant="solid" 260 color="secondary" 261 style={[ 262 a.rounded_sm, 263 // t.atoms.bg_contrast_100, 264 {marginLeft: 'auto', left: 6, padding: 6}, 265 a.z_10, 266 ]}> 267 <ButtonText> 268 <Trans>Forgot?</Trans> 269 </ButtonText> 270 </Button> 271 </TextField.Root> 272 </View> 273 </View> 274 {isAuthFactorTokenNeeded && ( 275 <View> 276 <TextField.LabelText> 277 <Trans>2FA Confirmation</Trans> 278 </TextField.LabelText> 279 <TextField.Root isInvalid={errorField === '2fa'}> 280 <TextField.Icon icon={Ticket} /> 281 <TextField.Input 282 testID="loginAuthFactorTokenInput" 283 label={_(msg`Confirmation code`)} 284 autoCapitalize="none" 285 autoFocus 286 autoCorrect={false} 287 autoComplete="one-time-code" 288 returnKeyType="done" 289 blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 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 }} 295 onSubmitEditing={onPressNext} 296 editable={!isProcessing} 297 accessibilityHint={_( 298 msg`Input the code which has been emailed to you`, 299 )} 300 style={{ 301 textTransform: authFactorToken === '' ? 'none' : 'uppercase', 302 }} 303 /> 304 </TextField.Root> 305 <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}> 306 <Trans> 307 Check your email for a sign in code and enter it here. 308 </Trans> 309 </Text> 310 </View> 311 )} 312 <FormError error={error} /> 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 )} 325 {!serviceDescription && error ? ( 326 <Button 327 testID="loginRetryButton" 328 label={_(msg`Retry`)} 329 accessibilityHint={_(msg`Retries signing in`)} 330 color="primary_subtle" 331 size="large" 332 onPress={onPressRetryConnect}> 333 <ButtonText> 334 <Trans>Retry</Trans> 335 </ButtonText> 336 </Button> 337 ) : !serviceDescription ? ( 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> 346 ) : ( 347 <Button 348 testID="loginNextButton" 349 label={_(msg`Sign in`)} 350 accessibilityHint={_(msg`Navigates to the next screen`)} 351 color="primary" 352 size="large" 353 onPress={onPressNext}> 354 <ButtonText> 355 <Trans>Sign in</Trans> 356 </ButtonText> 357 {isProcessing && <ButtonIcon icon={Loader} />} 358 </Button> 359 )} 360 </View> 361 </FormContainer> 362 ) 363}