about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/strings/handles.ts29
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx6
-rw-r--r--src/view/com/auth/create/Step2.tsx139
-rw-r--r--src/view/com/auth/create/state.ts5
4 files changed, 145 insertions, 34 deletions
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 6ce462435..a18fef453 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 8aefffa6d..d193802fe 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 87d414bb9..a38920309 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<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`)} />
-      {uiState.error ? (
-        <ErrorMessage message={uiState.error} style={styles.error} />
-      ) : undefined}
       <View style={s.pb10}>
-        <TextInput
-          testID="handleInput"
-          icon="at"
-          placeholder="e.g. alice"
-          value={uiState.handle}
-          editable
-          autoFocus
-          autoComplete="off"
-          autoCorrect={false}
-          onChange={value => uiDispatch({type: 'set-handle', value})}
-          // 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)}
+        <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>
-        </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>
   )
 }
 
-const styles = StyleSheet.create({
-  error: {
-    borderRadius: 6,
-    marginBottom: 10,
-  },
-})
+function IsValidIcon({valid}: {valid: boolean}) {
+  const t = useTheme()
+
+  if (!valid) {
+    return <Check size="md" style={{color: t.palette.negative_500}} />
+  }
+
+  return <Times size="md" style={{color: t.palette.positive_700}} />
+}
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index 68cfaceec..7a727ec0b 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