Bluesky app fork with some witchin' additions 💫

Merge pull request #3217 from bluesky-social/samuel/alf-login

Use ALF for login & signup flow

authored by samuel.fm and committed by

GitHub c649ee1a 8ad813cd

+2570 -2555
+1
assets/icons/calendar_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z" clip-rule="evenodd"/></svg>
+1
assets/icons/envelope_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z" clip-rule="evenodd"/></svg>
+1
assets/icons/lock_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
+1
assets/icons/pencilLine_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
+1
assets/icons/ticket_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" stroke="#000" stroke-linejoin="round" d="M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z"/></svg>
+1 -1
package.json
··· 1 1 { 2 2 "name": "bsky.app", 3 - "version": "1.73.0", 3 + "version": "1.74.0", 4 4 "private": true, 5 5 "engines": { 6 6 "node": ">=18"
+78 -6
src/alf/atoms.ts
··· 1 1 import {Platform} from 'react-native' 2 - import {web, native} from '#/alf/util/platform' 2 + 3 3 import * as tokens from '#/alf/tokens' 4 + import {native, web} from '#/alf/util/platform' 4 5 5 6 export const atoms = { 6 7 /* ··· 157 158 align_end: { 158 159 alignItems: 'flex-end', 159 160 }, 161 + align_baseline: { 162 + alignItems: 'baseline', 163 + }, 164 + align_stretch: { 165 + alignItems: 'stretch', 166 + }, 160 167 self_auto: { 161 168 alignSelf: 'auto', 162 169 }, ··· 247 254 fontWeight: tokens.fontWeight.normal, 248 255 }, 249 256 font_semibold: { 250 - fontWeight: '500', 257 + fontWeight: tokens.fontWeight.semibold, 251 258 }, 252 259 font_bold: { 253 - fontWeight: tokens.fontWeight.semibold, 260 + fontWeight: tokens.fontWeight.bold, 254 261 }, 255 262 italic: { 256 263 fontStyle: 'italic', ··· 300 307 /* 301 308 * Padding 302 309 */ 310 + p_0: { 311 + padding: 0, 312 + }, 303 313 p_2xs: { 304 314 padding: tokens.space._2xs, 305 315 }, ··· 330 340 p_5xl: { 331 341 padding: tokens.space._5xl, 332 342 }, 343 + px_0: { 344 + paddingLeft: 0, 345 + paddingRight: 0, 346 + }, 333 347 px_2xs: { 334 348 paddingLeft: tokens.space._2xs, 335 349 paddingRight: tokens.space._2xs, ··· 370 384 paddingLeft: tokens.space._5xl, 371 385 paddingRight: tokens.space._5xl, 372 386 }, 387 + py_0: { 388 + paddingTop: 0, 389 + paddingBottom: 0, 390 + }, 373 391 py_2xs: { 374 392 paddingTop: tokens.space._2xs, 375 393 paddingBottom: tokens.space._2xs, ··· 409 427 py_5xl: { 410 428 paddingTop: tokens.space._5xl, 411 429 paddingBottom: tokens.space._5xl, 430 + }, 431 + pt_0: { 432 + paddingTop: 0, 412 433 }, 413 434 pt_2xs: { 414 435 paddingTop: tokens.space._2xs, ··· 440 461 pt_5xl: { 441 462 paddingTop: tokens.space._5xl, 442 463 }, 464 + pb_0: { 465 + paddingBottom: 0, 466 + }, 443 467 pb_2xs: { 444 468 paddingBottom: tokens.space._2xs, 445 469 }, ··· 470 494 pb_5xl: { 471 495 paddingBottom: tokens.space._5xl, 472 496 }, 497 + pl_0: { 498 + paddingLeft: 0, 499 + }, 473 500 pl_2xs: { 474 501 paddingLeft: tokens.space._2xs, 475 502 }, ··· 499 526 }, 500 527 pl_5xl: { 501 528 paddingLeft: tokens.space._5xl, 529 + }, 530 + pr_0: { 531 + paddingRight: 0, 502 532 }, 503 533 pr_2xs: { 504 534 paddingRight: tokens.space._2xs, ··· 534 564 /* 535 565 * Margin 536 566 */ 537 - mx_auto: { 538 - marginLeft: 'auto', 539 - marginRight: 'auto', 567 + m_0: { 568 + margin: 0, 540 569 }, 541 570 m_2xs: { 542 571 margin: tokens.space._2xs, ··· 568 597 m_5xl: { 569 598 margin: tokens.space._5xl, 570 599 }, 600 + m_auto: { 601 + margin: 'auto', 602 + }, 603 + mx_0: { 604 + marginLeft: 0, 605 + marginRight: 0, 606 + }, 571 607 mx_2xs: { 572 608 marginLeft: tokens.space._2xs, 573 609 marginRight: tokens.space._2xs, ··· 608 644 marginLeft: tokens.space._5xl, 609 645 marginRight: tokens.space._5xl, 610 646 }, 647 + mx_auto: { 648 + marginLeft: 'auto', 649 + marginRight: 'auto', 650 + }, 651 + my_0: { 652 + marginTop: 0, 653 + marginBottom: 0, 654 + }, 611 655 my_2xs: { 612 656 marginTop: tokens.space._2xs, 613 657 marginBottom: tokens.space._2xs, ··· 647 691 my_5xl: { 648 692 marginTop: tokens.space._5xl, 649 693 marginBottom: tokens.space._5xl, 694 + }, 695 + my_auto: { 696 + marginTop: 'auto', 697 + marginBottom: 'auto', 698 + }, 699 + mt_0: { 700 + marginTop: 0, 650 701 }, 651 702 mt_2xs: { 652 703 marginTop: tokens.space._2xs, ··· 678 729 mt_5xl: { 679 730 marginTop: tokens.space._5xl, 680 731 }, 732 + mt_auto: { 733 + marginTop: 'auto', 734 + }, 735 + mb_0: { 736 + marginBottom: 0, 737 + }, 681 738 mb_2xs: { 682 739 marginBottom: tokens.space._2xs, 683 740 }, ··· 708 765 mb_5xl: { 709 766 marginBottom: tokens.space._5xl, 710 767 }, 768 + mb_auto: { 769 + marginBottom: 'auto', 770 + }, 771 + ml_0: { 772 + marginLeft: 0, 773 + }, 711 774 ml_2xs: { 712 775 marginLeft: tokens.space._2xs, 713 776 }, ··· 738 801 ml_5xl: { 739 802 marginLeft: tokens.space._5xl, 740 803 }, 804 + ml_auto: { 805 + marginLeft: 'auto', 806 + }, 807 + mr_0: { 808 + marginRight: 0, 809 + }, 741 810 mr_2xs: { 742 811 marginRight: tokens.space._2xs, 743 812 }, ··· 767 836 }, 768 837 mr_5xl: { 769 838 marginRight: tokens.space._5xl, 839 + }, 840 + mr_auto: { 841 + marginRight: 'auto', 770 842 }, 771 843 } as const
+4 -4
src/alf/tokens.ts
··· 1 1 import { 2 2 BLUE_HUE, 3 - RED_HUE, 4 - GREEN_HUE, 5 3 generateScale, 4 + GREEN_HUE, 5 + RED_HUE, 6 6 } from '#/alf/util/colorGeneration' 7 7 8 8 export const scale = generateScale(6, 100) ··· 116 116 117 117 export const fontWeight = { 118 118 normal: '400', 119 - semibold: '600', 120 - bold: '900', 119 + semibold: '500', 120 + bold: '600', 121 121 } as const 122 122 123 123 export const gradients = {
+23 -60
src/components/forms/DateField/index.android.tsx
··· 1 1 import React from 'react' 2 - import {View, Pressable} from 'react-native' 2 + import DatePicker from 'react-native-date-picker' 3 3 4 - import {useTheme, atoms} from '#/alf' 5 - import {Text} from '#/components/Typography' 6 - import {useInteractionState} from '#/components/hooks/useInteractionState' 4 + import {useTheme} from '#/alf' 5 + import {DateFieldProps} from '#/components/forms/DateField/types' 6 + import {toSimpleDateString} from '#/components/forms/DateField/utils' 7 7 import * as TextField from '#/components/forms/TextField' 8 - import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 9 - 10 - import {DateFieldProps} from '#/components/forms/DateField/types' 11 - import { 12 - localizeDate, 13 - toSimpleDateString, 14 - } from '#/components/forms/DateField/utils' 15 - import DatePicker from 'react-native-date-picker' 16 - import {isAndroid} from 'platform/detection' 8 + import {DateFieldButton} from './index.shared' 17 9 18 10 export * as utils from '#/components/forms/DateField/utils' 19 11 export const Label = TextField.Label ··· 24 16 label, 25 17 isInvalid, 26 18 testID, 19 + accessibilityHint, 27 20 }: DateFieldProps) { 28 21 const t = useTheme() 29 22 const [open, setOpen] = React.useState(false) 30 - const { 31 - state: pressed, 32 - onIn: onPressIn, 33 - onOut: onPressOut, 34 - } = useInteractionState() 35 - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 36 - 37 - const {chromeFocus, chromeError, chromeErrorHover} = 38 - TextField.useSharedInputStyles() 39 23 40 24 const onChangeInternal = React.useCallback( 41 25 (date: Date) => { ··· 46 30 }, 47 31 [onChangeDate, setOpen], 48 32 ) 33 + 34 + const onPress = React.useCallback(() => { 35 + setOpen(true) 36 + }, []) 49 37 50 38 const onCancel = React.useCallback(() => { 51 39 setOpen(false) 52 40 }, []) 53 41 54 42 return ( 55 - <View style={[atoms.relative, atoms.w_full]}> 56 - <Pressable 57 - aria-label={label} 58 - accessibilityLabel={label} 59 - accessibilityHint={undefined} 60 - onPress={() => setOpen(true)} 61 - onPressIn={onPressIn} 62 - onPressOut={onPressOut} 63 - onFocus={onFocus} 64 - onBlur={onBlur} 65 - style={[ 66 - { 67 - paddingTop: 16, 68 - paddingBottom: 16, 69 - borderColor: 'transparent', 70 - borderWidth: 2, 71 - }, 72 - atoms.flex_row, 73 - atoms.flex_1, 74 - atoms.w_full, 75 - atoms.px_lg, 76 - atoms.rounded_sm, 77 - t.atoms.bg_contrast_50, 78 - focused || pressed ? chromeFocus : {}, 79 - isInvalid ? chromeError : {}, 80 - isInvalid && (focused || pressed) ? chromeErrorHover : {}, 81 - ]}> 82 - <TextField.Icon icon={CalendarDays} /> 83 - 84 - <Text 85 - style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}> 86 - {localizeDate(value)} 87 - </Text> 88 - </Pressable> 43 + <> 44 + <DateFieldButton 45 + label={label} 46 + value={value} 47 + onPress={onPress} 48 + isInvalid={isInvalid} 49 + accessibilityHint={accessibilityHint} 50 + /> 89 51 90 52 {open && ( 91 53 <DatePicker 92 - modal={isAndroid} 93 - open={isAndroid} 54 + modal 55 + open 56 + timeZoneOffsetInMinutes={0} 94 57 theme={t.name === 'light' ? 'light' : 'dark'} 95 58 date={new Date(value)} 96 59 onConfirm={onChangeInternal} ··· 99 62 testID={`${testID}-datepicker`} 100 63 aria-label={label} 101 64 accessibilityLabel={label} 102 - accessibilityHint={undefined} 65 + accessibilityHint={accessibilityHint} 103 66 /> 104 67 )} 105 - </View> 68 + </> 106 69 ) 107 70 }
+99
src/components/forms/DateField/index.shared.tsx
··· 1 + import React from 'react' 2 + import {Pressable, View} from 'react-native' 3 + 4 + import {android, atoms as a, useTheme, web} from '#/alf' 5 + import * as TextField from '#/components/forms/TextField' 6 + import {useInteractionState} from '#/components/hooks/useInteractionState' 7 + import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 8 + import {Text} from '#/components/Typography' 9 + import {localizeDate} from './utils' 10 + 11 + // looks like a TextField.Input, but is just a button. It'll do something different on each platform on press 12 + // iOS: open a dialog with an inline date picker 13 + // Android: open the date picker modal 14 + 15 + export function DateFieldButton({ 16 + label, 17 + value, 18 + onPress, 19 + isInvalid, 20 + accessibilityHint, 21 + }: { 22 + label: string 23 + value: string 24 + onPress: () => void 25 + isInvalid?: boolean 26 + accessibilityHint?: string 27 + }) { 28 + const t = useTheme() 29 + 30 + const { 31 + state: pressed, 32 + onIn: onPressIn, 33 + onOut: onPressOut, 34 + } = useInteractionState() 35 + const { 36 + state: hovered, 37 + onIn: onHoverIn, 38 + onOut: onHoverOut, 39 + } = useInteractionState() 40 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 41 + 42 + const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = 43 + TextField.useSharedInputStyles() 44 + 45 + return ( 46 + <View 47 + style={[a.relative, a.w_full]} 48 + {...web({ 49 + onMouseOver: onHoverIn, 50 + onMouseOut: onHoverOut, 51 + })}> 52 + <Pressable 53 + aria-label={label} 54 + accessibilityLabel={label} 55 + accessibilityHint={accessibilityHint} 56 + onPress={onPress} 57 + onPressIn={onPressIn} 58 + onPressOut={onPressOut} 59 + onFocus={onFocus} 60 + onBlur={onBlur} 61 + style={[ 62 + { 63 + paddingTop: 12, 64 + paddingBottom: 12, 65 + paddingLeft: 14, 66 + paddingRight: 14, 67 + borderColor: 'transparent', 68 + borderWidth: 2, 69 + }, 70 + android({ 71 + minHeight: 57.5, 72 + }), 73 + a.flex_row, 74 + a.flex_1, 75 + a.w_full, 76 + a.rounded_sm, 77 + t.atoms.bg_contrast_25, 78 + a.align_center, 79 + hovered ? chromeHover : {}, 80 + focused || pressed ? chromeFocus : {}, 81 + isInvalid || isInvalid ? chromeError : {}, 82 + (isInvalid || isInvalid) && (hovered || focused) 83 + ? chromeErrorHover 84 + : {}, 85 + ]}> 86 + <TextField.Icon icon={CalendarDays} /> 87 + <Text 88 + style={[ 89 + a.text_md, 90 + a.pl_xs, 91 + t.atoms.text, 92 + {lineHeight: a.text_md.fontSize * 1.1875}, 93 + ]}> 94 + {localizeDate(value)} 95 + </Text> 96 + </Pressable> 97 + </View> 98 + ) 99 + }
+51 -15
src/components/forms/DateField/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import DatePicker from 'react-native-date-picker' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 3 6 4 - import {useTheme, atoms} from '#/alf' 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {Button, ButtonText} from '#/components/Button' 9 + import * as Dialog from '#/components/Dialog' 10 + import {DateFieldProps} from '#/components/forms/DateField/types' 11 + import {toSimpleDateString} from '#/components/forms/DateField/utils' 5 12 import * as TextField from '#/components/forms/TextField' 6 - import {toSimpleDateString} from '#/components/forms/DateField/utils' 7 - import {DateFieldProps} from '#/components/forms/DateField/types' 8 - import DatePicker from 'react-native-date-picker' 13 + import {DateFieldButton} from './index.shared' 9 14 10 15 export * as utils from '#/components/forms/DateField/utils' 11 16 export const Label = TextField.Label ··· 22 27 onChangeDate, 23 28 testID, 24 29 label, 30 + isInvalid, 31 + accessibilityHint, 25 32 }: DateFieldProps) { 33 + const {_} = useLingui() 26 34 const t = useTheme() 35 + const control = Dialog.useDialogControl() 27 36 28 37 const onChangeInternal = React.useCallback( 29 38 (date: Date | undefined) => { ··· 36 45 ) 37 46 38 47 return ( 39 - <View style={[atoms.relative, atoms.w_full]}> 40 - <DatePicker 41 - theme={t.name === 'light' ? 'light' : 'dark'} 42 - date={new Date(value)} 43 - onDateChange={onChangeInternal} 44 - mode="date" 45 - testID={`${testID}-datepicker`} 46 - aria-label={label} 47 - accessibilityLabel={label} 48 - accessibilityHint={undefined} 48 + <> 49 + <DateFieldButton 50 + label={label} 51 + value={value} 52 + onPress={control.open} 53 + isInvalid={isInvalid} 54 + accessibilityHint={accessibilityHint} 49 55 /> 50 - </View> 56 + <Dialog.Outer control={control} testID={testID}> 57 + <Dialog.Handle /> 58 + <Dialog.Inner label={label}> 59 + <View style={a.gap_lg}> 60 + <View style={[a.relative, a.w_full, a.align_center]}> 61 + <DatePicker 62 + timeZoneOffsetInMinutes={0} 63 + theme={t.name === 'light' ? 'light' : 'dark'} 64 + date={new Date(value)} 65 + onDateChange={onChangeInternal} 66 + mode="date" 67 + testID={`${testID}-datepicker`} 68 + aria-label={label} 69 + accessibilityLabel={label} 70 + accessibilityHint={accessibilityHint} 71 + /> 72 + </View> 73 + <Button 74 + label={_(msg`Done`)} 75 + onPress={() => control.close()} 76 + size="medium" 77 + color="primary" 78 + variant="solid"> 79 + <ButtonText> 80 + <Trans>Done</Trans> 81 + </ButtonText> 82 + </Button> 83 + </View> 84 + </Dialog.Inner> 85 + </Dialog.Outer> 86 + </> 51 87 ) 52 88 }
+7 -3
src/components/forms/DateField/index.web.tsx
··· 1 1 import React from 'react' 2 - import {TextInput, TextInputProps, StyleSheet} from 'react-native' 2 + import {StyleSheet, TextInput, TextInputProps} from 'react-native' 3 3 // @ts-ignore 4 4 import {unstable_createElement} from 'react-native-web' 5 5 6 - import * as TextField from '#/components/forms/TextField' 6 + import {DateFieldProps} from '#/components/forms/DateField/types' 7 7 import {toSimpleDateString} from '#/components/forms/DateField/utils' 8 - import {DateFieldProps} from '#/components/forms/DateField/types' 8 + import * as TextField from '#/components/forms/TextField' 9 + import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 9 10 10 11 export * as utils from '#/components/forms/DateField/utils' 11 12 export const Label = TextField.Label ··· 37 38 label, 38 39 isInvalid, 39 40 testID, 41 + accessibilityHint, 40 42 }: DateFieldProps) { 41 43 const handleOnChange = React.useCallback( 42 44 (e: any) => { ··· 52 54 53 55 return ( 54 56 <TextField.Root isInvalid={isInvalid}> 57 + <TextField.Icon icon={CalendarDays} /> 55 58 <Input 56 59 value={value} 57 60 label={label} 58 61 onChange={handleOnChange} 59 62 onChangeText={() => {}} 60 63 testID={testID} 64 + accessibilityHint={accessibilityHint} 61 65 /> 62 66 </TextField.Root> 63 67 )
+1
src/components/forms/DateField/types.ts
··· 4 4 label: string 5 5 isInvalid?: boolean 6 6 testID?: string 7 + accessibilityHint?: string 7 8 }
+30
src/components/forms/FormError.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 6 + import {Text} from '#/components/Typography' 7 + 8 + export function FormError({error}: {error?: string}) { 9 + const t = useTheme() 10 + 11 + if (!error) return null 12 + 13 + return ( 14 + <View 15 + style={[ 16 + {backgroundColor: t.palette.negative_400}, 17 + a.flex_row, 18 + a.rounded_sm, 19 + a.p_md, 20 + a.gap_sm, 21 + ]}> 22 + <Warning fill={t.palette.white} size="md" /> 23 + <View> 24 + <Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}> 25 + {error} 26 + </Text> 27 + </View> 28 + </View> 29 + ) 30 + }
+95
src/components/forms/HostingProvider.tsx
··· 1 + import React from 'react' 2 + import {Keyboard, View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {toNiceDomain} from '#/lib/strings/url-helpers' 7 + import {isAndroid} from '#/platform/detection' 8 + import {ServerInputDialog} from '#/view/com/auth/server-input' 9 + import {atoms as a, useTheme} from '#/alf' 10 + import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 11 + import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' 12 + import {Button} from '../Button' 13 + import {useDialogControl} from '../Dialog' 14 + import {Text} from '../Typography' 15 + 16 + export function HostingProvider({ 17 + serviceUrl, 18 + onSelectServiceUrl, 19 + onOpenDialog, 20 + }: { 21 + serviceUrl: string 22 + onSelectServiceUrl: (provider: string) => void 23 + onOpenDialog?: () => void 24 + }) { 25 + const serverInputControl = useDialogControl() 26 + const t = useTheme() 27 + const {_} = useLingui() 28 + 29 + const onPressSelectService = React.useCallback(() => { 30 + Keyboard.dismiss() 31 + serverInputControl.open() 32 + if (onOpenDialog) { 33 + onOpenDialog() 34 + } 35 + }, [onOpenDialog, serverInputControl]) 36 + 37 + return ( 38 + <> 39 + <ServerInputDialog 40 + control={serverInputControl} 41 + onSelect={onSelectServiceUrl} 42 + /> 43 + <Button 44 + label={toNiceDomain(serviceUrl)} 45 + accessibilityHint={_(msg`Press to change hosting provider`)} 46 + variant="solid" 47 + color="secondary" 48 + style={[ 49 + a.w_full, 50 + a.flex_row, 51 + a.align_center, 52 + a.rounded_sm, 53 + a.px_md, 54 + a.pr_sm, 55 + a.gap_xs, 56 + {paddingVertical: isAndroid ? 14 : 9}, 57 + ]} 58 + onPress={onPressSelectService}> 59 + {({hovered, pressed}) => { 60 + const interacted = hovered || pressed 61 + return ( 62 + <> 63 + <View style={a.pr_xs}> 64 + <Globe 65 + size="md" 66 + fill={ 67 + interacted ? t.palette.contrast_800 : t.palette.contrast_500 68 + } 69 + /> 70 + </View> 71 + <Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text> 72 + <View 73 + style={[ 74 + a.rounded_sm, 75 + interacted 76 + ? t.atoms.bg_contrast_300 77 + : t.atoms.bg_contrast_100, 78 + {marginLeft: 'auto', padding: 6}, 79 + ]}> 80 + <Pencil 81 + size="sm" 82 + style={{ 83 + color: interacted 84 + ? t.palette.contrast_800 85 + : t.palette.contrast_500, 86 + }} 87 + /> 88 + </View> 89 + </> 90 + ) 91 + }} 92 + </Button> 93 + </> 94 + ) 95 + }
+10 -4
src/components/forms/TextField.tsx
··· 14 14 import {Text} from '#/components/Typography' 15 15 import {useInteractionState} from '#/components/hooks/useInteractionState' 16 16 import {Props as SVGIconProps} from '#/components/icons/common' 17 + import {mergeRefs} from '#/lib/merge-refs' 17 18 18 19 const Context = React.createContext<{ 19 20 inputRef: React.RefObject<TextInput> | null ··· 125 126 126 127 export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { 127 128 label: string 128 - value: string 129 - onChangeText: (value: string) => void 129 + value?: string 130 + onChangeText?: (value: string) => void 130 131 isInvalid?: boolean 132 + inputRef?: React.RefObject<TextInput> 131 133 } 132 134 133 135 export function createInput(Component: typeof TextInput) { ··· 137 139 value, 138 140 onChangeText, 139 141 isInvalid, 142 + inputRef, 140 143 ...rest 141 144 }: InputProps) { 142 145 const t = useTheme() ··· 161 164 ) 162 165 } 163 166 167 + const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) 168 + 164 169 return ( 165 170 <> 166 171 <Component 167 172 accessibilityHint={undefined} 168 173 {...rest} 169 174 accessibilityLabel={label} 170 - ref={ctx.inputRef} 175 + ref={refs} 171 176 value={value} 172 177 onChangeText={onChangeText} 173 178 onFocus={ctx.onFocus} 174 179 onBlur={ctx.onBlur} 175 180 placeholder={placeholder || label} 176 181 placeholderTextColor={t.palette.contrast_500} 182 + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 177 183 hitSlop={HITSLOP_20} 178 184 style={[ 179 185 a.relative, ··· 271 277 <Comp 272 278 size="md" 273 279 style={[ 274 - {color: t.palette.contrast_500, pointerEvents: 'none'}, 280 + {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0}, 275 281 ctx.hovered ? hover : {}, 276 282 ctx.focused ? focus : {}, 277 283 ctx.isInvalid && ctx.hovered ? errorHover : {},
+5
src/components/icons/Calendar.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z', 5 + })
+5
src/components/icons/Envelope.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z', 5 + })
+5
src/components/icons/Lock.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z', 5 + })
+5
src/components/icons/Pencil.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z', 5 + })
+5
src/components/icons/Ticket.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z', 5 + })
+2
src/lib/strings/handles.ts
··· 27 27 28 28 export interface IsValidHandle { 29 29 handleChars: boolean 30 + hyphenStartOrEnd: boolean 30 31 frontLength: boolean 31 32 totalLength: boolean 32 33 overall: boolean ··· 39 40 const results = { 40 41 handleChars: 41 42 !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), 43 + hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'), 42 44 frontLength: str.length >= 3, 43 45 totalLength: fullHandle.length <= 253, 44 46 }
+188
src/screens/Login/ChooseAccountForm.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useAnalytics} from '#/lib/analytics/analytics' 7 + import {logEvent} from '#/lib/statsig/statsig' 8 + import {colors} from '#/lib/styles' 9 + import {useProfileQuery} from '#/state/queries/profile' 10 + import {SessionAccount, useSession, useSessionApi} from '#/state/session' 11 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 12 + import * as Toast from '#/view/com/util/Toast' 13 + import {UserAvatar} from '#/view/com/util/UserAvatar' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {Button} from '#/components/Button' 16 + import * as TextField from '#/components/forms/TextField' 17 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 + import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' 19 + import {Text} from '#/components/Typography' 20 + import {FormContainer} from './FormContainer' 21 + 22 + function AccountItem({ 23 + account, 24 + onSelect, 25 + isCurrentAccount, 26 + }: { 27 + account: SessionAccount 28 + onSelect: (account: SessionAccount) => void 29 + isCurrentAccount: boolean 30 + }) { 31 + const t = useTheme() 32 + const {_} = useLingui() 33 + const {data: profile} = useProfileQuery({did: account.did}) 34 + 35 + const onPress = React.useCallback(() => { 36 + onSelect(account) 37 + }, [account, onSelect]) 38 + 39 + return ( 40 + <Button 41 + testID={`chooseAccountBtn-${account.handle}`} 42 + key={account.did} 43 + style={[a.flex_1]} 44 + onPress={onPress} 45 + label={ 46 + isCurrentAccount 47 + ? _(msg`Continue as ${account.handle} (currently signed in)`) 48 + : _(msg`Sign in as ${account.handle}`) 49 + }> 50 + {({hovered, pressed}) => ( 51 + <View 52 + style={[ 53 + a.flex_1, 54 + a.flex_row, 55 + a.align_center, 56 + {height: 48}, 57 + (hovered || pressed) && t.atoms.bg_contrast_25, 58 + ]}> 59 + <View style={a.p_md}> 60 + <UserAvatar avatar={profile?.avatar} size={24} /> 61 + </View> 62 + <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}> 63 + <Text style={[a.font_bold]}> 64 + {profile?.displayName || account.handle}{' '} 65 + </Text> 66 + <Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text> 67 + </Text> 68 + {isCurrentAccount ? ( 69 + <Check size="sm" style={[{color: colors.green3}, a.mr_md]} /> 70 + ) : ( 71 + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> 72 + )} 73 + </View> 74 + )} 75 + </Button> 76 + ) 77 + } 78 + export const ChooseAccountForm = ({ 79 + onSelectAccount, 80 + onPressBack, 81 + }: { 82 + onSelectAccount: (account?: SessionAccount) => void 83 + onPressBack: () => void 84 + }) => { 85 + const {track, screen} = useAnalytics() 86 + const {_} = useLingui() 87 + const t = useTheme() 88 + const {accounts, currentAccount} = useSession() 89 + const {initSession} = useSessionApi() 90 + const {setShowLoggedOut} = useLoggedOutViewControls() 91 + 92 + React.useEffect(() => { 93 + screen('Choose Account') 94 + }, [screen]) 95 + 96 + const onSelect = React.useCallback( 97 + async (account: SessionAccount) => { 98 + if (account.accessJwt) { 99 + if (account.did === currentAccount?.did) { 100 + setShowLoggedOut(false) 101 + Toast.show(_(msg`Already signed in as @${account.handle}`)) 102 + } else { 103 + await initSession(account) 104 + logEvent('account:loggedIn', { 105 + logContext: 'ChooseAccountForm', 106 + withPassword: false, 107 + }) 108 + track('Sign In', {resumedSession: true}) 109 + setTimeout(() => { 110 + Toast.show(_(msg`Signed in as @${account.handle}`)) 111 + }, 100) 112 + } 113 + } else { 114 + onSelectAccount(account) 115 + } 116 + }, 117 + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], 118 + ) 119 + 120 + return ( 121 + <FormContainer 122 + testID="chooseAccountForm" 123 + title={<Trans>Select account</Trans>}> 124 + <View> 125 + <TextField.Label> 126 + <Trans>Sign in as...</Trans> 127 + </TextField.Label> 128 + <View 129 + style={[ 130 + a.rounded_md, 131 + a.overflow_hidden, 132 + a.border, 133 + t.atoms.border_contrast_low, 134 + ]}> 135 + {accounts.map(account => ( 136 + <React.Fragment key={account.did}> 137 + <AccountItem 138 + account={account} 139 + onSelect={onSelect} 140 + isCurrentAccount={account.did === currentAccount?.did} 141 + /> 142 + <View style={[a.border_b, t.atoms.border_contrast_low]} /> 143 + </React.Fragment> 144 + ))} 145 + <Button 146 + testID="chooseNewAccountBtn" 147 + style={[a.flex_1]} 148 + onPress={() => onSelectAccount(undefined)} 149 + label={_(msg`Login to account that is not listed`)}> 150 + {({hovered, pressed}) => ( 151 + <View 152 + style={[ 153 + a.flex_1, 154 + a.flex_row, 155 + a.align_center, 156 + {height: 48}, 157 + (hovered || pressed) && t.atoms.bg_contrast_25, 158 + ]}> 159 + <Text 160 + style={[ 161 + a.align_baseline, 162 + a.flex_1, 163 + a.flex_row, 164 + a.py_sm, 165 + {paddingLeft: 48}, 166 + ]}> 167 + <Trans>Other account</Trans> 168 + </Text> 169 + <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> 170 + </View> 171 + )} 172 + </Button> 173 + </View> 174 + </View> 175 + <View style={[a.flex_row]}> 176 + <Button 177 + label={_(msg`Back`)} 178 + variant="solid" 179 + color="secondary" 180 + size="medium" 181 + onPress={onPressBack}> 182 + {_(msg`Back`)} 183 + </Button> 184 + <View style={[a.flex_1]} /> 185 + </View> 186 + </FormContainer> 187 + ) 188 + }
+184
src/screens/Login/ForgotPasswordForm.tsx
··· 1 + import React, {useEffect, useState} from 'react' 2 + import {ActivityIndicator, Keyboard, View} from 'react-native' 3 + import {ComAtprotoServerDescribeServer} from '@atproto/api' 4 + import {BskyAgent} from '@atproto/api' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + import * as EmailValidator from 'email-validator' 8 + 9 + import {useAnalytics} from '#/lib/analytics/analytics' 10 + import {isNetworkError} from '#/lib/strings/errors' 11 + import {cleanError} from '#/lib/strings/errors' 12 + import {logger} from '#/logger' 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {Button, ButtonText} from '#/components/Button' 15 + import {FormError} from '#/components/forms/FormError' 16 + import {HostingProvider} from '#/components/forms/HostingProvider' 17 + import * as TextField from '#/components/forms/TextField' 18 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 19 + import {Text} from '#/components/Typography' 20 + import {FormContainer} from './FormContainer' 21 + 22 + type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 23 + 24 + export const ForgotPasswordForm = ({ 25 + error, 26 + serviceUrl, 27 + serviceDescription, 28 + setError, 29 + setServiceUrl, 30 + onPressBack, 31 + onEmailSent, 32 + }: { 33 + error: string 34 + serviceUrl: string 35 + serviceDescription: ServiceDescription | undefined 36 + setError: (v: string) => void 37 + setServiceUrl: (v: string) => void 38 + onPressBack: () => void 39 + onEmailSent: () => void 40 + }) => { 41 + const t = useTheme() 42 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 43 + const [email, setEmail] = useState<string>('') 44 + const {screen} = useAnalytics() 45 + const {_} = useLingui() 46 + 47 + useEffect(() => { 48 + screen('Signin:ForgotPassword') 49 + }, [screen]) 50 + 51 + const onPressSelectService = React.useCallback(() => { 52 + Keyboard.dismiss() 53 + }, []) 54 + 55 + const onPressNext = async () => { 56 + if (!EmailValidator.validate(email)) { 57 + return setError(_(msg`Your email appears to be invalid.`)) 58 + } 59 + 60 + setError('') 61 + setIsProcessing(true) 62 + 63 + try { 64 + const agent = new BskyAgent({service: serviceUrl}) 65 + await agent.com.atproto.server.requestPasswordReset({email}) 66 + onEmailSent() 67 + } catch (e: any) { 68 + const errMsg = e.toString() 69 + logger.warn('Failed to request password reset', {error: e}) 70 + setIsProcessing(false) 71 + if (isNetworkError(e)) { 72 + setError( 73 + _( 74 + msg`Unable to contact your service. Please check your Internet connection.`, 75 + ), 76 + ) 77 + } else { 78 + setError(cleanError(errMsg)) 79 + } 80 + } 81 + } 82 + 83 + return ( 84 + <FormContainer 85 + testID="forgotPasswordForm" 86 + title={<Trans>Reset password</Trans>}> 87 + <View> 88 + <TextField.Label> 89 + <Trans>Hosting provider</Trans> 90 + </TextField.Label> 91 + <HostingProvider 92 + serviceUrl={serviceUrl} 93 + onSelectServiceUrl={setServiceUrl} 94 + onOpenDialog={onPressSelectService} 95 + /> 96 + </View> 97 + <View> 98 + <TextField.Label> 99 + <Trans>Email address</Trans> 100 + </TextField.Label> 101 + <TextField.Root> 102 + <TextField.Icon icon={At} /> 103 + <TextField.Input 104 + testID="forgotPasswordEmail" 105 + label={_(msg`Enter your email address`)} 106 + autoCapitalize="none" 107 + autoFocus 108 + autoCorrect={false} 109 + autoComplete="email" 110 + value={email} 111 + onChangeText={setEmail} 112 + editable={!isProcessing} 113 + accessibilityHint={_(msg`Sets email for password reset`)} 114 + /> 115 + </TextField.Root> 116 + </View> 117 + 118 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 119 + <Trans> 120 + Enter the email you used to create your account. We'll send you a 121 + "reset code" so you can set a new password. 122 + </Trans> 123 + </Text> 124 + 125 + <FormError error={error} /> 126 + 127 + <View style={[a.flex_row, a.align_center, a.pt_md]}> 128 + <Button 129 + label={_(msg`Back`)} 130 + variant="solid" 131 + color="secondary" 132 + size="medium" 133 + onPress={onPressBack}> 134 + <ButtonText> 135 + <Trans>Back</Trans> 136 + </ButtonText> 137 + </Button> 138 + <View style={a.flex_1} /> 139 + {!serviceDescription || isProcessing ? ( 140 + <ActivityIndicator /> 141 + ) : ( 142 + <Button 143 + label={_(msg`Next`)} 144 + variant="solid" 145 + color={'primary'} 146 + size="medium" 147 + onPress={onPressNext} 148 + disabled={!email}> 149 + <ButtonText> 150 + <Trans>Next</Trans> 151 + </ButtonText> 152 + </Button> 153 + )} 154 + {!serviceDescription || isProcessing ? ( 155 + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 156 + <Trans>Processing...</Trans> 157 + </Text> 158 + ) : undefined} 159 + </View> 160 + <View 161 + style={[ 162 + t.atoms.border_contrast_medium, 163 + a.border_t, 164 + a.pt_2xl, 165 + a.mt_md, 166 + a.flex_row, 167 + a.justify_center, 168 + ]}> 169 + <Button 170 + testID="skipSendEmailButton" 171 + onPress={onEmailSent} 172 + label={_(msg`Go to next`)} 173 + accessibilityHint={_(msg`Navigates to the next screen`)} 174 + size="medium" 175 + variant="ghost" 176 + color="secondary"> 177 + <ButtonText> 178 + <Trans>Already have a code?</Trans> 179 + </ButtonText> 180 + </Button> 181 + </View> 182 + </FormContainer> 183 + ) 184 + }
+53
src/screens/Login/FormContainer.tsx
··· 1 + import React from 'react' 2 + import { 3 + ScrollView, 4 + type StyleProp, 5 + StyleSheet, 6 + View, 7 + type ViewStyle, 8 + } from 'react-native' 9 + 10 + import {isWeb} from '#/platform/detection' 11 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 + import {Text} from '#/components/Typography' 13 + 14 + export function FormContainer({ 15 + testID, 16 + title, 17 + children, 18 + style, 19 + contentContainerStyle, 20 + }: { 21 + testID?: string 22 + title?: React.ReactNode 23 + children: React.ReactNode 24 + style?: StyleProp<ViewStyle> 25 + contentContainerStyle?: StyleProp<ViewStyle> 26 + }) { 27 + const {gtMobile} = useBreakpoints() 28 + const t = useTheme() 29 + return ( 30 + <ScrollView 31 + testID={testID} 32 + style={[styles.maxHeight, contentContainerStyle]} 33 + keyboardShouldPersistTaps="handled"> 34 + <View 35 + style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.pt_md], style]}> 36 + {title && !gtMobile && ( 37 + <Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}> 38 + {title} 39 + </Text> 40 + )} 41 + {children} 42 + </View> 43 + </ScrollView> 44 + ) 45 + } 46 + 47 + const styles = StyleSheet.create({ 48 + maxHeight: { 49 + // @ts-ignore web only -prf 50 + maxHeight: isWeb ? '100vh' : undefined, 51 + height: !isWeb ? '100%' : undefined, 52 + }, 53 + })
+266
src/screens/Login/LoginForm.tsx
··· 1 + import React, {useRef, useState} from 'react' 2 + import { 3 + ActivityIndicator, 4 + Keyboard, 5 + LayoutAnimation, 6 + TextInput, 7 + View, 8 + } from 'react-native' 9 + import {ComAtprotoServerDescribeServer} from '@atproto/api' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {useAnalytics} from '#/lib/analytics/analytics' 14 + import {isNetworkError} from '#/lib/strings/errors' 15 + import {cleanError} from '#/lib/strings/errors' 16 + import {createFullHandle} from '#/lib/strings/handles' 17 + import {logger} from '#/logger' 18 + import {useSessionApi} from '#/state/session' 19 + import {atoms as a, useTheme} from '#/alf' 20 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21 + import {FormError} from '#/components/forms/FormError' 22 + import {HostingProvider} from '#/components/forms/HostingProvider' 23 + import * as TextField from '#/components/forms/TextField' 24 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 25 + import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 26 + import {Loader} from '#/components/Loader' 27 + import {Text} from '#/components/Typography' 28 + import {FormContainer} from './FormContainer' 29 + 30 + type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 31 + 32 + export const LoginForm = ({ 33 + error, 34 + serviceUrl, 35 + serviceDescription, 36 + initialHandle, 37 + setError, 38 + setServiceUrl, 39 + onPressRetryConnect, 40 + onPressBack, 41 + onPressForgotPassword, 42 + }: { 43 + error: string 44 + serviceUrl: string 45 + serviceDescription: ServiceDescription | undefined 46 + initialHandle: string 47 + setError: (v: string) => void 48 + setServiceUrl: (v: string) => void 49 + onPressRetryConnect: () => void 50 + onPressBack: () => void 51 + onPressForgotPassword: () => void 52 + }) => { 53 + const {track} = useAnalytics() 54 + const t = useTheme() 55 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 56 + const [identifier, setIdentifier] = useState<string>(initialHandle) 57 + const [password, setPassword] = useState<string>('') 58 + const passwordInputRef = useRef<TextInput>(null) 59 + const {_} = useLingui() 60 + const {login} = useSessionApi() 61 + 62 + const onPressSelectService = React.useCallback(() => { 63 + Keyboard.dismiss() 64 + track('Signin:PressedSelectService') 65 + }, [track]) 66 + 67 + const onPressNext = async () => { 68 + if (isProcessing) return 69 + Keyboard.dismiss() 70 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 71 + setError('') 72 + setIsProcessing(true) 73 + 74 + try { 75 + // try to guess the handle if the user just gave their own username 76 + let fullIdent = identifier 77 + if ( 78 + !identifier.includes('@') && // not an email 79 + !identifier.includes('.') && // not a domain 80 + serviceDescription && 81 + serviceDescription.availableUserDomains.length > 0 82 + ) { 83 + let matched = false 84 + for (const domain of serviceDescription.availableUserDomains) { 85 + if (fullIdent.endsWith(domain)) { 86 + matched = true 87 + } 88 + } 89 + if (!matched) { 90 + fullIdent = createFullHandle( 91 + identifier, 92 + serviceDescription.availableUserDomains[0], 93 + ) 94 + } 95 + } 96 + 97 + // TODO remove double login 98 + await login( 99 + { 100 + service: serviceUrl, 101 + identifier: fullIdent, 102 + password, 103 + }, 104 + 'LoginForm', 105 + ) 106 + } catch (e: any) { 107 + const errMsg = e.toString() 108 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 109 + setIsProcessing(false) 110 + if (errMsg.includes('Authentication Required')) { 111 + logger.debug('Failed to login due to invalid credentials', { 112 + error: errMsg, 113 + }) 114 + setError(_(msg`Invalid username or password`)) 115 + } else if (isNetworkError(e)) { 116 + logger.warn('Failed to login due to network error', {error: errMsg}) 117 + setError( 118 + _( 119 + msg`Unable to contact your service. Please check your Internet connection.`, 120 + ), 121 + ) 122 + } else { 123 + logger.warn('Failed to login', {error: errMsg}) 124 + setError(cleanError(errMsg)) 125 + } 126 + } 127 + } 128 + 129 + const isReady = !!serviceDescription && !!identifier && !!password 130 + return ( 131 + <FormContainer testID="loginForm" title={<Trans>Sign in</Trans>}> 132 + <View> 133 + <TextField.Label> 134 + <Trans>Hosting provider</Trans> 135 + </TextField.Label> 136 + <HostingProvider 137 + serviceUrl={serviceUrl} 138 + onSelectServiceUrl={setServiceUrl} 139 + onOpenDialog={onPressSelectService} 140 + /> 141 + </View> 142 + <View> 143 + <TextField.Label> 144 + <Trans>Account</Trans> 145 + </TextField.Label> 146 + <View style={[a.gap_sm]}> 147 + <TextField.Root> 148 + <TextField.Icon icon={At} /> 149 + <TextField.Input 150 + testID="loginUsernameInput" 151 + label={_(msg`Username or email address`)} 152 + autoCapitalize="none" 153 + autoFocus 154 + autoCorrect={false} 155 + autoComplete="username" 156 + returnKeyType="next" 157 + textContentType="username" 158 + onSubmitEditing={() => { 159 + passwordInputRef.current?.focus() 160 + }} 161 + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 162 + value={identifier} 163 + onChangeText={str => 164 + setIdentifier((str || '').toLowerCase().trim()) 165 + } 166 + editable={!isProcessing} 167 + accessibilityHint={_( 168 + msg`Input the username or email address you used at signup`, 169 + )} 170 + /> 171 + </TextField.Root> 172 + 173 + <TextField.Root> 174 + <TextField.Icon icon={Lock} /> 175 + <TextField.Input 176 + testID="loginPasswordInput" 177 + inputRef={passwordInputRef} 178 + label={_(msg`Password`)} 179 + autoCapitalize="none" 180 + autoCorrect={false} 181 + autoComplete="password" 182 + returnKeyType="done" 183 + enablesReturnKeyAutomatically={true} 184 + secureTextEntry={true} 185 + textContentType="password" 186 + clearButtonMode="while-editing" 187 + value={password} 188 + onChangeText={setPassword} 189 + onSubmitEditing={onPressNext} 190 + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing 191 + editable={!isProcessing} 192 + accessibilityHint={ 193 + identifier === '' 194 + ? _(msg`Input your password`) 195 + : _(msg`Input the password tied to ${identifier}`) 196 + } 197 + /> 198 + <Button 199 + testID="forgotPasswordButton" 200 + onPress={onPressForgotPassword} 201 + label={_(msg`Forgot password?`)} 202 + accessibilityHint={_(msg`Opens password reset form`)} 203 + variant="solid" 204 + color="secondary" 205 + style={[ 206 + a.rounded_sm, 207 + // t.atoms.bg_contrast_100, 208 + {marginLeft: 'auto', left: 6, padding: 6}, 209 + a.z_10, 210 + ]}> 211 + <ButtonText> 212 + <Trans>Forgot?</Trans> 213 + </ButtonText> 214 + </Button> 215 + </TextField.Root> 216 + </View> 217 + </View> 218 + <FormError error={error} /> 219 + <View style={[a.flex_row, a.align_center, a.pt_md]}> 220 + <Button 221 + label={_(msg`Back`)} 222 + variant="solid" 223 + color="secondary" 224 + size="medium" 225 + onPress={onPressBack}> 226 + <ButtonText> 227 + <Trans>Back</Trans> 228 + </ButtonText> 229 + </Button> 230 + <View style={a.flex_1} /> 231 + {!serviceDescription && error ? ( 232 + <Button 233 + testID="loginRetryButton" 234 + label={_(msg`Retry`)} 235 + accessibilityHint={_(msg`Retries login`)} 236 + variant="solid" 237 + color="secondary" 238 + size="medium" 239 + onPress={onPressRetryConnect}> 240 + {_(msg`Retry`)} 241 + </Button> 242 + ) : !serviceDescription ? ( 243 + <> 244 + <ActivityIndicator /> 245 + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 246 + <Trans>Connecting...</Trans> 247 + </Text> 248 + </> 249 + ) : isReady ? ( 250 + <Button 251 + label={_(msg`Next`)} 252 + accessibilityHint={_(msg`Navigates to the next screen`)} 253 + variant="solid" 254 + color="primary" 255 + size="medium" 256 + onPress={onPressNext}> 257 + <ButtonText> 258 + <Trans>Next</Trans> 259 + </ButtonText> 260 + {isProcessing && <ButtonIcon icon={Loader} />} 261 + </Button> 262 + ) : undefined} 263 + </View> 264 + </FormContainer> 265 + ) 266 + }
+50
src/screens/Login/PasswordUpdatedForm.tsx
··· 1 + import React, {useEffect} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useAnalytics} from '#/lib/analytics/analytics' 7 + import {atoms as a, useBreakpoints} from '#/alf' 8 + import {Button, ButtonText} from '#/components/Button' 9 + import {Text} from '#/components/Typography' 10 + import {FormContainer} from './FormContainer' 11 + 12 + export const PasswordUpdatedForm = ({ 13 + onPressNext, 14 + }: { 15 + onPressNext: () => void 16 + }) => { 17 + const {screen} = useAnalytics() 18 + const {_} = useLingui() 19 + const {gtMobile} = useBreakpoints() 20 + 21 + useEffect(() => { 22 + screen('Signin:PasswordUpdatedForm') 23 + }, [screen]) 24 + 25 + return ( 26 + <FormContainer 27 + testID="passwordUpdatedForm" 28 + style={[a.gap_2xl, !gtMobile && a.mt_5xl]}> 29 + <Text style={[a.text_3xl, a.font_bold, a.text_center]}> 30 + <Trans>Password updated!</Trans> 31 + </Text> 32 + <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}> 33 + <Trans>You can now sign in with your new password.</Trans> 34 + </Text> 35 + <View style={[a.flex_row, a.justify_center]}> 36 + <Button 37 + onPress={onPressNext} 38 + label={_(msg`Close alert`)} 39 + accessibilityHint={_(msg`Closes password update alert`)} 40 + variant="solid" 41 + color="primary" 42 + size="medium"> 43 + <ButtonText> 44 + <Trans>Okay</Trans> 45 + </ButtonText> 46 + </Button> 47 + </View> 48 + </FormContainer> 49 + ) 50 + }
+10
src/screens/Login/ScreenTransition.tsx
··· 1 + import React from 'react' 2 + import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' 3 + 4 + export function ScreenTransition({children}: {children: React.ReactNode}) { 5 + return ( 6 + <Animated.View entering={FadeInRight} exiting={FadeOutLeft}> 7 + {children} 8 + </Animated.View> 9 + ) 10 + }
+1
src/screens/Login/ScreenTransition.web.tsx
··· 1 + export {Fragment as ScreenTransition} from 'react'
+192
src/screens/Login/SetNewPasswordForm.tsx
··· 1 + import React, {useEffect, useState} from 'react' 2 + import {ActivityIndicator, View} from 'react-native' 3 + import {BskyAgent} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {useAnalytics} from '#/lib/analytics/analytics' 8 + import {isNetworkError} from '#/lib/strings/errors' 9 + import {cleanError} from '#/lib/strings/errors' 10 + import {checkAndFormatResetCode} from '#/lib/strings/password' 11 + import {logger} from '#/logger' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Button, ButtonText} from '#/components/Button' 14 + import {FormError} from '#/components/forms/FormError' 15 + import * as TextField from '#/components/forms/TextField' 16 + import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 17 + import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 18 + import {Text} from '#/components/Typography' 19 + import {FormContainer} from './FormContainer' 20 + 21 + export const SetNewPasswordForm = ({ 22 + error, 23 + serviceUrl, 24 + setError, 25 + onPressBack, 26 + onPasswordSet, 27 + }: { 28 + error: string 29 + serviceUrl: string 30 + setError: (v: string) => void 31 + onPressBack: () => void 32 + onPasswordSet: () => void 33 + }) => { 34 + const {screen} = useAnalytics() 35 + const {_} = useLingui() 36 + const t = useTheme() 37 + 38 + useEffect(() => { 39 + screen('Signin:SetNewPasswordForm') 40 + }, [screen]) 41 + 42 + const [isProcessing, setIsProcessing] = useState<boolean>(false) 43 + const [resetCode, setResetCode] = useState<string>('') 44 + const [password, setPassword] = useState<string>('') 45 + 46 + const onPressNext = async () => { 47 + // Check that the code is correct. We do this again just incase the user enters the code after their pw and we 48 + // don't get to call onBlur first 49 + const formattedCode = checkAndFormatResetCode(resetCode) 50 + // TODO Better password strength check 51 + if (!formattedCode || !password) { 52 + setError( 53 + _( 54 + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 55 + ), 56 + ) 57 + return 58 + } 59 + 60 + setError('') 61 + setIsProcessing(true) 62 + 63 + try { 64 + const agent = new BskyAgent({service: serviceUrl}) 65 + await agent.com.atproto.server.resetPassword({ 66 + token: formattedCode, 67 + password, 68 + }) 69 + onPasswordSet() 70 + } catch (e: any) { 71 + const errMsg = e.toString() 72 + logger.warn('Failed to set new password', {error: e}) 73 + setIsProcessing(false) 74 + if (isNetworkError(e)) { 75 + setError( 76 + _( 77 + msg`Unable to contact your service. Please check your Internet connection.`, 78 + ), 79 + ) 80 + } else { 81 + setError(cleanError(errMsg)) 82 + } 83 + } 84 + } 85 + 86 + const onBlur = () => { 87 + const formattedCode = checkAndFormatResetCode(resetCode) 88 + if (!formattedCode) { 89 + setError( 90 + _( 91 + msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 92 + ), 93 + ) 94 + return 95 + } 96 + setResetCode(formattedCode) 97 + } 98 + 99 + return ( 100 + <FormContainer 101 + testID="setNewPasswordForm" 102 + title={<Trans>Set new password</Trans>}> 103 + <Text style={[a.leading_snug, a.mb_sm]}> 104 + <Trans> 105 + You will receive an email with a "reset code." Enter that code here, 106 + then enter your new password. 107 + </Trans> 108 + </Text> 109 + 110 + <View> 111 + <TextField.Label>Reset code</TextField.Label> 112 + <TextField.Root> 113 + <TextField.Icon icon={Ticket} /> 114 + <TextField.Input 115 + testID="resetCodeInput" 116 + label={_(msg`Looks like XXXXX-XXXXX`)} 117 + autoCapitalize="none" 118 + autoFocus={true} 119 + autoCorrect={false} 120 + autoComplete="off" 121 + value={resetCode} 122 + onChangeText={setResetCode} 123 + onFocus={() => setError('')} 124 + onBlur={onBlur} 125 + editable={!isProcessing} 126 + accessibilityHint={_( 127 + msg`Input code sent to your email for password reset`, 128 + )} 129 + /> 130 + </TextField.Root> 131 + </View> 132 + 133 + <View> 134 + <TextField.Label>New password</TextField.Label> 135 + <TextField.Root> 136 + <TextField.Icon icon={Lock} /> 137 + <TextField.Input 138 + testID="newPasswordInput" 139 + label={_(msg`Enter a password`)} 140 + autoCapitalize="none" 141 + autoCorrect={false} 142 + autoComplete="password" 143 + returnKeyType="done" 144 + secureTextEntry={true} 145 + textContentType="password" 146 + clearButtonMode="while-editing" 147 + value={password} 148 + onChangeText={setPassword} 149 + onSubmitEditing={onPressNext} 150 + editable={!isProcessing} 151 + accessibilityHint={_(msg`Input new password`)} 152 + /> 153 + </TextField.Root> 154 + </View> 155 + 156 + <FormError error={error} /> 157 + 158 + <View style={[a.flex_row, a.align_center, a.pt_lg]}> 159 + <Button 160 + label={_(msg`Back`)} 161 + variant="solid" 162 + color="secondary" 163 + size="medium" 164 + onPress={onPressBack}> 165 + <ButtonText> 166 + <Trans>Back</Trans> 167 + </ButtonText> 168 + </Button> 169 + <View style={a.flex_1} /> 170 + {isProcessing ? ( 171 + <ActivityIndicator /> 172 + ) : ( 173 + <Button 174 + label={_(msg`Next`)} 175 + variant="solid" 176 + color="primary" 177 + size="medium" 178 + onPress={onPressNext}> 179 + <ButtonText> 180 + <Trans>Next</Trans> 181 + </ButtonText> 182 + </Button> 183 + )} 184 + {isProcessing ? ( 185 + <Text style={[t.atoms.text_contrast_high, a.pl_md]}> 186 + <Trans>Updating...</Trans> 187 + </Text> 188 + ) : undefined} 189 + </View> 190 + </FormContainer> 191 + ) 192 + }
+174
src/screens/Login/index.tsx
··· 1 + import React from 'react' 2 + import {KeyboardAvoidingView} from 'react-native' 3 + import {LayoutAnimationConfig} from 'react-native-reanimated' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {useAnalytics} from '#/lib/analytics/analytics' 8 + import {DEFAULT_SERVICE} from '#/lib/constants' 9 + import {logger} from '#/logger' 10 + import {useServiceQuery} from '#/state/queries/service' 11 + import {SessionAccount, useSession} from '#/state/session' 12 + import {useLoggedOutView} from '#/state/shell/logged-out' 13 + import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' 14 + import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm' 15 + import {LoginForm} from '#/screens/Login/LoginForm' 16 + import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm' 17 + import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm' 18 + import {atoms as a} from '#/alf' 19 + import {ChooseAccountForm} from './ChooseAccountForm' 20 + import {ScreenTransition} from './ScreenTransition' 21 + 22 + enum Forms { 23 + Login, 24 + ChooseAccount, 25 + ForgotPassword, 26 + SetNewPassword, 27 + PasswordUpdated, 28 + } 29 + 30 + export const Login = ({onPressBack}: {onPressBack: () => void}) => { 31 + const {_} = useLingui() 32 + 33 + const {accounts} = useSession() 34 + const {track} = useAnalytics() 35 + const {requestedAccountSwitchTo} = useLoggedOutView() 36 + const requestedAccount = accounts.find( 37 + acc => acc.did === requestedAccountSwitchTo, 38 + ) 39 + 40 + const [error, setError] = React.useState<string>('') 41 + const [serviceUrl, setServiceUrl] = React.useState<string>( 42 + requestedAccount?.service || DEFAULT_SERVICE, 43 + ) 44 + const [initialHandle, setInitialHandle] = React.useState<string>( 45 + requestedAccount?.handle || '', 46 + ) 47 + const [currentForm, setCurrentForm] = React.useState<Forms>( 48 + requestedAccount 49 + ? Forms.Login 50 + : accounts.length 51 + ? Forms.ChooseAccount 52 + : Forms.Login, 53 + ) 54 + 55 + const { 56 + data: serviceDescription, 57 + error: serviceError, 58 + refetch: refetchService, 59 + } = useServiceQuery(serviceUrl) 60 + 61 + const onSelectAccount = (account?: SessionAccount) => { 62 + if (account?.service) { 63 + setServiceUrl(account.service) 64 + } 65 + setInitialHandle(account?.handle || '') 66 + setCurrentForm(Forms.Login) 67 + } 68 + 69 + const gotoForm = (form: Forms) => { 70 + setError('') 71 + setCurrentForm(form) 72 + } 73 + 74 + React.useEffect(() => { 75 + if (serviceError) { 76 + setError( 77 + _( 78 + msg`Unable to contact your service. Please check your Internet connection.`, 79 + ), 80 + ) 81 + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { 82 + error: String(serviceError), 83 + }) 84 + } else { 85 + setError('') 86 + } 87 + }, [serviceError, serviceUrl, _]) 88 + 89 + const onPressForgotPassword = () => { 90 + track('Signin:PressedForgotPassword') 91 + setCurrentForm(Forms.ForgotPassword) 92 + } 93 + 94 + let content = null 95 + let title = '' 96 + let description = '' 97 + 98 + switch (currentForm) { 99 + case Forms.Login: 100 + title = _(msg`Sign in`) 101 + description = _(msg`Enter your username and password`) 102 + content = ( 103 + <LoginForm 104 + error={error} 105 + serviceUrl={serviceUrl} 106 + serviceDescription={serviceDescription} 107 + initialHandle={initialHandle} 108 + setError={setError} 109 + setServiceUrl={setServiceUrl} 110 + onPressBack={() => 111 + accounts.length ? gotoForm(Forms.ChooseAccount) : onPressBack() 112 + } 113 + onPressForgotPassword={onPressForgotPassword} 114 + onPressRetryConnect={refetchService} 115 + /> 116 + ) 117 + break 118 + case Forms.ChooseAccount: 119 + title = _(msg`Sign in`) 120 + description = _(msg`Select from an existing account`) 121 + content = ( 122 + <ChooseAccountForm 123 + onSelectAccount={onSelectAccount} 124 + onPressBack={onPressBack} 125 + /> 126 + ) 127 + break 128 + case Forms.ForgotPassword: 129 + title = _(msg`Forgot Password`) 130 + description = _(msg`Let's get your password reset!`) 131 + content = ( 132 + <ForgotPasswordForm 133 + error={error} 134 + serviceUrl={serviceUrl} 135 + serviceDescription={serviceDescription} 136 + setError={setError} 137 + setServiceUrl={setServiceUrl} 138 + onPressBack={() => gotoForm(Forms.Login)} 139 + onEmailSent={() => gotoForm(Forms.SetNewPassword)} 140 + /> 141 + ) 142 + break 143 + case Forms.SetNewPassword: 144 + title = _(msg`Forgot Password`) 145 + description = _(msg`Let's get your password reset!`) 146 + content = ( 147 + <SetNewPasswordForm 148 + error={error} 149 + serviceUrl={serviceUrl} 150 + setError={setError} 151 + onPressBack={() => gotoForm(Forms.ForgotPassword)} 152 + onPasswordSet={() => gotoForm(Forms.PasswordUpdated)} 153 + /> 154 + ) 155 + break 156 + case Forms.PasswordUpdated: 157 + title = _(msg`Password updated`) 158 + description = _(msg`You can now sign in with your new password.`) 159 + content = ( 160 + <PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} /> 161 + ) 162 + break 163 + } 164 + 165 + return ( 166 + <KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}> 167 + <LoggedOutLayout leadin="" title={title} description={description}> 168 + <LayoutAnimationConfig skipEntering skipExiting> 169 + <ScreenTransition key={currentForm}>{content}</ScreenTransition> 170 + </LayoutAnimationConfig> 171 + </LoggedOutLayout> 172 + </KeyboardAvoidingView> 173 + ) 174 + }
+95
src/screens/Signup/StepCaptcha/index.tsx
··· 1 + import React from 'react' 2 + import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {nanoid} from 'nanoid/non-secure' 6 + 7 + import {createFullHandle} from '#/lib/strings/handles' 8 + import {isWeb} from '#/platform/detection' 9 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 10 + import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' 11 + import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {FormError} from '#/components/forms/FormError' 14 + 15 + const CAPTCHA_PATH = '/gate/signup' 16 + 17 + export function StepCaptcha() { 18 + const {_} = useLingui() 19 + const theme = useTheme() 20 + const {state, dispatch} = useSignupContext() 21 + const submit = useSubmitSignup({state, dispatch}) 22 + 23 + const [completed, setCompleted] = React.useState(false) 24 + 25 + const stateParam = React.useMemo(() => nanoid(15), []) 26 + const url = React.useMemo(() => { 27 + const newUrl = new URL(state.serviceUrl) 28 + newUrl.pathname = CAPTCHA_PATH 29 + newUrl.searchParams.set( 30 + 'handle', 31 + createFullHandle(state.handle, state.userDomain), 32 + ) 33 + newUrl.searchParams.set('state', stateParam) 34 + newUrl.searchParams.set('colorScheme', theme.name) 35 + 36 + return newUrl.href 37 + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) 38 + 39 + const onSuccess = React.useCallback( 40 + (code: string) => { 41 + setCompleted(true) 42 + submit(code) 43 + }, 44 + [submit], 45 + ) 46 + 47 + const onError = React.useCallback(() => { 48 + dispatch({ 49 + type: 'setError', 50 + value: _(msg`Error receiving captcha response.`), 51 + }) 52 + }, [_, dispatch]) 53 + 54 + return ( 55 + <ScreenTransition> 56 + <View style={[a.gap_lg]}> 57 + <View style={[styles.container, completed && styles.center]}> 58 + {!completed ? ( 59 + <CaptchaWebView 60 + url={url} 61 + stateParam={stateParam} 62 + state={state} 63 + onSuccess={onSuccess} 64 + onError={onError} 65 + /> 66 + ) : ( 67 + <ActivityIndicator size="large" /> 68 + )} 69 + </View> 70 + <FormError error={state.error} /> 71 + </View> 72 + </ScreenTransition> 73 + ) 74 + } 75 + 76 + const styles = StyleSheet.create({ 77 + error: { 78 + borderRadius: 6, 79 + marginTop: 10, 80 + }, 81 + // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. 82 + touchable: { 83 + ...(isWeb && {cursor: 'pointer'}), 84 + }, 85 + container: { 86 + minHeight: 500, 87 + width: '100%', 88 + paddingBottom: 20, 89 + overflow: 'hidden', 90 + }, 91 + center: { 92 + alignItems: 'center', 93 + justifyContent: 'center', 94 + }, 95 + })
+134
src/screens/Signup/StepHandle.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useFocusEffect} from '@react-navigation/native' 6 + 7 + import { 8 + createFullHandle, 9 + IsValidHandle, 10 + validateHandle, 11 + } from '#/lib/strings/handles' 12 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 13 + import {useSignupContext} from '#/screens/Signup/state' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import * as TextField from '#/components/forms/TextField' 16 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 17 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 + import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 19 + import {Text} from '#/components/Typography' 20 + 21 + export function StepHandle() { 22 + const {_} = useLingui() 23 + const t = useTheme() 24 + const {state, dispatch} = useSignupContext() 25 + 26 + const [validCheck, setValidCheck] = React.useState<IsValidHandle>({ 27 + handleChars: false, 28 + hyphenStartOrEnd: false, 29 + frontLength: false, 30 + totalLength: true, 31 + overall: false, 32 + }) 33 + 34 + useFocusEffect( 35 + React.useCallback(() => { 36 + setValidCheck(validateHandle(state.handle, state.userDomain)) 37 + }, [state.handle, state.userDomain]), 38 + ) 39 + 40 + const onHandleChange = React.useCallback( 41 + (value: string) => { 42 + if (state.error) { 43 + dispatch({type: 'setError', value: ''}) 44 + } 45 + 46 + dispatch({ 47 + type: 'setHandle', 48 + value, 49 + }) 50 + }, 51 + [dispatch, state.error], 52 + ) 53 + 54 + return ( 55 + <ScreenTransition> 56 + <View style={[a.gap_lg]}> 57 + <View> 58 + <TextField.Root> 59 + <TextField.Icon icon={At} /> 60 + <TextField.Input 61 + onChangeText={onHandleChange} 62 + label={_(msg`Input your user handle`)} 63 + defaultValue={state.handle} 64 + autoCapitalize="none" 65 + autoCorrect={false} 66 + autoFocus 67 + autoComplete="off" 68 + /> 69 + </TextField.Root> 70 + </View> 71 + <Text style={[a.text_md]}> 72 + <Trans>Your full handle will be</Trans>{' '} 73 + <Text style={[a.text_md, a.font_bold]}> 74 + @{createFullHandle(state.handle, state.userDomain)} 75 + </Text> 76 + </Text> 77 + 78 + <View 79 + style={[ 80 + a.w_full, 81 + a.rounded_sm, 82 + a.border, 83 + a.p_md, 84 + a.gap_sm, 85 + t.atoms.border_contrast_low, 86 + ]}> 87 + {state.error ? ( 88 + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 89 + <IsValidIcon valid={false} /> 90 + <Text style={[a.text_md, a.flex_1]}>{state.error}</Text> 91 + </View> 92 + ) : undefined} 93 + {validCheck.hyphenStartOrEnd ? ( 94 + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 95 + <IsValidIcon valid={validCheck.handleChars} /> 96 + <Text style={[a.text_md, a.flex_1]}> 97 + <Trans>Only contains letters, numbers, and hyphens</Trans> 98 + </Text> 99 + </View> 100 + ) : ( 101 + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 102 + <IsValidIcon valid={validCheck.hyphenStartOrEnd} /> 103 + <Text style={[a.text_md, a.flex_1]}> 104 + <Trans>Doesn't begin or end with a hyphen</Trans> 105 + </Text> 106 + </View> 107 + )} 108 + <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 109 + <IsValidIcon 110 + valid={validCheck.frontLength && validCheck.totalLength} 111 + /> 112 + {!validCheck.totalLength ? ( 113 + <Text style={[a.text_md, a.flex_1]}> 114 + <Trans>No longer than 253 characters</Trans> 115 + </Text> 116 + ) : ( 117 + <Text style={[a.text_md, a.flex_1]}> 118 + <Trans>At least 3 characters</Trans> 119 + </Text> 120 + )} 121 + </View> 122 + </View> 123 + </View> 124 + </ScreenTransition> 125 + ) 126 + } 127 + 128 + function IsValidIcon({valid}: {valid: boolean}) { 129 + const t = useTheme() 130 + if (!valid) { 131 + return <Times size="md" style={{color: t.palette.negative_500}} /> 132 + } 133 + return <Check size="md" style={{color: t.palette.positive_700}} /> 134 + }
+97
src/screens/Signup/StepInfo/Policies.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {ComAtprotoServerDescribeServer} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 9 + import {InlineLink} from '#/components/Link' 10 + import {Text} from '#/components/Typography' 11 + 12 + export const Policies = ({ 13 + serviceDescription, 14 + needsGuardian, 15 + under13, 16 + }: { 17 + serviceDescription: ComAtprotoServerDescribeServer.OutputSchema 18 + needsGuardian: boolean 19 + under13: boolean 20 + }) => { 21 + const t = useTheme() 22 + const {_} = useLingui() 23 + 24 + if (!serviceDescription) { 25 + return <View /> 26 + } 27 + 28 + const tos = validWebLink(serviceDescription.links?.termsOfService) 29 + const pp = validWebLink(serviceDescription.links?.privacyPolicy) 30 + 31 + if (!tos && !pp) { 32 + return ( 33 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 34 + <CircleInfo size="md" fill={t.atoms.text_contrast_low.color} /> 35 + 36 + <Text style={[t.atoms.text_contrast_medium]}> 37 + <Trans> 38 + This service has not provided terms of service or a privacy policy. 39 + </Trans> 40 + </Text> 41 + </View> 42 + ) 43 + } 44 + 45 + const els = [] 46 + if (tos) { 47 + els.push( 48 + <InlineLink key="tos" to={tos}> 49 + {_(msg`Terms of Service`)} 50 + </InlineLink>, 51 + ) 52 + } 53 + if (pp) { 54 + els.push( 55 + <InlineLink key="pp" to={pp}> 56 + {_(msg`Privacy Policy`)} 57 + </InlineLink>, 58 + ) 59 + } 60 + if (els.length === 2) { 61 + els.splice( 62 + 1, 63 + 0, 64 + <Text key="and" style={[t.atoms.text_contrast_medium]}> 65 + {' '} 66 + and{' '} 67 + </Text>, 68 + ) 69 + } 70 + 71 + return ( 72 + <View style={[a.gap_sm]}> 73 + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> 74 + <Trans>By creating an account you agree to the {els}.</Trans> 75 + </Text> 76 + 77 + {under13 ? ( 78 + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> 79 + You must be 13 years of age or older to sign up. 80 + </Text> 81 + ) : needsGuardian ? ( 82 + <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}> 83 + <Trans> 84 + If you are not yet an adult according to the laws of your country, 85 + your parent or legal guardian must read these Terms on your behalf. 86 + </Trans> 87 + </Text> 88 + ) : undefined} 89 + </View> 90 + ) 91 + } 92 + 93 + function validWebLink(url?: string): string | undefined { 94 + return url && (url.startsWith('http://') || url.startsWith('https://')) 95 + ? url 96 + : undefined 97 + }
+146
src/screens/Signup/StepInfo/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {logger} from '#/logger' 7 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 8 + import {is13, is18, useSignupContext} from '#/screens/Signup/state' 9 + import {Policies} from '#/screens/Signup/StepInfo/Policies' 10 + import {atoms as a} from '#/alf' 11 + import * as DateField from '#/components/forms/DateField' 12 + import {FormError} from '#/components/forms/FormError' 13 + import {HostingProvider} from '#/components/forms/HostingProvider' 14 + import * as TextField from '#/components/forms/TextField' 15 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 16 + import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 17 + import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 18 + import {Loader} from '#/components/Loader' 19 + 20 + function sanitizeDate(date: Date): Date { 21 + if (!date || date.toString() === 'Invalid Date') { 22 + logger.error(`Create account: handled invalid date for birthDate`, { 23 + hasDate: !!date, 24 + }) 25 + return new Date() 26 + } 27 + return date 28 + } 29 + 30 + export function StepInfo() { 31 + const {_} = useLingui() 32 + const {state, dispatch} = useSignupContext() 33 + 34 + return ( 35 + <ScreenTransition> 36 + <View style={[a.gap_md]}> 37 + <FormError error={state.error} /> 38 + <View> 39 + <TextField.Label> 40 + <Trans>Hosting provider</Trans> 41 + </TextField.Label> 42 + <HostingProvider 43 + serviceUrl={state.serviceUrl} 44 + onSelectServiceUrl={v => 45 + dispatch({type: 'setServiceUrl', value: v}) 46 + } 47 + /> 48 + </View> 49 + {state.isLoading ? ( 50 + <View style={[a.align_center]}> 51 + <Loader size="xl" /> 52 + </View> 53 + ) : state.serviceDescription ? ( 54 + <> 55 + {state.serviceDescription.inviteCodeRequired && ( 56 + <View> 57 + <TextField.Label> 58 + <Trans>Invite code</Trans> 59 + </TextField.Label> 60 + <TextField.Root> 61 + <TextField.Icon icon={Ticket} /> 62 + <TextField.Input 63 + onChangeText={value => { 64 + dispatch({ 65 + type: 'setInviteCode', 66 + value: value.trim(), 67 + }) 68 + }} 69 + label={_(msg`Required for this provider`)} 70 + defaultValue={state.inviteCode} 71 + autoCapitalize="none" 72 + autoComplete="email" 73 + keyboardType="email-address" 74 + /> 75 + </TextField.Root> 76 + </View> 77 + )} 78 + <View> 79 + <TextField.Label> 80 + <Trans>Email</Trans> 81 + </TextField.Label> 82 + <TextField.Root> 83 + <TextField.Icon icon={Envelope} /> 84 + <TextField.Input 85 + onChangeText={value => { 86 + dispatch({ 87 + type: 'setEmail', 88 + value: value.trim(), 89 + }) 90 + }} 91 + label={_(msg`Enter your email address`)} 92 + defaultValue={state.email} 93 + autoCapitalize="none" 94 + autoComplete="email" 95 + keyboardType="email-address" 96 + /> 97 + </TextField.Root> 98 + </View> 99 + <View> 100 + <TextField.Label> 101 + <Trans>Password</Trans> 102 + </TextField.Label> 103 + <TextField.Root> 104 + <TextField.Icon icon={Lock} /> 105 + <TextField.Input 106 + onChangeText={value => { 107 + dispatch({ 108 + type: 'setPassword', 109 + value, 110 + }) 111 + }} 112 + label={_(msg`Choose your password`)} 113 + defaultValue={state.password} 114 + secureTextEntry 115 + autoComplete="new-password" 116 + /> 117 + </TextField.Root> 118 + </View> 119 + <View> 120 + <DateField.Label> 121 + <Trans>Your birth date</Trans> 122 + </DateField.Label> 123 + <DateField.DateField 124 + testID="date" 125 + value={DateField.utils.toSimpleDateString(state.dateOfBirth)} 126 + onChangeDate={date => { 127 + dispatch({ 128 + type: 'setDateOfBirth', 129 + value: sanitizeDate(new Date(date)), 130 + }) 131 + }} 132 + label={_(msg`Date of birth`)} 133 + accessibilityHint={_(msg`Select your date of birth`)} 134 + /> 135 + </View> 136 + <Policies 137 + serviceDescription={state.serviceDescription} 138 + needsGuardian={!is18(state.dateOfBirth)} 139 + under13={!is13(state.dateOfBirth)} 140 + /> 141 + </> 142 + ) : undefined} 143 + </View> 144 + </ScreenTransition> 145 + ) 146 + }
+211
src/screens/Signup/index.tsx
··· 1 + import React from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useAnalytics} from '#/lib/analytics/analytics' 7 + import {FEEDBACK_FORM_URL} from '#/lib/constants' 8 + import {createFullHandle} from '#/lib/strings/handles' 9 + import {useServiceQuery} from '#/state/queries/service' 10 + import {getAgent} from '#/state/session' 11 + import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' 12 + import { 13 + initialState, 14 + reducer, 15 + SignupContext, 16 + SignupStep, 17 + useSubmitSignup, 18 + } from '#/screens/Signup/state' 19 + import {StepCaptcha} from '#/screens/Signup/StepCaptcha' 20 + import {StepHandle} from '#/screens/Signup/StepHandle' 21 + import {StepInfo} from '#/screens/Signup/StepInfo' 22 + import {atoms as a, useTheme} from '#/alf' 23 + import {Button, ButtonText} from '#/components/Button' 24 + import {Divider} from '#/components/Divider' 25 + import {InlineLink} from '#/components/Link' 26 + import {Text} from '#/components/Typography' 27 + 28 + export function Signup({onPressBack}: {onPressBack: () => void}) { 29 + const {_} = useLingui() 30 + const t = useTheme() 31 + const {screen} = useAnalytics() 32 + const [state, dispatch] = React.useReducer(reducer, initialState) 33 + const submit = useSubmitSignup({state, dispatch}) 34 + 35 + const { 36 + data: serviceInfo, 37 + isFetching, 38 + isError, 39 + refetch, 40 + } = useServiceQuery(state.serviceUrl) 41 + 42 + React.useEffect(() => { 43 + screen('CreateAccount') 44 + }, [screen]) 45 + 46 + React.useEffect(() => { 47 + if (isFetching) { 48 + dispatch({type: 'setIsLoading', value: true}) 49 + } else if (!isFetching) { 50 + dispatch({type: 'setIsLoading', value: false}) 51 + } 52 + }, [isFetching]) 53 + 54 + React.useEffect(() => { 55 + if (isError) { 56 + dispatch({type: 'setServiceDescription', value: undefined}) 57 + dispatch({ 58 + type: 'setError', 59 + value: _( 60 + msg`Unable to contact your service. Please check your Internet connection.`, 61 + ), 62 + }) 63 + } else if (serviceInfo) { 64 + dispatch({type: 'setServiceDescription', value: serviceInfo}) 65 + dispatch({type: 'setError', value: ''}) 66 + } 67 + }, [_, serviceInfo, isError]) 68 + 69 + const onNextPress = React.useCallback(async () => { 70 + if (state.activeStep === SignupStep.HANDLE) { 71 + try { 72 + dispatch({type: 'setIsLoading', value: true}) 73 + 74 + const res = await getAgent().resolveHandle({ 75 + handle: createFullHandle(state.handle, state.userDomain), 76 + }) 77 + 78 + if (res.data.did) { 79 + dispatch({ 80 + type: 'setError', 81 + value: _(msg`That handle is already taken.`), 82 + }) 83 + return 84 + } 85 + } catch (e) { 86 + // Don't have to handle 87 + } finally { 88 + dispatch({type: 'setIsLoading', value: false}) 89 + } 90 + } 91 + 92 + // phoneVerificationRequired is actually whether a captcha is required 93 + if ( 94 + state.activeStep === SignupStep.HANDLE && 95 + !state.serviceDescription?.phoneVerificationRequired 96 + ) { 97 + submit() 98 + return 99 + } 100 + 101 + dispatch({type: 'next'}) 102 + }, [ 103 + _, 104 + state.activeStep, 105 + state.handle, 106 + state.serviceDescription?.phoneVerificationRequired, 107 + state.userDomain, 108 + submit, 109 + ]) 110 + 111 + const onBackPress = React.useCallback(() => { 112 + if (state.activeStep !== SignupStep.INFO) { 113 + dispatch({type: 'prev'}) 114 + } else { 115 + onPressBack() 116 + } 117 + }, [onPressBack, state.activeStep]) 118 + 119 + return ( 120 + <SignupContext.Provider value={{state, dispatch}}> 121 + <LoggedOutLayout 122 + leadin="" 123 + title={_(msg`Create Account`)} 124 + description={_(msg`We're so excited to have you join us!`)}> 125 + <ScrollView 126 + testID="createAccount" 127 + keyboardShouldPersistTaps="handled" 128 + style={a.h_full} 129 + keyboardDismissMode="on-drag"> 130 + <View style={[a.flex_1, a.px_xl, a.pt_2xl, {paddingBottom: 100}]}> 131 + <View style={[a.gap_sm, a.pb_3xl]}> 132 + <Text style={[a.font_semibold, t.atoms.text_contrast_medium]}> 133 + <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '} 134 + {state.serviceDescription && 135 + !state.serviceDescription.phoneVerificationRequired 136 + ? '2' 137 + : '3'} 138 + </Text> 139 + <Text style={[a.text_3xl, a.font_bold]}> 140 + {state.activeStep === SignupStep.INFO ? ( 141 + <Trans>Your account</Trans> 142 + ) : state.activeStep === SignupStep.HANDLE ? ( 143 + <Trans>Your user handle</Trans> 144 + ) : ( 145 + <Trans>Complete the challenge</Trans> 146 + )} 147 + </Text> 148 + </View> 149 + 150 + <View style={[a.pb_3xl]}> 151 + {state.activeStep === SignupStep.INFO ? ( 152 + <StepInfo /> 153 + ) : state.activeStep === SignupStep.HANDLE ? ( 154 + <StepHandle /> 155 + ) : ( 156 + <StepCaptcha /> 157 + )} 158 + </View> 159 + 160 + <View style={[a.flex_row, a.justify_between, a.pb_lg]}> 161 + <Button 162 + label="Back" 163 + variant="solid" 164 + color="secondary" 165 + size="medium" 166 + onPress={onBackPress}> 167 + Back 168 + </Button> 169 + {state.activeStep !== SignupStep.CAPTCHA && ( 170 + <> 171 + {isError ? ( 172 + <Button 173 + label="Retry" 174 + variant="solid" 175 + color="primary" 176 + size="medium" 177 + disabled={state.isLoading} 178 + onPress={() => refetch()}> 179 + Retry 180 + </Button> 181 + ) : ( 182 + <Button 183 + label="Next" 184 + variant="solid" 185 + color="primary" 186 + size="medium" 187 + disabled={!state.canNext || state.isLoading} 188 + onPress={onNextPress}> 189 + <ButtonText>Next</ButtonText> 190 + </Button> 191 + )} 192 + </> 193 + )} 194 + </View> 195 + 196 + <Divider /> 197 + 198 + <View style={[a.w_full, a.py_lg]}> 199 + <Text style={[t.atoms.text_contrast_medium]}> 200 + <Trans>Having trouble?</Trans>{' '} 201 + <InlineLink to={FEEDBACK_FORM_URL({email: state.email})}> 202 + <Trans>Contact support</Trans> 203 + </InlineLink> 204 + </Text> 205 + </View> 206 + </View> 207 + </ScrollView> 208 + </LoggedOutLayout> 209 + </SignupContext.Provider> 210 + ) 211 + }
+320
src/screens/Signup/state.ts
··· 1 + import React, {useCallback} from 'react' 2 + import {LayoutAnimation} from 'react-native' 3 + import { 4 + ComAtprotoServerCreateAccount, 5 + ComAtprotoServerDescribeServer, 6 + } from '@atproto/api' 7 + import {msg} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + import * as EmailValidator from 'email-validator' 10 + 11 + import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants' 12 + import {cleanError} from '#/lib/strings/errors' 13 + import {createFullHandle, validateHandle} from '#/lib/strings/handles' 14 + import {getAge} from '#/lib/strings/time' 15 + import {logger} from '#/logger' 16 + import { 17 + DEFAULT_PROD_FEEDS, 18 + usePreferencesSetBirthDateMutation, 19 + useSetSaveFeedsMutation, 20 + } from '#/state/queries/preferences' 21 + import {useSessionApi} from '#/state/session' 22 + import {useOnboardingDispatch} from '#/state/shell' 23 + 24 + export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 25 + 26 + const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago 27 + 28 + export enum SignupStep { 29 + INFO, 30 + HANDLE, 31 + CAPTCHA, 32 + } 33 + 34 + export type SignupState = { 35 + hasPrev: boolean 36 + canNext: boolean 37 + activeStep: SignupStep 38 + 39 + serviceUrl: string 40 + serviceDescription?: ServiceDescription 41 + userDomain: string 42 + dateOfBirth: Date 43 + email: string 44 + password: string 45 + inviteCode: string 46 + handle: string 47 + 48 + error: string 49 + isLoading: boolean 50 + } 51 + 52 + export type SignupAction = 53 + | {type: 'prev'} 54 + | {type: 'next'} 55 + | {type: 'finish'} 56 + | {type: 'setStep'; value: SignupStep} 57 + | {type: 'setServiceUrl'; value: string} 58 + | {type: 'setServiceDescription'; value: ServiceDescription | undefined} 59 + | {type: 'setEmail'; value: string} 60 + | {type: 'setPassword'; value: string} 61 + | {type: 'setDateOfBirth'; value: Date} 62 + | {type: 'setInviteCode'; value: string} 63 + | {type: 'setHandle'; value: string} 64 + | {type: 'setVerificationCode'; value: string} 65 + | {type: 'setError'; value: string} 66 + | {type: 'setCanNext'; value: boolean} 67 + | {type: 'setIsLoading'; value: boolean} 68 + 69 + export const initialState: SignupState = { 70 + hasPrev: false, 71 + canNext: false, 72 + activeStep: SignupStep.INFO, 73 + 74 + serviceUrl: DEFAULT_SERVICE, 75 + serviceDescription: undefined, 76 + userDomain: '', 77 + dateOfBirth: DEFAULT_DATE, 78 + email: '', 79 + password: '', 80 + handle: '', 81 + inviteCode: '', 82 + 83 + error: '', 84 + isLoading: false, 85 + } 86 + 87 + export function is13(date: Date) { 88 + return getAge(date) >= 13 89 + } 90 + 91 + export function is18(date: Date) { 92 + return getAge(date) >= 18 93 + } 94 + 95 + export function reducer(s: SignupState, a: SignupAction): SignupState { 96 + let next = {...s} 97 + 98 + switch (a.type) { 99 + case 'prev': { 100 + if (s.activeStep !== SignupStep.INFO) { 101 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 102 + next.activeStep-- 103 + next.error = '' 104 + } 105 + break 106 + } 107 + case 'next': { 108 + if (s.activeStep !== SignupStep.CAPTCHA) { 109 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 110 + next.activeStep++ 111 + next.error = '' 112 + } 113 + break 114 + } 115 + case 'setStep': { 116 + next.activeStep = a.value 117 + break 118 + } 119 + case 'setServiceUrl': { 120 + next.serviceUrl = a.value 121 + break 122 + } 123 + case 'setServiceDescription': { 124 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 125 + 126 + next.serviceDescription = a.value 127 + next.userDomain = a.value?.availableUserDomains[0] ?? '' 128 + next.isLoading = false 129 + break 130 + } 131 + 132 + case 'setEmail': { 133 + next.email = a.value 134 + break 135 + } 136 + case 'setPassword': { 137 + next.password = a.value 138 + break 139 + } 140 + case 'setDateOfBirth': { 141 + next.dateOfBirth = a.value 142 + break 143 + } 144 + case 'setInviteCode': { 145 + next.inviteCode = a.value 146 + break 147 + } 148 + case 'setHandle': { 149 + next.handle = a.value 150 + break 151 + } 152 + case 'setCanNext': { 153 + next.canNext = a.value 154 + break 155 + } 156 + case 'setIsLoading': { 157 + next.isLoading = a.value 158 + break 159 + } 160 + case 'setError': { 161 + next.error = a.value 162 + break 163 + } 164 + } 165 + 166 + next.hasPrev = next.activeStep !== SignupStep.INFO 167 + 168 + switch (next.activeStep) { 169 + case SignupStep.INFO: { 170 + const isValidEmail = EmailValidator.validate(next.email) 171 + next.canNext = 172 + !!(next.email && next.password && next.dateOfBirth) && 173 + (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) && 174 + is13(next.dateOfBirth) && 175 + isValidEmail 176 + break 177 + } 178 + case SignupStep.HANDLE: { 179 + next.canNext = 180 + !!next.handle && validateHandle(next.handle, next.userDomain).overall 181 + break 182 + } 183 + } 184 + 185 + logger.debug('signup', next) 186 + 187 + if (s.activeStep !== next.activeStep) { 188 + logger.debug('signup: step changed', {activeStep: next.activeStep}) 189 + } 190 + 191 + return next 192 + } 193 + 194 + interface IContext { 195 + state: SignupState 196 + dispatch: React.Dispatch<SignupAction> 197 + } 198 + export const SignupContext = React.createContext<IContext>({} as IContext) 199 + export const useSignupContext = () => React.useContext(SignupContext) 200 + 201 + export function useSubmitSignup({ 202 + state, 203 + dispatch, 204 + }: { 205 + state: SignupState 206 + dispatch: (action: SignupAction) => void 207 + }) { 208 + const {_} = useLingui() 209 + const {createAccount} = useSessionApi() 210 + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() 211 + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() 212 + const onboardingDispatch = useOnboardingDispatch() 213 + 214 + return useCallback( 215 + async (verificationCode?: string) => { 216 + if (!state.email) { 217 + dispatch({type: 'setStep', value: SignupStep.INFO}) 218 + return dispatch({ 219 + type: 'setError', 220 + value: _(msg`Please enter your email.`), 221 + }) 222 + } 223 + if (!EmailValidator.validate(state.email)) { 224 + dispatch({type: 'setStep', value: SignupStep.INFO}) 225 + return dispatch({ 226 + type: 'setError', 227 + value: _(msg`Your email appears to be invalid.`), 228 + }) 229 + } 230 + if (!state.password) { 231 + dispatch({type: 'setStep', value: SignupStep.INFO}) 232 + return dispatch({ 233 + type: 'setError', 234 + value: _(msg`Please choose your password.`), 235 + }) 236 + } 237 + if (!state.handle) { 238 + dispatch({type: 'setStep', value: SignupStep.HANDLE}) 239 + return dispatch({ 240 + type: 'setError', 241 + value: _(msg`Please choose your handle.`), 242 + }) 243 + } 244 + if ( 245 + state.serviceDescription?.phoneVerificationRequired && 246 + !verificationCode 247 + ) { 248 + dispatch({type: 'setStep', value: SignupStep.CAPTCHA}) 249 + return dispatch({ 250 + type: 'setError', 251 + value: _(msg`Please complete the verification captcha.`), 252 + }) 253 + } 254 + dispatch({type: 'setError', value: ''}) 255 + dispatch({type: 'setIsLoading', value: true}) 256 + 257 + try { 258 + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view 259 + await createAccount({ 260 + service: state.serviceUrl, 261 + email: state.email, 262 + handle: createFullHandle(state.handle, state.userDomain), 263 + password: state.password, 264 + inviteCode: state.inviteCode.trim(), 265 + verificationCode: verificationCode, 266 + }) 267 + setBirthDate({birthDate: state.dateOfBirth}) 268 + if (IS_PROD_SERVICE(state.serviceUrl)) { 269 + setSavedFeeds(DEFAULT_PROD_FEEDS) 270 + } 271 + } catch (e: any) { 272 + onboardingDispatch({type: 'skip'}) // undo starting the onboard 273 + let errMsg = e.toString() 274 + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { 275 + dispatch({ 276 + type: 'setError', 277 + value: _( 278 + msg`Invite code not accepted. Check that you input it correctly and try again.`, 279 + ), 280 + }) 281 + dispatch({type: 'setStep', value: SignupStep.INFO}) 282 + return 283 + } 284 + 285 + if ([400, 429].includes(e.status)) { 286 + logger.warn('Failed to create account', {message: e}) 287 + } else { 288 + logger.error(`Failed to create account (${e.status} status)`, { 289 + message: e, 290 + }) 291 + } 292 + 293 + const error = cleanError(errMsg) 294 + const isHandleError = error.toLowerCase().includes('handle') 295 + 296 + dispatch({type: 'setIsLoading', value: false}) 297 + dispatch({type: 'setError', value: cleanError(errMsg)}) 298 + dispatch({type: 'setStep', value: isHandleError ? 2 : 1}) 299 + } finally { 300 + dispatch({type: 'setIsLoading', value: false}) 301 + } 302 + }, 303 + [ 304 + state.email, 305 + state.password, 306 + state.handle, 307 + state.serviceDescription?.phoneVerificationRequired, 308 + state.serviceUrl, 309 + state.userDomain, 310 + state.inviteCode, 311 + state.dateOfBirth, 312 + dispatch, 313 + _, 314 + onboardingDispatch, 315 + createAccount, 316 + setBirthDate, 317 + setSavedFeeds, 318 + ], 319 + ) 320 + }
+9 -9
src/view/com/auth/LoggedOut.tsx
··· 5 5 import {Trans, msg} from '@lingui/macro' 6 6 import {useNavigation} from '@react-navigation/native' 7 7 8 - import {isIOS, isNative} from 'platform/detection' 9 - import {Login} from 'view/com/auth/login/Login' 10 - import {CreateAccount} from 'view/com/auth/create/CreateAccount' 11 - import {ErrorBoundary} from 'view/com/util/ErrorBoundary' 12 - import {s} from 'lib/styles' 13 - import {usePalette} from 'lib/hooks/usePalette' 14 - import {useAnalytics} from 'lib/analytics/analytics' 8 + import {isIOS, isNative} from '#/platform/detection' 9 + import {Login} from '#/screens/Login' 10 + import {Signup} from '#/screens/Signup' 11 + import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 12 + import {s} from '#/lib/styles' 13 + import {usePalette} from '#/lib/hooks/usePalette' 14 + import {useAnalytics} from '#/lib/analytics/analytics' 15 15 import {SplashScreen} from './SplashScreen' 16 16 import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' 17 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 17 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18 18 import { 19 19 useLoggedOutView, 20 20 useLoggedOutViewControls, ··· 148 148 /> 149 149 ) : undefined} 150 150 {screenState === ScreenState.S_CreateAccount ? ( 151 - <CreateAccount 151 + <Signup 152 152 onPressBack={() => 153 153 setScreenState(ScreenState.S_LoginOrCreateAccount) 154 154 }
+9 -8
src/view/com/auth/create/CaptchaWebView.tsx src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
··· 1 1 import React from 'react' 2 + import {StyleSheet} from 'react-native' 2 3 import {WebView, WebViewNavigation} from 'react-native-webview' 3 4 import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' 4 - import {StyleSheet} from 'react-native' 5 - import {CreateAccountState} from 'view/com/auth/create/state' 5 + 6 + import {SignupState} from '#/screens/Signup/state' 6 7 7 8 const ALLOWED_HOSTS = [ 8 9 'bsky.social', ··· 17 18 export function CaptchaWebView({ 18 19 url, 19 20 stateParam, 20 - uiState, 21 + state, 21 22 onSuccess, 22 23 onError, 23 24 }: { 24 25 url: string 25 26 stateParam: string 26 - uiState?: CreateAccountState 27 + state?: SignupState 27 28 onSuccess: (code: string) => void 28 29 onError: () => void 29 30 }) { 30 31 const redirectHost = React.useMemo(() => { 31 - if (!uiState?.serviceUrl) return 'bsky.app' 32 + if (!state?.serviceUrl) return 'bsky.app' 32 33 33 - return uiState?.serviceUrl && 34 - new URL(uiState?.serviceUrl).host === 'staging.bsky.dev' 34 + return state?.serviceUrl && 35 + new URL(state?.serviceUrl).host === 'staging.bsky.dev' 35 36 ? 'staging.bsky.app' 36 37 : 'bsky.app' 37 - }, [uiState?.serviceUrl]) 38 + }, [state?.serviceUrl]) 38 39 39 40 const wasSuccessful = React.useRef(false) 40 41
src/view/com/auth/create/CaptchaWebView.web.tsx src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
-230
src/view/com/auth/create/CreateAccount.tsx
··· 1 - import React from 'react' 2 - import { 3 - ActivityIndicator, 4 - ScrollView, 5 - StyleSheet, 6 - TouchableOpacity, 7 - View, 8 - } from 'react-native' 9 - import {useAnalytics} from 'lib/analytics/analytics' 10 - import {Text} from '../../util/text/Text' 11 - import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' 12 - import {s} from 'lib/styles' 13 - import {usePalette} from 'lib/hooks/usePalette' 14 - import {msg, Trans} from '@lingui/macro' 15 - import {useLingui} from '@lingui/react' 16 - import {useCreateAccount, useSubmitCreateAccount} from './state' 17 - import {useServiceQuery} from '#/state/queries/service' 18 - import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants' 19 - 20 - import {Step1} from './Step1' 21 - import {Step2} from './Step2' 22 - import {Step3} from './Step3' 23 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 24 - import {TextLink} from '../../util/Link' 25 - import {getAgent} from 'state/session' 26 - import {createFullHandle, validateHandle} from 'lib/strings/handles' 27 - 28 - export function CreateAccount({onPressBack}: {onPressBack: () => void}) { 29 - const {screen} = useAnalytics() 30 - const pal = usePalette('default') 31 - const {_} = useLingui() 32 - const [uiState, uiDispatch] = useCreateAccount() 33 - const {isTabletOrDesktop} = useWebMediaQueries() 34 - const submit = useSubmitCreateAccount(uiState, uiDispatch) 35 - 36 - React.useEffect(() => { 37 - screen('CreateAccount') 38 - }, [screen]) 39 - 40 - // fetch service info 41 - // = 42 - 43 - const { 44 - data: serviceInfo, 45 - isFetching: serviceInfoIsFetching, 46 - error: serviceInfoError, 47 - refetch: refetchServiceInfo, 48 - } = useServiceQuery(uiState.serviceUrl) 49 - 50 - React.useEffect(() => { 51 - if (serviceInfo) { 52 - uiDispatch({type: 'set-service-description', value: serviceInfo}) 53 - uiDispatch({type: 'set-error', value: ''}) 54 - } else if (serviceInfoError) { 55 - uiDispatch({ 56 - type: 'set-error', 57 - value: _( 58 - msg`Unable to contact your service. Please check your Internet connection.`, 59 - ), 60 - }) 61 - } 62 - }, [_, uiDispatch, serviceInfo, serviceInfoError]) 63 - 64 - // event handlers 65 - // = 66 - 67 - const onPressBackInner = React.useCallback(() => { 68 - if (uiState.canBack) { 69 - uiDispatch({type: 'back'}) 70 - } else { 71 - onPressBack() 72 - } 73 - }, [uiState, uiDispatch, onPressBack]) 74 - 75 - const onPressNext = React.useCallback(async () => { 76 - if (!uiState.canNext) { 77 - return 78 - } 79 - 80 - if (uiState.step === 2) { 81 - if (!validateHandle(uiState.handle, uiState.userDomain).overall) { 82 - return 83 - } 84 - 85 - uiDispatch({type: 'set-processing', value: true}) 86 - try { 87 - const res = await getAgent().resolveHandle({ 88 - handle: createFullHandle(uiState.handle, uiState.userDomain), 89 - }) 90 - 91 - if (res.data.did) { 92 - uiDispatch({ 93 - type: 'set-error', 94 - value: _(msg`That handle is already taken.`), 95 - }) 96 - return 97 - } 98 - } catch (e) { 99 - // Don't need to handle 100 - } finally { 101 - uiDispatch({type: 'set-processing', value: false}) 102 - } 103 - 104 - if (!uiState.isCaptchaRequired) { 105 - try { 106 - await submit() 107 - } catch { 108 - // dont need to handle here 109 - } 110 - // We don't need to go to the next page if there wasn't a captcha required 111 - return 112 - } 113 - } 114 - 115 - uiDispatch({type: 'next'}) 116 - }, [ 117 - uiState.canNext, 118 - uiState.step, 119 - uiState.isCaptchaRequired, 120 - uiState.handle, 121 - uiState.userDomain, 122 - uiDispatch, 123 - _, 124 - submit, 125 - ]) 126 - 127 - // rendering 128 - // = 129 - 130 - return ( 131 - <LoggedOutLayout 132 - leadin="" 133 - title={_(msg`Create Account`)} 134 - description={_(msg`We're so excited to have you join us!`)}> 135 - <ScrollView 136 - testID="createAccount" 137 - style={pal.view} 138 - keyboardShouldPersistTaps="handled" 139 - keyboardDismissMode="on-drag"> 140 - <View style={styles.stepContainer}> 141 - {uiState.step === 1 && ( 142 - <Step1 uiState={uiState} uiDispatch={uiDispatch} /> 143 - )} 144 - {uiState.step === 2 && ( 145 - <Step2 uiState={uiState} uiDispatch={uiDispatch} /> 146 - )} 147 - {uiState.step === 3 && ( 148 - <Step3 uiState={uiState} uiDispatch={uiDispatch} /> 149 - )} 150 - </View> 151 - <View style={[s.flexRow, s.pl20, s.pr20]}> 152 - <TouchableOpacity 153 - onPress={onPressBackInner} 154 - testID="backBtn" 155 - accessibilityRole="button" 156 - hitSlop={HITSLOP_10}> 157 - <Text type="xl" style={pal.link}> 158 - <Trans>Back</Trans> 159 - </Text> 160 - </TouchableOpacity> 161 - <View style={s.flex1} /> 162 - {uiState.canNext ? ( 163 - <TouchableOpacity 164 - testID="nextBtn" 165 - onPress={onPressNext} 166 - accessibilityRole="button" 167 - hitSlop={HITSLOP_10}> 168 - {uiState.isProcessing ? ( 169 - <ActivityIndicator /> 170 - ) : ( 171 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 172 - <Trans>Next</Trans> 173 - </Text> 174 - )} 175 - </TouchableOpacity> 176 - ) : serviceInfoError ? ( 177 - <TouchableOpacity 178 - testID="retryConnectBtn" 179 - onPress={() => refetchServiceInfo()} 180 - accessibilityRole="button" 181 - accessibilityLabel={_(msg`Retry`)} 182 - accessibilityHint="" 183 - accessibilityLiveRegion="polite" 184 - hitSlop={HITSLOP_10}> 185 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 186 - <Trans>Retry</Trans> 187 - </Text> 188 - </TouchableOpacity> 189 - ) : serviceInfoIsFetching ? ( 190 - <> 191 - <ActivityIndicator color="#fff" /> 192 - <Text type="xl" style={[pal.text, s.pr5]}> 193 - <Trans>Connecting...</Trans> 194 - </Text> 195 - </> 196 - ) : undefined} 197 - </View> 198 - 199 - <View style={styles.stepContainer}> 200 - <View 201 - style={[ 202 - s.flexRow, 203 - s.alignCenter, 204 - pal.viewLight, 205 - {borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12}, 206 - ]}> 207 - <Text type="md" style={pal.textLight}> 208 - <Trans>Having trouble?</Trans>{' '} 209 - </Text> 210 - <TextLink 211 - type="md" 212 - style={pal.link} 213 - text={_(msg`Contact support`)} 214 - href={FEEDBACK_FORM_URL({email: uiState.email})} 215 - /> 216 - </View> 217 - </View> 218 - 219 - <View style={{height: isTabletOrDesktop ? 50 : 400}} /> 220 - </ScrollView> 221 - </LoggedOutLayout> 222 - ) 223 - } 224 - 225 - const styles = StyleSheet.create({ 226 - stepContainer: { 227 - paddingHorizontal: 20, 228 - paddingVertical: 20, 229 - }, 230 - })
-121
src/view/com/auth/create/Policies.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import { 4 - FontAwesomeIcon, 5 - FontAwesomeIconStyle, 6 - } from '@fortawesome/react-native-fontawesome' 7 - import {ComAtprotoServerDescribeServer} from '@atproto/api' 8 - import {TextLink} from '../../util/Link' 9 - import {Text} from '../../util/text/Text' 10 - import {s, colors} from 'lib/styles' 11 - import {usePalette} from 'lib/hooks/usePalette' 12 - import {Trans, msg} from '@lingui/macro' 13 - import {useLingui} from '@lingui/react' 14 - 15 - type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 16 - 17 - export const Policies = ({ 18 - serviceDescription, 19 - needsGuardian, 20 - }: { 21 - serviceDescription: ServiceDescription 22 - needsGuardian: boolean 23 - }) => { 24 - const pal = usePalette('default') 25 - const {_} = useLingui() 26 - if (!serviceDescription) { 27 - return <View /> 28 - } 29 - const tos = validWebLink(serviceDescription.links?.termsOfService) 30 - const pp = validWebLink(serviceDescription.links?.privacyPolicy) 31 - if (!tos && !pp) { 32 - return ( 33 - <View style={[styles.policies, {flexDirection: 'row'}]}> 34 - <View 35 - style={[ 36 - styles.errorIcon, 37 - {borderColor: pal.colors.text, marginTop: 1}, 38 - ]}> 39 - <FontAwesomeIcon 40 - icon="exclamation" 41 - style={pal.textLight as FontAwesomeIconStyle} 42 - size={10} 43 - /> 44 - </View> 45 - <Text style={[pal.textLight, s.pl5, s.flex1]}> 46 - <Trans> 47 - This service has not provided terms of service or a privacy policy. 48 - </Trans> 49 - </Text> 50 - </View> 51 - ) 52 - } 53 - const els = [] 54 - if (tos) { 55 - els.push( 56 - <TextLink 57 - key="tos" 58 - href={tos} 59 - text={_(msg`Terms of Service`)} 60 - style={[pal.link, s.underline]} 61 - />, 62 - ) 63 - } 64 - if (pp) { 65 - els.push( 66 - <TextLink 67 - key="pp" 68 - href={pp} 69 - text={_(msg`Privacy Policy`)} 70 - style={[pal.link, s.underline]} 71 - />, 72 - ) 73 - } 74 - if (els.length === 2) { 75 - els.splice( 76 - 1, 77 - 0, 78 - <Text key="and" style={pal.textLight}> 79 - {' '} 80 - and{' '} 81 - </Text>, 82 - ) 83 - } 84 - return ( 85 - <View style={styles.policies}> 86 - <Text style={pal.textLight}> 87 - <Trans>By creating an account you agree to the {els}.</Trans> 88 - </Text> 89 - {needsGuardian && ( 90 - <Text style={[pal.textLight, s.bold]}> 91 - <Trans> 92 - If you are not yet an adult according to the laws of your country, 93 - your parent or legal guardian must read these Terms on your behalf. 94 - </Trans> 95 - </Text> 96 - )} 97 - </View> 98 - ) 99 - } 100 - 101 - function validWebLink(url?: string): string | undefined { 102 - return url && (url.startsWith('http://') || url.startsWith('https://')) 103 - ? url 104 - : undefined 105 - } 106 - 107 - const styles = StyleSheet.create({ 108 - policies: { 109 - flexDirection: 'column', 110 - gap: 8, 111 - }, 112 - errorIcon: { 113 - borderWidth: 1, 114 - borderColor: colors.white, 115 - borderRadius: 30, 116 - width: 16, 117 - height: 16, 118 - alignItems: 'center', 119 - justifyContent: 'center', 120 - }, 121 - })
-261
src/view/com/auth/create/Step1.tsx
··· 1 - import React from 'react' 2 - import { 3 - ActivityIndicator, 4 - Keyboard, 5 - StyleSheet, 6 - TouchableOpacity, 7 - View, 8 - } from 'react-native' 9 - import {CreateAccountState, CreateAccountDispatch, is18} from './state' 10 - import {Text} from 'view/com/util/text/Text' 11 - import {DateInput} from 'view/com/util/forms/DateInput' 12 - import {StepHeader} from './StepHeader' 13 - import {s} from 'lib/styles' 14 - import {usePalette} from 'lib/hooks/usePalette' 15 - import {TextInput} from '../util/TextInput' 16 - import {Policies} from './Policies' 17 - import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 18 - import {isWeb} from 'platform/detection' 19 - import {Trans, msg} from '@lingui/macro' 20 - import {useLingui} from '@lingui/react' 21 - import {logger} from '#/logger' 22 - import { 23 - FontAwesomeIcon, 24 - FontAwesomeIconStyle, 25 - } from '@fortawesome/react-native-fontawesome' 26 - import {useDialogControl} from '#/components/Dialog' 27 - 28 - import {ServerInputDialog} from '../server-input' 29 - import {toNiceDomain} from '#/lib/strings/url-helpers' 30 - 31 - function sanitizeDate(date: Date): Date { 32 - if (!date || date.toString() === 'Invalid Date') { 33 - logger.error(`Create account: handled invalid date for birthDate`, { 34 - hasDate: !!date, 35 - }) 36 - return new Date() 37 - } 38 - return date 39 - } 40 - 41 - export function Step1({ 42 - uiState, 43 - uiDispatch, 44 - }: { 45 - uiState: CreateAccountState 46 - uiDispatch: CreateAccountDispatch 47 - }) { 48 - const pal = usePalette('default') 49 - const {_} = useLingui() 50 - const serverInputControl = useDialogControl() 51 - 52 - const onPressSelectService = React.useCallback(() => { 53 - serverInputControl.open() 54 - Keyboard.dismiss() 55 - }, [serverInputControl]) 56 - 57 - const birthDate = React.useMemo(() => { 58 - return sanitizeDate(uiState.birthDate) 59 - }, [uiState.birthDate]) 60 - 61 - return ( 62 - <View> 63 - <ServerInputDialog 64 - control={serverInputControl} 65 - onSelect={url => uiDispatch({type: 'set-service-url', value: url})} 66 - /> 67 - <StepHeader uiState={uiState} title={_(msg`Your account`)} /> 68 - 69 - {uiState.error ? ( 70 - <ErrorMessage message={uiState.error} style={styles.error} /> 71 - ) : undefined} 72 - 73 - <View style={s.pb20}> 74 - <Text type="md-medium" style={[pal.text, s.mb2]}> 75 - <Trans>Hosting provider</Trans> 76 - </Text> 77 - <View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}> 78 - <View 79 - style={[ 80 - pal.borderDark, 81 - {flexDirection: 'row', alignItems: 'center'}, 82 - ]}> 83 - <FontAwesomeIcon 84 - icon="globe" 85 - style={[pal.textLight, {marginLeft: 14}]} 86 - /> 87 - <TouchableOpacity 88 - testID="selectServiceButton" 89 - style={{ 90 - flexDirection: 'row', 91 - flex: 1, 92 - alignItems: 'center', 93 - }} 94 - onPress={onPressSelectService} 95 - accessibilityRole="button" 96 - accessibilityLabel={_(msg`Select service`)} 97 - accessibilityHint={_(msg`Sets server for the Bluesky client`)}> 98 - <Text 99 - type="xl" 100 - style={[ 101 - pal.text, 102 - { 103 - flex: 1, 104 - paddingVertical: 10, 105 - paddingRight: 12, 106 - paddingLeft: 10, 107 - }, 108 - ]}> 109 - {toNiceDomain(uiState.serviceUrl)} 110 - </Text> 111 - <View 112 - style={[ 113 - pal.btn, 114 - { 115 - flexDirection: 'row', 116 - alignItems: 'center', 117 - borderRadius: 6, 118 - paddingVertical: 6, 119 - paddingHorizontal: 8, 120 - marginHorizontal: 6, 121 - }, 122 - ]}> 123 - <FontAwesomeIcon 124 - icon="pen" 125 - size={12} 126 - style={pal.textLight as FontAwesomeIconStyle} 127 - /> 128 - </View> 129 - </TouchableOpacity> 130 - </View> 131 - </View> 132 - </View> 133 - 134 - {!uiState.serviceDescription ? ( 135 - <ActivityIndicator /> 136 - ) : ( 137 - <> 138 - {uiState.isInviteCodeRequired && ( 139 - <View style={s.pb20}> 140 - <Text type="md-medium" style={[pal.text, s.mb2]}> 141 - <Trans>Invite code</Trans> 142 - </Text> 143 - <TextInput 144 - testID="inviteCodeInput" 145 - icon="ticket" 146 - placeholder={_(msg`Required for this provider`)} 147 - value={uiState.inviteCode} 148 - editable 149 - onChange={value => uiDispatch({type: 'set-invite-code', value})} 150 - accessibilityLabel={_(msg`Invite code`)} 151 - accessibilityHint={_(msg`Input invite code to proceed`)} 152 - autoCapitalize="none" 153 - autoComplete="off" 154 - autoCorrect={false} 155 - autoFocus={true} 156 - /> 157 - </View> 158 - )} 159 - 160 - {!uiState.isInviteCodeRequired || uiState.inviteCode ? ( 161 - <> 162 - <View style={s.pb20}> 163 - <Text 164 - type="md-medium" 165 - style={[pal.text, s.mb2]} 166 - nativeID="email"> 167 - <Trans>Email address</Trans> 168 - </Text> 169 - <TextInput 170 - testID="emailInput" 171 - icon="envelope" 172 - placeholder={_(msg`Enter your email address`)} 173 - value={uiState.email} 174 - editable 175 - onChange={value => uiDispatch({type: 'set-email', value})} 176 - accessibilityLabel={_(msg`Email`)} 177 - accessibilityHint={_(msg`Input email for Bluesky account`)} 178 - accessibilityLabelledBy="email" 179 - autoCapitalize="none" 180 - autoComplete="email" 181 - autoCorrect={false} 182 - autoFocus={!uiState.isInviteCodeRequired} 183 - /> 184 - </View> 185 - 186 - <View style={s.pb20}> 187 - <Text 188 - type="md-medium" 189 - style={[pal.text, s.mb2]} 190 - nativeID="password"> 191 - <Trans>Password</Trans> 192 - </Text> 193 - <TextInput 194 - testID="passwordInput" 195 - icon="lock" 196 - placeholder={_(msg`Choose your password`)} 197 - value={uiState.password} 198 - editable 199 - secureTextEntry 200 - onChange={value => uiDispatch({type: 'set-password', value})} 201 - accessibilityLabel={_(msg`Password`)} 202 - accessibilityHint={_(msg`Set password`)} 203 - accessibilityLabelledBy="password" 204 - autoCapitalize="none" 205 - autoComplete="new-password" 206 - autoCorrect={false} 207 - /> 208 - </View> 209 - 210 - <View style={s.pb20}> 211 - <Text 212 - type="md-medium" 213 - style={[pal.text, s.mb2]} 214 - nativeID="birthDate"> 215 - <Trans>Your birth date</Trans> 216 - </Text> 217 - <DateInput 218 - handleAsUTC 219 - testID="birthdayInput" 220 - value={birthDate} 221 - onChange={value => 222 - uiDispatch({type: 'set-birth-date', value}) 223 - } 224 - buttonType="default-light" 225 - buttonStyle={[pal.border, styles.dateInputButton]} 226 - buttonLabelType="lg" 227 - accessibilityLabel={_(msg`Birthday`)} 228 - accessibilityHint={_(msg`Enter your birth date`)} 229 - accessibilityLabelledBy="birthDate" 230 - /> 231 - </View> 232 - 233 - {uiState.serviceDescription && ( 234 - <Policies 235 - serviceDescription={uiState.serviceDescription} 236 - needsGuardian={!is18(uiState)} 237 - /> 238 - )} 239 - </> 240 - ) : undefined} 241 - </> 242 - )} 243 - </View> 244 - ) 245 - } 246 - 247 - const styles = StyleSheet.create({ 248 - error: { 249 - borderRadius: 6, 250 - marginBottom: 10, 251 - }, 252 - dateInputButton: { 253 - borderWidth: 1, 254 - borderRadius: 6, 255 - paddingVertical: 14, 256 - }, 257 - // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. 258 - touchable: { 259 - ...(isWeb && {cursor: 'pointer'}), 260 - }, 261 - })
-140
src/view/com/auth/create/Step2.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {CreateAccountState, CreateAccountDispatch} from './state' 4 - import {Text} from 'view/com/util/text/Text' 5 - import {StepHeader} from './StepHeader' 6 - import {s} from 'lib/styles' 7 - import {TextInput} from '../util/TextInput' 8 - import { 9 - createFullHandle, 10 - IsValidHandle, 11 - validateHandle, 12 - } from 'lib/strings/handles' 13 - import {usePalette} from 'lib/hooks/usePalette' 14 - import {msg, Trans} from '@lingui/macro' 15 - import {useLingui} from '@lingui/react' 16 - import {atoms as a, useTheme} from '#/alf' 17 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 - import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 19 - import {useFocusEffect} from '@react-navigation/native' 20 - 21 - /** STEP 3: Your user handle 22 - * @field User handle 23 - */ 24 - export function Step2({ 25 - uiState, 26 - uiDispatch, 27 - }: { 28 - uiState: CreateAccountState 29 - uiDispatch: CreateAccountDispatch 30 - }) { 31 - const pal = usePalette('default') 32 - const {_} = useLingui() 33 - const t = useTheme() 34 - 35 - const [validCheck, setValidCheck] = React.useState<IsValidHandle>({ 36 - handleChars: false, 37 - frontLength: false, 38 - totalLength: true, 39 - overall: false, 40 - }) 41 - 42 - useFocusEffect( 43 - React.useCallback(() => { 44 - setValidCheck(validateHandle(uiState.handle, uiState.userDomain)) 45 - 46 - // Disabling this, because we only want to run this when we focus the screen 47 - // eslint-disable-next-line react-hooks/exhaustive-deps 48 - }, []), 49 - ) 50 - 51 - const onHandleChange = React.useCallback( 52 - (value: string) => { 53 - if (uiState.error) { 54 - uiDispatch({type: 'set-error', value: ''}) 55 - } 56 - 57 - setValidCheck(validateHandle(value, uiState.userDomain)) 58 - uiDispatch({type: 'set-handle', value}) 59 - }, 60 - [uiDispatch, uiState.error, uiState.userDomain], 61 - ) 62 - 63 - return ( 64 - <View> 65 - <StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> 66 - <View style={s.pb10}> 67 - <View style={s.mb20}> 68 - <TextInput 69 - testID="handleInput" 70 - icon="at" 71 - placeholder="e.g. alice" 72 - value={uiState.handle} 73 - editable 74 - autoFocus 75 - autoComplete="off" 76 - autoCorrect={false} 77 - onChange={onHandleChange} 78 - // TODO: Add explicit text label 79 - accessibilityLabel={_(msg`User handle`)} 80 - accessibilityHint={_(msg`Input your user handle`)} 81 - /> 82 - <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> 83 - <Trans>Your full handle will be</Trans>{' '} 84 - <Text type="lg-bold" style={pal.text}> 85 - @{createFullHandle(uiState.handle, uiState.userDomain)} 86 - </Text> 87 - </Text> 88 - </View> 89 - <View 90 - style={[ 91 - a.w_full, 92 - a.rounded_sm, 93 - a.border, 94 - a.p_md, 95 - a.gap_sm, 96 - t.atoms.border_contrast_low, 97 - ]}> 98 - {uiState.error ? ( 99 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 100 - <IsValidIcon valid={false} /> 101 - <Text style={[t.atoms.text, a.text_md, a.flex]}> 102 - {uiState.error} 103 - </Text> 104 - </View> 105 - ) : undefined} 106 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 107 - <IsValidIcon valid={validCheck.handleChars} /> 108 - <Text style={[t.atoms.text, a.text_md, a.flex]}> 109 - <Trans>May only contain letters and numbers</Trans> 110 - </Text> 111 - </View> 112 - <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}> 113 - <IsValidIcon 114 - valid={validCheck.frontLength && validCheck.totalLength} 115 - /> 116 - {!validCheck.totalLength ? ( 117 - <Text style={[t.atoms.text]}> 118 - <Trans>May not be longer than 253 characters</Trans> 119 - </Text> 120 - ) : ( 121 - <Text style={[t.atoms.text, a.text_md]}> 122 - <Trans>Must be at least 3 characters</Trans> 123 - </Text> 124 - )} 125 - </View> 126 - </View> 127 - </View> 128 - </View> 129 - ) 130 - } 131 - 132 - function IsValidIcon({valid}: {valid: boolean}) { 133 - const t = useTheme() 134 - 135 - if (!valid) { 136 - return <Times size="md" style={{color: t.palette.negative_500}} /> 137 - } 138 - 139 - return <Check size="md" style={{color: t.palette.positive_700}} /> 140 - }
-114
src/view/com/auth/create/Step3.tsx
··· 1 - import React from 'react' 2 - import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 - import { 4 - CreateAccountState, 5 - CreateAccountDispatch, 6 - useSubmitCreateAccount, 7 - } from './state' 8 - import {StepHeader} from './StepHeader' 9 - import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 10 - import {isWeb} from 'platform/detection' 11 - import {msg} from '@lingui/macro' 12 - import {useLingui} from '@lingui/react' 13 - 14 - import {nanoid} from 'nanoid/non-secure' 15 - import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView' 16 - import {useTheme} from 'lib/ThemeContext' 17 - import {createFullHandle} from 'lib/strings/handles' 18 - 19 - const CAPTCHA_PATH = '/gate/signup' 20 - 21 - export function Step3({ 22 - uiState, 23 - uiDispatch, 24 - }: { 25 - uiState: CreateAccountState 26 - uiDispatch: CreateAccountDispatch 27 - }) { 28 - const {_} = useLingui() 29 - const theme = useTheme() 30 - const submit = useSubmitCreateAccount(uiState, uiDispatch) 31 - 32 - const [completed, setCompleted] = React.useState(false) 33 - 34 - const stateParam = React.useMemo(() => nanoid(15), []) 35 - const url = React.useMemo(() => { 36 - const newUrl = new URL(uiState.serviceUrl) 37 - newUrl.pathname = CAPTCHA_PATH 38 - newUrl.searchParams.set( 39 - 'handle', 40 - createFullHandle(uiState.handle, uiState.userDomain), 41 - ) 42 - newUrl.searchParams.set('state', stateParam) 43 - newUrl.searchParams.set('colorScheme', theme.colorScheme) 44 - 45 - console.log(newUrl) 46 - 47 - return newUrl.href 48 - }, [ 49 - uiState.serviceUrl, 50 - uiState.handle, 51 - uiState.userDomain, 52 - stateParam, 53 - theme.colorScheme, 54 - ]) 55 - 56 - const onSuccess = React.useCallback( 57 - (code: string) => { 58 - setCompleted(true) 59 - submit(code) 60 - }, 61 - [submit], 62 - ) 63 - 64 - const onError = React.useCallback(() => { 65 - uiDispatch({ 66 - type: 'set-error', 67 - value: _(msg`Error receiving captcha response.`), 68 - }) 69 - }, [_, uiDispatch]) 70 - 71 - return ( 72 - <View> 73 - <StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} /> 74 - <View style={[styles.container, completed && styles.center]}> 75 - {!completed ? ( 76 - <CaptchaWebView 77 - url={url} 78 - stateParam={stateParam} 79 - uiState={uiState} 80 - onSuccess={onSuccess} 81 - onError={onError} 82 - /> 83 - ) : ( 84 - <ActivityIndicator size="large" /> 85 - )} 86 - </View> 87 - 88 - {uiState.error ? ( 89 - <ErrorMessage message={uiState.error} style={styles.error} /> 90 - ) : undefined} 91 - </View> 92 - ) 93 - } 94 - 95 - const styles = StyleSheet.create({ 96 - error: { 97 - borderRadius: 6, 98 - marginTop: 10, 99 - }, 100 - // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. 101 - touchable: { 102 - ...(isWeb && {cursor: 'pointer'}), 103 - }, 104 - container: { 105 - minHeight: 500, 106 - width: '100%', 107 - paddingBottom: 20, 108 - overflow: 'hidden', 109 - }, 110 - center: { 111 - alignItems: 'center', 112 - justifyContent: 'center', 113 - }, 114 - })
-44
src/view/com/auth/create/StepHeader.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {Text} from 'view/com/util/text/Text' 4 - import {usePalette} from 'lib/hooks/usePalette' 5 - import {Trans} from '@lingui/macro' 6 - import {CreateAccountState} from './state' 7 - 8 - export function StepHeader({ 9 - uiState, 10 - title, 11 - children, 12 - }: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) { 13 - const pal = usePalette('default') 14 - const numSteps = 3 15 - return ( 16 - <View style={styles.container}> 17 - <View> 18 - <Text type="lg" style={[pal.textLight]}> 19 - {uiState.step === 3 ? ( 20 - <Trans>Last step!</Trans> 21 - ) : ( 22 - <Trans> 23 - Step {uiState.step} of {numSteps} 24 - </Trans> 25 - )} 26 - </Text> 27 - 28 - <Text style={[pal.text]} type="title-xl"> 29 - {title} 30 - </Text> 31 - </View> 32 - {children} 33 - </View> 34 - ) 35 - } 36 - 37 - const styles = StyleSheet.create({ 38 - container: { 39 - flexDirection: 'row', 40 - justifyContent: 'space-between', 41 - alignItems: 'center', 42 - marginBottom: 20, 43 - }, 44 - })
-298
src/view/com/auth/create/state.ts
··· 1 - import {useCallback, useReducer} from 'react' 2 - import { 3 - ComAtprotoServerDescribeServer, 4 - ComAtprotoServerCreateAccount, 5 - } from '@atproto/api' 6 - import {I18nContext, useLingui} from '@lingui/react' 7 - import {msg} from '@lingui/macro' 8 - import * as EmailValidator from 'email-validator' 9 - import {getAge} from 'lib/strings/time' 10 - import {logger} from '#/logger' 11 - import {createFullHandle, validateHandle} from '#/lib/strings/handles' 12 - import {cleanError} from '#/lib/strings/errors' 13 - import {useOnboardingDispatch} from '#/state/shell/onboarding' 14 - import {useSessionApi} from '#/state/session' 15 - import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants' 16 - import { 17 - DEFAULT_PROD_FEEDS, 18 - usePreferencesSetBirthDateMutation, 19 - useSetSaveFeedsMutation, 20 - } from 'state/queries/preferences' 21 - 22 - export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 23 - const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago 24 - 25 - export type CreateAccountAction = 26 - | {type: 'set-step'; value: number} 27 - | {type: 'set-error'; value: string | undefined} 28 - | {type: 'set-processing'; value: boolean} 29 - | {type: 'set-service-url'; value: string} 30 - | {type: 'set-service-description'; value: ServiceDescription | undefined} 31 - | {type: 'set-user-domain'; value: string} 32 - | {type: 'set-invite-code'; value: string} 33 - | {type: 'set-email'; value: string} 34 - | {type: 'set-password'; value: string} 35 - | {type: 'set-handle'; value: string} 36 - | {type: 'set-birth-date'; value: Date} 37 - | {type: 'next'} 38 - | {type: 'back'} 39 - 40 - export interface CreateAccountState { 41 - // state 42 - step: number 43 - error: string | undefined 44 - isProcessing: boolean 45 - serviceUrl: string 46 - serviceDescription: ServiceDescription | undefined 47 - userDomain: string 48 - inviteCode: string 49 - email: string 50 - password: string 51 - handle: string 52 - birthDate: Date 53 - 54 - // computed 55 - canBack: boolean 56 - canNext: boolean 57 - isInviteCodeRequired: boolean 58 - isCaptchaRequired: boolean 59 - } 60 - 61 - export type CreateAccountDispatch = (action: CreateAccountAction) => void 62 - 63 - export function useCreateAccount() { 64 - const {_} = useLingui() 65 - 66 - return useReducer(createReducer({_}), { 67 - step: 1, 68 - error: undefined, 69 - isProcessing: false, 70 - serviceUrl: DEFAULT_SERVICE, 71 - serviceDescription: undefined, 72 - userDomain: '', 73 - inviteCode: '', 74 - email: '', 75 - password: '', 76 - handle: '', 77 - birthDate: DEFAULT_DATE, 78 - 79 - canBack: false, 80 - canNext: false, 81 - isInviteCodeRequired: false, 82 - isCaptchaRequired: false, 83 - }) 84 - } 85 - 86 - export function useSubmitCreateAccount( 87 - uiState: CreateAccountState, 88 - uiDispatch: CreateAccountDispatch, 89 - ) { 90 - const {_} = useLingui() 91 - const {createAccount} = useSessionApi() 92 - const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() 93 - const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() 94 - const onboardingDispatch = useOnboardingDispatch() 95 - 96 - return useCallback( 97 - async (verificationCode?: string) => { 98 - if (!uiState.email) { 99 - uiDispatch({type: 'set-step', value: 1}) 100 - console.log('no email?') 101 - return uiDispatch({ 102 - type: 'set-error', 103 - value: _(msg`Please enter your email.`), 104 - }) 105 - } 106 - if (!EmailValidator.validate(uiState.email)) { 107 - uiDispatch({type: 'set-step', value: 1}) 108 - return uiDispatch({ 109 - type: 'set-error', 110 - value: _(msg`Your email appears to be invalid.`), 111 - }) 112 - } 113 - if (!uiState.password) { 114 - uiDispatch({type: 'set-step', value: 1}) 115 - return uiDispatch({ 116 - type: 'set-error', 117 - value: _(msg`Please choose your password.`), 118 - }) 119 - } 120 - if (!uiState.handle) { 121 - uiDispatch({type: 'set-step', value: 2}) 122 - return uiDispatch({ 123 - type: 'set-error', 124 - value: _(msg`Please choose your handle.`), 125 - }) 126 - } 127 - if (uiState.isCaptchaRequired && !verificationCode) { 128 - uiDispatch({type: 'set-step', value: 3}) 129 - return uiDispatch({ 130 - type: 'set-error', 131 - value: _(msg`Please complete the verification captcha.`), 132 - }) 133 - } 134 - uiDispatch({type: 'set-error', value: ''}) 135 - uiDispatch({type: 'set-processing', value: true}) 136 - 137 - try { 138 - onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view 139 - await createAccount({ 140 - service: uiState.serviceUrl, 141 - email: uiState.email, 142 - handle: createFullHandle(uiState.handle, uiState.userDomain), 143 - password: uiState.password, 144 - inviteCode: uiState.inviteCode.trim(), 145 - verificationCode: uiState.isCaptchaRequired 146 - ? verificationCode 147 - : undefined, 148 - }) 149 - setBirthDate({birthDate: uiState.birthDate}) 150 - if (!IS_TEST_USER(uiState.handle)) { 151 - setSavedFeeds(DEFAULT_PROD_FEEDS) 152 - } 153 - } catch (e: any) { 154 - onboardingDispatch({type: 'skip'}) // undo starting the onboard 155 - let errMsg = e.toString() 156 - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { 157 - errMsg = _( 158 - msg`Invite code not accepted. Check that you input it correctly and try again.`, 159 - ) 160 - uiDispatch({type: 'set-step', value: 1}) 161 - } 162 - 163 - if ([400, 429].includes(e.status)) { 164 - logger.warn('Failed to create account', {message: e}) 165 - } else { 166 - logger.error(`Failed to create account (${e.status} status)`, { 167 - message: e, 168 - }) 169 - } 170 - 171 - const error = cleanError(errMsg) 172 - const isHandleError = error.toLowerCase().includes('handle') 173 - 174 - uiDispatch({type: 'set-processing', value: false}) 175 - uiDispatch({type: 'set-error', value: cleanError(errMsg)}) 176 - uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1}) 177 - } 178 - }, 179 - [ 180 - uiState.email, 181 - uiState.password, 182 - uiState.handle, 183 - uiState.isCaptchaRequired, 184 - uiState.serviceUrl, 185 - uiState.userDomain, 186 - uiState.inviteCode, 187 - uiState.birthDate, 188 - uiDispatch, 189 - _, 190 - onboardingDispatch, 191 - createAccount, 192 - setBirthDate, 193 - setSavedFeeds, 194 - ], 195 - ) 196 - } 197 - 198 - export function is13(state: CreateAccountState) { 199 - return getAge(state.birthDate) >= 13 200 - } 201 - 202 - export function is18(state: CreateAccountState) { 203 - return getAge(state.birthDate) >= 18 204 - } 205 - 206 - function createReducer({_}: {_: I18nContext['_']}) { 207 - return function reducer( 208 - state: CreateAccountState, 209 - action: CreateAccountAction, 210 - ): CreateAccountState { 211 - switch (action.type) { 212 - case 'set-step': { 213 - return compute({...state, step: action.value}) 214 - } 215 - case 'set-error': { 216 - return compute({...state, error: action.value}) 217 - } 218 - case 'set-processing': { 219 - return compute({...state, isProcessing: action.value}) 220 - } 221 - case 'set-service-url': { 222 - return compute({ 223 - ...state, 224 - serviceUrl: action.value, 225 - serviceDescription: 226 - state.serviceUrl !== action.value 227 - ? undefined 228 - : state.serviceDescription, 229 - }) 230 - } 231 - case 'set-service-description': { 232 - return compute({ 233 - ...state, 234 - serviceDescription: action.value, 235 - userDomain: action.value?.availableUserDomains[0] || '', 236 - }) 237 - } 238 - case 'set-user-domain': { 239 - return compute({...state, userDomain: action.value}) 240 - } 241 - case 'set-invite-code': { 242 - return compute({...state, inviteCode: action.value}) 243 - } 244 - case 'set-email': { 245 - return compute({...state, email: action.value}) 246 - } 247 - case 'set-password': { 248 - return compute({...state, password: action.value}) 249 - } 250 - case 'set-handle': { 251 - return compute({...state, handle: action.value}) 252 - } 253 - case 'set-birth-date': { 254 - return compute({...state, birthDate: action.value}) 255 - } 256 - case 'next': { 257 - if (state.step === 1) { 258 - if (!is13(state)) { 259 - return compute({ 260 - ...state, 261 - error: _( 262 - msg`Unfortunately, you do not meet the requirements to create an account.`, 263 - ), 264 - }) 265 - } 266 - } 267 - return compute({...state, error: '', step: state.step + 1}) 268 - } 269 - case 'back': { 270 - return compute({...state, error: '', step: state.step - 1}) 271 - } 272 - } 273 - } 274 - } 275 - 276 - function compute(state: CreateAccountState): CreateAccountState { 277 - let canNext = true 278 - if (state.step === 1) { 279 - canNext = 280 - !!state.serviceDescription && 281 - (!state.isInviteCodeRequired || !!state.inviteCode) && 282 - !!state.email && 283 - !!state.password 284 - } else if (state.step === 2) { 285 - canNext = 286 - !!state.handle && validateHandle(state.handle, state.userDomain).overall 287 - } else if (state.step === 3) { 288 - // Step 3 will automatically redirect as soon as the captcha completes 289 - canNext = false 290 - } 291 - return { 292 - ...state, 293 - canBack: state.step > 1, 294 - canNext, 295 - isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, 296 - isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired, 297 - } 298 - }
-167
src/view/com/auth/login/ChooseAccountForm.tsx
··· 1 - import React from 'react' 2 - import {ScrollView, TouchableOpacity, View} from 'react-native' 3 - import { 4 - FontAwesomeIcon, 5 - FontAwesomeIconStyle, 6 - } from '@fortawesome/react-native-fontawesome' 7 - import {useAnalytics} from 'lib/analytics/analytics' 8 - import {Text} from '../../util/text/Text' 9 - import {UserAvatar} from '../../util/UserAvatar' 10 - import {s, colors} from 'lib/styles' 11 - import {usePalette} from 'lib/hooks/usePalette' 12 - import {Trans, msg} from '@lingui/macro' 13 - import {useLingui} from '@lingui/react' 14 - import {styles} from './styles' 15 - import {useSession, useSessionApi, SessionAccount} from '#/state/session' 16 - import {useProfileQuery} from '#/state/queries/profile' 17 - import {useLoggedOutViewControls} from '#/state/shell/logged-out' 18 - import * as Toast from '#/view/com/util/Toast' 19 - import {logEvent} from '#/lib/statsig/statsig' 20 - 21 - function AccountItem({ 22 - account, 23 - onSelect, 24 - isCurrentAccount, 25 - }: { 26 - account: SessionAccount 27 - onSelect: (account: SessionAccount) => void 28 - isCurrentAccount: boolean 29 - }) { 30 - const pal = usePalette('default') 31 - const {_} = useLingui() 32 - const {data: profile} = useProfileQuery({did: account.did}) 33 - 34 - const onPress = React.useCallback(() => { 35 - onSelect(account) 36 - }, [account, onSelect]) 37 - 38 - return ( 39 - <TouchableOpacity 40 - testID={`chooseAccountBtn-${account.handle}`} 41 - key={account.did} 42 - style={[pal.view, pal.border, styles.account]} 43 - onPress={onPress} 44 - accessibilityRole="button" 45 - accessibilityLabel={_(msg`Sign in as ${account.handle}`)} 46 - accessibilityHint={_(msg`Double tap to sign in`)}> 47 - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> 48 - <View style={s.p10}> 49 - <UserAvatar 50 - avatar={profile?.avatar} 51 - size={30} 52 - type={profile?.associated?.labeler ? 'labeler' : 'user'} 53 - /> 54 - </View> 55 - <Text style={styles.accountText}> 56 - <Text type="lg-bold" style={pal.text}> 57 - {profile?.displayName || account.handle}{' '} 58 - </Text> 59 - <Text type="lg" style={[pal.textLight]}> 60 - {account.handle} 61 - </Text> 62 - </Text> 63 - {isCurrentAccount ? ( 64 - <FontAwesomeIcon 65 - icon="check" 66 - size={16} 67 - style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]} 68 - /> 69 - ) : ( 70 - <FontAwesomeIcon 71 - icon="angle-right" 72 - size={16} 73 - style={[pal.text, s.mr10]} 74 - /> 75 - )} 76 - </View> 77 - </TouchableOpacity> 78 - ) 79 - } 80 - export const ChooseAccountForm = ({ 81 - onSelectAccount, 82 - onPressBack, 83 - }: { 84 - onSelectAccount: (account?: SessionAccount) => void 85 - onPressBack: () => void 86 - }) => { 87 - const {track, screen} = useAnalytics() 88 - const pal = usePalette('default') 89 - const {_} = useLingui() 90 - const {accounts, currentAccount} = useSession() 91 - const {initSession} = useSessionApi() 92 - const {setShowLoggedOut} = useLoggedOutViewControls() 93 - 94 - React.useEffect(() => { 95 - screen('Choose Account') 96 - }, [screen]) 97 - 98 - const onSelect = React.useCallback( 99 - async (account: SessionAccount) => { 100 - if (account.accessJwt) { 101 - if (account.did === currentAccount?.did) { 102 - setShowLoggedOut(false) 103 - Toast.show(_(msg`Already signed in as @${account.handle}`)) 104 - } else { 105 - await initSession(account) 106 - logEvent('account:loggedIn', { 107 - logContext: 'ChooseAccountForm', 108 - withPassword: false, 109 - }) 110 - track('Sign In', {resumedSession: true}) 111 - setTimeout(() => { 112 - Toast.show(_(msg`Signed in as @${account.handle}`)) 113 - }, 100) 114 - } 115 - } else { 116 - onSelectAccount(account) 117 - } 118 - }, 119 - [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _], 120 - ) 121 - 122 - return ( 123 - <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> 124 - <Text 125 - type="2xl-medium" 126 - style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> 127 - <Trans>Sign in as...</Trans> 128 - </Text> 129 - {accounts.map(account => ( 130 - <AccountItem 131 - key={account.did} 132 - account={account} 133 - onSelect={onSelect} 134 - isCurrentAccount={account.did === currentAccount?.did} 135 - /> 136 - ))} 137 - <TouchableOpacity 138 - testID="chooseNewAccountBtn" 139 - style={[pal.view, pal.border, styles.account, styles.accountLast]} 140 - onPress={() => onSelectAccount(undefined)} 141 - accessibilityRole="button" 142 - accessibilityLabel={_(msg`Login to account that is not listed`)} 143 - accessibilityHint=""> 144 - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> 145 - <Text style={[styles.accountText, styles.accountTextOther]}> 146 - <Text type="lg" style={pal.text}> 147 - <Trans>Other account</Trans> 148 - </Text> 149 - </Text> 150 - <FontAwesomeIcon 151 - icon="angle-right" 152 - size={16} 153 - style={[pal.text, s.mr10]} 154 - /> 155 - </View> 156 - </TouchableOpacity> 157 - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> 158 - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> 159 - <Text type="xl" style={[pal.link, s.pl5]}> 160 - <Trans>Back</Trans> 161 - </Text> 162 - </TouchableOpacity> 163 - <View style={s.flex1} /> 164 - </View> 165 - </ScrollView> 166 - ) 167 - }
-228
src/view/com/auth/login/ForgotPasswordForm.tsx
··· 1 - import React, {useState, useEffect} from 'react' 2 - import { 3 - ActivityIndicator, 4 - Keyboard, 5 - TextInput, 6 - TouchableOpacity, 7 - View, 8 - } from 'react-native' 9 - import { 10 - FontAwesomeIcon, 11 - FontAwesomeIconStyle, 12 - } from '@fortawesome/react-native-fontawesome' 13 - import {ComAtprotoServerDescribeServer} from '@atproto/api' 14 - import * as EmailValidator from 'email-validator' 15 - import {BskyAgent} from '@atproto/api' 16 - import {useAnalytics} from 'lib/analytics/analytics' 17 - import {Text} from '../../util/text/Text' 18 - import {s} from 'lib/styles' 19 - import {toNiceDomain} from 'lib/strings/url-helpers' 20 - import {isNetworkError} from 'lib/strings/errors' 21 - import {usePalette} from 'lib/hooks/usePalette' 22 - import {useTheme} from 'lib/ThemeContext' 23 - import {cleanError} from 'lib/strings/errors' 24 - import {logger} from '#/logger' 25 - import {Trans, msg} from '@lingui/macro' 26 - import {useLingui} from '@lingui/react' 27 - import {styles} from './styles' 28 - import {useDialogControl} from '#/components/Dialog' 29 - 30 - import {ServerInputDialog} from '../server-input' 31 - 32 - type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 33 - 34 - export const ForgotPasswordForm = ({ 35 - error, 36 - serviceUrl, 37 - serviceDescription, 38 - setError, 39 - setServiceUrl, 40 - onPressBack, 41 - onEmailSent, 42 - }: { 43 - error: string 44 - serviceUrl: string 45 - serviceDescription: ServiceDescription | undefined 46 - setError: (v: string) => void 47 - setServiceUrl: (v: string) => void 48 - onPressBack: () => void 49 - onEmailSent: () => void 50 - }) => { 51 - const pal = usePalette('default') 52 - const theme = useTheme() 53 - const [isProcessing, setIsProcessing] = useState<boolean>(false) 54 - const [email, setEmail] = useState<string>('') 55 - const {screen} = useAnalytics() 56 - const {_} = useLingui() 57 - const serverInputControl = useDialogControl() 58 - 59 - useEffect(() => { 60 - screen('Signin:ForgotPassword') 61 - }, [screen]) 62 - 63 - const onPressSelectService = React.useCallback(() => { 64 - serverInputControl.open() 65 - Keyboard.dismiss() 66 - }, [serverInputControl]) 67 - 68 - const onPressNext = async () => { 69 - if (!EmailValidator.validate(email)) { 70 - return setError(_(msg`Your email appears to be invalid.`)) 71 - } 72 - 73 - setError('') 74 - setIsProcessing(true) 75 - 76 - try { 77 - const agent = new BskyAgent({service: serviceUrl}) 78 - await agent.com.atproto.server.requestPasswordReset({email}) 79 - onEmailSent() 80 - } catch (e: any) { 81 - const errMsg = e.toString() 82 - logger.warn('Failed to request password reset', {error: e}) 83 - setIsProcessing(false) 84 - if (isNetworkError(e)) { 85 - setError( 86 - _( 87 - msg`Unable to contact your service. Please check your Internet connection.`, 88 - ), 89 - ) 90 - } else { 91 - setError(cleanError(errMsg)) 92 - } 93 - } 94 - } 95 - 96 - return ( 97 - <> 98 - <View> 99 - <ServerInputDialog 100 - control={serverInputControl} 101 - onSelect={setServiceUrl} 102 - /> 103 - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> 104 - <Trans>Reset password</Trans> 105 - </Text> 106 - <Text type="md" style={[pal.text, styles.instructions]}> 107 - <Trans> 108 - Enter the email you used to create your account. We'll send you a 109 - "reset code" so you can set a new password. 110 - </Trans> 111 - </Text> 112 - <View 113 - testID="forgotPasswordView" 114 - style={[pal.borderDark, pal.view, styles.group]}> 115 - <TouchableOpacity 116 - testID="forgotPasswordSelectServiceButton" 117 - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} 118 - onPress={onPressSelectService} 119 - accessibilityRole="button" 120 - accessibilityLabel={_(msg`Hosting provider`)} 121 - accessibilityHint={_( 122 - msg`Sets hosting provider for password reset`, 123 - )}> 124 - <FontAwesomeIcon 125 - icon="globe" 126 - style={[pal.textLight, styles.groupContentIcon]} 127 - /> 128 - <Text style={[pal.text, styles.textInput]} numberOfLines={1}> 129 - {toNiceDomain(serviceUrl)} 130 - </Text> 131 - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> 132 - <FontAwesomeIcon 133 - icon="pen" 134 - size={12} 135 - style={pal.text as FontAwesomeIconStyle} 136 - /> 137 - </View> 138 - </TouchableOpacity> 139 - <View style={[pal.borderDark, styles.groupContent]}> 140 - <FontAwesomeIcon 141 - icon="envelope" 142 - style={[pal.textLight, styles.groupContentIcon]} 143 - /> 144 - <TextInput 145 - testID="forgotPasswordEmail" 146 - style={[pal.text, styles.textInput]} 147 - placeholder={_(msg`Email address`)} 148 - placeholderTextColor={pal.colors.textLight} 149 - autoCapitalize="none" 150 - autoFocus 151 - autoCorrect={false} 152 - keyboardAppearance={theme.colorScheme} 153 - value={email} 154 - onChangeText={setEmail} 155 - editable={!isProcessing} 156 - accessibilityLabel={_(msg`Email`)} 157 - accessibilityHint={_(msg`Sets email for password reset`)} 158 - /> 159 - </View> 160 - </View> 161 - {error ? ( 162 - <View style={styles.error}> 163 - <View style={styles.errorIcon}> 164 - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> 165 - </View> 166 - <View style={s.flex1}> 167 - <Text style={[s.white, s.bold]}>{error}</Text> 168 - </View> 169 - </View> 170 - ) : undefined} 171 - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> 172 - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> 173 - <Text type="xl" style={[pal.link, s.pl5]}> 174 - <Trans>Back</Trans> 175 - </Text> 176 - </TouchableOpacity> 177 - <View style={s.flex1} /> 178 - {!serviceDescription || isProcessing ? ( 179 - <ActivityIndicator /> 180 - ) : !email ? ( 181 - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> 182 - <Trans>Next</Trans> 183 - </Text> 184 - ) : ( 185 - <TouchableOpacity 186 - testID="newPasswordButton" 187 - onPress={onPressNext} 188 - accessibilityRole="button" 189 - accessibilityLabel={_(msg`Go to next`)} 190 - accessibilityHint={_(msg`Navigates to the next screen`)}> 191 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 192 - <Trans>Next</Trans> 193 - </Text> 194 - </TouchableOpacity> 195 - )} 196 - {!serviceDescription || isProcessing ? ( 197 - <Text type="xl" style={[pal.textLight, s.pl10]}> 198 - <Trans>Processing...</Trans> 199 - </Text> 200 - ) : undefined} 201 - </View> 202 - <View 203 - style={[ 204 - s.flexRow, 205 - s.alignCenter, 206 - s.mt20, 207 - s.mb20, 208 - pal.border, 209 - s.borderBottom1, 210 - {alignSelf: 'center', width: '90%'}, 211 - ]} 212 - /> 213 - <View style={[s.flexRow, s.justifyCenter]}> 214 - <TouchableOpacity 215 - testID="skipSendEmailButton" 216 - onPress={onEmailSent} 217 - accessibilityRole="button" 218 - accessibilityLabel={_(msg`Go to next`)} 219 - accessibilityHint={_(msg`Navigates to the next screen`)}> 220 - <Text type="xl" style={[pal.link, s.pr5]}> 221 - <Trans>Already have a code?</Trans> 222 - </Text> 223 - </TouchableOpacity> 224 - </View> 225 - </View> 226 - </> 227 - ) 228 - }
-164
src/view/com/auth/login/Login.tsx
··· 1 - import React, {useState, useEffect} from 'react' 2 - import {KeyboardAvoidingView} from 'react-native' 3 - import {useAnalytics} from 'lib/analytics/analytics' 4 - import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' 5 - import {DEFAULT_SERVICE} from '#/lib/constants' 6 - import {usePalette} from 'lib/hooks/usePalette' 7 - import {logger} from '#/logger' 8 - import {ChooseAccountForm} from './ChooseAccountForm' 9 - import {LoginForm} from './LoginForm' 10 - import {ForgotPasswordForm} from './ForgotPasswordForm' 11 - import {SetNewPasswordForm} from './SetNewPasswordForm' 12 - import {PasswordUpdatedForm} from './PasswordUpdatedForm' 13 - import {useLingui} from '@lingui/react' 14 - import {msg} from '@lingui/macro' 15 - import {useSession, SessionAccount} from '#/state/session' 16 - import {useServiceQuery} from '#/state/queries/service' 17 - import {useLoggedOutView} from '#/state/shell/logged-out' 18 - 19 - enum Forms { 20 - Login, 21 - ChooseAccount, 22 - ForgotPassword, 23 - SetNewPassword, 24 - PasswordUpdated, 25 - } 26 - 27 - export const Login = ({onPressBack}: {onPressBack: () => void}) => { 28 - const {_} = useLingui() 29 - const pal = usePalette('default') 30 - 31 - const {accounts} = useSession() 32 - const {track} = useAnalytics() 33 - const {requestedAccountSwitchTo} = useLoggedOutView() 34 - const requestedAccount = accounts.find( 35 - a => a.did === requestedAccountSwitchTo, 36 - ) 37 - 38 - const [error, setError] = useState<string>('') 39 - const [serviceUrl, setServiceUrl] = useState<string>( 40 - requestedAccount?.service || DEFAULT_SERVICE, 41 - ) 42 - const [initialHandle, setInitialHandle] = useState<string>( 43 - requestedAccount?.handle || '', 44 - ) 45 - const [currentForm, setCurrentForm] = useState<Forms>( 46 - requestedAccount 47 - ? Forms.Login 48 - : accounts.length 49 - ? Forms.ChooseAccount 50 - : Forms.Login, 51 - ) 52 - 53 - const { 54 - data: serviceDescription, 55 - error: serviceError, 56 - refetch: refetchService, 57 - } = useServiceQuery(serviceUrl) 58 - 59 - const onSelectAccount = (account?: SessionAccount) => { 60 - if (account?.service) { 61 - setServiceUrl(account.service) 62 - } 63 - setInitialHandle(account?.handle || '') 64 - setCurrentForm(Forms.Login) 65 - } 66 - 67 - const gotoForm = (form: Forms) => () => { 68 - setError('') 69 - setCurrentForm(form) 70 - } 71 - 72 - useEffect(() => { 73 - if (serviceError) { 74 - setError( 75 - _( 76 - msg`Unable to contact your service. Please check your Internet connection.`, 77 - ), 78 - ) 79 - logger.warn(`Failed to fetch service description for ${serviceUrl}`, { 80 - error: String(serviceError), 81 - }) 82 - } else { 83 - setError('') 84 - } 85 - }, [serviceError, serviceUrl, _]) 86 - 87 - const onPressRetryConnect = () => refetchService() 88 - const onPressForgotPassword = () => { 89 - track('Signin:PressedForgotPassword') 90 - setCurrentForm(Forms.ForgotPassword) 91 - } 92 - 93 - return ( 94 - <KeyboardAvoidingView testID="signIn" behavior="padding" style={pal.view}> 95 - {currentForm === Forms.Login ? ( 96 - <LoggedOutLayout 97 - leadin="" 98 - title={_(msg`Sign in`)} 99 - description={_(msg`Enter your username and password`)}> 100 - <LoginForm 101 - error={error} 102 - serviceUrl={serviceUrl} 103 - serviceDescription={serviceDescription} 104 - initialHandle={initialHandle} 105 - setError={setError} 106 - setServiceUrl={setServiceUrl} 107 - onPressBack={onPressBack} 108 - onPressForgotPassword={onPressForgotPassword} 109 - onPressRetryConnect={onPressRetryConnect} 110 - /> 111 - </LoggedOutLayout> 112 - ) : undefined} 113 - {currentForm === Forms.ChooseAccount ? ( 114 - <LoggedOutLayout 115 - leadin="" 116 - title={_(msg`Sign in as...`)} 117 - description={_(msg`Select from an existing account`)}> 118 - <ChooseAccountForm 119 - onSelectAccount={onSelectAccount} 120 - onPressBack={onPressBack} 121 - /> 122 - </LoggedOutLayout> 123 - ) : undefined} 124 - {currentForm === Forms.ForgotPassword ? ( 125 - <LoggedOutLayout 126 - leadin="" 127 - title={_(msg`Forgot Password`)} 128 - description={_(msg`Let's get your password reset!`)}> 129 - <ForgotPasswordForm 130 - error={error} 131 - serviceUrl={serviceUrl} 132 - serviceDescription={serviceDescription} 133 - setError={setError} 134 - setServiceUrl={setServiceUrl} 135 - onPressBack={gotoForm(Forms.Login)} 136 - onEmailSent={gotoForm(Forms.SetNewPassword)} 137 - /> 138 - </LoggedOutLayout> 139 - ) : undefined} 140 - {currentForm === Forms.SetNewPassword ? ( 141 - <LoggedOutLayout 142 - leadin="" 143 - title={_(msg`Forgot Password`)} 144 - description={_(msg`Let's get your password reset!`)}> 145 - <SetNewPasswordForm 146 - error={error} 147 - serviceUrl={serviceUrl} 148 - setError={setError} 149 - onPressBack={gotoForm(Forms.ForgotPassword)} 150 - onPasswordSet={gotoForm(Forms.PasswordUpdated)} 151 - /> 152 - </LoggedOutLayout> 153 - ) : undefined} 154 - {currentForm === Forms.PasswordUpdated ? ( 155 - <LoggedOutLayout 156 - leadin="" 157 - title={_(msg`Password updated`)} 158 - description={_(msg`You can now sign in with your new password.`)}> 159 - <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> 160 - </LoggedOutLayout> 161 - ) : undefined} 162 - </KeyboardAvoidingView> 163 - ) 164 - }
-301
src/view/com/auth/login/LoginForm.tsx
··· 1 - import React, {useState, useRef} from 'react' 2 - import { 3 - ActivityIndicator, 4 - Keyboard, 5 - TextInput, 6 - TouchableOpacity, 7 - View, 8 - } from 'react-native' 9 - import { 10 - FontAwesomeIcon, 11 - FontAwesomeIconStyle, 12 - } from '@fortawesome/react-native-fontawesome' 13 - import {ComAtprotoServerDescribeServer} from '@atproto/api' 14 - import {useAnalytics} from 'lib/analytics/analytics' 15 - import {Text} from '../../util/text/Text' 16 - import {s} from 'lib/styles' 17 - import {createFullHandle} from 'lib/strings/handles' 18 - import {toNiceDomain} from 'lib/strings/url-helpers' 19 - import {isNetworkError} from 'lib/strings/errors' 20 - import {usePalette} from 'lib/hooks/usePalette' 21 - import {useTheme} from 'lib/ThemeContext' 22 - import {useSessionApi} from '#/state/session' 23 - import {cleanError} from 'lib/strings/errors' 24 - import {logger} from '#/logger' 25 - import {Trans, msg} from '@lingui/macro' 26 - import {styles} from './styles' 27 - import {useLingui} from '@lingui/react' 28 - import {useDialogControl} from '#/components/Dialog' 29 - 30 - import {ServerInputDialog} from '../server-input' 31 - 32 - type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema 33 - 34 - export const LoginForm = ({ 35 - error, 36 - serviceUrl, 37 - serviceDescription, 38 - initialHandle, 39 - setError, 40 - setServiceUrl, 41 - onPressRetryConnect, 42 - onPressBack, 43 - onPressForgotPassword, 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 - }) => { 55 - const {track} = useAnalytics() 56 - const pal = usePalette('default') 57 - const theme = useTheme() 58 - const [isProcessing, setIsProcessing] = useState<boolean>(false) 59 - const [identifier, setIdentifier] = useState<string>(initialHandle) 60 - const [password, setPassword] = useState<string>('') 61 - const passwordInputRef = useRef<TextInput>(null) 62 - const {_} = useLingui() 63 - const {login} = useSessionApi() 64 - const serverInputControl = useDialogControl() 65 - 66 - const onPressSelectService = () => { 67 - serverInputControl.open() 68 - Keyboard.dismiss() 69 - track('Signin:PressedSelectService') 70 - } 71 - 72 - const onPressNext = async () => { 73 - Keyboard.dismiss() 74 - setError('') 75 - setIsProcessing(true) 76 - 77 - try { 78 - // try to guess the handle if the user just gave their own username 79 - let fullIdent = identifier 80 - if ( 81 - !identifier.includes('@') && // not an email 82 - !identifier.includes('.') && // not a domain 83 - serviceDescription && 84 - serviceDescription.availableUserDomains.length > 0 85 - ) { 86 - let matched = false 87 - for (const domain of serviceDescription.availableUserDomains) { 88 - if (fullIdent.endsWith(domain)) { 89 - matched = true 90 - } 91 - } 92 - if (!matched) { 93 - fullIdent = createFullHandle( 94 - identifier, 95 - serviceDescription.availableUserDomains[0], 96 - ) 97 - } 98 - } 99 - 100 - // TODO remove double login 101 - await login( 102 - { 103 - service: serviceUrl, 104 - identifier: fullIdent, 105 - password, 106 - }, 107 - 'LoginForm', 108 - ) 109 - } catch (e: any) { 110 - const errMsg = e.toString() 111 - setIsProcessing(false) 112 - if (errMsg.includes('Authentication Required')) { 113 - logger.debug('Failed to login due to invalid credentials', { 114 - error: errMsg, 115 - }) 116 - setError(_(msg`Invalid username or password`)) 117 - } else if (isNetworkError(e)) { 118 - logger.warn('Failed to login due to network error', {error: errMsg}) 119 - setError( 120 - _( 121 - msg`Unable to contact your service. Please check your Internet connection.`, 122 - ), 123 - ) 124 - } else { 125 - logger.warn('Failed to login', {error: errMsg}) 126 - setError(cleanError(errMsg)) 127 - } 128 - } 129 - } 130 - 131 - const isReady = !!serviceDescription && !!identifier && !!password 132 - return ( 133 - <View testID="loginForm"> 134 - <ServerInputDialog 135 - control={serverInputControl} 136 - onSelect={setServiceUrl} 137 - /> 138 - 139 - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> 140 - <Trans>Sign into</Trans> 141 - </Text> 142 - <View style={[pal.borderDark, styles.group]}> 143 - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> 144 - <FontAwesomeIcon 145 - icon="globe" 146 - style={[pal.textLight, styles.groupContentIcon]} 147 - /> 148 - <TouchableOpacity 149 - testID="loginSelectServiceButton" 150 - style={styles.textBtn} 151 - onPress={onPressSelectService} 152 - accessibilityRole="button" 153 - accessibilityLabel={_(msg`Select service`)} 154 - accessibilityHint={_(msg`Sets server for the Bluesky client`)}> 155 - <Text type="xl" style={[pal.text, styles.textBtnLabel]}> 156 - {toNiceDomain(serviceUrl)} 157 - </Text> 158 - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> 159 - <FontAwesomeIcon 160 - icon="pen" 161 - size={12} 162 - style={pal.textLight as FontAwesomeIconStyle} 163 - /> 164 - </View> 165 - </TouchableOpacity> 166 - </View> 167 - </View> 168 - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> 169 - <Trans>Account</Trans> 170 - </Text> 171 - <View style={[pal.borderDark, styles.group]}> 172 - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> 173 - <FontAwesomeIcon 174 - icon="at" 175 - style={[pal.textLight, styles.groupContentIcon]} 176 - /> 177 - <TextInput 178 - testID="loginUsernameInput" 179 - style={[pal.text, styles.textInput]} 180 - placeholder={_(msg`Username or email address`)} 181 - placeholderTextColor={pal.colors.textLight} 182 - autoCapitalize="none" 183 - autoFocus 184 - autoCorrect={false} 185 - autoComplete="username" 186 - returnKeyType="next" 187 - textContentType="username" 188 - onSubmitEditing={() => { 189 - passwordInputRef.current?.focus() 190 - }} 191 - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field 192 - keyboardAppearance={theme.colorScheme} 193 - value={identifier} 194 - onChangeText={str => 195 - setIdentifier((str || '').toLowerCase().trim()) 196 - } 197 - editable={!isProcessing} 198 - accessibilityLabel={_(msg`Username or email address`)} 199 - accessibilityHint={_( 200 - msg`Input the username or email address you used at signup`, 201 - )} 202 - /> 203 - </View> 204 - <View style={[pal.borderDark, styles.groupContent]}> 205 - <FontAwesomeIcon 206 - icon="lock" 207 - style={[pal.textLight, styles.groupContentIcon]} 208 - /> 209 - <TextInput 210 - testID="loginPasswordInput" 211 - ref={passwordInputRef} 212 - style={[pal.text, styles.textInput]} 213 - placeholder={_(msg`Password`)} 214 - placeholderTextColor={pal.colors.textLight} 215 - autoCapitalize="none" 216 - autoCorrect={false} 217 - autoComplete="password" 218 - returnKeyType="done" 219 - enablesReturnKeyAutomatically={true} 220 - keyboardAppearance={theme.colorScheme} 221 - secureTextEntry={true} 222 - textContentType="password" 223 - clearButtonMode="while-editing" 224 - value={password} 225 - onChangeText={setPassword} 226 - onSubmitEditing={onPressNext} 227 - blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing 228 - editable={!isProcessing} 229 - accessibilityLabel={_(msg`Password`)} 230 - accessibilityHint={ 231 - identifier === '' 232 - ? _(msg`Input your password`) 233 - : _(msg`Input the password tied to ${identifier}`) 234 - } 235 - /> 236 - <TouchableOpacity 237 - testID="forgotPasswordButton" 238 - style={styles.textInputInnerBtn} 239 - onPress={onPressForgotPassword} 240 - accessibilityRole="button" 241 - accessibilityLabel={_(msg`Forgot password`)} 242 - accessibilityHint={_(msg`Opens password reset form`)}> 243 - <Text style={pal.link}> 244 - <Trans>Forgot</Trans> 245 - </Text> 246 - </TouchableOpacity> 247 - </View> 248 - </View> 249 - {error ? ( 250 - <View style={styles.error}> 251 - <View style={styles.errorIcon}> 252 - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> 253 - </View> 254 - <View style={s.flex1}> 255 - <Text style={[s.white, s.bold]}>{error}</Text> 256 - </View> 257 - </View> 258 - ) : undefined} 259 - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> 260 - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> 261 - <Text type="xl" style={[pal.link, s.pl5]}> 262 - <Trans>Back</Trans> 263 - </Text> 264 - </TouchableOpacity> 265 - <View style={s.flex1} /> 266 - {!serviceDescription && error ? ( 267 - <TouchableOpacity 268 - testID="loginRetryButton" 269 - onPress={onPressRetryConnect} 270 - accessibilityRole="button" 271 - accessibilityLabel={_(msg`Retry`)} 272 - accessibilityHint={_(msg`Retries login`)}> 273 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 274 - <Trans>Retry</Trans> 275 - </Text> 276 - </TouchableOpacity> 277 - ) : !serviceDescription ? ( 278 - <> 279 - <ActivityIndicator /> 280 - <Text type="xl" style={[pal.textLight, s.pl10]}> 281 - <Trans>Connecting...</Trans> 282 - </Text> 283 - </> 284 - ) : isProcessing ? ( 285 - <ActivityIndicator /> 286 - ) : isReady ? ( 287 - <TouchableOpacity 288 - testID="loginNextButton" 289 - onPress={onPressNext} 290 - accessibilityRole="button" 291 - accessibilityLabel={_(msg`Go to next`)} 292 - accessibilityHint={_(msg`Navigates to the next screen`)}> 293 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 294 - <Trans>Next</Trans> 295 - </Text> 296 - </TouchableOpacity> 297 - ) : undefined} 298 - </View> 299 - </View> 300 - ) 301 - }
-48
src/view/com/auth/login/PasswordUpdatedForm.tsx
··· 1 - import React, {useEffect} from 'react' 2 - import {TouchableOpacity, View} from 'react-native' 3 - import {useAnalytics} from 'lib/analytics/analytics' 4 - import {Text} from '../../util/text/Text' 5 - import {s} from 'lib/styles' 6 - import {usePalette} from 'lib/hooks/usePalette' 7 - import {styles} from './styles' 8 - import {msg, Trans} from '@lingui/macro' 9 - import {useLingui} from '@lingui/react' 10 - 11 - export const PasswordUpdatedForm = ({ 12 - onPressNext, 13 - }: { 14 - onPressNext: () => void 15 - }) => { 16 - const {screen} = useAnalytics() 17 - const pal = usePalette('default') 18 - const {_} = useLingui() 19 - 20 - useEffect(() => { 21 - screen('Signin:PasswordUpdatedForm') 22 - }, [screen]) 23 - 24 - return ( 25 - <> 26 - <View> 27 - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> 28 - <Trans>Password updated!</Trans> 29 - </Text> 30 - <Text type="lg" style={[pal.text, styles.instructions]}> 31 - <Trans>You can now sign in with your new password.</Trans> 32 - </Text> 33 - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> 34 - <View style={s.flex1} /> 35 - <TouchableOpacity 36 - onPress={onPressNext} 37 - accessibilityRole="button" 38 - accessibilityLabel={_(msg`Close alert`)} 39 - accessibilityHint={_(msg`Closes password update alert`)}> 40 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 41 - <Trans>Okay</Trans> 42 - </Text> 43 - </TouchableOpacity> 44 - </View> 45 - </View> 46 - </> 47 - ) 48 - }
-211
src/view/com/auth/login/SetNewPasswordForm.tsx
··· 1 - import React, {useState, useEffect} from 'react' 2 - import { 3 - ActivityIndicator, 4 - TextInput, 5 - TouchableOpacity, 6 - View, 7 - } from 'react-native' 8 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 - import {BskyAgent} from '@atproto/api' 10 - import {useAnalytics} from 'lib/analytics/analytics' 11 - import {Text} from '../../util/text/Text' 12 - import {s} from 'lib/styles' 13 - import {isNetworkError} from 'lib/strings/errors' 14 - import {usePalette} from 'lib/hooks/usePalette' 15 - import {useTheme} from 'lib/ThemeContext' 16 - import {cleanError} from 'lib/strings/errors' 17 - import {checkAndFormatResetCode} from 'lib/strings/password' 18 - import {logger} from '#/logger' 19 - import {styles} from './styles' 20 - import {Trans, msg} from '@lingui/macro' 21 - import {useLingui} from '@lingui/react' 22 - 23 - export const SetNewPasswordForm = ({ 24 - error, 25 - serviceUrl, 26 - setError, 27 - onPressBack, 28 - onPasswordSet, 29 - }: { 30 - error: string 31 - serviceUrl: string 32 - setError: (v: string) => void 33 - onPressBack: () => void 34 - onPasswordSet: () => void 35 - }) => { 36 - const pal = usePalette('default') 37 - const theme = useTheme() 38 - const {screen} = useAnalytics() 39 - const {_} = useLingui() 40 - 41 - useEffect(() => { 42 - screen('Signin:SetNewPasswordForm') 43 - }, [screen]) 44 - 45 - const [isProcessing, setIsProcessing] = useState<boolean>(false) 46 - const [resetCode, setResetCode] = useState<string>('') 47 - const [password, setPassword] = useState<string>('') 48 - 49 - const onPressNext = async () => { 50 - // Check that the code is correct. We do this again just incase the user enters the code after their pw and we 51 - // don't get to call onBlur first 52 - const formattedCode = checkAndFormatResetCode(resetCode) 53 - // TODO Better password strength check 54 - if (!formattedCode || !password) { 55 - setError( 56 - _( 57 - msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 58 - ), 59 - ) 60 - return 61 - } 62 - 63 - setError('') 64 - setIsProcessing(true) 65 - 66 - try { 67 - const agent = new BskyAgent({service: serviceUrl}) 68 - await agent.com.atproto.server.resetPassword({ 69 - token: formattedCode, 70 - password, 71 - }) 72 - onPasswordSet() 73 - } catch (e: any) { 74 - const errMsg = e.toString() 75 - logger.warn('Failed to set new password', {error: e}) 76 - setIsProcessing(false) 77 - if (isNetworkError(e)) { 78 - setError( 79 - 'Unable to contact your service. Please check your Internet connection.', 80 - ) 81 - } else { 82 - setError(cleanError(errMsg)) 83 - } 84 - } 85 - } 86 - 87 - const onBlur = () => { 88 - const formattedCode = checkAndFormatResetCode(resetCode) 89 - if (!formattedCode) { 90 - setError( 91 - _( 92 - msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`, 93 - ), 94 - ) 95 - return 96 - } 97 - setResetCode(formattedCode) 98 - } 99 - 100 - return ( 101 - <> 102 - <View> 103 - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> 104 - <Trans>Set new password</Trans> 105 - </Text> 106 - <Text type="lg" style={[pal.text, styles.instructions]}> 107 - <Trans> 108 - You will receive an email with a "reset code." Enter that code here, 109 - then enter your new password. 110 - </Trans> 111 - </Text> 112 - <View 113 - testID="newPasswordView" 114 - style={[pal.view, pal.borderDark, styles.group]}> 115 - <View 116 - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> 117 - <FontAwesomeIcon 118 - icon="ticket" 119 - style={[pal.textLight, styles.groupContentIcon]} 120 - /> 121 - <TextInput 122 - testID="resetCodeInput" 123 - style={[pal.text, styles.textInput]} 124 - placeholder={_(msg`Reset code`)} 125 - placeholderTextColor={pal.colors.textLight} 126 - autoCapitalize="none" 127 - autoCorrect={false} 128 - keyboardAppearance={theme.colorScheme} 129 - autoComplete="off" 130 - value={resetCode} 131 - onChangeText={setResetCode} 132 - onFocus={() => setError('')} 133 - onBlur={onBlur} 134 - editable={!isProcessing} 135 - accessible={true} 136 - accessibilityLabel={_(msg`Reset code`)} 137 - accessibilityHint={_( 138 - msg`Input code sent to your email for password reset`, 139 - )} 140 - /> 141 - </View> 142 - <View style={[pal.borderDark, styles.groupContent]}> 143 - <FontAwesomeIcon 144 - icon="lock" 145 - style={[pal.textLight, styles.groupContentIcon]} 146 - /> 147 - <TextInput 148 - testID="newPasswordInput" 149 - style={[pal.text, styles.textInput]} 150 - placeholder={_(msg`New password`)} 151 - placeholderTextColor={pal.colors.textLight} 152 - autoCapitalize="none" 153 - autoCorrect={false} 154 - autoComplete="new-password" 155 - keyboardAppearance={theme.colorScheme} 156 - secureTextEntry 157 - value={password} 158 - onChangeText={setPassword} 159 - editable={!isProcessing} 160 - accessible={true} 161 - accessibilityLabel={_(msg`Password`)} 162 - accessibilityHint={_(msg`Input new password`)} 163 - /> 164 - </View> 165 - </View> 166 - {error ? ( 167 - <View style={styles.error}> 168 - <View style={styles.errorIcon}> 169 - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> 170 - </View> 171 - <View style={s.flex1}> 172 - <Text style={[s.white, s.bold]}>{error}</Text> 173 - </View> 174 - </View> 175 - ) : undefined} 176 - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> 177 - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> 178 - <Text type="xl" style={[pal.link, s.pl5]}> 179 - <Trans>Back</Trans> 180 - </Text> 181 - </TouchableOpacity> 182 - <View style={s.flex1} /> 183 - {isProcessing ? ( 184 - <ActivityIndicator /> 185 - ) : !resetCode || !password ? ( 186 - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> 187 - <Trans>Next</Trans> 188 - </Text> 189 - ) : ( 190 - <TouchableOpacity 191 - testID="setNewPasswordButton" 192 - // Check the code before running the callback 193 - onPress={onPressNext} 194 - accessibilityRole="button" 195 - accessibilityLabel={_(msg`Go to next`)} 196 - accessibilityHint={_(msg`Navigates to the next screen`)}> 197 - <Text type="xl-bold" style={[pal.link, s.pr5]}> 198 - <Trans>Next</Trans> 199 - </Text> 200 - </TouchableOpacity> 201 - )} 202 - {isProcessing ? ( 203 - <Text type="xl" style={[pal.textLight, s.pl10]}> 204 - <Trans>Updating...</Trans> 205 - </Text> 206 - ) : undefined} 207 - </View> 208 - </View> 209 - </> 210 - ) 211 - }
-118
src/view/com/auth/login/styles.ts
··· 1 - import {StyleSheet} from 'react-native' 2 - import {colors} from 'lib/styles' 3 - import {isWeb} from '#/platform/detection' 4 - 5 - export const styles = StyleSheet.create({ 6 - screenTitle: { 7 - marginBottom: 10, 8 - marginHorizontal: 20, 9 - }, 10 - instructions: { 11 - marginBottom: 20, 12 - marginHorizontal: 20, 13 - }, 14 - group: { 15 - borderWidth: 1, 16 - borderRadius: 10, 17 - marginBottom: 20, 18 - marginHorizontal: 20, 19 - }, 20 - groupLabel: { 21 - paddingHorizontal: 20, 22 - paddingBottom: 5, 23 - }, 24 - groupContent: { 25 - borderTopWidth: 1, 26 - flexDirection: 'row', 27 - alignItems: 'center', 28 - }, 29 - noTopBorder: { 30 - borderTopWidth: 0, 31 - }, 32 - groupContentIcon: { 33 - marginLeft: 10, 34 - }, 35 - account: { 36 - borderTopWidth: 1, 37 - paddingHorizontal: 20, 38 - paddingVertical: 4, 39 - }, 40 - accountLast: { 41 - borderBottomWidth: 1, 42 - marginBottom: 20, 43 - paddingVertical: 8, 44 - }, 45 - textInput: { 46 - flex: 1, 47 - width: '100%', 48 - paddingVertical: 10, 49 - paddingHorizontal: 12, 50 - fontSize: 17, 51 - letterSpacing: 0.25, 52 - fontWeight: '400', 53 - borderRadius: 10, 54 - }, 55 - textInputInnerBtn: { 56 - flexDirection: 'row', 57 - alignItems: 'center', 58 - paddingVertical: 6, 59 - paddingHorizontal: 8, 60 - marginHorizontal: 6, 61 - }, 62 - textBtn: { 63 - flexDirection: 'row', 64 - flex: 1, 65 - alignItems: 'center', 66 - }, 67 - textBtnLabel: { 68 - flex: 1, 69 - paddingVertical: 10, 70 - paddingHorizontal: 12, 71 - }, 72 - textBtnFakeInnerBtn: { 73 - flexDirection: 'row', 74 - alignItems: 'center', 75 - borderRadius: 6, 76 - paddingVertical: 6, 77 - paddingHorizontal: 8, 78 - marginHorizontal: 6, 79 - }, 80 - accountText: { 81 - flex: 1, 82 - flexDirection: 'row', 83 - alignItems: 'baseline', 84 - paddingVertical: 10, 85 - }, 86 - accountTextOther: { 87 - paddingLeft: 12, 88 - }, 89 - error: { 90 - backgroundColor: colors.red4, 91 - flexDirection: 'row', 92 - alignItems: 'center', 93 - marginTop: -5, 94 - marginHorizontal: 20, 95 - marginBottom: 15, 96 - borderRadius: 8, 97 - paddingHorizontal: 8, 98 - paddingVertical: 8, 99 - }, 100 - errorIcon: { 101 - borderWidth: 1, 102 - borderColor: colors.white, 103 - color: colors.white, 104 - borderRadius: 30, 105 - width: 16, 106 - height: 16, 107 - alignItems: 'center', 108 - justifyContent: 'center', 109 - marginRight: 5, 110 - }, 111 - dimmed: {opacity: 0.5}, 112 - 113 - maxHeight: { 114 - // @ts-ignore web only -prf 115 - maxHeight: isWeb ? '100vh' : undefined, 116 - height: !isWeb ? '100%' : undefined, 117 - }, 118 - })