forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useState} from 'react'
2import {Keyboard, View} from 'react-native'
3import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
4import {useSafeAreaInsets} from 'react-native-safe-area-context'
5import {AppBskyContactStartPhoneVerification} from '@atproto/api'
6import {msg, Trans} from '@lingui/macro'
7import {useLingui} from '@lingui/react'
8import {useMutation} from '@tanstack/react-query'
9
10import {urls} from '#/lib/constants'
11import {
12 type CountryCode,
13 getDefaultCountry,
14} from '#/lib/international-telephone-codes'
15import {cleanError, isNetworkError} from '#/lib/strings/errors'
16import {logger} from '#/logger'
17import {useAgent} from '#/state/session'
18import {OnboardingPosition} from '#/screens/Onboarding/Layout'
19import {
20 android,
21 atoms as a,
22 platform,
23 tokens,
24 useGutters,
25 useTheme,
26} from '#/alf'
27import {Button, ButtonIcon, ButtonText} from '#/components/Button'
28import * as TextField from '#/components/forms/TextField'
29import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect'
30import * as Layout from '#/components/Layout'
31import {InlineLinkText} from '#/components/Link'
32import {Loader} from '#/components/Loader'
33import {Text} from '#/components/Typography'
34import {useAnalytics} from '#/analytics'
35import {useGeolocation} from '#/geolocation'
36import {isFindContactsFeatureEnabled} from '../country-allowlist'
37import {
38 constructFullPhoneNumber,
39 getCountryCodeFromPastedNumber,
40 processPhoneNumber,
41} from '../phone-number'
42import {type Action, type State, useOnPressBackButton} from '../state'
43
44export function PhoneInput({
45 state,
46 dispatch,
47 context,
48 onSkip,
49}: {
50 state: Extract<State, {step: '1: phone input'}>
51 dispatch: React.ActionDispatch<[Action]>
52 context: 'Onboarding' | 'Standalone'
53 onSkip: () => void
54}) {
55 const {_} = useLingui()
56 const ax = useAnalytics()
57 const t = useTheme()
58 const agent = useAgent()
59 const location = useGeolocation()
60 const [countryCode, setCountryCode] = useState(
61 () => state.phoneCountryCode ?? getDefaultCountry(location),
62 )
63 const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
64 const gutters = useGutters([0, 'wide'])
65 const insets = useSafeAreaInsets()
66 // for API/generic errors
67 const [error, setError] = useState('')
68 // for issues with parsing the number
69 const [formatError, setFormatError] = useState('')
70
71 const {mutate: submit, isPending} = useMutation({
72 mutationFn: async ({
73 phoneCountryCode,
74 phoneNumber,
75 }: {
76 phoneCountryCode: CountryCode
77 phoneNumber: string
78 }) => {
79 // sends a onetime code to the user's phone number
80 await agent.app.bsky.contact.startPhoneVerification({
81 phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
82 })
83 },
84 onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
85 dispatch({
86 type: 'SUBMIT_PHONE_NUMBER',
87 payload: {phoneCountryCode, phoneNumber},
88 })
89
90 ax.metric('contacts:phone:phoneEntered', {entryPoint: context})
91 },
92 onMutate: () => {
93 Keyboard.dismiss()
94 setError('')
95 setFormatError('')
96 },
97 onError: err => {
98 if (isNetworkError(err)) {
99 setError(
100 _(
101 msg`A network error occurred. Please check your internet connection`,
102 ),
103 )
104 } else if (
105 err instanceof
106 AppBskyContactStartPhoneVerification.RateLimitExceededError
107 ) {
108 setError(_(msg`Rate limit exceeded. Please try again later.`))
109 } else if (
110 err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
111 ) {
112 setError(
113 _(
114 msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
115 ),
116 )
117 } else {
118 logger.error('Verify phone number failed', {safeMessage: err})
119 setError(_(msg`An error occurred. ${cleanError(err)}`))
120 }
121 },
122 })
123
124 const isFeatureEnabled = isFindContactsFeatureEnabled(countryCode)
125
126 const onSubmitNumber = () => {
127 if (!isFeatureEnabled) return
128 if (!phoneNumber) return
129 const result = processPhoneNumber(phoneNumber, countryCode)
130 if (result.valid) {
131 setPhoneNumber(result.formatted)
132 setCountryCode(result.countryCode)
133
134 if (!isFindContactsFeatureEnabled(result.countryCode)) return
135
136 submit({
137 phoneCountryCode: result.countryCode,
138 phoneNumber: result.formatted,
139 })
140 } else {
141 setFormatError(result.reason ?? _(msg`Invalid phone number`))
142 }
143 }
144
145 const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
146
147 const onPressBack = useOnPressBackButton()
148
149 return (
150 <View style={[a.h_full]}>
151 <Layout.Header.Outer noBottomBorder>
152 <Layout.Header.BackButton onPress={onPressBack} />
153 <Layout.Header.Content />
154 {context === 'Onboarding' ? (
155 <Button
156 size="small"
157 color="secondary"
158 variant="ghost"
159 label={_(msg`Skip contact sharing and continue to the app`)}
160 onPress={onSkip}>
161 <ButtonText>
162 <Trans>Skip</Trans>
163 </ButtonText>
164 </Button>
165 ) : (
166 <Layout.Header.Slot />
167 )}
168 </Layout.Header.Outer>
169 <Layout.Content
170 contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
171 keyboardShouldPersistTaps="handled">
172 {context === 'Onboarding' && <OnboardingPosition />}
173 <Text style={[a.font_bold, a.text_3xl]}>
174 <Trans>Verify phone number</Trans>
175 </Text>
176 <Text
177 style={[
178 a.text_md,
179 t.atoms.text_contrast_medium,
180 a.leading_snug,
181 a.mt_sm,
182 ]}>
183 <Trans>
184 We need to verify your number before we can look for your friends. A
185 verification code will be sent to this number.
186 </Trans>
187 </Text>
188
189 <View style={[a.mt_2xl]}>
190 <TextField.LabelText>
191 <Trans>Phone number</Trans>
192 </TextField.LabelText>
193 <View style={[a.flex_row, a.gap_sm, a.align_center]}>
194 <View>
195 <InternationalPhoneCodeSelect
196 value={countryCode}
197 onChange={value => setCountryCode(value)}
198 />
199 </View>
200 <View style={[a.flex_1]}>
201 <TextField.Root isInvalid={!!formatError || !isFeatureEnabled}>
202 <TextField.Input
203 label={_(msg`Phone number`)}
204 value={phoneNumber}
205 onChangeText={text => {
206 if (formatError) setFormatError('')
207 if (Math.abs(text.length - phoneNumber.length) > 1) {
208 // possibly pasted/autocompleted? auto-switch
209 // country code if possible
210 const result = getCountryCodeFromPastedNumber(text)
211 if (result) {
212 setCountryCode(result.countryCode)
213 setPhoneNumber(result.rest)
214 return
215 }
216 }
217 setPhoneNumber(text)
218 }}
219 placeholder={null}
220 keyboardType={platform({
221 ios: 'number-pad',
222 android: 'phone-pad',
223 })}
224 autoComplete="tel"
225 returnKeyType={android('next')}
226 onSubmitEditing={onSubmitNumber}
227 />
228 </TextField.Root>
229 </View>
230 </View>
231 </View>
232
233 {!isFeatureEnabled && (
234 <ErrorText>
235 <Trans>
236 Support for this feature in your country has not been enabled yet!
237 Please check back later.
238 </Trans>
239 </ErrorText>
240 )}
241 {error && <ErrorText>{error}</ErrorText>}
242 {formatError && <ErrorText>{formatError}</ErrorText>}
243
244 <View style={[a.mt_auto, a.py_xl]}>
245 <LegalDisclaimer />
246 </View>
247 </Layout.Content>
248 <KeyboardAvoidingView
249 behavior="padding"
250 keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}>
251 <View style={[gutters, {paddingBottom}]}>
252 <Button
253 disabled={!phoneNumber || isPending}
254 label={_(msg`Send code`)}
255 size="large"
256 color="primary"
257 onPress={onSubmitNumber}>
258 <ButtonText>
259 <Trans>Send code</Trans>
260 </ButtonText>
261 {isPending && <ButtonIcon icon={Loader} />}
262 </Button>
263 </View>
264 </KeyboardAvoidingView>
265 </View>
266 )
267}
268
269function LegalDisclaimer() {
270 const t = useTheme()
271 const {_} = useLingui()
272
273 const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]
274
275 return (
276 <View style={[a.gap_xs]}>
277 <Text style={[style, a.font_medium]}>
278 <Trans>How we use your number:</Trans>
279 </Text>
280 <Text style={style}>
281 •{' '}
282 <Trans>Sent to our phone number verification provider Plivo</Trans>
283 </Text>
284 <Text style={style}>
285 • <Trans>Deleted by Plivo after verification</Trans>
286 </Text>
287 <Text style={style}>
288 •{' '}
289 <Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
290 </Text>
291 <Text style={style}>
292 •{' '}
293 <Trans>Stored as part of a secure code for matching with others</Trans>
294 </Text>
295 <Text style={[style, a.mt_xs]}>
296 <Trans>
297 By continuing, you consent to this use. You may change your mind any
298 time by visiting settings.{' '}
299 <InlineLinkText
300 to={urls.website.support.findFriendsPrivacyPolicy}
301 label={_(
302 msg({
303 message: `Learn more about importing contacts`,
304 context: `english-only-resource`,
305 }),
306 )}
307 style={[a.text_xs, a.leading_snug]}>
308 <Trans context="english-only-resource">Learn more</Trans>
309 </InlineLinkText>
310 </Trans>
311 </Text>
312 </View>
313 )
314}
315
316function ErrorText({children}: {children: React.ReactNode}) {
317 const t = useTheme()
318 return (
319 <Text
320 style={[
321 a.text_md,
322 {color: t.palette.negative_500},
323 a.leading_snug,
324 a.mt_md,
325 ]}>
326 {children}
327 </Text>
328 )
329}