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
parent
4771caf204
commit
de9df50af3
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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}} />
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue