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

Configure Feed

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

at main 631 lines 20 kB view raw
1import {useCallback, useRef, useState} from 'react' 2import { 3 ActivityIndicator, 4 Keyboard, 5 Pressable, 6 type TextInput, 7 View, 8} from 'react-native' 9import { 10 ComAtprotoServerCreateSession, 11 type ComAtprotoServerDescribeServer, 12} from '@atproto/api' 13import {msg} from '@lingui/core/macro' 14import {useLingui} from '@lingui/react' 15import {Trans} from '@lingui/react/macro' 16 17import {cleanError, isNetworkError} from '#/lib/strings/errors' 18import {createFullHandle} from '#/lib/strings/handles' 19import {isValidDomain} from '#/lib/strings/url-helpers' 20import {logger} from '#/logger' 21import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 22import {useSessionApi} from '#/state/session' 23import {getWebOAuthClient} from '#/state/session/oauth-web-client' 24import {useLoggedOutViewControls} from '#/state/shell/logged-out' 25import {atoms as a, useTheme} from '#/alf' 26import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27import {FormError} from '#/components/forms/FormError' 28import {HostingProvider} from '#/components/forms/HostingProvider' 29import * as TextField from '#/components/forms/TextField' 30import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 31import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 32import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 33import {Loader} from '#/components/Loader' 34import {Text} from '#/components/Typography' 35import {FormContainer} from './FormContainer' 36 37type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 38 39type LoginMode = 'oauth' | 'legacy' 40 41/** 42 * Web-specific LoginForm with two tabs: 43 * - OAuth (default): handle-only flow, redirects to PDS authorization server 44 * - Legacy sign-in: username + password, for operations that may not support OAuth 45 */ 46export const LoginForm = ({ 47 error, 48 serviceUrl, 49 serviceDescription, 50 initialHandle, 51 setError, 52 setServiceUrl, 53 onPressRetryConnect, 54 onPressBack, 55 onPressForgotPassword, 56 onAttemptSuccess, 57 onAttemptFailed, 58 debouncedResolveService, 59 isResolvingService, 60}: { 61 error: string 62 serviceUrl?: string | undefined 63 serviceDescription: ServiceDescription | undefined 64 initialHandle: string 65 setError: (v: string) => void 66 setServiceUrl: (v: string) => void 67 onPressRetryConnect: () => void 68 onPressBack: () => void 69 onPressForgotPassword: () => void 70 onAttemptSuccess: () => void 71 onAttemptFailed: () => void 72 debouncedResolveService: (identifier: string) => void 73 isResolvingService: boolean 74}) => { 75 const t = useTheme() 76 const [mode, setMode] = useState<LoginMode>('oauth') 77 const [isProcessing, setIsProcessing] = useState(false) 78 79 const switchMode = (next: LoginMode) => { 80 if (next === mode) return 81 setError('') 82 setMode(next) 83 } 84 85 return ( 86 <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> 87 <View 88 style={[a.flex_row, a.mb_md, a.rounded_sm, a.overflow_hidden]} 89 accessibilityRole="tablist"> 90 <Pressable 91 accessibilityRole="tab" 92 accessibilityState={{selected: mode === 'oauth'}} 93 onPress={() => switchMode('oauth')} 94 style={[ 95 a.flex_1, 96 a.align_center, 97 a.py_sm, 98 a.border_b, 99 {borderBottomWidth: 2}, 100 mode === 'oauth' 101 ? {borderBottomColor: t.palette.primary_500} 102 : {borderBottomColor: 'transparent'}, 103 ]}> 104 <Text 105 style={[ 106 a.text_sm, 107 a.font_bold, 108 mode === 'oauth' 109 ? {color: t.palette.primary_500} 110 : t.atoms.text_contrast_medium, 111 ]}> 112 <Trans>OAuth</Trans> 113 </Text> 114 </Pressable> 115 <Pressable 116 accessibilityRole="tab" 117 accessibilityState={{selected: mode === 'legacy'}} 118 onPress={() => switchMode('legacy')} 119 style={[ 120 a.flex_1, 121 a.align_center, 122 a.py_sm, 123 a.border_b, 124 {borderBottomWidth: 2}, 125 mode === 'legacy' 126 ? {borderBottomColor: t.palette.primary_500} 127 : {borderBottomColor: 'transparent'}, 128 ]}> 129 <Text 130 style={[ 131 a.text_sm, 132 a.font_bold, 133 mode === 'legacy' 134 ? {color: t.palette.primary_500} 135 : t.atoms.text_contrast_medium, 136 ]}> 137 <Trans>Legacy sign-in</Trans> 138 </Text> 139 </Pressable> 140 </View> 141 142 {mode === 'oauth' ? ( 143 <OAuthLoginFields 144 error={error} 145 initialHandle={initialHandle} 146 setError={setError} 147 isProcessing={isProcessing} 148 setIsProcessing={setIsProcessing} 149 onPressBack={onPressBack} 150 /> 151 ) : ( 152 <LegacyLoginFields 153 error={error} 154 serviceUrl={serviceUrl} 155 serviceDescription={serviceDescription} 156 initialHandle={initialHandle} 157 setError={setError} 158 setServiceUrl={setServiceUrl} 159 onPressRetryConnect={onPressRetryConnect} 160 onPressBack={onPressBack} 161 onPressForgotPassword={onPressForgotPassword} 162 onAttemptSuccess={onAttemptSuccess} 163 onAttemptFailed={onAttemptFailed} 164 debouncedResolveService={debouncedResolveService} 165 isResolvingService={isResolvingService} 166 isProcessing={isProcessing} 167 setIsProcessing={setIsProcessing} 168 /> 169 )} 170 </FormContainer> 171 ) 172} 173 174function OAuthLoginFields({ 175 error, 176 initialHandle, 177 setError, 178 isProcessing, 179 setIsProcessing, 180 onPressBack, 181}: { 182 error: string 183 initialHandle: string 184 setError: (v: string) => void 185 isProcessing: boolean 186 setIsProcessing: (v: boolean) => void 187 onPressBack: () => void 188}) { 189 const {_} = useLingui() 190 const identifierValueRef = useRef<string>(initialHandle || '') 191 192 const onPressNext = async () => { 193 if (isProcessing) return 194 Keyboard.dismiss() 195 setError('') 196 197 const identifier = identifierValueRef.current.trim() 198 199 if (!identifier) { 200 setError(_(msg`Please enter your username or handle`)) 201 return 202 } 203 204 setIsProcessing(true) 205 206 try { 207 const client = getWebOAuthClient() 208 await client.signIn(identifier) 209 // Browser will redirect to authorization server 210 } catch (e: any) { 211 const errMsg = e.toString() 212 setIsProcessing(false) 213 if (isNetworkError(e)) { 214 logger.warn('Failed to start OAuth sign-in due to network error', { 215 error: errMsg, 216 }) 217 setError( 218 _( 219 msg`Unable to contact your service. Please check your Internet connection.`, 220 ), 221 ) 222 } else { 223 logger.warn('Failed to start OAuth sign-in', {error: errMsg}) 224 setError(cleanError(errMsg)) 225 } 226 } 227 } 228 229 return ( 230 <> 231 <View> 232 <TextField.LabelText> 233 <Trans>Account</Trans> 234 </TextField.LabelText> 235 <View style={[a.gap_sm]}> 236 <TextField.Root> 237 <TextField.Icon icon={At} /> 238 <TextField.Input 239 testID="loginUsernameInput" 240 label={_(msg`Username or handle`)} 241 autoCapitalize="none" 242 autoFocus 243 autoCorrect={false} 244 autoComplete="username" 245 returnKeyType="done" 246 textContentType="username" 247 defaultValue={initialHandle || ''} 248 onChangeText={v => { 249 identifierValueRef.current = v 250 }} 251 onSubmitEditing={onPressNext} 252 blurOnSubmit={false} 253 editable={!isProcessing} 254 accessibilityHint={_( 255 msg`Enter your handle (e.g. alice.bsky.social)`, 256 )} 257 /> 258 </TextField.Root> 259 </View> 260 </View> 261 <FormError error={error} /> 262 <View style={[a.flex_row, a.align_center, a.pt_md]}> 263 <Button 264 label={_(msg`Back`)} 265 variant="solid" 266 color="secondary" 267 size="large" 268 onPress={onPressBack}> 269 <ButtonText> 270 <Trans>Back</Trans> 271 </ButtonText> 272 </Button> 273 <View style={a.flex_1} /> 274 <Button 275 testID="loginNextButton" 276 label={_(msg`Sign in`)} 277 accessibilityHint={_(msg`Redirects to your authorization server`)} 278 color="primary" 279 size="large" 280 onPress={onPressNext}> 281 <ButtonText> 282 <Trans>Sign in</Trans> 283 </ButtonText> 284 {isProcessing && <ButtonIcon icon={Loader} />} 285 </Button> 286 </View> 287 </> 288 ) 289} 290 291function LegacyLoginFields({ 292 error, 293 serviceUrl, 294 serviceDescription, 295 initialHandle, 296 setError, 297 setServiceUrl, 298 onPressRetryConnect, 299 onPressBack, 300 onPressForgotPassword, 301 onAttemptSuccess, 302 onAttemptFailed, 303 debouncedResolveService, 304 isResolvingService, 305 isProcessing, 306 setIsProcessing, 307}: { 308 error: string 309 serviceUrl?: string | undefined 310 serviceDescription: ServiceDescription | undefined 311 initialHandle: string 312 setError: (v: string) => void 313 setServiceUrl: (v: string) => void 314 onPressRetryConnect: () => void 315 onPressBack: () => void 316 onPressForgotPassword: () => void 317 onAttemptSuccess: () => void 318 onAttemptFailed: () => void 319 debouncedResolveService: (identifier: string) => void 320 isResolvingService: boolean 321 isProcessing: boolean 322 setIsProcessing: (v: boolean) => void 323}) { 324 const t = useTheme() 325 const {_} = useLingui() 326 const {login} = useSessionApi() 327 const {setShowLoggedOut} = useLoggedOutViewControls() 328 const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 329 330 const [errorField, setErrorField] = useState< 331 'none' | 'identifier' | 'password' | '2fa' 332 >('none') 333 const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) 334 const identifierValueRef = useRef<string>(initialHandle || '') 335 const passwordValueRef = useRef<string>('') 336 const [authFactorToken, setAuthFactorToken] = useState('') 337 const identifierRef = useRef<TextInput>(null) 338 const passwordRef = useRef<TextInput>(null) 339 340 const onPressSelectService = useCallback(() => { 341 Keyboard.dismiss() 342 }, []) 343 344 const onPressNext = async () => { 345 if (isProcessing || isResolvingService || serviceUrl === undefined) return 346 Keyboard.dismiss() 347 setError('') 348 setErrorField('none') 349 350 const identifier = identifierValueRef.current.toLowerCase().trim() 351 const password = passwordValueRef.current 352 353 if (!identifier) { 354 setError(_(msg`Please enter your username`)) 355 setErrorField('identifier') 356 return 357 } 358 359 if (!password) { 360 setError(_(msg`Please enter your password`)) 361 return 362 } 363 364 setIsProcessing(true) 365 366 try { 367 let fullIdent = identifier 368 if ( 369 !identifier.includes('@') && 370 !identifier.includes('.') && 371 serviceDescription && 372 serviceDescription.availableUserDomains.length > 0 373 ) { 374 let matched = false 375 for (const domain of serviceDescription.availableUserDomains) { 376 if (fullIdent.endsWith(domain)) { 377 matched = true 378 } 379 } 380 if (!matched) { 381 fullIdent = createFullHandle( 382 identifier, 383 serviceDescription.availableUserDomains[0], 384 ) 385 } 386 } 387 388 await login( 389 { 390 service: serviceUrl, 391 identifier: fullIdent, 392 password, 393 authFactorToken: authFactorToken.trim(), 394 }, 395 'LoginForm', 396 ) 397 onAttemptSuccess() 398 setShowLoggedOut(false) 399 setHasCheckedForStarterPack(true) 400 } catch (e: any) { 401 const errMsg = e.toString() 402 setIsProcessing(false) 403 if ( 404 e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError 405 ) { 406 setIsAuthFactorTokenNeeded(true) 407 } else { 408 onAttemptFailed() 409 if (errMsg.includes('Token is invalid')) { 410 logger.debug('Failed to login due to invalid 2fa token', { 411 error: errMsg, 412 }) 413 setError(_(msg`Invalid 2FA confirmation code.`)) 414 setErrorField('2fa') 415 } else if ( 416 errMsg.includes('Authentication Required') || 417 errMsg.includes('Invalid identifier or password') 418 ) { 419 logger.debug('Failed to login due to invalid credentials', { 420 error: errMsg, 421 }) 422 setError(_(msg`Incorrect username or password`)) 423 } else if (isNetworkError(e)) { 424 logger.warn('Failed to login due to network error', {error: errMsg}) 425 setError( 426 _( 427 msg`Unable to contact your service. Please check your Internet connection.`, 428 ), 429 ) 430 } else { 431 logger.warn('Failed to login', {error: errMsg}) 432 setError(cleanError(errMsg)) 433 } 434 } 435 } 436 } 437 438 return ( 439 <> 440 <View> 441 <TextField.LabelText> 442 <Trans>Hosting provider</Trans> 443 {isResolvingService && ( 444 <ActivityIndicator 445 size={10} 446 color={t.palette.contrast_500} 447 style={a.ml_sm} 448 /> 449 )} 450 </TextField.LabelText> 451 <HostingProvider 452 serviceUrl={serviceUrl} 453 onSelectServiceUrl={setServiceUrl} 454 onOpenDialog={onPressSelectService} 455 /> 456 </View> 457 <View> 458 <TextField.LabelText> 459 <Trans>Account</Trans> 460 </TextField.LabelText> 461 <View style={[a.gap_sm]}> 462 <TextField.Root isInvalid={errorField === 'identifier'}> 463 <TextField.Icon icon={At} /> 464 <TextField.Input 465 testID="loginUsernameInput" 466 inputRef={identifierRef} 467 label={ 468 serviceUrl === undefined 469 ? _(msg`Username (full handle)`) 470 : _(msg`Username or email address`) 471 } 472 autoCapitalize="none" 473 autoFocus 474 autoCorrect={false} 475 autoComplete="username" 476 returnKeyType="next" 477 textContentType="username" 478 defaultValue={initialHandle || ''} 479 onChangeText={v => { 480 identifierValueRef.current = v 481 const id = v.trim() 482 if (!id) return 483 if ( 484 id.startsWith('did:') || 485 (!id.includes('@') && isValidDomain(id)) 486 ) { 487 debouncedResolveService(id) 488 } 489 if (errorField) setErrorField('none') 490 }} 491 onSubmitEditing={() => { 492 passwordRef.current?.focus() 493 }} 494 blurOnSubmit={false} 495 editable={!isProcessing} 496 accessibilityHint={_( 497 msg`Enter the username or email address you used when you created your account`, 498 )} 499 /> 500 </TextField.Root> 501 502 <TextField.Root isInvalid={errorField === 'password'}> 503 <TextField.Icon icon={Lock} /> 504 <TextField.Input 505 testID="loginPasswordInput" 506 inputRef={passwordRef} 507 label={_(msg`Password`)} 508 autoCapitalize="none" 509 autoCorrect={false} 510 autoComplete="current-password" 511 returnKeyType="done" 512 enablesReturnKeyAutomatically={true} 513 secureTextEntry={true} 514 clearButtonMode="while-editing" 515 onChangeText={v => { 516 passwordValueRef.current = v 517 if (errorField) setErrorField('none') 518 }} 519 onSubmitEditing={onPressNext} 520 blurOnSubmit={false} 521 editable={!isProcessing} 522 accessibilityHint={_(msg`Enter your password`)} 523 /> 524 <Button 525 testID="forgotPasswordButton" 526 onPress={onPressForgotPassword} 527 label={_(msg`Forgot password?`)} 528 accessibilityHint={_(msg`Opens password reset form`)} 529 variant="solid" 530 color="secondary" 531 style={[ 532 a.rounded_sm, 533 {marginLeft: 'auto', left: 6, padding: 6}, 534 a.z_10, 535 ]}> 536 <ButtonText> 537 <Trans>Forgot?</Trans> 538 </ButtonText> 539 </Button> 540 </TextField.Root> 541 </View> 542 </View> 543 {isAuthFactorTokenNeeded && ( 544 <View> 545 <TextField.LabelText> 546 <Trans>2FA Confirmation</Trans> 547 </TextField.LabelText> 548 <TextField.Root isInvalid={errorField === '2fa'}> 549 <TextField.Icon icon={Ticket} /> 550 <TextField.Input 551 testID="loginAuthFactorTokenInput" 552 label={_(msg`Confirmation code`)} 553 autoCapitalize="none" 554 autoFocus 555 autoCorrect={false} 556 autoComplete="one-time-code" 557 returnKeyType="done" 558 blurOnSubmit={false} 559 value={authFactorToken} 560 onChangeText={text => { 561 setAuthFactorToken(text) 562 if (errorField) setErrorField('none') 563 }} 564 onSubmitEditing={onPressNext} 565 editable={!isProcessing} 566 accessibilityHint={_( 567 msg`Input the code which has been emailed to you`, 568 )} 569 style={{ 570 textTransform: authFactorToken === '' ? 'none' : 'uppercase', 571 }} 572 /> 573 </TextField.Root> 574 <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.mt_sm]}> 575 <Trans> 576 Check your email for a sign in code and enter it here. 577 </Trans> 578 </Text> 579 </View> 580 )} 581 <FormError error={error} /> 582 <View style={[a.pt_md, a.justify_between, a.flex_row]}> 583 <Button 584 label={_(msg`Back`)} 585 color="secondary" 586 size="large" 587 onPress={onPressBack}> 588 <ButtonText> 589 <Trans>Back</Trans> 590 </ButtonText> 591 </Button> 592 {!serviceDescription && error ? ( 593 <Button 594 testID="loginRetryButton" 595 label={_(msg`Retry`)} 596 accessibilityHint={_(msg`Retries signing in`)} 597 color="primary_subtle" 598 size="large" 599 onPress={onPressRetryConnect}> 600 <ButtonText> 601 <Trans>Retry</Trans> 602 </ButtonText> 603 </Button> 604 ) : !serviceDescription && serviceUrl !== undefined ? ( 605 <Button 606 label={_(msg`Connecting to service...`)} 607 size="large" 608 color="secondary" 609 disabled> 610 <ButtonIcon icon={Loader} /> 611 <ButtonText>Connecting...</ButtonText> 612 </Button> 613 ) : ( 614 <Button 615 testID="loginNextButton" 616 label={_(msg`Sign in`)} 617 accessibilityHint={_(msg`Navigates to the next screen`)} 618 color="primary" 619 size="large" 620 onPress={onPressNext} 621 disabled={isResolvingService || serviceUrl === undefined}> 622 <ButtonText> 623 <Trans>Sign in</Trans> 624 </ButtonText> 625 {isProcessing && <ButtonIcon icon={Loader} />} 626 </Button> 627 )} 628 </View> 629 </> 630 ) 631}