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