check handle as you type
This commit is contained in:
@@ -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 |
@@ -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',
|
||||
})
|
||||
@@ -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
@@ -68,6 +68,7 @@ export type MetricEvents = {
|
||||
backgroundCount: number
|
||||
}
|
||||
'signup:handleTaken': {}
|
||||
'signup:handleReserved': {}
|
||||
'signin:hostingProviderPressed': {
|
||||
hostingProviderDidChange: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user