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 {View, Pressable} from 'react-native'
import {useTheme, atoms} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {useTheme} from '#/alf'
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 {
localizeDate,
toSimpleDateString,
} from '#/components/forms/DateField/utils'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import DatePicker from 'react-native-date-picker'
import {isAndroid} from 'platform/detection'
import {DateFieldButton} from './index.shared'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@ -24,18 +17,10 @@ export function DateField({
label,
isInvalid,
testID,
accessibilityHint,
}: DateFieldProps) {
const t = useTheme()
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(
(date: Date) => {
@ -47,45 +32,23 @@ export function DateField({
[onChangeDate, setOpen],
)
const onPress = React.useCallback(() => {
setOpen(true)
}, [])
const onCancel = React.useCallback(() => {
setOpen(false)
}, [])
return (
<View style={[atoms.relative, atoms.w_full]}>
<Pressable
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
onPress={() => setOpen(true)}
onPressIn={onPressIn}
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>
<>
<DateFieldButton
label={label}
value={value}
onPress={onPress}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
{open && (
<DatePicker
@ -99,9 +62,9 @@ export function DateField({
testID={`${testID}-datepicker`}
aria-label={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 {View} from 'react-native'
import {useTheme, atoms} from '#/alf'
import {useTheme, atoms as a} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
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 const Label = TextField.Label
@ -22,8 +27,12 @@ export function DateField({
onChangeDate,
testID,
label,
isInvalid,
accessibilityHint,
}: DateFieldProps) {
const {_} = useLingui()
const t = useTheme()
const control = Dialog.useDialogControl()
const onChangeInternal = React.useCallback(
(date: Date | undefined) => {
@ -36,7 +45,19 @@ export function DateField({
)
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
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
@ -45,8 +66,22 @@ export function DateField({
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
accessibilityHint={accessibilityHint}
/>
</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'
// @ts-ignore
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 {toSimpleDateString} from '#/components/forms/DateField/utils'
@ -37,6 +38,7 @@ export function DateField({
label,
isInvalid,
testID,
accessibilityHint,
}: DateFieldProps) {
const handleOnChange = React.useCallback(
(e: any) => {
@ -52,12 +54,14 @@ export function DateField({
return (
<TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} />
<Input
value={value}
label={label}
onChange={handleOnChange}
onChangeText={() => {}}
testID={testID}
accessibilityHint={accessibilityHint}
/>
</TextField.Root>
)

View File

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

View File

@ -126,8 +126,8 @@ export function useSharedInputStyles() {
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
label: string
value: string
onChangeText: (value: string) => void
value?: string
onChangeText?: (value: string) => void
isInvalid?: boolean
inputRef?: React.RefObject<TextInput>
}
@ -277,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
<Comp
size="md"
style={[
{color: t.palette.contrast_500, pointerEvents: 'none'},
{color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
ctx.hovered ? hover : {},
ctx.focused ? focus : {},
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 {
handleChars: boolean
hyphenStartOrEnd: boolean
frontLength: boolean
totalLength: boolean
overall: boolean
@ -39,6 +40,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle {
const results = {
handleChars:
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
frontLength: str.length >= 3,
totalLength: fullHandle.length <= 253,
}

View File

@ -1,57 +1,39 @@
import React from 'react'
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 {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
import {useTheme} from 'lib/ThemeContext'
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'
export function Step3({
uiState,
uiDispatch,
}: {
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
}) {
export function StepCaptcha() {
const {_} = useLingui()
const theme = useTheme()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
const {state, dispatch} = useSignupContext()
const submit = useSubmitSignup({state, dispatch})
const [completed, setCompleted] = React.useState(false)
const stateParam = React.useMemo(() => nanoid(15), [])
const url = React.useMemo(() => {
const newUrl = new URL(uiState.serviceUrl)
const newUrl = new URL(state.serviceUrl)
newUrl.pathname = CAPTCHA_PATH
newUrl.searchParams.set(
'handle',
createFullHandle(uiState.handle, uiState.userDomain),
createFullHandle(state.handle, state.userDomain),
)
newUrl.searchParams.set('state', stateParam)
newUrl.searchParams.set('colorScheme', theme.colorScheme)
console.log(newUrl)
newUrl.searchParams.set('colorScheme', theme.name)
return newUrl.href
}, [
uiState.serviceUrl,
uiState.handle,
uiState.userDomain,
stateParam,
theme.colorScheme,
])
}, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
const onSuccess = React.useCallback(
(code: string) => {
@ -62,21 +44,21 @@ export function Step3({
)
const onError = React.useCallback(() => {
uiDispatch({
type: 'set-error',
dispatch({
type: 'setError',
value: _(msg`Error receiving captcha response.`),
})
}, [_, uiDispatch])
}, [_, dispatch])
return (
<View>
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
<ScreenTransition>
<View style={[a.gap_lg]}>
<View style={[styles.container, completed && styles.center]}>
{!completed ? (
<CaptchaWebView
url={url}
stateParam={stateParam}
uiState={uiState}
state={state}
onSuccess={onSuccess}
onError={onError}
/>
@ -84,11 +66,9 @@ export function Step3({
<ActivityIndicator size="large" />
)}
</View>
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<FormError error={state.error} />
</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 {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 {s} from '#/lib/styles'
import {usePalette} from '#/lib/hooks/usePalette'
@ -148,7 +148,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<CreateAccount
<Signup
onPressBack={() =>
setScreenState(ScreenState.S_LoginOrCreateAccount)
}

View File

@ -2,7 +2,7 @@ import React from 'react'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {StyleSheet} from 'react-native'
import {CreateAccountState} from 'view/com/auth/create/state'
import {SignupState} from '#/screens/Signup/state'
const ALLOWED_HOSTS = [
'bsky.social',
@ -17,24 +17,24 @@ const ALLOWED_HOSTS = [
export function CaptchaWebView({
url,
stateParam,
uiState,
state,
onSuccess,
onError,
}: {
url: string
stateParam: string
uiState?: CreateAccountState
state?: SignupState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!uiState?.serviceUrl) return 'bsky.app'
if (!state?.serviceUrl) return 'bsky.app'
return uiState?.serviceUrl &&
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
return state?.serviceUrl &&
new URL(state?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
}, [uiState?.serviceUrl])
}, [state?.serviceUrl])
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 {StyleSheet, View} from 'react-native'
import {Linking, StyleSheet, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@ -15,9 +15,11 @@ type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
export const Policies = ({
serviceDescription,
needsGuardian,
under13,
}: {
serviceDescription: ServiceDescription
needsGuardian: boolean
under13: boolean
}) => {
const pal = usePalette('default')
if (!serviceDescription) {
@ -53,6 +55,7 @@ export const Policies = ({
href={tos}
text="Terms of Service"
style={[pal.link, s.underline]}
onPress={() => Linking.openURL(tos)}
/>,
)
}
@ -63,6 +66,7 @@ export const Policies = ({
href={pp}
text="Privacy Policy"
style={[pal.link, s.underline]}
onPress={() => Linking.openURL(pp)}
/>,
)
}
@ -81,12 +85,16 @@ export const Policies = ({
<Text style={pal.textLight}>
By creating an account you agree to the {els}.
</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]}>
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.
</Text>
)}
) : undefined}
</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,
},
})