about summary refs log tree commit diff
path: root/src/screens/Signup
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Signup')
-rw-r--r--src/screens/Signup/BackNextButtons.tsx2
-rw-r--r--src/screens/Signup/StepCaptcha/CaptchaWebView.tsx88
-rw-r--r--src/screens/Signup/StepCaptcha/index.tsx89
-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/Policies.tsx52
-rw-r--r--src/screens/Signup/StepInfo/index.tsx2
-rw-r--r--src/screens/Signup/index.tsx21
-rw-r--r--src/screens/Signup/state.ts16
10 files changed, 570 insertions, 276 deletions
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/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
index caa0aa28a..27951d4cc 100644
--- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
+++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
@@ -1,20 +1,22 @@
-import React from 'react'
-import {StyleSheet} from 'react-native'
-import {WebView, WebViewNavigation} from 'react-native-webview'
-import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
+import {useEffect, useMemo, useRef} from 'react'
+import {WebView, type WebViewNavigation} from 'react-native-webview'
+import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
 
-import {SignupState} from '#/screens/Signup/state'
+import {type SignupState} from '#/screens/Signup/state'
 
 const ALLOWED_HOSTS = [
   'bsky.social',
   'bsky.app',
   'staging.bsky.app',
   'staging.bsky.dev',
+  'app.staging.bsky.dev',
   'js.hcaptcha.com',
   'newassets.hcaptcha.com',
   'api2.hcaptcha.com',
 ]
 
