diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts index 6ce46243..a18fef45 100644 --- a/src/lib/strings/handles.ts +++ b/src/lib/strings/handles.ts @@ -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 { if (str.length > 20) { str = str.slice(0, 20) @@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean { export function sanitizeHandle(handle: string, prefix = ''): string { 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), + } +} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 8aefffa6..d193802f 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -23,7 +23,7 @@ import {Step3} from './Step3' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {TextLink} from '../../util/Link' import {getAgent} from 'state/session' -import {createFullHandle} from 'lib/strings/handles' +import {createFullHandle, validateHandle} from 'lib/strings/handles' export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {screen} = useAnalytics() @@ -78,6 +78,10 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) { } if (uiState.step === 2) { + if (!validateHandle(uiState.handle, uiState.userDomain).overall) { + return + } + uiDispatch({type: 'set-processing', value: true}) try { const res = await getAgent().resolveHandle({ diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 87d414bb..a3892030 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,15 +1,22 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' +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} from 'lib/strings/handles' +import { + createFullHandle, + IsValidHandle, + validateHandle, +} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' -import {ErrorMessage} from 'view/com/util/error/ErrorMessage' 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 @@ -23,41 +30,111 @@ export function Step2({ }) { const pal = usePalette('default') const {_} = useLingui() + const t = useTheme() + + const [validCheck, setValidCheck] = React.useState({ + 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 ( - {uiState.error ? ( - - ) : undefined} - uiDispatch({type: 'set-handle', value})} - // TODO: Add explicit text label - accessibilityLabel={_(msg`User handle`)} - accessibilityHint={_(msg`Input your user handle`)} - /> - - Your full handle will be{' '} - - @{createFullHandle(uiState.handle, uiState.userDomain)} + + + + Your full handle will be{' '} + + @{createFullHandle(uiState.handle, uiState.userDomain)} + - + + + {uiState.error ? ( + + + + {uiState.error} + + + ) : undefined} + + + + May only contain letters and numbers + + + + + {!validCheck.totalLength ? ( + + May not be longer than 253 characters + + ) : ( + + Must be at least 3 characters + + )} + + ) } -const styles = StyleSheet.create({ - error: { - borderRadius: 6, - marginBottom: 10, - }, -}) +function IsValidIcon({valid}: {valid: boolean}) { + const t = useTheme() + + if (!valid) { + return + } + + return +} diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts index 68cfacee..7a727ec0 100644 --- a/src/view/com/auth/create/state.ts +++ b/src/view/com/auth/create/state.ts @@ -8,7 +8,7 @@ import {msg} from '@lingui/macro' import * as EmailValidator from 'email-validator' import {getAge} from 'lib/strings/time' import {logger} from '#/logger' -import {createFullHandle} from '#/lib/strings/handles' +import {createFullHandle, validateHandle} from '#/lib/strings/handles' import {cleanError} from '#/lib/strings/errors' import {useOnboardingDispatch} from '#/state/shell/onboarding' import {useSessionApi} from '#/state/session' @@ -282,7 +282,8 @@ function compute(state: CreateAccountState): CreateAccountState { !!state.email && !!state.password } else if (state.step === 2) { - canNext = !!state.handle + canNext = + !!state.handle && validateHandle(state.handle, state.userDomain).overall } else if (state.step === 3) { // Step 3 will automatically redirect as soon as the captcha completes canNext = false