Add handle validation to create account UI (#2959)

* show uiState errors in the box as well

simplify copy

update ui for only letters and numbers

add ui validation to handle selection

* simplify names

* Fix accidental text-node render

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Hailey 2024-02-23 13:38:47 -08:00 committed by GitHub
parent 4771caf204
commit de9df50af3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 145 additions and 34 deletions

View File

@ -1,3 +1,8 @@
// Regex from the go implementation
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
const VALIDATE_REGEX =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export function makeValidHandle(str: string): string { export function makeValidHandle(str: string): string {
if (str.length > 20) { if (str.length > 20) {
str = str.slice(0, 20) str = str.slice(0, 20)
@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean {
export function sanitizeHandle(handle: string, prefix = ''): string { export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}` return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
} }
export interface IsValidHandle {
handleChars: boolean
frontLength: boolean
totalLength: boolean
overall: boolean
}
// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
export function validateHandle(str: string, userDomain: string): IsValidHandle {
const fullHandle = createFullHandle(str, userDomain)
const results = {
handleChars:
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
frontLength: str.length >= 3,
totalLength: fullHandle.length <= 253,
}
return {
...results,
overall: !Object.values(results).includes(false),
}
}

View File

@ -23,7 +23,7 @@ import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link' import {TextLink} from '../../util/Link'
import {getAgent} from 'state/session' import {getAgent} from 'state/session'
import {createFullHandle} from 'lib/strings/handles' import {createFullHandle, validateHandle} from 'lib/strings/handles'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) { export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics() const {screen} = useAnalytics()
@ -78,6 +78,10 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
} }
if (uiState.step === 2) { if (uiState.step === 2) {
if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
return
}
uiDispatch({type: 'set-processing', value: true}) uiDispatch({type: 'set-processing', value: true})
try { try {
const res = await getAgent().resolveHandle({ const res = await getAgent().resolveHandle({

View File

@ -1,15 +1,22 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state' import {CreateAccountState, CreateAccountDispatch} from './state'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput' import {TextInput} from '../util/TextInput'
import {createFullHandle} from 'lib/strings/handles' import {
createFullHandle,
IsValidHandle,
validateHandle,
} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 /** STEP 3: Your user handle
* @field User handle * @field User handle
@ -23,13 +30,41 @@ export function Step2({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() 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 ( return (
<View> <View>
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<View style={s.pb10}> <View style={s.pb10}>
<View style={s.mb20}>
<TextInput <TextInput
testID="handleInput" testID="handleInput"
icon="at" icon="at"
@ -39,7 +74,7 @@ export function Step2({
autoFocus autoFocus
autoComplete="off" autoComplete="off"
autoCorrect={false} autoCorrect={false}
onChange={value => uiDispatch({type: 'set-handle', value})} onChange={onHandleChange}
// TODO: Add explicit text label // TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)} accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)} accessibilityHint={_(msg`Input your user handle`)}
@ -51,13 +86,55 @@ export function Step2({
</Text> </Text>
</Text> </Text>
</View> </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> </View>
) )
} }
const styles = StyleSheet.create({ function IsValidIcon({valid}: {valid: boolean}) {
error: { const t = useTheme()
borderRadius: 6,
marginBottom: 10, if (!valid) {
}, return <Check size="md" style={{color: t.palette.negative_500}} />
}) }
return <Times size="md" style={{color: t.palette.positive_700}} />
}

View File

@ -8,7 +8,7 @@ import {msg} from '@lingui/macro'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {getAge} from 'lib/strings/time' import {getAge} from 'lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle, validateHandle} from '#/lib/strings/handles'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useOnboardingDispatch} from '#/state/shell/onboarding' import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
@ -282,7 +282,8 @@ function compute(state: CreateAccountState): CreateAccountState {
!!state.email && !!state.email &&
!!state.password !!state.password
} else if (state.step === 2) { } else if (state.step === 2) {
canNext = !!state.handle canNext =
!!state.handle && validateHandle(state.handle, state.userDomain).overall
} else if (state.step === 3) { } else if (state.step === 3) {
// Step 3 will automatically redirect as soon as the captcha completes // Step 3 will automatically redirect as soon as the captcha completes
canNext = false canNext = false