Bluesky app fork with some witchin' additions 💫

Use ALF for signup flow, improve a11y of signup (#3151)

* Use ALF for signup flow, improve a11y of signup

* adjust padding

* rm log

* org imports

* clarify allowance of hyphens

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix a few accessibility items

* Standardise date input across platforms (#3223)

* make the date input consistent across platforms

* integrate into new signup form

* rm log

* add transitions

* show correct # of steps

* use `FormError`

* animate buttons

* use `ScreenTransition`

* fix android text overflow via flex -> flex_1

* change button color

* (android) make date input the same height as others

* fix deps

* fix deps

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by hailey.at

surfdude29
Samuel Newman
and committed by
GitHub
a1c4f197 b6903419

+1126 -871
+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/pencil_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21"><path fill="#000" fill-rule="evenodd" d="M13.586 1.5a2 2 0 0 1 2.828 0L19.5 4.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 5.086 21H1a1 1 0 0 1-1-1v-4.086A2 2 0 0 1 .586 14.5l13-13ZM15 2.914l-13 13V19h3.086l13-13L15 2.914ZM11 20a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
+18 -55
src/components/forms/DateField/index.android.tsx
··· 1 1 import React from 'react' 2 - import {View, Pressable} from 'react-native' 3 2 4 - import {useTheme, atoms} from '#/alf' 5 - import {Text} from '#/components/Typography' 6 - import {useInteractionState} from '#/components/hooks/useInteractionState' 3 + import {useTheme} from '#/alf' 7 4 import * as TextField from '#/components/forms/TextField' 8 - import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 9 - 10 5 import {DateFieldProps} from '#/components/forms/DateField/types' 11 - import { 12 - localizeDate, 13 - toSimpleDateString, 14 - } from '#/components/forms/DateField/utils' 6 + import {toSimpleDateString} from '#/components/forms/DateField/utils' 15 7 import DatePicker from 'react-native-date-picker' 16 8 import {isAndroid} from 'platform/detection' 9 + import {DateFieldButton} from './index.shared' 17 10 18 11 export * as utils from '#/components/forms/DateField/utils' 19 12 export const Label = TextField.Label ··· 24 17 label, 25 18 isInvalid, 26 19 testID, 20 + accessibilityHint, 27 21 }: DateFieldProps) { 28 22 const t = useTheme() 29 23 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 24 40 25 const onChangeInternal = React.useCallback( 41 26 (date: Date) => { ··· 47 32 [onChangeDate, setOpen], 48 33 ) 49 34 35 + const onPress = React.useCallback(() => { 36 + setOpen(true) 37 + }, []) 38 + 50 39 const onCancel = React.useCallback(() => { 51 40 setOpen(false) 52 41 }, []) 53 42 54 43 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> 44 + <> 45 + <DateFieldButton 46 + label={label} 47 + value={value} 48 + onPress={onPress} 49 + isInvalid={isInvalid} 50 + accessibilityHint={accessibilityHint} 51 + /> 89 52 90 53 {open && ( 91 54 <DatePicker ··· 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 {View, Pressable} from 'react-native' 3 + 4 + import {atoms as a, android, useTheme, web} from '#/alf' 5 + import {Text} from '#/components/Typography' 6 + import {useInteractionState} from '#/components/hooks/useInteractionState' 7 + import * as TextField from '#/components/forms/TextField' 8 + import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 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 + }
+47 -12
src/components/forms/DateField/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 4 - import {useTheme, atoms} from '#/alf' 4 + import {useTheme, atoms as a} from '#/alf' 5 5 import * as TextField from '#/components/forms/TextField' 6 6 import {toSimpleDateString} from '#/components/forms/DateField/utils' 7 7 import {DateFieldProps} from '#/components/forms/DateField/types' 8 8 import DatePicker from 'react-native-date-picker' 9 + import * as Dialog from '#/components/Dialog' 10 + import {DateFieldButton} from './index.shared' 11 + import {Button, ButtonText} from '#/components/Button' 12 + import {Trans, msg} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 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 + theme={t.name === 'light' ? 'light' : 'dark'} 63 + date={new Date(value)} 64 + onDateChange={onChangeInternal} 65 + mode="date" 66 + testID={`${testID}-datepicker`} 67 + aria-label={label} 68 + accessibilityLabel={label} 69 + accessibilityHint={accessibilityHint} 70 + /> 71 + </View> 72 + <Button 73 + label={_(msg`Done`)} 74 + onPress={() => control.close()} 75 + size="medium" 76 + color="primary" 77 + variant="solid"> 78 + <ButtonText> 79 + <Trans>Done</Trans> 80 + </ButtonText> 81 + </Button> 82 + </View> 83 + </Dialog.Inner> 84 + </Dialog.Outer> 85 + </> 51 86 ) 52 87 }
+4
src/components/forms/DateField/index.web.tsx
··· 2 2 import {TextInput, TextInputProps, StyleSheet} from 'react-native' 3 3 // @ts-ignore 4 4 import {unstable_createElement} from 'react-native-web' 5 + import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 5 6 6 7 import * as TextField from '#/components/forms/TextField' 7 8 import {toSimpleDateString} from '#/components/forms/DateField/utils' ··· 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 }
+3 -3
src/components/forms/TextField.tsx
··· 126 126 127 127 export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { 128 128 label: string 129 - value: string 130 - onChangeText: (value: string) => void 129 + value?: string 130 + onChangeText?: (value: string) => void 131 131 isInvalid?: boolean 132 132 inputRef?: React.RefObject<TextInput> 133 133 } ··· 277 277 <Comp 278 278 size="md" 279 279 style={[ 280 - {color: t.palette.contrast_500, pointerEvents: 'none'}, 280 + {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0}, 281 281 ctx.hovered ? hover : {}, 282 282 ctx.focused ? focus : {}, 283 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 + })
+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 }
+94
src/screens/Signup/StepCaptcha.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 + import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' 7 + import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView' 8 + import {createFullHandle} from 'lib/strings/handles' 9 + import {isWeb} from 'platform/detection' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {FormError} from '#/components/forms/FormError' 12 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 13 + 14 + const CAPTCHA_PATH = '/gate/signup' 15 + 16 + export function StepCaptcha() { 17 + const {_} = useLingui() 18 + const theme = useTheme() 19 + const {state, dispatch} = useSignupContext() 20 + const submit = useSubmitSignup({state, dispatch}) 21 + 22 + const [completed, setCompleted] = React.useState(false) 23 + 24 + const stateParam = React.useMemo(() => nanoid(15), []) 25 + const url = React.useMemo(() => { 26 + const newUrl = new URL(state.serviceUrl) 27 + newUrl.pathname = CAPTCHA_PATH 28 + newUrl.searchParams.set( 29 + 'handle', 30 + createFullHandle(state.handle, state.userDomain), 31 + ) 32 + newUrl.searchParams.set('state', stateParam) 33 + newUrl.searchParams.set('colorScheme', theme.name) 34 + 35 + return newUrl.href 36 + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) 37 + 38 + const onSuccess = React.useCallback( 39 + (code: string) => { 40 + setCompleted(true) 41 + submit(code) 42 + }, 43 + [submit], 44 + ) 45 + 46 + const onError = React.useCallback(() => { 47 + dispatch({ 48 + type: 'setError', 49 + value: _(msg`Error receiving captcha response.`), 50 + }) 51 + }, [_, dispatch]) 52 + 53 + return ( 54 + <ScreenTransition> 55 + <View style={[a.gap_lg]}> 56 + <View style={[styles.container, completed && styles.center]}> 57 + {!completed ? ( 58 + <CaptchaWebView 59 + url={url} 60 + stateParam={stateParam} 61 + state={state} 62 + onSuccess={onSuccess} 63 + onError={onError} 64 + /> 65 + ) : ( 66 + <ActivityIndicator size="large" /> 67 + )} 68 + </View> 69 + <FormError error={state.error} /> 70 + </View> 71 + </ScreenTransition> 72 + ) 73 + } 74 + 75 + const styles = StyleSheet.create({ 76 + error: { 77 + borderRadius: 6, 78 + marginTop: 10, 79 + }, 80 + // @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. 81 + touchable: { 82 + ...(isWeb && {cursor: 'pointer'}), 83 + }, 84 + container: { 85 + minHeight: 500, 86 + width: '100%', 87 + paddingBottom: 20, 88 + overflow: 'hidden', 89 + }, 90 + center: { 91 + alignItems: 'center', 92 + justifyContent: 'center', 93 + }, 94 + })
+134
src/screens/Signup/StepHandle.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useFocusEffect} from '@react-navigation/native' 4 + import {useLingui} from '@lingui/react' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 7 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 8 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 9 + import * as TextField from '#/components/forms/TextField' 10 + import {useSignupContext} from '#/screens/Signup/state' 11 + import {Text} from '#/components/Typography' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import { 14 + createFullHandle, 15 + IsValidHandle, 16 + validateHandle, 17 + } from 'lib/strings/handles' 18 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 19 + 20 + export function StepHandle() { 21 + const {_} = useLingui() 22 + const t = useTheme() 23 + const {state, dispatch} = useSignupContext() 24 + 25 + const [validCheck, setValidCheck] = React.useState<IsValidHandle>({ 26 + handleChars: false, 27 + hyphenStartOrEnd: false, 28 + frontLength: false, 29 + totalLength: true, 30 + overall: false, 31 + }) 32 + 33 + useFocusEffect( 34 + React.useCallback(() => { 35 + console.log('run') 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 + }
+145
src/screens/Signup/StepInfo.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 {atoms as a} from '#/alf' 6 + import * as TextField from '#/components/forms/TextField' 7 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 8 + import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock' 9 + import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket' 10 + import {is13, is18, useSignupContext} from '#/screens/Signup/state' 11 + import * as DateField from '#/components/forms/DateField' 12 + import {logger} from '#/logger' 13 + import {Loader} from '#/components/Loader' 14 + import {Policies} from 'view/com/auth/create/Policies' 15 + import {HostingProvider} from '#/components/forms/HostingProvider' 16 + import {FormError} from '#/components/forms/FormError' 17 + import {ScreenTransition} from '#/screens/Login/ScreenTransition' 18 + 19 + function sanitizeDate(date: Date): Date { 20 + if (!date || date.toString() === 'Invalid Date') { 21 + logger.error(`Create account: handled invalid date for birthDate`, { 22 + hasDate: !!date, 23 + }) 24 + return new Date() 25 + } 26 + return date 27 + } 28 + 29 + export function StepInfo() { 30 + const {_} = useLingui() 31 + const {state, dispatch} = useSignupContext() 32 + 33 + return ( 34 + <ScreenTransition> 35 + <View style={[a.gap_lg]}> 36 + <FormError error={state.error} /> 37 + <View> 38 + <TextField.Label> 39 + <Trans>Hosting provider</Trans> 40 + </TextField.Label> 41 + <HostingProvider 42 + serviceUrl={state.serviceUrl} 43 + onSelectServiceUrl={v => 44 + dispatch({type: 'setServiceUrl', value: v}) 45 + } 46 + /> 47 + </View> 48 + {state.isLoading ? ( 49 + <View style={[a.align_center]}> 50 + <Loader size="xl" /> 51 + </View> 52 + ) : state.serviceDescription ? ( 53 + <> 54 + {state.serviceDescription.inviteCodeRequired && ( 55 + <View> 56 + <TextField.Label> 57 + <Trans>Invite code</Trans> 58 + </TextField.Label> 59 + <TextField.Root> 60 + <TextField.Icon icon={Ticket} /> 61 + <TextField.Input 62 + onChangeText={value => { 63 + dispatch({ 64 + type: 'setInviteCode', 65 + value: value.trim(), 66 + }) 67 + }} 68 + label={_(msg`Required for this provider`)} 69 + defaultValue={state.inviteCode} 70 + autoCapitalize="none" 71 + autoComplete="email" 72 + keyboardType="email-address" 73 + /> 74 + </TextField.Root> 75 + </View> 76 + )} 77 + <View> 78 + <TextField.Label> 79 + <Trans>Email</Trans> 80 + </TextField.Label> 81 + <TextField.Root> 82 + <TextField.Icon icon={Envelope} /> 83 + <TextField.Input 84 + onChangeText={value => { 85 + dispatch({ 86 + type: 'setEmail', 87 + value: value.trim(), 88 + }) 89 + }} 90 + label={_(msg`Enter your email address`)} 91 + defaultValue={state.email} 92 + autoCapitalize="none" 93 + autoComplete="email" 94 + keyboardType="email-address" 95 + /> 96 + </TextField.Root> 97 + </View> 98 + <View> 99 + <TextField.Label> 100 + <Trans>Password</Trans> 101 + </TextField.Label> 102 + <TextField.Root> 103 + <TextField.Icon icon={Lock} /> 104 + <TextField.Input 105 + onChangeText={value => { 106 + dispatch({ 107 + type: 'setPassword', 108 + value, 109 + }) 110 + }} 111 + label={_(msg`Choose your password`)} 112 + defaultValue={state.password} 113 + secureTextEntry 114 + autoComplete="new-password" 115 + /> 116 + </TextField.Root> 117 + </View> 118 + <View> 119 + <DateField.Label> 120 + <Trans>Your birth date</Trans> 121 + </DateField.Label> 122 + <DateField.DateField 123 + testID="date" 124 + value={DateField.utils.toSimpleDateString(state.dateOfBirth)} 125 + onChangeDate={date => { 126 + dispatch({ 127 + type: 'setDateOfBirth', 128 + value: sanitizeDate(new Date(date)), 129 + }) 130 + }} 131 + label={_(msg`Date of birth`)} 132 + accessibilityHint={_(msg`Select your date of birth`)} 133 + /> 134 + </View> 135 + <Policies 136 + serviceDescription={state.serviceDescription} 137 + needsGuardian={!is18(state.dateOfBirth)} 138 + under13={!is13(state.dateOfBirth)} 139 + /> 140 + </> 141 + ) : undefined} 142 + </View> 143 + </ScreenTransition> 144 + ) 145 + }
+225
src/screens/Signup/index.tsx
··· 1 + import React from 'react' 2 + import {ScrollView, View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + import { 6 + initialState, 7 + reducer, 8 + SignupContext, 9 + SignupStep, 10 + useSubmitSignup, 11 + } from '#/screens/Signup/state' 12 + import {StepInfo} from '#/screens/Signup/StepInfo' 13 + import {StepHandle} from '#/screens/Signup/StepHandle' 14 + import {StepCaptcha} from '#/screens/Signup/StepCaptcha' 15 + import {atoms as a, useTheme} from '#/alf' 16 + import {Button, ButtonText} from '#/components/Button' 17 + import {Text} from '#/components/Typography' 18 + import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' 19 + import {FEEDBACK_FORM_URL} from 'lib/constants' 20 + import {InlineLink} from '#/components/Link' 21 + import {useServiceQuery} from 'state/queries/service' 22 + import {getAgent} from 'state/session' 23 + import {createFullHandle} from 'lib/strings/handles' 24 + import {useAnalytics} from 'lib/analytics/analytics' 25 + 26 + export function Signup({onPressBack}: {onPressBack: () => void}) { 27 + const {_} = useLingui() 28 + const t = useTheme() 29 + const {screen} = useAnalytics() 30 + const [state, dispatch] = React.useReducer(reducer, initialState) 31 + const submit = useSubmitSignup({state, dispatch}) 32 + 33 + const { 34 + data: serviceInfo, 35 + isFetching, 36 + isError, 37 + refetch, 38 + } = useServiceQuery(state.serviceUrl) 39 + 40 + React.useEffect(() => { 41 + screen('CreateAccount') 42 + }, [screen]) 43 + 44 + React.useEffect(() => { 45 + if (isFetching) { 46 + dispatch({type: 'setIsLoading', value: true}) 47 + } else if (!isFetching) { 48 + dispatch({type: 'setIsLoading', value: false}) 49 + } 50 + }, [isFetching]) 51 + 52 + React.useEffect(() => { 53 + if (isError) { 54 + dispatch({type: 'setServiceDescription', value: undefined}) 55 + dispatch({ 56 + type: 'setError', 57 + value: _( 58 + msg`Unable to contact your service. Please check your Internet connection.`, 59 + ), 60 + }) 61 + } else if (serviceInfo) { 62 + dispatch({type: 'setServiceDescription', value: serviceInfo}) 63 + dispatch({type: 'setError', value: ''}) 64 + } 65 + }, [_, serviceInfo, isError]) 66 + 67 + const onNextPress = React.useCallback(async () => { 68 + if (state.activeStep === SignupStep.HANDLE) { 69 + try { 70 + dispatch({type: 'setIsLoading', value: true}) 71 + 72 + const res = await getAgent().resolveHandle({ 73 + handle: createFullHandle(state.handle, state.userDomain), 74 + }) 75 + 76 + if (res.data.did) { 77 + dispatch({ 78 + type: 'setError', 79 + value: _(msg`That handle is already taken.`), 80 + }) 81 + return 82 + } 83 + } catch (e) { 84 + // Don't have to handle 85 + } finally { 86 + dispatch({type: 'setIsLoading', value: false}) 87 + } 88 + } 89 + 90 + // phoneVerificationRequired is actually whether a captcha is required 91 + if ( 92 + state.activeStep === SignupStep.HANDLE && 93 + !state.serviceDescription?.phoneVerificationRequired 94 + ) { 95 + submit() 96 + return 97 + } 98 + 99 + dispatch({type: 'next'}) 100 + }, [ 101 + _, 102 + state.activeStep, 103 + state.handle, 104 + state.serviceDescription?.phoneVerificationRequired, 105 + state.userDomain, 106 + submit, 107 + ]) 108 + 109 + const onBackPress = React.useCallback(() => { 110 + if (state.activeStep !== SignupStep.INFO) { 111 + dispatch({type: 'prev'}) 112 + } else { 113 + onPressBack() 114 + } 115 + }, [onPressBack, state.activeStep]) 116 + 117 + return ( 118 + <SignupContext.Provider value={{state, dispatch}}> 119 + <LoggedOutLayout 120 + leadin="" 121 + title={_(msg`Create Account`)} 122 + description={_(msg`We're so excited to have you join us!`)}> 123 + <ScrollView 124 + testID="createAccount" 125 + keyboardShouldPersistTaps="handled" 126 + style={a.h_full} 127 + keyboardDismissMode="on-drag"> 128 + <View 129 + style={[ 130 + a.flex_1, 131 + a.px_xl, 132 + a.gap_3xl, 133 + a.pt_2xl, 134 + {paddingBottom: 100}, 135 + ]}> 136 + <View style={[a.gap_sm]}> 137 + <Text style={[a.text_lg, t.atoms.text_contrast_medium]}> 138 + <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '} 139 + {state.serviceDescription && 140 + !state.serviceDescription.phoneVerificationRequired 141 + ? '2' 142 + : '3'} 143 + </Text> 144 + <Text style={[a.text_3xl, a.font_bold]}> 145 + {state.activeStep === SignupStep.INFO ? ( 146 + <Trans>Your account</Trans> 147 + ) : state.activeStep === SignupStep.HANDLE ? ( 148 + <Trans>Your user handle</Trans> 149 + ) : ( 150 + <Trans>Complete the challenge</Trans> 151 + )} 152 + </Text> 153 + </View> 154 + <View> 155 + {state.activeStep === SignupStep.INFO ? ( 156 + <StepInfo /> 157 + ) : state.activeStep === SignupStep.HANDLE ? ( 158 + <StepHandle /> 159 + ) : ( 160 + <StepCaptcha /> 161 + )} 162 + </View> 163 + 164 + <View style={[a.flex_row, a.justify_between]}> 165 + <Button 166 + label="Back" 167 + variant="solid" 168 + color="secondary" 169 + size="small" 170 + onPress={onBackPress}> 171 + Back 172 + </Button> 173 + {state.activeStep !== SignupStep.CAPTCHA && ( 174 + <> 175 + {isError ? ( 176 + <Button 177 + label="Retry" 178 + variant="solid" 179 + color="primary" 180 + size="small" 181 + disabled={state.isLoading} 182 + onPress={() => refetch()}> 183 + Retry 184 + </Button> 185 + ) : ( 186 + <Button 187 + label="Next" 188 + variant="solid" 189 + color={ 190 + !state.canNext || state.isLoading 191 + ? 'secondary' 192 + : 'primary' 193 + } 194 + size="small" 195 + disabled={!state.canNext || state.isLoading} 196 + onPress={onNextPress}> 197 + <ButtonText>Next</ButtonText> 198 + </Button> 199 + )} 200 + </> 201 + )} 202 + </View> 203 + <View 204 + style={[ 205 + a.w_full, 206 + a.py_lg, 207 + a.px_md, 208 + a.rounded_sm, 209 + t.atoms.bg_contrast_25, 210 + ]}> 211 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 212 + <Trans>Having trouble?</Trans>{' '} 213 + <InlineLink 214 + style={[a.text_md]} 215 + to={FEEDBACK_FORM_URL({email: state.email})}> 216 + <Trans>Contact support</Trans> 217 + </InlineLink> 218 + </Text> 219 + </View> 220 + </View> 221 + </ScrollView> 222 + </LoggedOutLayout> 223 + </SignupContext.Provider> 224 + ) 225 + }
+320
src/screens/Signup/state.ts
··· 1 + import React, {useCallback} from 'react' 2 + import {LayoutAnimation} from 'react-native' 3 + import * as EmailValidator from 'email-validator' 4 + import {useLingui} from '@lingui/react' 5 + import {msg} from '@lingui/macro' 6 + import {cleanError} from 'lib/strings/errors' 7 + import { 8 + ComAtprotoServerCreateAccount, 9 + ComAtprotoServerDescribeServer, 10 + } from '@atproto/api' 11 + 12 + import {logger} from '#/logger' 13 + import {DEFAULT_SERVICE, IS_PROD_SERVICE} from 'lib/constants' 14 + import {createFullHandle, validateHandle} from 'lib/strings/handles' 15 + import {getAge} from 'lib/strings/time' 16 + import {useSessionApi} from 'state/session' 17 + import { 18 + DEFAULT_PROD_FEEDS, 19 + usePreferencesSetBirthDateMutation, 20 + useSetSaveFeedsMutation, 21 + } from 'state/queries/preferences' 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 + }
+2 -2
src/view/com/auth/LoggedOut.tsx
··· 7 7 8 8 import {isIOS, isNative} from '#/platform/detection' 9 9 import {Login} from '#/screens/Login' 10 - import {CreateAccount} from '#/view/com/auth/create/CreateAccount' 10 + import {Signup} from '#/screens/Signup' 11 11 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 12 12 import {s} from '#/lib/styles' 13 13 import {usePalette} from '#/lib/hooks/usePalette' ··· 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 }
+7 -7
src/view/com/auth/create/CaptchaWebView.tsx
··· 2 2 import {WebView, WebViewNavigation} from 'react-native-webview' 3 3 import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' 4 4 import {StyleSheet} from 'react-native' 5 - import {CreateAccountState} from 'view/com/auth/create/state' 5 + import {SignupState} from '#/screens/Signup/state' 6 6 7 7 const ALLOWED_HOSTS = [ 8 8 'bsky.social', ··· 17 17 export function CaptchaWebView({ 18 18 url, 19 19 stateParam, 20 - uiState, 20 + state, 21 21 onSuccess, 22 22 onError, 23 23 }: { 24 24 url: string 25 25 stateParam: string 26 - uiState?: CreateAccountState 26 + state?: SignupState 27 27 onSuccess: (code: string) => void 28 28 onError: () => void 29 29 }) { 30 30 const redirectHost = React.useMemo(() => { 31 - if (!uiState?.serviceUrl) return 'bsky.app' 31 + if (!state?.serviceUrl) return 'bsky.app' 32 32 33 - return uiState?.serviceUrl && 34 - new URL(uiState?.serviceUrl).host === 'staging.bsky.dev' 33 + return state?.serviceUrl && 34 + new URL(state?.serviceUrl).host === 'staging.bsky.dev' 35 35 ? 'staging.bsky.app' 36 36 : 'bsky.app' 37 - }, [uiState?.serviceUrl]) 37 + }, [state?.serviceUrl]) 38 38 39 39 const wasSuccessful = React.useRef(false) 40 40
-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 - })
+11 -3
src/view/com/auth/create/Policies.tsx
··· 1 1 import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 2 + import {Linking, StyleSheet, View} from 'react-native' 3 3 import { 4 4 FontAwesomeIcon, 5 5 FontAwesomeIconStyle, ··· 15 15 export const Policies = ({ 16 16 serviceDescription, 17 17 needsGuardian, 18 + under13, 18 19 }: { 19 20 serviceDescription: ServiceDescription 20 21 needsGuardian: boolean 22 + under13: boolean 21 23 }) => { 22 24 const pal = usePalette('default') 23 25 if (!serviceDescription) { ··· 53 55 href={tos} 54 56 text="Terms of Service" 55 57 style={[pal.link, s.underline]} 58 + onPress={() => Linking.openURL(tos)} 56 59 />, 57 60 ) 58 61 } ··· 63 66 href={pp} 64 67 text="Privacy Policy" 65 68 style={[pal.link, s.underline]} 69 + onPress={() => Linking.openURL(pp)} 66 70 />, 67 71 ) 68 72 } ··· 81 85 <Text style={pal.textLight}> 82 86 By creating an account you agree to the {els}. 83 87 </Text> 84 - {needsGuardian && ( 88 + {under13 ? ( 89 + <Text style={[pal.textLight, s.bold]}> 90 + You must be 13 years of age or older to sign up. 91 + </Text> 92 + ) : needsGuardian ? ( 85 93 <Text style={[pal.textLight, s.bold]}> 86 94 If you are not yet an adult according to the laws of your country, 87 95 your parent or legal guardian must read these Terms on your behalf. 88 96 </Text> 89 - )} 97 + ) : undefined} 90 98 </View> 91 99 ) 92 100 }
-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 - })