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