add a bunch of number parsing logic with libphonenumber
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export function SyncContactsFlow() {
|
||||
throw new Error('SyncContactsFlow is not available on web')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {t} from '@lingui/macro'
|
||||
import {
|
||||
ParseError,
|
||||
parsePhoneNumber,
|
||||
parsePhoneNumberWithError,
|
||||
type PhoneNumber,
|
||||
} from 'libphonenumber-js/max'
|
||||
|
||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
|
||||
/**
|
||||
* Intended for after the user has finished inputting their phone number.
|
||||
*/
|
||||
export function processPhoneNumber(
|
||||
number: string,
|
||||
country: CountryCode,
|
||||
):
|
||||
| {
|
||||
valid: true
|
||||
formatted: string
|
||||
}
|
||||
| {
|
||||
valid: false
|
||||
reason?: string
|
||||
} {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumberWithError(number, {
|
||||
defaultCountry: country,
|
||||
})
|
||||
if (!phoneNumber.isValid()) {
|
||||
return {valid: false, reason: t`Invalid phone number`}
|
||||
}
|
||||
const type = phoneNumber.getType()
|
||||
if (
|
||||
type !== 'MOBILE' &&
|
||||
type !== 'FIXED_LINE_OR_MOBILE' &&
|
||||
type !== 'PERSONAL_NUMBER'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: t`Number should be a mobile number`,
|
||||
}
|
||||
}
|
||||
if (phoneNumber.country !== country) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: t`Country code does not match`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
formatted: formatInternationalWithoutCountryCode(phoneNumber),
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
if (error instanceof ParseError) {
|
||||
return {valid: false, reason: error.message}
|
||||
} else {
|
||||
return {valid: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatInternationalWithoutCountryCode(phoneNumber: PhoneNumber) {
|
||||
const intl = phoneNumber.formatInternational()
|
||||
const prefix = '+' + phoneNumber.countryCallingCode
|
||||
return intl.replace(prefix, '').trim()
|
||||
}
|
||||
|
||||
export function getCountryCodeFromPastedNumber(
|
||||
text: string,
|
||||
): {countryCode: CountryCode; rest: string} | undefined {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumber(text)
|
||||
if (!phoneNumber.isValid()) {
|
||||
return undefined
|
||||
}
|
||||
const countryCode = phoneNumber.country
|
||||
// we don't have AC and TA in our dropdown - see `#/lib/international-telephone-codes`
|
||||
if (countryCode && countryCode !== 'AC' && countryCode !== 'TA') {
|
||||
return {
|
||||
countryCode,
|
||||
rest: formatInternationalWithoutCountryCode(phoneNumber),
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {getDefaultCountry} from '#/lib/international-telephone-codes'
|
||||
import {
|
||||
type CountryCode,
|
||||
getDefaultCountry,
|
||||
} from '#/lib/international-telephone-codes'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
@@ -18,6 +21,10 @@ import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
getCountryCodeFromPastedNumber,
|
||||
processPhoneNumber,
|
||||
} from '../phone-number'
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
export function PhoneInput({
|
||||
@@ -40,24 +47,31 @@ export function PhoneInput({
|
||||
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
|
||||
const gutters = useGutters([0, 'wide'])
|
||||
const insets = useSafeAreaInsets()
|
||||
// for API/generic errors
|
||||
const [error, setError] = useState('')
|
||||
// for issues with parsing the number
|
||||
const [formatError, setFormatError] = useState('')
|
||||
|
||||
const {mutate: submit, isPending} = useMutation({
|
||||
mutationFn: async ({}: {countryCode: string; phoneNumber: string}) => {
|
||||
mutationFn: async ({}: {
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
}) => {
|
||||
// get otp
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, {countryCode, phoneNumber}) => {
|
||||
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
|
||||
dispatch({
|
||||
type: 'SUBMIT_PHONE_NUMBER',
|
||||
payload: {phoneCountryCode: countryCode, phoneNumber},
|
||||
payload: {phoneCountryCode, phoneNumber},
|
||||
})
|
||||
},
|
||||
onMutate: () => {
|
||||
Keyboard.dismiss()
|
||||
setError('')
|
||||
setFormatError('')
|
||||
},
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
@@ -73,6 +87,16 @@ export function PhoneInput({
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmitNumber = () => {
|
||||
const result = processPhoneNumber(phoneNumber, countryCode)
|
||||
if (result.valid) {
|
||||
setPhoneNumber(result.formatted)
|
||||
submit({phoneCountryCode: countryCode, phoneNumber: result.formatted})
|
||||
} else {
|
||||
setFormatError(result.reason ?? _(msg`Invalid phone number`))
|
||||
}
|
||||
}
|
||||
|
||||
const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
|
||||
|
||||
return (
|
||||
@@ -126,32 +150,38 @@ export function PhoneInput({
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
<TextField.Root>
|
||||
<TextField.Root isInvalid={!!formatError}>
|
||||
<TextField.Input
|
||||
label={_(msg`Phone number`)}
|
||||
value={phoneNumber}
|
||||
onChangeText={setPhoneNumber}
|
||||
onChangeText={text => {
|
||||
if (formatError) setFormatError('')
|
||||
if (Math.abs(text.length - phoneNumber.length) > 1) {
|
||||
// possibly pasted/autocompleted? auto-switch
|
||||
// country code if possible
|
||||
const result = getCountryCodeFromPastedNumber(text)
|
||||
if (result) {
|
||||
setCountryCode(result.countryCode)
|
||||
setPhoneNumber(result.rest)
|
||||
return
|
||||
}
|
||||
}
|
||||
setPhoneNumber(text)
|
||||
}}
|
||||
placeholder={null}
|
||||
keyboardType="phone-pad"
|
||||
keyboardType="number-pad" // we don't want people entering +() etc
|
||||
autoComplete="tel"
|
||||
returnKeyType={android('next')}
|
||||
onSubmitEditing={() => submit({countryCode, phoneNumber})}
|
||||
onSubmitEditing={onSubmitNumber}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{error && (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.mt_xl,
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{error && <ErrorText>{error}</ErrorText>}
|
||||
{formatError && <ErrorText>{formatError}</ErrorText>}
|
||||
|
||||
<View style={[a.mt_auto, a.py_xl]}>
|
||||
<LegalDisclaimer />
|
||||
</View>
|
||||
@@ -165,7 +195,7 @@ export function PhoneInput({
|
||||
label={_(msg`Next`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => submit({countryCode, phoneNumber})}>
|
||||
onPress={onSubmitNumber}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
@@ -217,3 +247,18 @@ function LegalDisclaimer() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorText({children}: {children: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
{color: t.palette.negative_500},
|
||||
a.leading_snug,
|
||||
a.mt_md,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useReducer} from 'react'
|
||||
import {type ExistingContact} from 'expo-contacts'
|
||||
|
||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type Contact = ExistingContact
|
||||
@@ -14,12 +15,12 @@ export type Match = {
|
||||
export type State =
|
||||
| {
|
||||
step: '1: phone input'
|
||||
phoneCountryCode?: string
|
||||
phoneCountryCode?: CountryCode
|
||||
phoneNumber?: string
|
||||
}
|
||||
| {
|
||||
step: '2: verify number'
|
||||
phoneCountryCode: string
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
lastSentAt: Date
|
||||
}
|
||||
@@ -41,7 +42,7 @@ export type Action =
|
||||
| {
|
||||
type: 'SUBMIT_PHONE_NUMBER'
|
||||
payload: {
|
||||
phoneCountryCode: string
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user