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