Bluesky app fork with some witchin' additions 💫
at post-text-option 277 lines 8.6 kB view raw
1import {useState} from 'react' 2import {View} from 'react-native' 3import Animated, { 4 FadeIn, 5 FadeOut, 6 LayoutAnimationConfig, 7 LinearTransition, 8} from 'react-native-reanimated' 9import {msg, Plural, Trans} from '@lingui/macro' 10import {useLingui} from '@lingui/react' 11 12import { 13 createFullHandle, 14 MAX_SERVICE_HANDLE_LENGTH, 15 validateServiceHandle, 16} from '#/lib/strings/handles' 17import {logger} from '#/logger' 18import { 19 checkHandleAvailability, 20 useHandleAvailabilityQuery, 21} from '#/state/queries/handle-availability' 22import {useSignupContext} from '#/screens/Signup/state' 23import {atoms as a, native, useTheme} from '#/alf' 24import * as TextField from '#/components/forms/TextField' 25import {useThrottledValue} from '#/components/hooks/useThrottledValue' 26import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 27import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 28import {Text} from '#/components/Typography' 29import {BackNextButtons} from '../BackNextButtons' 30import {HandleSuggestions} from './HandleSuggestions' 31 32export function StepHandle() { 33 const {_} = useLingui() 34 const t = useTheme() 35 const {state, dispatch} = useSignupContext() 36 const [draftValue, setDraftValue] = useState(state.handle) 37 const isNextLoading = useThrottledValue(state.isLoading, 500) 38 39 const validCheck = validateServiceHandle(draftValue, state.userDomain) 40 41 const { 42 debouncedUsername: debouncedDraftValue, 43 enabled: queryEnabled, 44 query: {data: isHandleAvailable, isPending}, 45 } = useHandleAvailabilityQuery({ 46 username: draftValue, 47 serviceDid: state.serviceDescription?.did ?? 'UNKNOWN', 48 serviceDomain: state.userDomain, 49 birthDate: state.dateOfBirth.toISOString(), 50 email: state.email, 51 enabled: validCheck.overall, 52 }) 53 54 const onNextPress = async () => { 55 const handle = draftValue.trim() 56 dispatch({ 57 type: 'setHandle', 58 value: handle, 59 }) 60 61 if (!validCheck.overall) { 62 return 63 } 64 65 dispatch({type: 'setIsLoading', value: true}) 66 67 try { 68 const {available: handleAvailable} = await checkHandleAvailability( 69 createFullHandle(handle, state.userDomain), 70 state.serviceDescription?.did ?? 'UNKNOWN', 71 {typeahead: false}, 72 ) 73 74 if (!handleAvailable) { 75 dispatch({ 76 type: 'setError', 77 value: _(msg`That username is already taken`), 78 field: 'handle', 79 }) 80 return 81 } 82 } catch (error) { 83 logger.error('Failed to check handle availability on next press', { 84 safeMessage: error, 85 }) 86 // do nothing on error, let them pass 87 } finally { 88 dispatch({type: 'setIsLoading', value: false}) 89 } 90 91 logger.metric( 92 'signup:nextPressed', 93 { 94 activeStep: state.activeStep, 95 phoneVerificationRequired: 96 state.serviceDescription?.phoneVerificationRequired, 97 }, 98 {statsig: true}, 99 ) 100 // phoneVerificationRequired is actually whether a captcha is required 101 if (!state.serviceDescription?.phoneVerificationRequired) { 102 dispatch({ 103 type: 'submit', 104 task: {verificationCode: undefined, mutableProcessed: false}, 105 }) 106 return 107 } 108 dispatch({type: 'next'}) 109 } 110 111 const onBackPress = () => { 112 const handle = draftValue.trim() 113 dispatch({ 114 type: 'setHandle', 115 value: handle, 116 }) 117 dispatch({type: 'prev'}) 118 logger.metric( 119 'signup:backPressed', 120 {activeStep: state.activeStep}, 121 {statsig: true}, 122 ) 123 } 124 125 const hasDebounceSettled = draftValue === debouncedDraftValue 126 const isHandleTaken = 127 !isPending && 128 queryEnabled && 129 isHandleAvailable && 130 !isHandleAvailable.available 131 const isNotReady = isPending || !hasDebounceSettled 132 const isNextDisabled = 133 !validCheck.overall || !!state.error || isNotReady ? true : isHandleTaken 134 135 const textFieldInvalid = 136 isHandleTaken || 137 !validCheck.frontLengthNotTooLong || 138 !validCheck.handleChars || 139 !validCheck.hyphenStartOrEnd || 140 !validCheck.totalLength 141 142 return ( 143 <> 144 <View style={[a.gap_sm, a.pt_lg, a.z_10]}> 145 <View> 146 <TextField.Root isInvalid={textFieldInvalid}> 147 <TextField.Icon icon={AtIcon} /> 148 <TextField.Input 149 testID="handleInput" 150 onChangeText={val => { 151 if (state.error) { 152 dispatch({type: 'setError', value: ''}) 153 } 154 setDraftValue(val.toLocaleLowerCase()) 155 }} 156 label={state.userDomain} 157 value={draftValue} 158 keyboardType="ascii-capable" // fix for iOS replacing -- with — 159 autoCapitalize="none" 160 autoCorrect={false} 161 autoFocus 162 autoComplete="off" 163 /> 164 {draftValue.length > 0 && ( 165 <TextField.GhostText value={state.userDomain}> 166 {draftValue} 167 </TextField.GhostText> 168 )} 169 {isHandleAvailable?.available && ( 170 <CheckIcon 171 testID="handleAvailableCheck" 172 style={[{color: t.palette.positive_500}, a.z_20]} 173 /> 174 )} 175 </TextField.Root> 176 </View> 177 <LayoutAnimationConfig skipEntering skipExiting> 178 <View style={[a.gap_xs]}> 179 {state.error && ( 180 <Requirement> 181 <RequirementText>{state.error}</RequirementText> 182 </Requirement> 183 )} 184 {isHandleTaken && validCheck.overall && ( 185 <> 186 <Requirement> 187 <RequirementText> 188 <Trans> 189 {createFullHandle(draftValue, state.userDomain)} is not 190 available 191 </Trans> 192 </RequirementText> 193 </Requirement> 194 {isHandleAvailable.suggestions && 195 isHandleAvailable.suggestions.length > 0 && ( 196 <HandleSuggestions 197 suggestions={isHandleAvailable.suggestions} 198 onSelect={suggestion => { 199 setDraftValue( 200 suggestion.handle.slice( 201 0, 202 state.userDomain.length * -1, 203 ), 204 ) 205 logger.metric('signup:handleSuggestionSelected', { 206 method: suggestion.method, 207 }) 208 }} 209 /> 210 )} 211 </> 212 )} 213 {(!validCheck.handleChars || !validCheck.hyphenStartOrEnd) && ( 214 <Requirement> 215 {!validCheck.hyphenStartOrEnd ? ( 216 <RequirementText> 217 <Trans>Username cannot begin or end with a hyphen</Trans> 218 </RequirementText> 219 ) : ( 220 <RequirementText> 221 <Trans> 222 Username must only contain letters (a-z), numbers, and 223 hyphens 224 </Trans> 225 </RequirementText> 226 )} 227 </Requirement> 228 )} 229 <Requirement> 230 {(!validCheck.frontLengthNotTooLong || 231 !validCheck.totalLength) && ( 232 <RequirementText> 233 <Trans> 234 Username cannot be longer than{' '} 235 <Plural 236 value={MAX_SERVICE_HANDLE_LENGTH} 237 other="# characters" 238 /> 239 </Trans> 240 </RequirementText> 241 )} 242 </Requirement> 243 </View> 244 </LayoutAnimationConfig> 245 </View> 246 <Animated.View layout={native(LinearTransition)}> 247 <BackNextButtons 248 isLoading={isNextLoading} 249 isNextDisabled={isNextDisabled} 250 onBackPress={onBackPress} 251 onNextPress={onNextPress} 252 /> 253 </Animated.View> 254 </> 255 ) 256} 257 258function Requirement({children}: {children: React.ReactNode}) { 259 return ( 260 <Animated.View 261 style={[a.w_full]} 262 layout={native(LinearTransition)} 263 entering={native(FadeIn)} 264 exiting={native(FadeOut)}> 265 {children} 266 </Animated.View> 267 ) 268} 269 270function RequirementText({children}: {children: React.ReactNode}) { 271 const t = useTheme() 272 return ( 273 <Text style={[a.text_sm, a.flex_1, {color: t.palette.negative_500}]}> 274 {children} 275 </Text> 276 ) 277}