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
@@ -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>

After

Width:  |  Height:  |  Size: 250 B

+5
View File
@@ -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',
})
+6 -2
View File
@@ -2,6 +2,8 @@
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10 // https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
import {forceLTR} from '#/lib/strings/bidi' import {forceLTR} from '#/lib/strings/bidi'
export * from './reserved'
const VALIDATE_REGEX = 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])?$/ /^([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])?$/
@@ -34,7 +36,8 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
export interface IsValidHandle { export interface IsValidHandle {
handleChars: boolean handleChars: boolean
hyphenStartOrEnd: boolean hyphenStartOrEnd: boolean
frontLength: boolean frontLengthLongEnough: boolean
frontLengthNotTooLong: boolean
totalLength: boolean totalLength: boolean
overall: boolean overall: boolean
} }
@@ -50,7 +53,8 @@ export function validateServiceHandle(
handleChars: handleChars:
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'), hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
frontLength: str.length >= 3 && str.length <= MAX_SERVICE_HANDLE_LENGTH, frontLengthLongEnough: str.length >= 3,
frontLengthNotTooLong: str.length <= MAX_SERVICE_HANDLE_LENGTH,
totalLength: fullHandle.length <= 253, totalLength: fullHandle.length <= 253,
} }
File diff suppressed because it is too large Load Diff
+1
View File
@@ -68,6 +68,7 @@ export type MetricEvents = {
backgroundCount: number backgroundCount: number
} }
'signup:handleTaken': {} 'signup:handleTaken': {}
'signup:handleReserved': {}
'signin:hostingProviderPressed': { 'signin:hostingProviderPressed': {
hostingProviderDidChange: boolean hostingProviderDidChange: boolean
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import {Loader} from '#/components/Loader'
export interface BackNextButtonsProps { export interface BackNextButtonsProps {
hideNext?: boolean hideNext?: boolean
showRetry?: boolean showRetry?: boolean
isLoading: boolean isLoading?: boolean
isNextDisabled?: boolean isNextDisabled?: boolean
onBackPress: () => void onBackPress: () => void
onNextPress?: () => void onNextPress?: () => void
+1 -1
View File
@@ -75,7 +75,7 @@ export function StepCaptcha() {
return ( return (
<ScreenTransition> <ScreenTransition>
<View style={[a.gap_lg]}> <View style={[a.gap_lg, a.pt_lg]}>
<View <View
style={[ style={[
a.w_full, a.w_full,
+229 -90
View File
@@ -1,44 +1,72 @@
import React, {useRef} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import { import {
createFullHandle, createFullHandle,
isHandleReserved,
MAX_SERVICE_HANDLE_LENGTH, MAX_SERVICE_HANDLE_LENGTH,
validateServiceHandle, validateServiceHandle,
} from '#/lib/strings/handles' } from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useHandleAvailabilityQuery} from '#/state/queries/handle-availablility'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {ScreenTransition} from '#/screens/Login/ScreenTransition' import {ScreenTransition} from '#/screens/Login/ScreenTransition'
import {useSignupContext} from '#/screens/Signup/state' 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 * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Circle_Stroke2_Corner0_Rounded as CircleIcon} from '#/components/icons/Circle'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 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 {Text} from '#/components/Typography'
import {BackNextButtons} from './BackNextButtons' import {BackNextButtons} from './BackNextButtons'
export function StepHandle() { export function StepHandle() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme()
const {state, dispatch} = useSignupContext() const {state, dispatch} = useSignupContext()
const agent = useAgent() const agent = useAgent()
const handleValueRef = useRef<string>(state.handle) const [draftValue, setDraftValue] = useState(state.handle)
const [draftValue, setDraftValue] = React.useState(state.handle) const isNextLoading = useThrottledValue(state.isLoading, 500)
const isLoading = useThrottledValue(state.isLoading, 500)
const onNextPress = React.useCallback(async () => { const validCheck = validateServiceHandle(draftValue, state.userDomain)
const handle = handleValueRef.current.trim()
const {data: isHandleAvailable, isLoading} = useHandleAvailabilityQuery(
draftValue,
state.userDomain,
validCheck.overall,
)
const onNextPress = async () => {
if (!isHandleAvailable?.available) return
const handle = draftValue.trim()
dispatch({ dispatch({
type: 'setHandle', type: 'setHandle',
value: handle, value: handle,
}) })
const newValidCheck = validateServiceHandle(handle, state.userDomain) if (!validCheck.overall) {
if (!newValidCheck.overall) { return
}
if (isHandleReserved(handle)) {
dispatch({
type: 'setError',
value: _(msg`That username is not available`),
field: 'handle',
})
logger.metric('signup:handleReserved', {}, {statsig: true})
return return
} }
@@ -52,7 +80,7 @@ export function StepHandle() {
if (res.data.did) { if (res.data.did) {
dispatch({ dispatch({
type: 'setError', type: 'setError',
value: _(msg`That handle is already taken.`), value: _(msg`That username is already taken`),
field: 'handle', field: 'handle',
}) })
logger.metric('signup:handleTaken', {}, {statsig: true}) logger.metric('signup:handleTaken', {}, {statsig: true})
@@ -82,17 +110,10 @@ export function StepHandle() {
return return
} }
dispatch({type: 'next'}) dispatch({type: 'next'})
}, [ }
_,
dispatch,
state.activeStep,
state.serviceDescription?.phoneVerificationRequired,
state.userDomain,
agent,
])
const onBackPress = React.useCallback(() => { const onBackPress = () => {
const handle = handleValueRef.current.trim() const handle = draftValue.trim()
dispatch({ dispatch({
type: 'setHandle', type: 'setHandle',
value: handle, value: handle,
@@ -103,13 +124,18 @@ export function StepHandle() {
{activeStep: state.activeStep}, {activeStep: state.activeStep},
{statsig: true}, {statsig: true},
) )
}, [dispatch, state.activeStep]) }
const validCheck = validateServiceHandle(draftValue, state.userDomain)
return ( return (
<ScreenTransition> <ScreenTransition>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
<Text style={[a.text_sm]}>
<Trans>This can be changed at any time.</Trans>
</Text>
<View> <View>
<TextField.LabelText>
<Trans>Username</Trans>
</TextField.LabelText>
<TextField.Root> <TextField.Root>
<TextField.Icon icon={At} /> <TextField.Icon icon={At} />
<TextField.Input <TextField.Input
@@ -118,69 +144,87 @@ export function StepHandle() {
if (state.error) { if (state.error) {
dispatch({type: 'setError', value: ''}) dispatch({type: 'setError', value: ''})
} }
// replace em dash with double hyphen to fix iOS behaviour
// These need to always be in sync. setDraftValue(val.replaceAll('—', '--'))
handleValueRef.current = val
setDraftValue(val)
}} }}
label={_(msg`Type your desired username`)} label="alice.bsky.social"
defaultValue={draftValue} defaultValue={draftValue}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoFocus autoFocus
autoComplete="off" autoComplete="off"
/> />
{draftValue.length > 0 && (
<GhostText value={state.userDomain}>{draftValue}</GhostText>
)}
</TextField.Root> </TextField.Root>
</View> </View>
{draftValue !== '' && ( <LayoutAnimationConfig skipEntering skipExiting>
<Text style={[a.text_md]}> <View style={[a.gap_sm]}>
<Trans> {state.error && (
Your full username will be{' '} <Requirement fade>
<Text style={[a.text_md, a.font_bold]}> <RequirementIcon state="invalid" />
@{createFullHandle(draftValue, state.userDomain)} <RequirementText>{state.error}</RequirementText>
</Text> </Requirement>
</Trans>
</Text>
)} )}
{(isHandleAvailable || isLoading) && validCheck.overall && (
{draftValue !== '' && ( <Requirement fade>
<View <RequirementIcon
style={[ state={
a.w_full, isLoading
a.rounded_sm, ? 'loading'
a.border, : isHandleAvailable?.available
a.p_md, ? 'valid'
a.gap_sm, : 'invalid'
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 || <RequirementText>
draftValue.length > MAX_SERVICE_HANDLE_LENGTH ? ( {isLoading ? (
<Text style={[a.text_md, a.flex_1]}> <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.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> <Trans>
No longer than{' '} No longer than{' '}
<Plural <Plural
@@ -188,19 +232,19 @@ export function StepHandle() {
other="# characters" other="# characters"
/> />
</Trans> </Trans>
</Text> </RequirementText>
) : ( ) : (
<Text style={[a.text_md, a.flex_1]}> <RequirementText>
<Trans>At least 3 characters</Trans> <Trans>At least 3 characters</Trans>
</Text> </RequirementText>
)} )}
</Requirement>
</View> </View>
</View> </LayoutAnimationConfig>
)}
</View> </View>
<BackNextButtons <BackNextButtons
isLoading={isLoading} isLoading={isNextLoading}
isNextDisabled={!validCheck.overall} isNextDisabled={!validCheck.overall || !!state.error}
onBackPress={onBackPress} onBackPress={onBackPress}
onNextPress={onNextPress} onNextPress={onNextPress}
/> />
@@ -208,10 +252,105 @@ export function StepHandle() {
) )
} }
function IsValidIcon({valid}: {valid: boolean}) { function Requirement({
const t = useTheme() children,
if (!valid) { fade,
return <Times size="md" style={{color: t.palette.negative_500}} /> }: {
} children: React.ReactNode
return <Check size="md" style={{color: t.palette.positive_700}} /> 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>
)
} }
+1 -1
View File
@@ -144,7 +144,7 @@ export function StepInfo({
return ( return (
<ScreenTransition> <ScreenTransition>
<View style={[a.gap_md]}> <View style={[a.gap_md, a.pt_lg]}>
<FormError error={state.error} /> <FormError error={state.error} />
<HostingProvider <HostingProvider
minimal minimal
+4 -3
View File
@@ -143,8 +143,9 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
a.pt_2xl, a.pt_2xl,
!gtMobile && {paddingBottom: 100}, !gtMobile && {paddingBottom: 100},
]}> ]}>
<View style={[a.gap_sm, a.pb_3xl]}> <View style={[a.gap_sm, a.pb_sm]}>
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}> <Text
style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans> <Trans>
Step {state.activeStep + 1} of{' '} Step {state.activeStep + 1} of{' '}
{state.serviceDescription && {state.serviceDescription &&
@@ -153,7 +154,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
: '3'} : '3'}
</Trans> </Trans>
</Text> </Text>
<Text style={[a.text_3xl, a.font_bold]}> <Text style={[a.text_3xl, a.font_heavy]}>
{state.activeStep === SignupStep.INFO ? ( {state.activeStep === SignupStep.INFO ? (
<Trans>Your account</Trans> <Trans>Your account</Trans>
) : state.activeStep === SignupStep.HANDLE ? ( ) : state.activeStep === SignupStep.HANDLE ? (
+44
View File
@@ -0,0 +1,44 @@
import {useQuery} from '@tanstack/react-query'
import {createFullHandle, isHandleReserved} from '#/lib/strings/handles'
import {useAgent} from '#/state/session'
import {useDebouncedValue} from '#/components/live/utils'
export const RQKEY_handleAvailability = (handle: string) => [
'handle-availability',
handle,
]
export function useHandleAvailabilityQuery(
name: string,
domain: string,
enabled: boolean,
debounceDelayMs = 500,
) {
name = name.trim()
const handle = createFullHandle(name, domain)
const debouncedHandle = useDebouncedValue(handle, debounceDelayMs)
const agent = useAgent()
return useQuery({
enabled: enabled && handle === debouncedHandle,
queryKey: RQKEY_handleAvailability(debouncedHandle),
queryFn: async () => {
const frontSegment = debouncedHandle.split('.')[0]
if (isHandleReserved(frontSegment)) {
return {available: false, reason: 'reserved'} as const
}
try {
const res = await agent.resolveHandle({
handle: debouncedHandle,
})
if (res.data.did) {
return {available: false, reason: 'taken'} as const
}
} catch {}
return {available: true} as const
},
})
}