Files
bsky-social-app/src/screens/Signup/StepHandle.tsx
T
Samuel Newman acdd320e83 tweak input
2025-08-04 13:52:40 +03:00

358 lines
10 KiB
TypeScript

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 {BSKY_SERVICE_HANDLE_ENDSWITH} from '#/lib/constants'
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, platform, tokens, useTheme, web} 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 {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 {state, dispatch} = useSignupContext()
const agent = useAgent()
const [draftValue, setDraftValue] = useState(state.handle)
const isNextLoading = useThrottledValue(state.isLoading, 500)
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,
})
if (!validCheck.overall) {
return
}
if (
state.userDomain === BSKY_SERVICE_HANDLE_ENDSWITH &&
isHandleReserved(handle)
) {
dispatch({
type: 'setError',
value: _(msg`That username is not available`),
field: 'handle',
})
logger.metric('signup:handleReserved', {}, {statsig: true})
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 username 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'})
}
const onBackPress = () => {
const handle = draftValue.trim()
dispatch({
type: 'setHandle',
value: handle,
})
dispatch({type: 'prev'})
logger.metric(
'signup:backPressed',
{activeStep: state.activeStep},
{statsig: true},
)
}
return (
<ScreenTransition>
<View style={[a.gap_lg, a.pt_lg]}>
<View>
<TextField.Root>
<TextField.Icon icon={AtIcon} />
<TextField.Input
testID="handleInput"
onChangeText={val => {
if (state.error) {
dispatch({type: 'setError', value: ''})
}
setDraftValue(val)
}}
label={state.userDomain}
defaultValue={draftValue}
keyboardType="ascii-capable" // fix for iOS replacing -- with —
autoCapitalize="none"
autoCorrect={false}
autoFocus
autoComplete="off"
/>
{draftValue.length > 0 && (
<GhostText value={state.userDomain}>{draftValue}</GhostText>
)}
</TextField.Root>
</View>
<LayoutAnimationConfig skipEntering skipExiting>
<View style={[a.gap_sm]}>
{state.error && (
<Requirement fade>
<RequirementIcon state="invalid" />
<RequirementText>{state.error}</RequirementText>
</Requirement>
)}
{(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.frontLengthNotTooShort
? '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.frontLengthNotTooShort
? 'initial'
: validCheck.frontLengthNotTooLong && validCheck.totalLength
? 'valid'
: 'invalid'
}
/>
{!validCheck.frontLengthNotTooLong || !validCheck.totalLength ? (
<RequirementText>
<Trans>
No longer than{' '}
<Plural
value={MAX_SERVICE_HANDLE_LENGTH}
other="# characters"
/>
</Trans>
</RequirementText>
) : (
<RequirementText>
<Trans>At least 3 characters</Trans>
</RequirementText>
)}
</Requirement>
</View>
</LayoutAnimationConfig>
</View>
<BackNextButtons
isLoading={isNextLoading}
isNextDisabled={!validCheck.overall || !!state.error}
onBackPress={onBackPress}
onNextPress={onNextPress}
/>
</ScreenTransition>
)
}
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,
}),
},
web(a.pr_md),
a.overflow_hidden,
a.max_w_full,
]}
aria-hidden={true}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants">
<Text
style={[
{color: 'transparent'},
a.text_md,
{lineHeight: a.text_md.fontSize * 1.1875},
a.w_full,
]}
numberOfLines={1}>
{children}
<Text
style={[
t.atoms.text_contrast_low,
a.text_md,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{value}
</Text>
</Text>
</View>
)
}