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>
zio/stable
Hailey 2024-03-19 12:47:46 -07:00 committed by GitHub
parent b6903419a1
commit a1c4f19731
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1064 additions and 809 deletions

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 296 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 871 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 327 B

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -1,19 +1,12 @@
import React from 'react' import React from 'react'
import {View, Pressable} from 'react-native'
import {useTheme, atoms} from '#/alf' import {useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {DateFieldProps} from '#/components/forms/DateField/types' import {DateFieldProps} from '#/components/forms/DateField/types'
import { import {toSimpleDateString} from '#/components/forms/DateField/utils'
localizeDate,
toSimpleDateString,
} from '#/components/forms/DateField/utils'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import {isAndroid} from 'platform/detection' import {isAndroid} from 'platform/detection'
import {DateFieldButton} from './index.shared'
export * as utils from '#/components/forms/DateField/utils' export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label export const Label = TextField.Label
@ -24,18 +17,10 @@ export function DateField({
label, label,
isInvalid, isInvalid,
testID, testID,
accessibilityHint,
}: DateFieldProps) { }: DateFieldProps) {
const t = useTheme() const t = useTheme()
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {chromeFocus, chromeError, chromeErrorHover} =
TextField.useSharedInputStyles()
const onChangeInternal = React.useCallback( const onChangeInternal = React.useCallback(
(date: Date) => { (date: Date) => {
@ -47,45 +32,23 @@ export function DateField({
[onChangeDate, setOpen], [onChangeDate, setOpen],
) )
const onPress = React.useCallback(() => {
setOpen(true)
}, [])
const onCancel = React.useCallback(() => { const onCancel = React.useCallback(() => {
setOpen(false) setOpen(false)
}, []) }, [])
return ( return (
<View style={[atoms.relative, atoms.w_full]}> <>
<Pressable <DateFieldButton
aria-label={label} label={label}
accessibilityLabel={label} value={value}
accessibilityHint={undefined} onPress={onPress}
onPress={() => setOpen(true)} isInvalid={isInvalid}
onPressIn={onPressIn} accessibilityHint={accessibilityHint}
onPressOut={onPressOut} />
onFocus={onFocus}
onBlur={onBlur}
style={[
{
paddingTop: 16,
paddingBottom: 16,
borderColor: 'transparent',
borderWidth: 2,
},
atoms.flex_row,
atoms.flex_1,
atoms.w_full,
atoms.px_lg,
atoms.rounded_sm,
t.atoms.bg_contrast_50,
focused || pressed ? chromeFocus : {},
isInvalid ? chromeError : {},
isInvalid && (focused || pressed) ? chromeErrorHover : {},
]}>
<TextField.Icon icon={CalendarDays} />
<Text
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
{localizeDate(value)}
</Text>
</Pressable>
{open && ( {open && (
<DatePicker <DatePicker
@ -99,9 +62,9 @@ export function DateField({
testID={`${testID}-datepicker`} testID={`${testID}-datepicker`}
aria-label={label} aria-label={label}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={undefined} accessibilityHint={accessibilityHint}
/> />
)} )}
</View> </>
) )
} }

View File

@ -0,0 +1,99 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import {atoms as a, android, useTheme, web} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import * as TextField from '#/components/forms/TextField'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {localizeDate} from './utils'
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
// iOS: open a dialog with an inline date picker
// Android: open the date picker modal
export function DateFieldButton({
label,
value,
onPress,
isInvalid,
accessibilityHint,
}: {
label: string
value: string
onPress: () => void
isInvalid?: boolean
accessibilityHint?: string
}) {
const t = useTheme()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
TextField.useSharedInputStyles()
return (
<View
style={[a.relative, a.w_full]}
{...web({
onMouseOver: onHoverIn,
onMouseOut: onHoverOut,
})}>
<Pressable
aria-label={label}
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}
style={[
{
paddingTop: 12,
paddingBottom: 12,
paddingLeft: 14,
paddingRight: 14,
borderColor: 'transparent',
borderWidth: 2,
},
android({
minHeight: 57.5,
}),
a.flex_row,
a.flex_1,
a.w_full,
a.rounded_sm,
t.atoms.bg_contrast_25,
a.align_center,
hovered ? chromeHover : {},
focused || pressed ? chromeFocus : {},
isInvalid || isInvalid ? chromeError : {},
(isInvalid || isInvalid) && (hovered || focused)
? chromeErrorHover
: {},
]}>
<TextField.Icon icon={CalendarDays} />
<Text
style={[
a.text_md,
a.pl_xs,
t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{localizeDate(value)}
</Text>
</Pressable>
</View>
)
}

View File

@ -1,11 +1,16 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useTheme, atoms} from '#/alf' import {useTheme, atoms as a} from '#/alf'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils' import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types' import {DateFieldProps} from '#/components/forms/DateField/types'
import DatePicker from 'react-native-date-picker' import DatePicker from 'react-native-date-picker'
import * as Dialog from '#/components/Dialog'
import {DateFieldButton} from './index.shared'
import {Button, ButtonText} from '#/components/Button'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export * as utils from '#/components/forms/DateField/utils' export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label export const Label = TextField.Label
@ -22,8 +27,12 @@ export function DateField({
onChangeDate, onChangeDate,
testID, testID,
label, label,
isInvalid,
accessibilityHint,
}: DateFieldProps) { }: DateFieldProps) {
const {_} = useLingui()
const t = useTheme() const t = useTheme()
const control = Dialog.useDialogControl()
const onChangeInternal = React.useCallback( const onChangeInternal = React.useCallback(
(date: Date | undefined) => { (date: Date | undefined) => {
@ -36,7 +45,19 @@ export function DateField({
) )
return ( return (
<View style={[atoms.relative, atoms.w_full]}> <>
<DateFieldButton
label={label}
value={value}
onPress={control.open}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
<Dialog.Outer control={control} testID={testID}>
<Dialog.Handle />
<Dialog.Inner label={label}>
<View style={a.gap_lg}>
<View style={[a.relative, a.w_full, a.align_center]}>
<DatePicker <DatePicker
theme={t.name === 'light' ? 'light' : 'dark'} theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)} date={new Date(value)}
@ -45,8 +66,22 @@ export function DateField({
testID={`${testID}-datepicker`} testID={`${testID}-datepicker`}
aria-label={label} aria-label={label}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={undefined} accessibilityHint={accessibilityHint}
/> />
</View> </View>
<Button
label={_(msg`Done`)}
onPress={() => control.close()}
size="medium"
color="primary"
variant="solid">
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
</Dialog.Inner>
</Dialog.Outer>
</>
) )
} }

View File

@ -2,6 +2,7 @@ import React from 'react'
import {TextInput, TextInputProps, StyleSheet} from 'react-native' import {TextInput, TextInputProps, StyleSheet} from 'react-native'
// @ts-ignore // @ts-ignore
import {unstable_createElement} from 'react-native-web' import {unstable_createElement} from 'react-native-web'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils' import {toSimpleDateString} from '#/components/forms/DateField/utils'
@ -37,6 +38,7 @@ export function DateField({
label, label,
isInvalid, isInvalid,
testID, testID,
accessibilityHint,
}: DateFieldProps) { }: DateFieldProps) {
const handleOnChange = React.useCallback( const handleOnChange = React.useCallback(
(e: any) => { (e: any) => {
@ -52,12 +54,14 @@ export function DateField({
return ( return (
<TextField.Root isInvalid={isInvalid}> <TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} />
<Input <Input
value={value} value={value}
label={label} label={label}
onChange={handleOnChange} onChange={handleOnChange}
onChangeText={() => {}} onChangeText={() => {}}
testID={testID} testID={testID}
accessibilityHint={accessibilityHint}
/> />
</TextField.Root> </TextField.Root>
) )

View File

@ -4,4 +4,5 @@ export type DateFieldProps = {
label: string label: string
isInvalid?: boolean isInvalid?: boolean
testID?: string testID?: string
accessibilityHint?: string
} }

View File

@ -126,8 +126,8 @@ export function useSharedInputStyles() {
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
label: string label: string
value: string value?: string
onChangeText: (value: string) => void onChangeText?: (value: string) => void
isInvalid?: boolean isInvalid?: boolean
inputRef?: React.RefObject<TextInput> inputRef?: React.RefObject<TextInput>
} }
@ -277,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
<Comp <Comp
size="md" size="md"
style={[ style={[
{color: t.palette.contrast_500, pointerEvents: 'none'}, {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
ctx.hovered ? hover : {}, ctx.hovered ? hover : {},
ctx.focused ? focus : {}, ctx.focused ? focus : {},
ctx.isInvalid && ctx.hovered ? errorHover : {}, ctx.isInvalid && ctx.hovered ? errorHover : {},

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})

View File

@ -27,6 +27,7 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
export interface IsValidHandle { export interface IsValidHandle {
handleChars: boolean handleChars: boolean
hyphenStartOrEnd: boolean
frontLength: boolean frontLength: boolean
totalLength: boolean totalLength: boolean
overall: boolean overall: boolean
@ -39,6 +40,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle {
const results = { const results = {
handleChars: handleChars:
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
frontLength: str.length >= 3, frontLength: str.length >= 3,
totalLength: fullHandle.length <= 253, totalLength: fullHandle.length <= 253,
} }

View File

@ -1,57 +1,39 @@
import React from 'react' import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {
CreateAccountState,
CreateAccountDispatch,
useSubmitCreateAccount,
} from './state'
import {StepHeader} from './StepHeader'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView' import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
import {useTheme} from 'lib/ThemeContext'
import {createFullHandle} from 'lib/strings/handles' import {createFullHandle} from 'lib/strings/handles'
import {isWeb} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {FormError} from '#/components/forms/FormError'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
const CAPTCHA_PATH = '/gate/signup' const CAPTCHA_PATH = '/gate/signup'
export function Step3({ export function StepCaptcha() {
uiState,
uiDispatch,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
}) {
const {_} = useLingui() const {_} = useLingui()
const theme = useTheme() const theme = useTheme()
const submit = useSubmitCreateAccount(uiState, uiDispatch) const {state, dispatch} = useSignupContext()
const submit = useSubmitSignup({state, dispatch})
const [completed, setCompleted] = React.useState(false) const [completed, setCompleted] = React.useState(false)
const stateParam = React.useMemo(() => nanoid(15), []) const stateParam = React.useMemo(() => nanoid(15), [])
const url = React.useMemo(() => { const url = React.useMemo(() => {
const newUrl = new URL(uiState.serviceUrl) const newUrl = new URL(state.serviceUrl)
newUrl.pathname = CAPTCHA_PATH newUrl.pathname = CAPTCHA_PATH
newUrl.searchParams.set( newUrl.searchParams.set(
'handle', 'handle',
createFullHandle(uiState.handle, uiState.userDomain), createFullHandle(state.handle, state.userDomain),
) )
newUrl.searchParams.set('state', stateParam) newUrl.searchParams.set('state', stateParam)
newUrl.searchParams.set('colorScheme', theme.colorScheme) newUrl.searchParams.set('colorScheme', theme.name)
console.log(newUrl)
return newUrl.href return newUrl.href
}, [ }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
uiState.serviceUrl,
uiState.handle,
uiState.userDomain,
stateParam,
theme.colorScheme,
])
const onSuccess = React.useCallback( const onSuccess = React.useCallback(
(code: string) => { (code: string) => {
@ -62,21 +44,21 @@ export function Step3({
) )
const onError = React.useCallback(() => { const onError = React.useCallback(() => {
uiDispatch({ dispatch({
type: 'set-error', type: 'setError',
value: _(msg`Error receiving captcha response.`), value: _(msg`Error receiving captcha response.`),
}) })
}, [_, uiDispatch]) }, [_, dispatch])
return ( return (
<View> <ScreenTransition>
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} /> <View style={[a.gap_lg]}>
<View style={[styles.container, completed && styles.center]}> <View style={[styles.container, completed && styles.center]}>
{!completed ? ( {!completed ? (
<CaptchaWebView <CaptchaWebView
url={url} url={url}
stateParam={stateParam} stateParam={stateParam}
uiState={uiState} state={state}
onSuccess={onSuccess} onSuccess={onSuccess}
onError={onError} onError={onError}
/> />
@ -84,11 +66,9 @@ export function Step3({
<ActivityIndicator size="large" /> <ActivityIndicator size="large" />
)} )}
</View> </View>
<FormError error={state.error} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View> </View>
</ScreenTransition>
) )
} }

View File

@ -0,0 +1,134 @@
import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import * as TextField from '#/components/forms/TextField'
import {useSignupContext} from '#/screens/Signup/state'
import {Text} from '#/components/Typography'
import {atoms as a, useTheme} from '#/alf'
import {
createFullHandle,
IsValidHandle,
validateHandle,
} from 'lib/strings/handles'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
export function StepHandle() {
const {_} = useLingui()
const t = useTheme()
const {state, dispatch} = useSignupContext()
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
handleChars: false,
hyphenStartOrEnd: false,
frontLength: false,
totalLength: true,
overall: false,
})
useFocusEffect(
React.useCallback(() => {
console.log('run')
setValidCheck(validateHandle(state.handle, state.userDomain))
}, [state.handle, state.userDomain]),
)
const onHandleChange = React.useCallback(
(value: string) => {
if (state.error) {
dispatch({type: 'setError', value: ''})
}
dispatch({
type: 'setHandle',
value,
})
},
[dispatch, state.error],
)
return (
<ScreenTransition>
<View style={[a.gap_lg]}>
<View>
<TextField.Root>
<TextField.Icon icon={At} />
<TextField.Input
onChangeText={onHandleChange}
label={_(msg`Input your user handle`)}
defaultValue={state.handle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
autoComplete="off"
/>
</TextField.Root>
</View>
<Text style={[a.text_md]}>
<Trans>Your full handle will be</Trans>{' '}
<Text style={[a.text_md, a.font_bold]}>
@{createFullHandle(state.handle, state.userDomain)}
</Text>
</Text>
<View
style={[
a.w_full,
a.rounded_sm,
a.border,
a.p_md,
a.gap_sm,
t.atoms.border_contrast_low,
]}>
{state.error ? (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={false} />
<Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
</View>
) : undefined}
{validCheck.hyphenStartOrEnd ? (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={validCheck.handleChars} />
<Text style={[a.text_md, a.flex_1]}>
<Trans>Only contains letters, numbers, and hyphens</Trans>
</Text>
</View>
) : (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={validCheck.hyphenStartOrEnd} />
<Text style={[a.text_md, a.flex_1]}>
<Trans>Doesn't begin or end with a hyphen</Trans>
</Text>
</View>
)}
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon
valid={validCheck.frontLength && validCheck.totalLength}
/>
{!validCheck.totalLength ? (
<Text style={[a.text_md, a.flex_1]}>
<Trans>No longer than 253 characters</Trans>
</Text>
) : (
<Text style={[a.text_md, a.flex_1]}>
<Trans>At least 3 characters</Trans>
</Text>
)}
</View>
</View>
</View>
</ScreenTransition>
)
}
function IsValidIcon({valid}: {valid: boolean}) {
const t = useTheme()
if (!valid) {
return <Times size="md" style={{color: t.palette.negative_500}} />
}
return <Check size="md" style={{color: t.palette.positive_700}} />
}

View File

@ -0,0 +1,145 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {is13, is18, useSignupContext} from '#/screens/Signup/state'
import * as DateField from '#/components/forms/DateField'
import {logger} from '#/logger'
import {Loader} from '#/components/Loader'
import {Policies} from 'view/com/auth/create/Policies'
import {HostingProvider} from '#/components/forms/HostingProvider'
import {FormError} from '#/components/forms/FormError'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
export function StepInfo() {
const {_} = useLingui()
const {state, dispatch} = useSignupContext()
return (
<ScreenTransition>
<View style={[a.gap_lg]}>
<FormError error={state.error} />
<View>
<TextField.Label>
<Trans>Hosting provider</Trans>
</TextField.Label>
<HostingProvider
serviceUrl={state.serviceUrl}
onSelectServiceUrl={v =>
dispatch({type: 'setServiceUrl', value: v})
}
/>
</View>
{state.isLoading ? (
<View style={[a.align_center]}>
<Loader size="xl" />
</View>
) : state.serviceDescription ? (
<>
{state.serviceDescription.inviteCodeRequired && (
<View>
<TextField.Label>
<Trans>Invite code</Trans>
</TextField.Label>
<TextField.Root>
<TextField.Icon icon={Ticket} />
<TextField.Input
onChangeText={value => {
dispatch({
type: 'setInviteCode',
value: value.trim(),
})
}}
label={_(msg`Required for this provider`)}
defaultValue={state.inviteCode}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
/>
</TextField.Root>
</View>
)}
<View>
<TextField.Label>
<Trans>Email</Trans>
</TextField.Label>
<TextField.Root>
<TextField.Icon icon={Envelope} />
<TextField.Input
onChangeText={value => {
dispatch({
type: 'setEmail',
value: value.trim(),
})
}}
label={_(msg`Enter your email address`)}
defaultValue={state.email}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
/>
</TextField.Root>
</View>
<View>
<TextField.Label>
<Trans>Password</Trans>
</TextField.Label>
<TextField.Root>
<TextField.Icon icon={Lock} />
<TextField.Input
onChangeText={value => {
dispatch({
type: 'setPassword',
value,
})
}}
label={_(msg`Choose your password`)}
defaultValue={state.password}
secureTextEntry
autoComplete="new-password"
/>
</TextField.Root>
</View>
<View>
<DateField.Label>
<Trans>Your birth date</Trans>
</DateField.Label>
<DateField.DateField
testID="date"
value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
onChangeDate={date => {
dispatch({
type: 'setDateOfBirth',
value: sanitizeDate(new Date(date)),
})
}}
label={_(msg`Date of birth`)}
accessibilityHint={_(msg`Select your date of birth`)}
/>
</View>
<Policies
serviceDescription={state.serviceDescription}
needsGuardian={!is18(state.dateOfBirth)}
under13={!is13(state.dateOfBirth)}
/>
</>
) : undefined}
</View>
</ScreenTransition>
)
}

View File

@ -0,0 +1,225 @@
import React from 'react'
import {ScrollView, View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {
initialState,
reducer,
SignupContext,
SignupStep,
useSubmitSignup,
} from '#/screens/Signup/state'
import {StepInfo} from '#/screens/Signup/StepInfo'
import {StepHandle} from '#/screens/Signup/StepHandle'
import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
import {FEEDBACK_FORM_URL} from 'lib/constants'
import {InlineLink} from '#/components/Link'
import {useServiceQuery} from 'state/queries/service'
import {getAgent} from 'state/session'
import {createFullHandle} from 'lib/strings/handles'
import {useAnalytics} from 'lib/analytics/analytics'
export function Signup({onPressBack}: {onPressBack: () => void}) {
const {_} = useLingui()
const t = useTheme()
const {screen} = useAnalytics()
const [state, dispatch] = React.useReducer(reducer, initialState)
const submit = useSubmitSignup({state, dispatch})
const {
data: serviceInfo,
isFetching,
isError,
refetch,
} = useServiceQuery(state.serviceUrl)
React.useEffect(() => {
screen('CreateAccount')
}, [screen])
React.useEffect(() => {
if (isFetching) {
dispatch({type: 'setIsLoading', value: true})
} else if (!isFetching) {
dispatch({type: 'setIsLoading', value: false})
}
}, [isFetching])
React.useEffect(() => {
if (isError) {
dispatch({type: 'setServiceDescription', value: undefined})
dispatch({
type: 'setError',
value: _(
msg`Unable to contact your service. Please check your Internet connection.`,
),
})
} else if (serviceInfo) {
dispatch({type: 'setServiceDescription', value: serviceInfo})
dispatch({type: 'setError', value: ''})
}
}, [_, serviceInfo, isError])
const onNextPress = React.useCallback(async () => {
if (state.activeStep === SignupStep.HANDLE) {
try {
dispatch({type: 'setIsLoading', value: true})
const res = await getAgent().resolveHandle({
handle: createFullHandle(state.handle, state.userDomain),
})
if (res.data.did) {
dispatch({
type: 'setError',
value: _(msg`That handle is already taken.`),
})
return
}
} catch (e) {
// Don't have to handle
} finally {
dispatch({type: 'setIsLoading', value: false})
}
}
// phoneVerificationRequired is actually whether a captcha is required
if (
state.activeStep === SignupStep.HANDLE &&
!state.serviceDescription?.phoneVerificationRequired
) {
submit()
return
}
dispatch({type: 'next'})
}, [
_,
state.activeStep,
state.handle,
state.serviceDescription?.phoneVerificationRequired,
state.userDomain,
submit,
])
const onBackPress = React.useCallback(() => {
if (state.activeStep !== SignupStep.INFO) {
dispatch({type: 'prev'})
} else {
onPressBack()
}
}, [onPressBack, state.activeStep])
return (
<SignupContext.Provider value={{state, dispatch}}>
<LoggedOutLayout
leadin=""
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}>
<ScrollView
testID="createAccount"
keyboardShouldPersistTaps="handled"
style={a.h_full}
keyboardDismissMode="on-drag">
<View
style={[
a.flex_1,
a.px_xl,
a.gap_3xl,
a.pt_2xl,
{paddingBottom: 100},
]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_lg, t.atoms.text_contrast_medium]}>
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Text>
<Text style={[a.text_3xl, a.font_bold]}>
{state.activeStep === SignupStep.INFO ? (
<Trans>Your account</Trans>
) : state.activeStep === SignupStep.HANDLE ? (
<Trans>Your user handle</Trans>
) : (
<Trans>Complete the challenge</Trans>
)}
</Text>
</View>
<View>
{state.activeStep === SignupStep.INFO ? (
<StepInfo />
) : state.activeStep === SignupStep.HANDLE ? (
<StepHandle />
) : (
<StepCaptcha />
)}
</View>
<View style={[a.flex_row, a.justify_between]}>
<Button
label="Back"
variant="solid"
color="secondary"
size="small"
onPress={onBackPress}>
Back
</Button>
{state.activeStep !== SignupStep.CAPTCHA && (
<>
{isError ? (
<Button
label="Retry"
variant="solid"
color="primary"
size="small"
disabled={state.isLoading}
onPress={() => refetch()}>
Retry
</Button>
) : (
<Button
label="Next"
variant="solid"
color={
!state.canNext || state.isLoading
? 'secondary'
: 'primary'
}
size="small"
disabled={!state.canNext || state.isLoading}
onPress={onNextPress}>
<ButtonText>Next</ButtonText>
</Button>
)}
</>
)}
</View>
<View
style={[
a.w_full,
a.py_lg,
a.px_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLink
style={[a.text_md]}
to={FEEDBACK_FORM_URL({email: state.email})}>
<Trans>Contact support</Trans>
</InlineLink>
</Text>
</View>
</View>
</ScrollView>
</LoggedOutLayout>
</SignupContext.Provider>
)
}

View File

@ -0,0 +1,320 @@
import React, {useCallback} from 'react'
import {LayoutAnimation} from 'react-native'
import * as EmailValidator from 'email-validator'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {cleanError} from 'lib/strings/errors'
import {
ComAtprotoServerCreateAccount,
ComAtprotoServerDescribeServer,
} from '@atproto/api'
import {logger} from '#/logger'
import {DEFAULT_SERVICE, IS_PROD_SERVICE} from 'lib/constants'
import {createFullHandle, validateHandle} from 'lib/strings/handles'
import {getAge} from 'lib/strings/time'
import {useSessionApi} from 'state/session'
import {
DEFAULT_PROD_FEEDS,
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
} from 'state/queries/preferences'
import {useOnboardingDispatch} from 'state/shell'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
export enum SignupStep {
INFO,
HANDLE,
CAPTCHA,
}
export type SignupState = {
hasPrev: boolean
canNext: boolean
activeStep: SignupStep
serviceUrl: string
serviceDescription?: ServiceDescription
userDomain: string
dateOfBirth: Date
email: string
password: string
inviteCode: string
handle: string
error: string
isLoading: boolean
}
export type SignupAction =
| {type: 'prev'}
| {type: 'next'}
| {type: 'finish'}
| {type: 'setStep'; value: SignupStep}
| {type: 'setServiceUrl'; value: string}
| {type: 'setServiceDescription'; value: ServiceDescription | undefined}
| {type: 'setEmail'; value: string}
| {type: 'setPassword'; value: string}
| {type: 'setDateOfBirth'; value: Date}
| {type: 'setInviteCode'; value: string}
| {type: 'setHandle'; value: string}
| {type: 'setVerificationCode'; value: string}
| {type: 'setError'; value: string}
| {type: 'setCanNext'; value: boolean}
| {type: 'setIsLoading'; value: boolean}
export const initialState: SignupState = {
hasPrev: false,
canNext: false,
activeStep: SignupStep.INFO,
serviceUrl: DEFAULT_SERVICE,
serviceDescription: undefined,
userDomain: '',
dateOfBirth: DEFAULT_DATE,
email: '',
password: '',
handle: '',
inviteCode: '',
error: '',
isLoading: false,
}
export function is13(date: Date) {
return getAge(date) >= 13
}
export function is18(date: Date) {
return getAge(date) >= 18
}
export function reducer(s: SignupState, a: SignupAction): SignupState {
let next = {...s}
switch (a.type) {
case 'prev': {
if (s.activeStep !== SignupStep.INFO) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
next.activeStep--
next.error = ''
}
break
}
case 'next': {
if (s.activeStep !== SignupStep.CAPTCHA) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
next.activeStep++
next.error = ''
}
break
}
case 'setStep': {
next.activeStep = a.value
break
}
case 'setServiceUrl': {
next.serviceUrl = a.value
break
}
case 'setServiceDescription': {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
next.serviceDescription = a.value
next.userDomain = a.value?.availableUserDomains[0] ?? ''
next.isLoading = false
break
}
case 'setEmail': {
next.email = a.value
break
}
case 'setPassword': {
next.password = a.value
break
}
case 'setDateOfBirth': {
next.dateOfBirth = a.value
break
}
case 'setInviteCode': {
next.inviteCode = a.value
break
}
case 'setHandle': {
next.handle = a.value
break
}
case 'setCanNext': {
next.canNext = a.value
break
}
case 'setIsLoading': {
next.isLoading = a.value
break
}
case 'setError': {
next.error = a.value
break
}
}
next.hasPrev = next.activeStep !== SignupStep.INFO
switch (next.activeStep) {
case SignupStep.INFO: {
const isValidEmail = EmailValidator.validate(next.email)
next.canNext =
!!(next.email && next.password && next.dateOfBirth) &&
(!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
is13(next.dateOfBirth) &&
isValidEmail
break
}
case SignupStep.HANDLE: {
next.canNext =
!!next.handle && validateHandle(next.handle, next.userDomain).overall
break
}
}
logger.debug('signup', next)
if (s.activeStep !== next.activeStep) {
logger.debug('signup: step changed', {activeStep: next.activeStep})
}
return next
}
interface IContext {
state: SignupState
dispatch: React.Dispatch<SignupAction>
}
export const SignupContext = React.createContext<IContext>({} as IContext)
export const useSignupContext = () => React.useContext(SignupContext)
export function useSubmitSignup({
state,
dispatch,
}: {
state: SignupState
dispatch: (action: SignupAction) => void
}) {
const {_} = useLingui()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const onboardingDispatch = useOnboardingDispatch()
return useCallback(
async (verificationCode?: string) => {
if (!state.email) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
type: 'setError',
value: _(msg`Please enter your email.`),
})
}
if (!EmailValidator.validate(state.email)) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
type: 'setError',
value: _(msg`Your email appears to be invalid.`),
})
}
if (!state.password) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
type: 'setError',
value: _(msg`Please choose your password.`),
})
}
if (!state.handle) {
dispatch({type: 'setStep', value: SignupStep.HANDLE})
return dispatch({
type: 'setError',
value: _(msg`Please choose your handle.`),
})
}
if (
state.serviceDescription?.phoneVerificationRequired &&
!verificationCode
) {
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
return dispatch({
type: 'setError',
value: _(msg`Please complete the verification captcha.`),
})
}
dispatch({type: 'setError', value: ''})
dispatch({type: 'setIsLoading', value: true})
try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({
service: state.serviceUrl,
email: state.email,
handle: createFullHandle(state.handle, state.userDomain),
password: state.password,
inviteCode: state.inviteCode.trim(),
verificationCode: verificationCode,
})
setBirthDate({birthDate: state.dateOfBirth})
if (IS_PROD_SERVICE(state.serviceUrl)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
}
} catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
dispatch({
type: 'setError',
value: _(
msg`Invite code not accepted. Check that you input it correctly and try again.`,
),
})
dispatch({type: 'setStep', value: SignupStep.INFO})
return
}
if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {message: e})
} else {
logger.error(`Failed to create account (${e.status} status)`, {
message: e,
})
}
const error = cleanError(errMsg)
const isHandleError = error.toLowerCase().includes('handle')
dispatch({type: 'setIsLoading', value: false})
dispatch({type: 'setError', value: cleanError(errMsg)})
dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
} finally {
dispatch({type: 'setIsLoading', value: false})
}
},
[
state.email,
state.password,
state.handle,
state.serviceDescription?.phoneVerificationRequired,
state.serviceUrl,
state.userDomain,
state.inviteCode,
state.dateOfBirth,
dispatch,
_,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
],
)
}

View File

@ -7,7 +7,7 @@ import {useNavigation} from '@react-navigation/native'
import {isIOS, isNative} from '#/platform/detection' import {isIOS, isNative} from '#/platform/detection'
import {Login} from '#/screens/Login' import {Login} from '#/screens/Login'
import {CreateAccount} from '#/view/com/auth/create/CreateAccount' import {Signup} from '#/screens/Signup'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
@ -148,7 +148,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/> />
) : undefined} ) : undefined}
{screenState === ScreenState.S_CreateAccount ? ( {screenState === ScreenState.S_CreateAccount ? (
<CreateAccount <Signup
onPressBack={() => onPressBack={() =>
setScreenState(ScreenState.S_LoginOrCreateAccount) setScreenState(ScreenState.S_LoginOrCreateAccount)
} }

View File

@ -2,7 +2,7 @@ import React from 'react'
import {WebView, WebViewNavigation} from 'react-native-webview' import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {StyleSheet} from 'react-native' import {StyleSheet} from 'react-native'
import {CreateAccountState} from 'view/com/auth/create/state' import {SignupState} from '#/screens/Signup/state'
const ALLOWED_HOSTS = [ const ALLOWED_HOSTS = [
'bsky.social', 'bsky.social',
@ -17,24 +17,24 @@ const ALLOWED_HOSTS = [
export function CaptchaWebView({ export function CaptchaWebView({
url, url,
stateParam, stateParam,
uiState, state,
onSuccess, onSuccess,
onError, onError,
}: { }: {
url: string url: string
stateParam: string stateParam: string
uiState?: CreateAccountState state?: SignupState
onSuccess: (code: string) => void onSuccess: (code: string) => void
onError: () => void onError: () => void
}) { }) {
const redirectHost = React.useMemo(() => { const redirectHost = React.useMemo(() => {
if (!uiState?.serviceUrl) return 'bsky.app' if (!state?.serviceUrl) return 'bsky.app'
return uiState?.serviceUrl && return state?.serviceUrl &&
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev' new URL(state?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app' ? 'staging.bsky.app'
: 'bsky.app' : 'bsky.app'
}, [uiState?.serviceUrl]) }, [state?.serviceUrl])
const wasSuccessful = React.useRef(false) const wasSuccessful = React.useRef(false)

View File

@ -1,230 +0,0 @@
import React from 'react'
import {
ActivityIndicator,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {useAnalytics} from 'lib/analytics/analytics'
import {Text} from '../../util/text/Text'
import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCreateAccount, useSubmitCreateAccount} from './state'
import {useServiceQuery} from '#/state/queries/service'
import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link'
import {getAgent} from 'state/session'
import {createFullHandle, validateHandle} from 'lib/strings/handles'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
const pal = usePalette('default')
const {_} = useLingui()
const [uiState, uiDispatch] = useCreateAccount()
const {isTabletOrDesktop} = useWebMediaQueries()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
React.useEffect(() => {
screen('CreateAccount')
}, [screen])
// fetch service info
// =
const {
data: serviceInfo,
isFetching: serviceInfoIsFetching,
error: serviceInfoError,
refetch: refetchServiceInfo,
} = useServiceQuery(uiState.serviceUrl)
React.useEffect(() => {
if (serviceInfo) {
uiDispatch({type: 'set-service-description', value: serviceInfo})
uiDispatch({type: 'set-error', value: ''})
} else if (serviceInfoError) {
uiDispatch({
type: 'set-error',
value: _(
msg`Unable to contact your service. Please check your Internet connection.`,
),
})
}
}, [_, uiDispatch, serviceInfo, serviceInfoError])
// event handlers
// =
const onPressBackInner = React.useCallback(() => {
if (uiState.canBack) {
uiDispatch({type: 'back'})
} else {
onPressBack()
}
}, [uiState, uiDispatch, onPressBack])
const onPressNext = React.useCallback(async () => {
if (!uiState.canNext) {
return
}
if (uiState.step === 2) {
if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
return
}
uiDispatch({type: 'set-processing', value: true})
try {
const res = await getAgent().resolveHandle({
handle: createFullHandle(uiState.handle, uiState.userDomain),
})
if (res.data.did) {
uiDispatch({
type: 'set-error',
value: _(msg`That handle is already taken.`),
})
return
}
} catch (e) {
// Don't need to handle
} finally {
uiDispatch({type: 'set-processing', value: false})
}
if (!uiState.isCaptchaRequired) {
try {
await submit()
} catch {
// dont need to handle here
}
// We don't need to go to the next page if there wasn't a captcha required
return
}
}
uiDispatch({type: 'next'})
}, [
uiState.canNext,
uiState.step,
uiState.isCaptchaRequired,
uiState.handle,
uiState.userDomain,
uiDispatch,
_,
submit,
])
// rendering
// =
return (
<LoggedOutLayout
leadin=""
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}>
<ScrollView
testID="createAccount"
style={pal.view}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
<View style={styles.stepContainer}>
{uiState.step === 1 && (
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 2 && (
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 3 && (
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
)}
</View>
<View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity
onPress={onPressBackInner}
testID="backBtn"
accessibilityRole="button"
hitSlop={HITSLOP_10}>
<Text type="xl" style={pal.link}>
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{uiState.canNext ? (
<TouchableOpacity
testID="nextBtn"
onPress={onPressNext}
accessibilityRole="button"
hitSlop={HITSLOP_10}>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
)}
</TouchableOpacity>
) : serviceInfoError ? (
<TouchableOpacity
testID="retryConnectBtn"
onPress={() => refetchServiceInfo()}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint=""
accessibilityLiveRegion="polite"
hitSlop={HITSLOP_10}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
</TouchableOpacity>
) : serviceInfoIsFetching ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl" style={[pal.text, s.pr5]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : undefined}
</View>
<View style={styles.stepContainer}>
<View
style={[
s.flexRow,
s.alignCenter,
pal.viewLight,
{borderRadius: 8, paddingHorizontal: 14, paddingVertical: 12},
]}>
<Text type="md" style={pal.textLight}>
<Trans>Having trouble?</Trans>{' '}
</Text>
<TextLink
type="md"
style={pal.link}
text={_(msg`Contact support`)}
href={FEEDBACK_FORM_URL({email: uiState.email})}
/>
</View>
</View>
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
</ScrollView>
</LoggedOutLayout>
)
}
const styles = StyleSheet.create({
stepContainer: {
paddingHorizontal: 20,
paddingVertical: 20,
},
})

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {Linking, StyleSheet, View} from 'react-native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -15,9 +15,11 @@ type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
export const Policies = ({ export const Policies = ({
serviceDescription, serviceDescription,
needsGuardian, needsGuardian,
under13,
}: { }: {
serviceDescription: ServiceDescription serviceDescription: ServiceDescription
needsGuardian: boolean needsGuardian: boolean
under13: boolean
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
if (!serviceDescription) { if (!serviceDescription) {
@ -53,6 +55,7 @@ export const Policies = ({
href={tos} href={tos}
text="Terms of Service" text="Terms of Service"
style={[pal.link, s.underline]} style={[pal.link, s.underline]}
onPress={() => Linking.openURL(tos)}
/>, />,
) )
} }
@ -63,6 +66,7 @@ export const Policies = ({
href={pp} href={pp}
text="Privacy Policy" text="Privacy Policy"
style={[pal.link, s.underline]} style={[pal.link, s.underline]}
onPress={() => Linking.openURL(pp)}
/>, />,
) )
} }
@ -81,12 +85,16 @@ export const Policies = ({
<Text style={pal.textLight}> <Text style={pal.textLight}>
By creating an account you agree to the {els}. By creating an account you agree to the {els}.
</Text> </Text>
{needsGuardian && ( {under13 ? (
<Text style={[pal.textLight, s.bold]}>
You must be 13 years of age or older to sign up.
</Text>
) : needsGuardian ? (
<Text style={[pal.textLight, s.bold]}> <Text style={[pal.textLight, s.bold]}>
If you are not yet an adult according to the laws of your country, If you are not yet an adult according to the laws of your country,
your parent or legal guardian must read these Terms on your behalf. your parent or legal guardian must read these Terms on your behalf.
</Text> </Text>
)} ) : undefined}
</View> </View>
) )
} }

View File

@ -1,261 +0,0 @@
import React from 'react'
import {
ActivityIndicator,
Keyboard,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
import {Text} from 'view/com/util/text/Text'
import {DateInput} from 'view/com/util/forms/DateInput'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Policies} from './Policies'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useDialogControl} from '#/components/Dialog'
import {ServerInputDialog} from '../server-input'
import {toNiceDomain} from '#/lib/strings/url-helpers'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
export function Step1({
uiState,
uiDispatch,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
}) {
const pal = usePalette('default')
const {_} = useLingui()
const serverInputControl = useDialogControl()
const onPressSelectService = React.useCallback(() => {
serverInputControl.open()
Keyboard.dismiss()
}, [serverInputControl])
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
return (
<View>
<ServerInputDialog
control={serverInputControl}
onSelect={url => uiDispatch({type: 'set-service-url', value: url})}
/>
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Hosting provider</Trans>
</Text>
<View style={[pal.border, {borderWidth: 1, borderRadius: 6}]}>
<View
style={[
pal.borderDark,
{flexDirection: 'row', alignItems: 'center'},
]}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, {marginLeft: 14}]}
/>
<TouchableOpacity
testID="selectServiceButton"
style={{
flexDirection: 'row',
flex: 1,
alignItems: 'center',
}}
onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel={_(msg`Select service`)}
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
<Text
type="xl"
style={[
pal.text,
{
flex: 1,
paddingVertical: 10,
paddingRight: 12,
paddingLeft: 10,
},
]}>
{toNiceDomain(uiState.serviceUrl)}
</Text>
<View
style={[
pal.btn,
{
flexDirection: 'row',
alignItems: 'center',
borderRadius: 6,
paddingVertical: 6,
paddingHorizontal: 8,
marginHorizontal: 6,
},
]}>
<FontAwesomeIcon
icon="pen"
size={12}
style={pal.textLight as FontAwesomeIconStyle}
/>
</View>
</TouchableOpacity>
</View>
</View>
</View>
{!uiState.serviceDescription ? (
<ActivityIndicator />
) : (
<>
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
icon="ticket"
placeholder={_(msg`Required for this provider`)}
value={uiState.inviteCode}
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus={true}
/>
</View>
)}
{!uiState.isInviteCodeRequired || uiState.inviteCode ? (
<>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="email">
<Trans>Email address</Trans>
</Text>
<TextInput
testID="emailInput"
icon="envelope"
placeholder={_(msg`Enter your email address`)}
value={uiState.email}
editable
onChange={value => uiDispatch({type: 'set-email', value})}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(msg`Input email for Bluesky account`)}
accessibilityLabelledBy="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
autoFocus={!uiState.isInviteCodeRequired}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="password">
<Trans>Password</Trans>
</Text>
<TextInput
testID="passwordInput"
icon="lock"
placeholder={_(msg`Choose your password`)}
value={uiState.password}
editable
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect={false}
/>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="birthDate">
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={birthDate}
onChange={value =>
uiDispatch({type: 'set-birth-date', value})
}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{uiState.serviceDescription && (
<Policies
serviceDescription={uiState.serviceDescription}
needsGuardian={!is18(uiState)}
/>
)}
</>
) : undefined}
</>
)}
</View>
)
}
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginBottom: 10,
},
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
paddingVertical: 14,
},
// @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.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
})

