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} 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}