about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-08-08 01:01:01 +0300
committerGitHub <noreply@github.com>2025-08-07 17:01:01 -0500
commit93c4719a2140070b33f69dd0f12b4de2619a25a6 (patch)
tree0396daa890b1023097385748f2194b16d1fa6fa4
parentf708e884107c75559759b22901c87e57d5b979da (diff)
downloadvoidsky-93c4719a2140070b33f69dd0f12b4de2619a25a6.tar.zst
Check handle as you type (#8601)
* check handle as you type

* metrics

* add metric types

* fix overflow

* only check reserved handles for bsky.social, fix test

* change validation check name

* tweak input

* move ghosttext component to textfield

* tweak styles to try and match latest

* add suggestions

* improvements, metrics

* share logic between typeahead and next button

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* update checks, disable button if unavailable

* convert to lowercase

* fix bug with checkHandleAvailability

* add gate

* move files around to make clearer

* fix bad import

* Fix flashing next button

* Enable for TF

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Eric Bailey <git@esb.lol>
-rw-r--r--__tests__/lib/strings/handles.test.ts10
-rw-r--r--assets/icons/circle_stroke2_corner0_rounded.svg1
-rw-r--r--package.json2
-rw-r--r--src/components/forms/TextField.tsx84
-rw-r--r--src/components/icons/Circle.tsx5
-rw-r--r--src/lib/constants.ts3
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/lib/strings/handles.ts6
-rw-r--r--src/logger/metrics.ts4
-rw-r--r--src/screens/Signup/BackNextButtons.tsx2
-rw-r--r--src/screens/Signup/StepCaptcha/index.tsx2
-rw-r--r--src/screens/Signup/StepHandle.tsx217
-rw-r--r--src/screens/Signup/StepHandle/HandleSuggestions.tsx80
-rw-r--r--src/screens/Signup/StepHandle/index.tsx279
-rw-r--r--src/screens/Signup/StepInfo/index.tsx2
-rw-r--r--src/screens/Signup/index.tsx7
-rw-r--r--src/state/queries/handle-availability.ts126
-rw-r--r--yarn.lock8
18 files changed, 593 insertions, 246 deletions
diff --git a/__tests__/lib/strings/handles.test.ts b/__tests__/lib/strings/handles.test.ts
index 4456fae94..f3b289afd 100644
--- a/__tests__/lib/strings/handles.test.ts
+++ b/__tests__/lib/strings/handles.test.ts
@@ -1,4 +1,4 @@
-import {IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
+import {type IsValidHandle, validateServiceHandle} from '#/lib/strings/handles'
 
 describe('handle validation', () => {
   const valid = [
@@ -18,17 +18,17 @@ describe('handle validation', () => {
   })
 
   const invalid = [
-    ['al', 'bsky.social', 'frontLength'],
+    ['al', 'bsky.social', 'frontLengthNotTooShort'],
     ['-alice', 'bsky.social', 'hyphenStartOrEnd'],
     ['alice-', 'bsky.social', 'hyphenStartOrEnd'],
     ['%%%', 'bsky.social', 'handleChars'],
-    ['1234567890123456789', 'bsky.social', 'frontLength'],
+    ['1234567890123456789', 'bsky.social', 'frontLengthNotTooLong'],
     [
       '1234567890123456789',
       'my-custom-pds-with-long-name.social',
-      'frontLength',
+      'frontLengthNotTooLong',
     ],
-    ['al', 'my-custom-pds-with-long-name.social', 'frontLength'],
+    ['al', 'my-custom-pds-with-long-name.social', 'frontLengthNotTooShort'],
     ['a'.repeat(300), 'toolong.com', 'totalLength'],
   ] satisfies [string, string, keyof IsValidHandle][]
   it.each(invalid)(
diff --git a/assets/icons/circle_stroke2_corner0_rounded.svg b/assets/icons/circle_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..98fe755b0
--- /dev/null
+++ b/assets/icons/circle_stroke2_corner0_rounded.svg
@@ -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="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/></svg>
diff --git a/package.json b/package.json
index dc8888860..2b44ead9f 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
     "icons:optimize": "svgo -f ./assets/icons"
   },
   "dependencies": {
-    "@atproto/api": "^0.15.26",
+    "@atproto/api": "^0.16.2",
     "@bitdrift/react-native": "^0.6.8",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 9b7ada319..3913c3283 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {createContext, useContext, useMemo, useRef} from 'react'
 import {
   type AccessibilityProps,
   StyleSheet,
@@ -16,7 +16,9 @@ import {
   applyFonts,
   atoms as a,
   ios,
+  platform,
   type TextStyleProp,
+  tokens,
   useAlf,
   useTheme,
   web,
@@ -25,7 +27,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {type Props as SVGIconProps} from '#/components/icons/common'
 import {Text} from '#/components/Typography'
 
-const Context = React.createContext<{
+const Context = createContext<{
   inputRef: React.RefObject<TextInput> | null
   isInvalid: boolean
   hovered: boolean
@@ -48,7 +50,7 @@ const Context = React.createContext<{
 export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
 
 export function Root({children, isInvalid = false}: RootProps) {
-  const inputRef = React.useRef<TextInput>(null)
+  const inputRef = useRef<TextInput>(null)
   const {
     state: hovered,
     onIn: onHoverIn,
@@ -56,7 +58,7 @@ export function Root({children, isInvalid = false}: RootProps) {
   } = useInteractionState()
   const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
 
-  const context = React.useMemo(
+  const context = useMemo(
     () => ({
       inputRef,
       hovered,
@@ -96,7 +98,7 @@ export function Root({children, isInvalid = false}: RootProps) {
 
 export function useSharedInputStyles() {
   const t = useTheme()
-  return React.useMemo(() => {
+  return useMemo(() => {
     const hover: ViewStyle[] = [
       {
         borderColor: t.palette.contrast_100,
@@ -158,7 +160,7 @@ export function createInput(Component: typeof TextInput) {
   }: InputProps) {
     const t = useTheme()
     const {fonts} = useAlf()
-    const ctx = React.useContext(Context)
+    const ctx = useContext(Context)
     const withinRoot = Boolean(ctx.inputRef)
 
     const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
@@ -283,8 +285,8 @@ export function LabelText({
 
 export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
   const t = useTheme()
-  const ctx = React.useContext(Context)
-  const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
+  const ctx = useContext(Context)
+  const {hover, focus, errorHover, errorFocus} = useMemo(() => {
     const hover: TextStyle[] = [
       {
         color: t.palette.contrast_800,
@@ -342,7 +344,7 @@ export function SuffixText({
   }
 >) {
   const t = useTheme()
-  const ctx = React.useContext(Context)
+  const ctx = useContext(Context)
   return (
     <Text
       accessibilityLabel={label}
@@ -362,3 +364,67 @@ export function SuffixText({
     </Text>
   )
 }
+
+export function GhostText({
+  children,
+  value,
+}: {
+  children: string
+  value: string
+}) {
+  const t = useTheme()
+  // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
+  return (
+    <View
+      style={[
+        a.pointer_events_none,
+        a.absolute,
+        a.z_10,
+        {
+          paddingLeft: platform({
+            native:
+              // input padding
+              tokens.space.md +
+              // icon
+              tokens.space.xl +
+              // icon padding
+              tokens.space.xs +
+              // text input padding
+              tokens.space.xs,
+            web:
+              // icon
+              tokens.space.xl +
+              // icon padding
+              tokens.space.xs +
+              // text input padding
+              tokens.space.xs,
+          }),
+        },
+        web(a.pr_md),
+        a.overflow_hidden,
+        a.max_w_full,
+      ]}
+      aria-hidden={true}
+      accessibilityElementsHidden
+      importantForAccessibility="no-hide-descendants">
+      <Text
+        style={[
+          {color: 'transparent'},
+          a.text_md,
+          {lineHeight: a.text_md.fontSize * 1.1875},
+          a.w_full,
+        ]}
+        numberOfLines={1}>
+        {children}
+        <Text
+          style={[
+            t.atoms.text_contrast_low,
+            a.text_md,
+            {lineHeight: a.text_md.fontSize * 1.1875},
+          ]}>
+          {value}
+        </Text>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/components/icons/Circle.tsx b/src/components/icons/Circle.tsx
new file mode 100644
index 000000000..93d837119
--- /dev/null
+++ b/src/components/icons/Circle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Circle_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z',
+})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index ab52b8710..21f0ab870 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -5,6 +5,7 @@ export const LOCAL_DEV_SERVICE =
   Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
 export const STAGING_SERVICE = 'https://staging.bsky.dev'
 export const BSKY_SERVICE = 'https://bsky.social'
+export const BSKY_SERVICE_DID = 'did:web:bsky.social'
 export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
 export const DEFAULT_SERVICE = BSKY_SERVICE
 const HELP_DESK_LANG = 'en-us'
@@ -31,7 +32,7 @@ export const DISCOVER_DEBUG_DIDS: Record<string, true> = {
   'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol
   'did:plc:vjug55kidv6sye7ykr5faxxn': true, // emilyliu.me
   'did:plc:tgqseeot47ymot4zro244fj3': true, // iwsmith.bsky.social
-  'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // mrnuma.bsky.social
+  'did:plc:2dzyut5lxna5ljiaasgeuffz': true, // darrin.bsky.team
 }
 
 const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index c3bd1a7cb..66134a462 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -5,6 +5,7 @@ export type Gate =
   | 'debug_subscriptions'
   | 'disable_onboarding_policy_update_notice'
   | 'explore_show_suggested_feeds'
+  | 'handle_suggestions'
   | 'old_postonboarding'
   | 'onboarding_add_video_feed'
   | 'post_threads_v2_unspecced'
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 78a2e1a09..02b9943d3 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -34,7 +34,8 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
 export interface IsValidHandle {
   handleChars: boolean
   hyphenStartOrEnd: boolean
-  frontLength: boolean
+  frontLengthNotTooShort: boolean
+  frontLengthNotTooLong: boolean
   totalLength: boolean
   overall: boolean
 }
@@ -50,7 +51,8 @@ export function validateServiceHandle(
     handleChars:
       !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
     hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
-    frontLength: str.length >= 3 && str.length <= MAX_SERVICE_HANDLE_LENGTH,
+    frontLengthNotTooShort: str.length >= 3,
+    frontLengthNotTooLong: str.length <= MAX_SERVICE_HANDLE_LENGTH,
     totalLength: fullHandle.length <= 253,
   }
 
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index dfca1f7d8..0c9ea1ef6 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -67,7 +67,9 @@ export type MetricEvents = {
     activeStep: number
     backgroundCount: number
   }
-  'signup:handleTaken': {}
+  'signup:handleTaken': {typeahead?: boolean}
+  'signup:handleAvailable': {typeahead?: boolean}
+  'signup:handleSuggestionSelected': {method: string}
   'signin:hostingProviderPressed': {
     hostingProviderDidChange: boolean
   }
diff --git a/src/screens/Signup/BackNextButtons.tsx b/src/screens/Signup/BackNextButtons.tsx
index 888b9071e..5a85a85d1 100644
--- a/src/screens/Signup/BackNextButtons.tsx
+++ b/src/screens/Signup/BackNextButtons.tsx
@@ -9,7 +9,7 @@ import {Loader} from '#/components/Loader'
 export interface BackNextButtonsProps {
   hideNext?: boolean
   showRetry?: boolean
-  isLoading: boolean
+  isLoading?: boolean
   isNextDisabled?: boolean
   onBackPress: () => void
   onNextPress?: () => void
diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
index e2f249a13..8ea893c4a 100644
--- a/src/screens/Signup/StepCaptcha/index.tsx
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -144,7 +144,7 @@ function StepCaptchaInner({
 
   return (
     <ScreenTransition>
-      <View style={[a.gap_lg]}>
+      <View style={[a.gap_lg, a.pt_lg]}>
         <View
           style={[
             a.w_full,
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
deleted file mode 100644
index 8bf0c3364..000000000
--- a/src/screens/Signup/StepHandle.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import React, {useRef} from 'react'
-import {View} from 'react-native'
-import {msg, Plural, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {
-  createFullHandle,
-  MAX_SERVICE_HANDLE_LENGTH,
-  validateServiceHandle,
-} from '#/lib/strings/handles'
-import {logger} from '#/logger'
-import {useAgent} from '#/state/session'
-import {ScreenTransition} from '#/screens/Login/ScreenTransition'
-import {useSignupContext} from '#/screens/Signup/state'
-import {atoms as a, useTheme} from '#/alf'
-import * as TextField from '#/components/forms/TextField'
-import {useThrottledValue} from '#/components/hooks/useThrottledValue'
-import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
-import {Text} from '#/components/Typography'
-import {BackNextButtons} from './BackNextButtons'
-
-export function StepHandle() {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {state, dispatch} = useSignupContext()
-  const agent = useAgent()
-  const handleValueRef = useRef<string>(state.handle)
-  const [draftValue, setDraftValue] = React.useState(state.handle)
-  const isLoading = useThrottledValue(state.isLoading, 500)
-
-  const onNextPress = React.useCallback(async () => {
-    const handle = handleValueRef.current.trim()
-    dispatch({
-      type: 'setHandle',
-      value: handle,
-    })
-
-    const newValidCheck = validateServiceHandle(handle, state.userDomain)
-    if (!newValidCheck.overall) {
-      return
-    }
-
-    try {
-      dispatch({type: 'setIsLoading', value: true})
-
-      const res = await agent.resolveHandle({
-        handle: createFullHandle(handle, state.userDomain),
-      })
-
-      if (res.data.did) {
-        dispatch({
-          type: 'setError',
-          value: _(msg`That handle is already taken.`),
-          field: 'handle',
-        })
-        logger.metric('signup:handleTaken', {}, {statsig: true})
-        return
-      }
-    } catch (e) {
-      // Don't have to handle
-    } finally {
-      dispatch({type: 'setIsLoading', value: false})
-    }
-
-    logger.metric(
-      'signup:nextPressed',
-      {
-        activeStep: state.activeStep,
-        phoneVerificationRequired:
-          state.serviceDescription?.phoneVerificationRequired,
-      },
-      {statsig: true},
-    )
-    // phoneVerificationRequired is actually whether a captcha is required
-    if (!state.serviceDescription?.phoneVerificationRequired) {
-      dispatch({
-        type: 'submit',
-        task: {verificationCode: undefined, mutableProcessed: false},
-      })
-      return
-    }
-    dispatch({type: 'next'})
-  }, [
-    _,
-    dispatch,
-    state.activeStep,
-    state.serviceDescription?.phoneVerificationRequired,
-    state.userDomain,
-    agent,
-  ])
-
-  const onBackPress = React.useCallback(() => {
-    const handle = handleValueRef.current.trim()
-    dispatch({
-      type: 'setHandle',
-      value: handle,
-    })
-    dispatch({type: 'prev'})
-    logger.metric(
-      'signup:backPressed',
-      {activeStep: state.activeStep},
-      {statsig: true},
-    )
-  }, [dispatch, state.activeStep])
-
-  const validCheck = validateServiceHandle(draftValue, state.userDomain)
-  return (
-    <ScreenTransition>
-      <View style={[a.gap_lg]}>
-        <View>
-          <TextField.Root>
-            <TextField.Icon icon={At} />
-            <TextField.Input
-              testID="handleInput"
-              onChangeText={val => {
-                if (state.error) {
-                  dispatch({type: 'setError', value: ''})
-                }
-
-                // These need to always be in sync.
-                handleValueRef.current = val
-                setDraftValue(val)
-              }}
-              label={_(msg`Type your desired username`)}
-              defaultValue={draftValue}
-              autoCapitalize="none"
-              autoCorrect={false}
-              autoFocus
-              autoComplete="off"
-            />
-          </TextField.Root>
-        </View>
-        {draftValue !== '' && (
-          <Text style={[a.text_md]}>
-            <Trans>
-              Your full username will be{' '}
-              <Text style={[a.text_md, a.font_bold]}>
-                @{createFullHandle(draftValue, state.userDomain)}
-              </Text>
-            </Trans>
-          </Text>
-        )}
-
-        {draftValue !== '' && (
-          <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 ||
-              draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? (
-                <Text style={[a.text_md, a.flex_1]}>
-                  <Trans>
-                    No longer than{' '}
-                    <Plural
-                      value={MAX_SERVICE_HANDLE_LENGTH}
-                      other="# characters"
-                    />
-                  </Trans>
-                </Text>
-              ) : (
-                <Text style={[a.text_md, a.flex_1]}>
-                  <Trans>At least 3 characters</Trans>
-                </Text>
-              )}
-            </View>
-          </View>
-        )}
-      </View>
-      <BackNextButtons
-        isLoading={isLoading}
-        isNextDisabled={!validCheck.overall}
-        onBackPress={onBackPress}
-        onNextPress={onNextPress}
-      />
-    </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}} />
-}
diff --git a/src/screens/Signup/StepHandle/HandleSuggestions.tsx b/src/screens/Signup/StepHandle/HandleSuggestions.tsx
new file mode 100644
index 000000000..3d219d886
--- /dev/null
+++ b/src/screens/Signup/StepHandle/HandleSuggestions.tsx
@@ -0,0 +1,80 @@
+import Animated, {Easing, FadeInDown, FadeOut} from 'react-native-reanimated'
+import {type ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, native, useTheme} from '#/alf'
+import {borderRadius} from '#/alf/tokens'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+
+export function HandleSuggestions({
+  suggestions,
+  onSelect,
+}: {
+  suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion[]
+  onSelect: (
+    suggestions: ComAtprotoTempCheckHandleAvailability.Suggestion,
+  ) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Animated.View
+      entering={native(FadeInDown.easing(Easing.out(Easing.exp)))}
+      exiting={native(FadeOut)}
+      style={[
+        a.flex_1,
+        a.border,
+        a.rounded_sm,
+        t.atoms.shadow_sm,
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.mt_xs,
+        a.z_50,
+        a.w_full,
+        a.zoom_fade_in,
+      ]}>
+      {suggestions.map((suggestion, index) => (
+        <Button
+          label={_(
+            msg({
+              message: `Select ${suggestion.handle}`,
+              comment: `Accessibility label for a username suggestion in the account creation flow`,
+            }),
+          )}
+          key={index}
+          onPress={() => onSelect(suggestion)}
+          hoverStyle={[t.atoms.bg_contrast_25]}
+          style={[
+            a.w_full,
+            a.flex_row,
+            a.align_center,
+            a.justify_between,
+            a.p_md,
+            a.border_b,
+            t.atoms.border_contrast_low,
+            index === 0 && {
+              borderTopStartRadius: borderRadius.sm,
+              borderTopEndRadius: borderRadius.sm,
+            },
+            index === suggestions.length - 1 && [
+              {
+                borderBottomStartRadius: borderRadius.sm,
+                borderBottomEndRadius: borderRadius.sm,
+              },
+              a.border_b_0,
+            ],
+          ]}>
+          <Text style={[a.text_md]}>{suggestion.handle}</Text>
+          <Text style={[a.text_sm, {color: t.palette.positive_700}]}>
+            <Trans comment="Shown next to an available username suggestion in the account creation flow">
+              Available
+            </Trans>
+          </Text>
+        </Button>
+      ))}
+    </Animated.View>
+  )
+}
diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx
new file mode 100644
index 000000000..aaab435ae
--- /dev/null
+++ b/src/screens/Signup/StepHandle/index.tsx
@@ -0,0 +1,279 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+  LinearTransition,
+} from 'react-native-reanimated'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {
+  createFullHandle,
+  MAX_SERVICE_HANDLE_LENGTH,
+  validateServiceHandle,
+} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {
+  checkHandleAvailability,
+  useHandleAvailabilityQuery,
+} from '#/state/queries/handle-availability'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {useSignupContext} from '#/screens/Signup/state'
+import {atoms as a, native, useTheme} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
+import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {Text} from '#/components/Typography'
+import {IS_INTERNAL} from '#/env'
+import {BackNextButtons} from '../BackNextButtons'
+import {HandleSuggestions} from './HandleSuggestions'
+
+export function StepHandle() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const gate = useGate()
+  const {state, dispatch} = useSignupContext()
+  const [draftValue, setDraftValue] = useState(state.handle)
+  const isNextLoading = useThrottledValue(state.isLoading, 500)
+
+  const validCheck = validateServiceHandle(draftValue, state.userDomain)
+
+  const {
+    debouncedUsername: debouncedDraftValue,
+    enabled: queryEnabled,
+    query: {data: isHandleAvailable, isPending},
+  } = useHandleAvailabilityQuery({
+    username: draftValue,
+    serviceDid: state.serviceDescription?.did ?? 'UNKNOWN',
+    serviceDomain: state.userDomain,
+    birthDate: state.dateOfBirth.toISOString(),
+    email: state.email,
+    enabled: validCheck.overall,
+  })
+
+  const onNextPress = async () => {
+    const handle = draftValue.trim()
+    dispatch({
+      type: 'setHandle',
+      value: handle,
+    })
+
+    if (!validCheck.overall) {
+      return
+    }
+
+    dispatch({type: 'setIsLoading', value: true})
+
+    try {
+      const {available: handleAvailable} = await checkHandleAvailability(
+        createFullHandle(handle, state.userDomain),
+        state.serviceDescription?.did ?? 'UNKNOWN',
+        {typeahead: false},
+      )
+
+      if (!handleAvailable) {
+        dispatch({
+          type: 'setError',
+          value: _(msg`That username is already taken`),
+          field: 'handle',
+        })
+        return
+      }
+    } catch (error) {
+      logger.error('Failed to check handle availability on next press', {
+        safeMessage: error,
+      })
+      // do nothing on error, let them pass
+    } finally {
+      dispatch({type: 'setIsLoading', value: false})
+    }
+
+    logger.metric(
+      'signup:nextPressed',
+      {
+        activeStep: state.activeStep,
+        phoneVerificationRequired:
+          state.serviceDescription?.phoneVerificationRequired,
+      },
+      {statsig: true},
+    )
+    // phoneVerificationRequired is actually whether a captcha is required
+    if (!state.serviceDescription?.phoneVerificationRequired) {
+      dispatch({
+        type: 'submit',
+        task: {verificationCode: undefined, mutableProcessed: false},
+      })
+      return
+    }
+    dispatch({type: 'next'})
+  }
+
+  const onBackPress = () => {
+    const handle = draftValue.trim()
+    dispatch({
+      type: 'setHandle',
+      value: handle,
+    })
+    dispatch({type: 'prev'})
+    logger.metric(
+      'signup:backPressed',
+      {activeStep: state.activeStep},
+      {statsig: true},
+    )
+  }
+
+  const hasDebounceSettled = draftValue === debouncedDraftValue
+  const isHandleTaken =
+    !isPending &&
+    queryEnabled &&
+    isHandleAvailable &&
+    !isHandleAvailable.available
+  const isNotReady = isPending || !hasDebounceSettled
+  const isNextDisabled =
+    !validCheck.overall || !!state.error || isNotReady ? true : isHandleTaken
+
+  const textFieldInvalid =
+    isHandleTaken ||
+    !validCheck.frontLengthNotTooLong ||
+    !validCheck.handleChars ||
+    !validCheck.hyphenStartOrEnd ||
+    !validCheck.totalLength
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_sm, a.pt_lg, a.z_10]}>
+        <View>
+          <TextField.Root isInvalid={textFieldInvalid}>
+            <TextField.Icon icon={AtIcon} />
+            <TextField.Input
+              testID="handleInput"
+              onChangeText={val => {
+                if (state.error) {
+                  dispatch({type: 'setError', value: ''})
+                }
+                setDraftValue(val.toLocaleLowerCase())
+              }}
+              label={state.userDomain}
+              value={draftValue}
+              keyboardType="ascii-capable" // fix for iOS replacing -- with —
+              autoCapitalize="none"
+              autoCorrect={false}
+              autoFocus
+              autoComplete="off"
+            />
+            {draftValue.length > 0 && (
+              <TextField.GhostText value={state.userDomain}>
+                {draftValue}
+              </TextField.GhostText>
+            )}
+            {isHandleAvailable?.available && (
+              <CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} />
+            )}
+          </TextField.Root>
+        </View>
+        <LayoutAnimationConfig skipEntering skipExiting>
+          <View style={[a.gap_xs]}>
+            {state.error && (
+              <Requirement>
+                <RequirementText>{state.error}</RequirementText>
+              </Requirement>
+            )}
+            {isHandleTaken && validCheck.overall && (
+              <>
+                <Requirement>
+                  <RequirementText>
+                    <Trans>
+                      {createFullHandle(draftValue, state.userDomain)} is not
+                      available
+                    </Trans>
+                  </RequirementText>
+                </Requirement>
+                {isHandleAvailable.suggestions &&
+                  isHandleAvailable.suggestions.length > 0 &&
+                  (gate('handle_suggestions') || IS_INTERNAL) && (
+                    <HandleSuggestions
+                      suggestions={isHandleAvailable.suggestions}
+                      onSelect={suggestion => {
+                        setDraftValue(
+                          suggestion.handle.slice(
+                            0,
+                            state.userDomain.length * -1,
+                          ),
+                        )
+                        logger.metric('signup:handleSuggestionSelected', {
+                          method: suggestion.method,
+                        })
+                      }}
+                    />
+                  )}
+              </>
+            )}
+            {(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && (
+              <Requirement>
+                {!validCheck.hyphenStartOrEnd ? (
+                  <RequirementText>
+                    <Trans>Username cannot begin or end with a hyphen</Trans>
+                  </RequirementText>
+                ) : (
+                  <RequirementText>
+                    <Trans>
+                      Username must only contain letters (a-z), numbers, and
+                      hyphens
+                    </Trans>
+                  </RequirementText>
+                )}
+              </Requirement>
+            )}
+            <Requirement>
+              {(!validCheck.frontLengthNotTooLong ||
+                !validCheck.totalLength) && (
+                <RequirementText>
+                  <Trans>
+                    Username cannot be longer than{' '}
+                    <Plural
+                      value={MAX_SERVICE_HANDLE_LENGTH}
+                      other="# characters"
+                    />
+                  </Trans>
+                </RequirementText>
+              )}
+            </Requirement>
+          </View>
+        </LayoutAnimationConfig>
+      </View>
+      <Animated.View layout={native(LinearTransition)}>
+        <BackNextButtons
+          isLoading={isNextLoading}
+          isNextDisabled={isNextDisabled}
+          onBackPress={onBackPress}
+          onNextPress={onNextPress}
+        />
+      </Animated.View>
+    </ScreenTransition>
+  )
+}
+
+function Requirement({children}: {children: React.ReactNode}) {
+  return (
+    <Animated.View
+      style={[a.w_full]}
+      layout={native(LinearTransition)}
+      entering={native(FadeIn)}
+      exiting={native(FadeOut)}>
+      {children}
+    </Animated.View>
+  )
+}
+
+function RequirementText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  return (
+    <Text style={[a.text_sm, a.flex_1, {color: t.palette.negative_500}]}>
+      {children}
+    </Text>
+  )
+}
diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx
index f24cd0e45..cf4a9297e 100644
--- a/src/screens/Signup/StepInfo/index.tsx
+++ b/src/screens/Signup/StepInfo/index.tsx
@@ -144,7 +144,7 @@ export function StepInfo({
 
   return (
     <ScreenTransition>
-      <View style={[a.gap_md]}>
+      <View style={[a.gap_md, a.pt_lg]}>
         <FormError error={state.error} />
         <HostingProvider
           minimal
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
index 50cc5aa26..807bbff4f 100644
--- a/src/screens/Signup/index.tsx
+++ b/src/screens/Signup/index.tsx
@@ -157,8 +157,9 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
               a.pt_2xl,
               !gtMobile && {paddingBottom: 100},
             ]}>
-            <View style={[a.gap_sm, a.pb_3xl]}>
-              <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+            <View style={[a.gap_sm, a.pb_sm]}>
+              <Text
+                style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
                 <Trans>
                   Step {state.activeStep + 1} of{' '}
                   {state.serviceDescription &&
@@ -167,7 +168,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
                     : '3'}
                 </Trans>
               </Text>
-              <Text style={[a.text_3xl, a.font_bold]}>
+              <Text style={[a.text_3xl, a.font_heavy]}>
                 {state.activeStep === SignupStep.INFO ? (
                   <Trans>Your account</Trans>
                 ) : state.activeStep === SignupStep.HANDLE ? (
diff --git a/src/state/queries/handle-availability.ts b/src/state/queries/handle-availability.ts
new file mode 100644
index 000000000..9391f5d09
--- /dev/null
+++ b/src/state/queries/handle-availability.ts
@@ -0,0 +1,126 @@
+import {Agent, ComAtprotoTempCheckHandleAvailability} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+
+import {
+  BSKY_SERVICE,
+  BSKY_SERVICE_DID,
+  PUBLIC_BSKY_SERVICE,
+} from '#/lib/constants'
+import {createFullHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {useDebouncedValue} from '#/components/live/utils'
+import * as bsky from '#/types/bsky'
+
+export const RQKEY_handleAvailability = (
+  handle: string,
+  domain: string,
+  serviceDid: string,
+) => ['handle-availability', {handle, domain, serviceDid}]
+
+export function useHandleAvailabilityQuery(
+  {
+    username,
+    serviceDomain,
+    serviceDid,
+    enabled,
+    birthDate,
+    email,
+  }: {
+    username: string
+    serviceDomain: string
+    serviceDid: string
+    enabled: boolean
+    birthDate?: string
+    email?: string
+  },
+  debounceDelayMs = 500,
+) {
+  const name = username.trim()
+  const debouncedHandle = useDebouncedValue(name, debounceDelayMs)
+
+  return {
+    debouncedUsername: debouncedHandle,
+    enabled: enabled && name === debouncedHandle,
+    query: useQuery({
+      enabled: enabled && name === debouncedHandle,
+      queryKey: RQKEY_handleAvailability(
+        debouncedHandle,
+        serviceDomain,
+        serviceDid,
+      ),
+      queryFn: async () => {
+        const handle = createFullHandle(name, serviceDomain)
+        return await checkHandleAvailability(handle, serviceDid, {
+          email,
+          birthDate,
+          typeahead: true,
+        })
+      },
+    }),
+  }
+}
+
+export async function checkHandleAvailability(
+  handle: string,
+  serviceDid: string,
+  {
+    email,
+    birthDate,
+    typeahead,
+  }: {
+    email?: string
+    birthDate?: string
+    typeahead?: boolean
+  },
+) {
+  if (serviceDid === BSKY_SERVICE_DID) {
+    const agent = new Agent({service: BSKY_SERVICE})
+    // entryway has a special API for handle availability
+    const {data} = await agent.com.atproto.temp.checkHandleAvailability({
+      handle,
+      birthDate,
+      email,
+    })
+
+    if (
+      bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultAvailable>(
+        data.result,
+        ComAtprotoTempCheckHandleAvailability.isResultAvailable,
+      )
+    ) {
+      logger.metric('signup:handleAvailable', {typeahead}, {statsig: true})
+
+      return {available: true} as const
+    } else if (
+      bsky.dangerousIsType<ComAtprotoTempCheckHandleAvailability.ResultUnavailable>(
+        data.result,
+        ComAtprotoTempCheckHandleAvailability.isResultUnavailable,
+      )
+    ) {
+      logger.metric('signup:handleTaken', {typeahead}, {statsig: true})
+      return {
+        available: false,
+        suggestions: data.result.suggestions,
+      } as const
+    } else {
+      throw new Error(
+        `Unexpected result of \`checkHandleAvailability\`: ${JSON.stringify(data.result)}`,
+      )
+    }
+  } else {
+    // 3rd party PDSes won't have this API so just try and resolve the handle
+    const agent = new Agent({service: PUBLIC_BSKY_SERVICE})
+    try {
+      const res = await agent.resolveHandle({
+        handle,
+      })
+
+      if (res.data.did) {
+        logger.metric('signup:handleTaken', {typeahead}, {statsig: true})
+        return {available: false} as const
+      }
+    } catch {}
+    logger.metric('signup:handleAvailable', {typeahead}, {statsig: true})
+    return {available: true} as const
+  }
+}
diff --git a/yarn.lock b/yarn.lock
index 3cd6abc12..c6ef295ea 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -63,10 +63,10 @@
     "@atproto/xrpc" "^0.7.1"
     "@atproto/xrpc-server" "^0.9.1"
 
-"@atproto/api@^0.15.26":
-  version "0.15.26"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.26.tgz#452019d6d0753d4caa0f7941e8e87e9f8bfbee52"
-  integrity sha512-AdXGjeCpLZiP9YMGi4YOdK1ayqkBhklmGfSG8UefqR6tTHth59PZvYs5KiwLnFhedt2Xljt3eUlhkn14Y48wEA==
+"@atproto/api@^0.16.2":
+  version "0.16.2"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd"
+  integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ==
   dependencies:
     "@atproto/common-web" "^0.4.2"
     "@atproto/lexicon" "^0.4.12"