View File

@ -1,140 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state'
import {Text} from 'view/com/util/text/Text'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput'
import {
createFullHandle,
IsValidHandle,
validateHandle,
} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {useFocusEffect} from '@react-navigation/native'
/** STEP 3: Your user handle
* @field User handle
*/
export function Step2({
uiState,
uiDispatch,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
}) {
const pal = usePalette('default')
const {_} = useLingui()
const t = useTheme()
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
handleChars: false,
frontLength: false,
totalLength: true,
overall: false,
})
useFocusEffect(
React.useCallback(() => {
setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
// Disabling this, because we only want to run this when we focus the screen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []),
)
const onHandleChange = React.useCallback(
(value: string) => {
if (uiState.error) {
uiDispatch({type: 'set-error', value: ''})
}
setValidCheck(validateHandle(value, uiState.userDomain))
uiDispatch({type: 'set-handle', value})
},
[uiDispatch, uiState.error, uiState.userDomain],
)
return (
<View>
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}>
<View style={s.mb20}>
<TextInput
testID="handleInput"
icon="at"
placeholder="e.g. alice"
value={uiState.handle}
editable
autoFocus
autoComplete="off"
autoCorrect={false}
onChange={onHandleChange}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text>
</Text>
</View>
<View
style={[
a.w_full,
a.rounded_sm,
a.border,
a.p_md,
a.gap_sm,
t.atoms.border_contrast_low,
]}>
{uiState.error ? (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={false} />
<Text style={[t.atoms.text, a.text_md, a.flex]}>
{uiState.error}
</Text>
</View>
) : undefined}
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={validCheck.handleChars} />
<Text style={[t.atoms.text, a.text_md, a.flex]}>
<Trans>May only contain letters and numbers</Trans>
</Text>
</View>
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon
valid={validCheck.frontLength && validCheck.totalLength}
/>
{!validCheck.totalLength ? (
<Text style={[t.atoms.text]}>
<Trans>May not be longer than 253 characters</Trans>
</Text>
) : (
<Text style={[t.atoms.text, a.text_md]}>
<Trans>Must be at least 3 characters</Trans>
</Text>
)}
</View>
</View>
</View>
</View>
)
}
function IsValidIcon({valid}: {valid: boolean}) {
const t = useTheme()
if (!valid) {
return <Times size="md" style={{color: t.palette.negative_500}} />
}
return <Check size="md" style={{color: t.palette.positive_700}} />
}

View File

@ -1,44 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {Trans} from '@lingui/macro'
import {CreateAccountState} from './state'
export function StepHeader({
uiState,
title,
children,
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
const pal = usePalette('default')
const numSteps = 3
return (
<View style={styles.container}>
<View>
<Text type="lg" style={[pal.textLight]}>
{uiState.step === 3 ? (
<Trans>Last step!</Trans>
) : (
<Trans>
Step {uiState.step} of {numSteps}
</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
</Text>
</View>
{children}
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
})