forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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}