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