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
import {forceLTR} from '#/lib/strings/bidi'
export * from './reserved'
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])?$/
@@ -34,7 +36,8 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
export interface IsValidHandle {
handleChars: boolean
hyphenStartOrEnd: boolean
frontLength: boolean
frontLengthLongEnough: boolean
frontLengthNotTooLong: boolean
totalLength: boolean
overall: boolean
}
@@ -50,7 +53,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,
frontLengthLongEnough: str.length >= 3,
frontLengthNotTooLong: str.length <= MAX_SERVICE_HANDLE_LENGTH,
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
}
'signup:handleTaken': {}
'signup:handleReserved': {}
'signin:hostingProviderPressed': {
hostingProviderDidChange: boolean
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -75,7 +75,7 @@ export function StepCaptcha() {
return (
<ScreenTransition>
<View style={[a.gap_lg]}>
<View style={[a.gap_lg, a.pt_lg]}>
<View
style={[
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 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>
)
}
+1 -1
View File
@@ -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
+4 -3
View File
@@ -143,8 +143,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 +154,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 ? (
+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
},
})
}