check handle as you type

This commit is contained in:
Samuel Newman
2025-07-03 14:30:15 +03:00
parent 598641742e
commit fed21caad3
11 changed files with 1359 additions and 98 deletions
+229 -90
View File
@@ -1,44 +1,72 @@
import React, {useRef} from 'react'
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 {
createFullHandle,
isHandleReserved,
MAX_SERVICE_HANDLE_LENGTH,
validateServiceHandle,
} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useHandleAvailabilityQuery} from '#/state/queries/handle-availablility'
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 {atoms as a, platform, tokens, 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 {Circle_Stroke2_Corner0_Rounded as CircleIcon} from '#/components/icons/Circle'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
import {Loader} from '#/components/Loader'
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 [draftValue, setDraftValue] = useState(state.handle)
const isNextLoading = useThrottledValue(state.isLoading, 500)
const onNextPress = React.useCallback(async () => {
const handle = handleValueRef.current.trim()
const validCheck = validateServiceHandle(draftValue, state.userDomain)
const {data: isHandleAvailable, isLoading} = useHandleAvailabilityQuery(
draftValue,
state.userDomain,
validCheck.overall,
)
const onNextPress = async () => {
if (!isHandleAvailable?.available) return
const handle = draftValue.trim()
dispatch({
type: 'setHandle',
value: handle,
})
const newValidCheck = validateServiceHandle(handle, state.userDomain)
if (!newValidCheck.overall) {
if (!validCheck.overall) {
return
}
if (isHandleReserved(handle)) {
dispatch({
type: 'setError',
value: _(msg`That username is not available`),
field: 'handle',
})
logger.metric('signup:handleReserved', {}, {statsig: true})
return
}
@@ -52,7 +80,7 @@ export function StepHandle() {
if (res.data.did) {
dispatch({
type: 'setError',
value: _(msg`That handle is already taken.`),
value: _(msg`That username is already taken`),
field: 'handle',
})
logger.metric('signup:handleTaken', {}, {statsig: true})
@@ -82,17 +110,10 @@ export function StepHandle() {
return
}
dispatch({type: 'next'})
}, [
_,
dispatch,
state.activeStep,
state.serviceDescription?.phoneVerificationRequired,
state.userDomain,
agent,
])
}
const onBackPress = React.useCallback(() => {
const handle = handleValueRef.current.trim()
const onBackPress = () => {
const handle = draftValue.trim()
dispatch({
type: 'setHandle',
value: handle,
@@ -103,13 +124,18 @@ export function StepHandle() {
{activeStep: state.activeStep},
{statsig: true},
)
}, [dispatch, state.activeStep])
}
const validCheck = validateServiceHandle(draftValue, state.userDomain)
return (
<ScreenTransition>
<View style={[a.gap_lg]}>
<Text style={[a.text_sm]}>
<Trans>This can be changed at any time.</Trans>
</Text>
<View>
<TextField.LabelText>
<Trans>Username</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={At} />
<TextField.Input
@@ -118,69 +144,87 @@ export function StepHandle() {
if (state.error) {
dispatch({type: 'setError', value: ''})
}
// These need to always be in sync.
handleValueRef.current = val
setDraftValue(val)
// replace em dash with double hyphen to fix iOS behaviour
setDraftValue(val.replaceAll('—', '--'))
}}
label={_(msg`Type your desired username`)}
label="alice.bsky.social"
defaultValue={draftValue}
autoCapitalize="none"
autoCorrect={false}
autoFocus
autoComplete="off"
/>
{draftValue.length > 0 && (
<GhostText value={state.userDomain}>{draftValue}</GhostText>
)}
</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>
<LayoutAnimationConfig skipEntering skipExiting>
<View style={[a.gap_sm]}>
{state.error && (
<Requirement fade>
<RequirementIcon state="invalid" />
<RequirementText>{state.error}</RequirementText>
</Requirement>
)}
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon
valid={validCheck.frontLength && validCheck.totalLength}
{(isHandleAvailable || isLoading) && validCheck.overall && (
<Requirement fade>
<RequirementIcon
state={
isLoading
? 'loading'
: isHandleAvailable?.available
? 'valid'
: 'invalid'
}
/>
<RequirementText>
{isLoading ? (
<Trans>Checking...</Trans>
) : isHandleAvailable?.available ? (
<Trans>Available!</Trans>
) : isHandleAvailable?.reason === 'taken' ? (
<Trans>Taken</Trans>
) : (
<Trans>Invalid</Trans>
)}
</RequirementText>
</Requirement>
)}
<Requirement>
<RequirementIcon
state={
validCheck.handleChars && validCheck.hyphenStartOrEnd
? !validCheck.frontLengthLongEnough
? 'initial'
: 'valid'
: 'invalid'
}
/>
{!validCheck.totalLength ||
draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? (
<Text style={[a.text_md, a.flex_1]}>
{!validCheck.hyphenStartOrEnd ? (
<RequirementText>
<Trans>Doesn't begin or end with a hyphen</Trans>
</RequirementText>
) : (
<RequirementText>
<Trans>Only contains letters, numbers, and hyphens</Trans>
</RequirementText>
)}
</Requirement>
<Requirement>
<RequirementIcon
state={
!validCheck.frontLengthLongEnough
? 'initial'
: validCheck.frontLengthNotTooLong && validCheck.totalLength
? 'valid'
: 'invalid'
}
/>
{!validCheck.frontLengthNotTooLong || !validCheck.totalLength ? (
<RequirementText>
<Trans>
No longer than{' '}
<Plural
@@ -188,19 +232,19 @@ export function StepHandle() {
other="# characters"
/>
</Trans>
</Text>
</RequirementText>
) : (
<Text style={[a.text_md, a.flex_1]}>
<RequirementText>
<Trans>At least 3 characters</Trans>
</Text>
</RequirementText>
)}
</View>
</Requirement>
</View>
)}
</LayoutAnimationConfig>
</View>
<BackNextButtons
isLoading={isLoading}
isNextDisabled={!validCheck.overall}
isLoading={isNextLoading}
isNextDisabled={!validCheck.overall || !!state.error}
onBackPress={onBackPress}
onNextPress={onNextPress}
/>
@@ -208,10 +252,105 @@ export function StepHandle() {
)
}
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}} />
function Requirement({
children,
fade,
}: {
children: React.ReactNode
fade?: boolean
}) {
return (
<Animated.View
style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}
layout={isNative ? LinearTransition : undefined}
entering={fade && isNative ? FadeIn : undefined}
exiting={fade && isNative ? FadeOut : undefined}>
{children}
</Animated.View>
)
}
function RequirementText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text style={[a.text_sm, a.flex_1, t.atoms.text_contrast_medium]}>
<Trans>{children}</Trans>
</Text>
)
}
function RequirementIcon({
state,
}: {
state: 'initial' | 'valid' | 'invalid' | 'loading'
}) {
const t = useTheme()
switch (state) {
case 'initial':
return <CircleIcon size="sm" style={t.atoms.text_contrast_low} />
case 'valid':
return (
<CircleCheckIcon size="sm" style={{color: t.palette.positive_700}} />
)
case 'invalid':
return <CircleXIcon size="sm" style={{color: t.palette.negative_500}} />
case 'loading':
return <Loader size="sm" style={t.atoms.text_contrast_low} />
}
}
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,
}),
},
a.pr_xs,
]}
aria-hidden={true}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants">
<Text
style={[
a.text_md,
{lineHeight: a.text_md.fontSize * 1.1875},
{color: 'transparent'},
]}
numberOfLines={1}>
{children}
<Text
style={[
t.atoms.text_contrast_low,
a.text_md,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{value}
</Text>
</Text>
</View>
)
}