+const MIN_DELAY = 3_500
+
 export function CaptchaWebView({
   url,
   stateParam,
@@ -28,49 +30,67 @@ export function CaptchaWebView({
   onSuccess: (code: string) => void
   onError: (error: unknown) => void
 }) {
-  const redirectHost = React.useMemo(() => {
+  const startedAt = useRef(Date.now())
+  const successTo = useRef<NodeJS.Timeout>()
+
+  useEffect(() => {
+    return () => {
+      if (successTo.current) {
+        clearTimeout(successTo.current)
+      }
+    }
+  }, [])
+
+  const redirectHost = useMemo(() => {
     if (!state?.serviceUrl) return 'bsky.app'
 
     return state?.serviceUrl &&
       new URL(state?.serviceUrl).host === 'staging.bsky.dev'
-      ? 'staging.bsky.app'
+      ? 'app.staging.bsky.dev'
       : 'bsky.app'
   }, [state?.serviceUrl])
 
-  const wasSuccessful = React.useRef(false)
+  const wasSuccessful = useRef(false)
 
-  const onShouldStartLoadWithRequest = React.useCallback(
-    (event: ShouldStartLoadRequest) => {
-      const urlp = new URL(event.url)
-      return ALLOWED_HOSTS.includes(urlp.host)
-    },
-    [],
-  )
+  const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => {
+    const urlp = new URL(event.url)
+    return ALLOWED_HOSTS.includes(urlp.host)
+  }
 
-  const onNavigationStateChange = React.useCallback(
-    (e: WebViewNavigation) => {
-      if (wasSuccessful.current) return
+  const onNavigationStateChange = (e: WebViewNavigation) => {
+    if (wasSuccessful.current) return
 
-      const urlp = new URL(e.url)
-      if (urlp.host !== redirectHost) return
+    const urlp = new URL(e.url)
+    if (urlp.host !== redirectHost || urlp.pathname === '/gate/signup') return
 
-      const code = urlp.searchParams.get('code')
-      if (urlp.searchParams.get('state') !== stateParam || !code) {
-        onError({error: 'Invalid state or code'})
-        return
-      }
+    const code = urlp.searchParams.get('code')
+    if (urlp.searchParams.get('state') !== stateParam || !code) {
+      onError({error: 'Invalid state or code'})
+      return
+    }
 
-      wasSuccessful.current = true
+    // We want to delay the completion of this screen ever so slightly so that it doesn't appear to be a glitch if it completes too fast
+    wasSuccessful.current = true
+    const now = Date.now()
+    const timeTaken = now - startedAt.current
+    if (timeTaken < MIN_DELAY) {
+      successTo.current = setTimeout(() => {
+        onSuccess(code)
+      }, MIN_DELAY - timeTaken)
+    } else {
       onSuccess(code)
-    },
-    [redirectHost, stateParam, onSuccess, onError],
-  )
+    }
+  }
 
   return (
     <WebView
       source={{uri: url}}
       javaScriptEnabled
-      style={styles.webview}
+      style={{
+        flex: 1,
+        backgroundColor: 'transparent',
+        borderRadius: 10,
+      }}
       onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
       onNavigationStateChange={onNavigationStateChange}
       scrollEnabled={false}
@@ -83,11 +103,3 @@ export function CaptchaWebView({
     />
   )
 }
-
-const styles = StyleSheet.create({
-  webview: {
-    flex: 1,
-    backgroundColor: 'transparent',
-    borderRadius: 10,
-  },
-})
diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
index 388deecaf..8ea893c4a 100644
--- a/src/screens/Signup/StepCaptcha/index.tsx
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -1,21 +1,74 @@
-import React from 'react'
-import {ActivityIndicator, View} from 'react-native'
+import React, {useEffect, useState} from 'react'
+import {ActivityIndicator, Platform, View} from 'react-native'
+import ReactNativeDeviceAttest from 'react-native-device-attest'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {nanoid} from 'nanoid/non-secure'
 
 import {createFullHandle} from '#/lib/strings/handles'
 import {logger} from '#/logger'
+import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {ScreenTransition} from '#/screens/Login/ScreenTransition'
 import {useSignupContext} from '#/screens/Signup/state'
 import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
 import {atoms as a, useTheme} from '#/alf'
 import {FormError} from '#/components/forms/FormError'
+import {GCP_PROJECT_ID} from '#/env'
 import {BackNextButtons} from '../BackNextButtons'
 
-const CAPTCHA_PATH = '/gate/signup'
+const CAPTCHA_PATH =
+  isWeb || GCP_PROJECT_ID === 0 ? '/gate/signup' : '/gate/signup/attempt-attest'
 
 export function StepCaptcha() {
+  if (isWeb) {
+    return <StepCaptchaInner />
+  } else {
+    return <StepCaptchaNative />
+  }
+}
+
+export function StepCaptchaNative() {
+  const [token, setToken] = useState<string>()
+  const [payload, setPayload] = useState<string>()
+  const [ready, setReady] = useState(false)
+
+  useEffect(() => {
+    ;(async () => {
+      logger.debug('trying to generate attestation token...')
+      try {
+        if (isIOS) {
+          logger.debug('starting to generate devicecheck token...')
+          const token = await ReactNativeDeviceAttest.getDeviceCheckToken()
+          setToken(token)
+          logger.debug(`generated devicecheck token: ${token}`)
+        } else {
+          const {token, payload} =
+            await ReactNativeDeviceAttest.getIntegrityToken('signup')
+          setToken(token)
+          setPayload(base64UrlEncode(payload))
+        }
+      } catch (e: any) {
+        logger.error(e)
+      } finally {
+        setReady(true)
+      }
+    })()
+  }, [])
+
+  if (!ready) {
+    return <View />
+  }
+
+  return <StepCaptchaInner token={token} payload={payload} />
+}
+
+function StepCaptchaInner({
+  token,
+  payload,
+}: {
+  token?: string
+  payload?: string
+}) {
   const {_} = useLingui()
   const theme = useTheme()
   const {state, dispatch} = useSignupContext()
@@ -33,8 +86,24 @@ export function StepCaptcha() {
     newUrl.searchParams.set('state', stateParam)
     newUrl.searchParams.set('colorScheme', theme.name)
 
+    if (isNative && token) {
+      newUrl.searchParams.set('platform', Platform.OS)
+      newUrl.searchParams.set('token', token)
+      if (isAndroid && payload) {
+        newUrl.searchParams.set('payload', payload)
+      }
+    }
+
     return newUrl.href
-  }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
+  }, [
+    state.serviceUrl,
+    state.handle,
+    state.userDomain,
+    stateParam,
+    theme.name,
+    token,
+    payload,
+  ])
 
   const onSuccess = React.useCallback(
     (code: string) => {
@@ -75,7 +144,7 @@ export function StepCaptcha() {
 
   return (
     <ScreenTransition>
-      <View style={[a.gap_lg]}>
+      <View style={[a.gap_lg, a.pt_lg]}>
         <View
           style={[
             a.w_full,
@@ -105,3 +174,13 @@ export function StepCaptcha() {
     </ScreenTransition>
   )
 }
+
+function base64UrlEncode(data: string): string {
+  const encoder = new TextEncoder()
+  const bytes = encoder.encode(data)
+
+  const binaryString = String.fromCharCode(...bytes)
+  const base64 = btoa(binaryString)
+
+  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, '')
+}
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/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx
index 17980172d..0bc14fa6a 100644
--- a/src/screens/Signup/StepInfo/Policies.tsx
+++ b/src/screens/Signup/StepInfo/Policies.tsx
@@ -4,11 +4,42 @@ import {type ComAtprotoServerDescribeServer} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {webLinks} from '#/lib/constants'
+import {useGate} from '#/lib/statsig/statsig'
 import {atoms as a, useTheme} from '#/alf'
-import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {Admonition} from '#/components/Admonition'
 import {InlineLinkText} from '#/components/Link'
 import {Text} from '#/components/Typography'
 
+function CommunityGuidelinesNotice({}: {}) {
+  const {_} = useLingui()
+  const gate = useGate()
+
+  if (gate('disable_onboarding_policy_update_notice')) return null
+
+  return (
+    <View style={[a.pt_xs]}>
+      <Admonition type="tip">
+        <Trans>
+          You also agree to{' '}
+          <InlineLinkText
+            label={_(msg`Bluesky's Community Guidelines`)}
+            to={webLinks.communityDeprecated}>
+            Bluesky’s Community Guidelines
+          </InlineLinkText>
+          . An{' '}
+          <InlineLinkText
+            label={_(msg`Bluesky's Updated Community Guidelines`)}
+            to={webLinks.community}>
+            updated version of our Community Guidelines
+          </InlineLinkText>{' '}
+          will take effect on October 13th.
+        </Trans>
+      </Admonition>
+    </View>
+  )
+}
+
 export const Policies = ({
   serviceDescription,
   needsGuardian,
@@ -30,14 +61,13 @@ export const Policies = ({
 
   if (!tos && !pp) {
     return (
-      <View style={[a.flex_row, a.align_center, a.gap_xs]}>
-        <CircleInfo size="md" fill={t.atoms.text_contrast_low.color} />
-
-        <Text style={[t.atoms.text_contrast_medium]}>
+      <View style={[a.gap_sm]}>
+        <Admonition type="info">
           <Trans>
             This service has not provided terms of service or a privacy policy.
           </Trans>
-        </Text>
+        </Admonition>
+        <CommunityGuidelinesNotice />
       </View>
     )
   }
@@ -102,19 +132,21 @@ export const Policies = ({
       ) : null}
 
       {under13 ? (
-        <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+        <Admonition type="error">
           <Trans>
             You must be 13 years of age or older to create an account.
           </Trans>
-        </Text>
+        </Admonition>
       ) : needsGuardian ? (
-        <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+        <Admonition type="warning">
           <Trans>
             If you are not yet an adult according to the laws of your country,
             your parent or legal guardian must read these Terms on your behalf.
           </Trans>
-        </Text>
+        </Admonition>
       ) : undefined}
+
+      <CommunityGuidelinesNotice />
     </View>
   )
 }
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 03f4e2cdd..807bbff4f 100644
--- a/src/screens/Signup/index.tsx
+++ b/src/screens/Signup/index.tsx
@@ -1,11 +1,14 @@
 import {useEffect, useReducer, useState} from 'react'
 import {AppState, type AppStateStatus, View} from 'react-native'
+import ReactNativeDeviceAttest from 'react-native-device-attest'
 import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
 import {AppBskyGraphStarterpack} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {FEEDBACK_FORM_URL} from '#/lib/constants'
+import {logger} from '#/logger'
+import {isAndroid} from '#/platform/detection'
 import {useServiceQuery} from '#/state/queries/service'
 import {useStarterPackQuery} from '#/state/queries/starter-packs'
 import {useActiveStarterPack} from '#/state/shell/starter-pack'
@@ -26,6 +29,7 @@ import {Divider} from '#/components/Divider'
 import {LinearGradientBackground} from '#/components/LinearGradientBackground'
 import {InlineLinkText} from '#/components/Link'
 import {Text} from '#/components/Typography'
+import {GCP_PROJECT_ID} from '#/env'
 import * as bsky from '#/types/bsky'
 
 export function Signup({onPressBack}: {onPressBack: () => void}) {
@@ -101,6 +105,16 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
     return () => subscription.remove()
   }, [])
 
+  // On Android, warmup the Play Integrity API on the signup screen so it is ready by the time we get to the gate screen.
+  useEffect(() => {
+    if (!isAndroid) {
+      return
+    }
+    ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(err =>
+      logger.error(err),
+    )
+  }, [])
+
   return (
     <SignupContext.Provider value={{state, dispatch}}>
       <LoggedOutLayout
@@ -143,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 &&
@@ -153,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/screens/Signup/state.ts b/src/screens/Signup/state.ts
index 48ea4ccd9..ae0b20f1c 100644
--- a/src/screens/Signup/state.ts
+++ b/src/screens/Signup/state.ts
@@ -15,6 +15,7 @@ import {getAge} from '#/lib/strings/time'
 import {logger} from '#/logger'
 import {useSessionApi} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
+import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
 
 export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
 
@@ -252,6 +253,8 @@ export function useSubmitSignup() {
   const {_} = useLingui()
   const {createAccount} = useSessionApi()
   const onboardingDispatch = useOnboardingDispatch()
+  const preemptivelyCompleteActivePolicyUpdate =
+    usePreemptivelyCompleteActivePolicyUpdate()
 
   return useCallback(
     async (state: SignupState, dispatch: (action: SignupAction) => void) => {
@@ -325,6 +328,12 @@ export function useSubmitSignup() {
           },
         )
 
+        /**
+         * Marks any active policy update as completed, since user just agreed
+         * to TOS/privacy during sign up
+         */
+        preemptivelyCompleteActivePolicyUpdate()
+
         /*
          * Must happen last so that if the user has multiple tabs open and
          * createAccount fails, one tab is not stuck in onboarding — Eric
@@ -363,6 +372,11 @@ export function useSubmitSignup() {
         dispatch({type: 'setIsLoading', value: false})
       }
     },
-    [_, onboardingDispatch, createAccount],
+    [
+      _,
+      onboardingDispatch,
+      createAccount,
+      preemptivelyCompleteActivePolicyUpdate,
+    ],
   )
 }