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