add a bunch of number parsing logic with libphonenumber

This commit is contained in:
Samuel Newman
2025-12-02 19:54:43 +02:00
parent 47f237860e
commit 11c8b06a19
8 changed files with 243 additions and 69 deletions
+1
View File
@@ -171,6 +171,7 @@
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.12.30",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
@@ -4,6 +4,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type CountryCode,
getDefaultCountry,
INTERNATIONAL_TELEPHONE_CODES,
} from '#/lib/international-telephone-codes'
@@ -23,8 +24,8 @@ export function InternationalPhoneCodeSelect({
value,
onChange,
}: {
value?: string
onChange: (value: string) => void
value?: CountryCode
onChange: (value: CountryCode) => void
}) {
const {_, i18n} = useLingui()
const location = useGeolocation()
@@ -0,0 +1,3 @@
export function SyncContactsFlow() {
throw new Error('SyncContactsFlow is not available on web')
}
+91
View File
@@ -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
}
}
+65 -20
View File
@@ -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>
)
}
+4 -3
View File
@@ -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
}
}
+71 -44
View File
@@ -1,3 +1,14 @@
import {type CountryCode as LibPhoneNumberJsCountryCode} from 'libphonenumber-js'
// Exclude Ascension Island and Tristan da Cunha - merged into `SH` in 2009
export type CountryCode = Exclude<LibPhoneNumberJsCountryCode, 'AC' | 'TA'>
/**
* Note: data is from Wikipedia, but some have been removed to match `libphonenumber-js`
* Mostly tiny British overseas territories + Antarctica, all of which
* share codes with a larger country. If you've one of the 10 people from these
* places, you probably know what to do.
*/
export const INTERNATIONAL_TELEPHONE_CODES = {
AD: {
code: '+376',
@@ -34,11 +45,13 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇦🇴',
svgFlag: require('../../assets/icons/flags/AO.svg'),
},
AQ: {
code: '+672',
unicodeFlag: '🇦🇶',
svgFlag: require('../../assets/icons/flags/AQ.svg'),
},
// sorry penguins :(
// same as Norfolk Island
// AQ: {
// code: '+672',
// unicodeFlag: '🇦🇶',
// svgFlag: require('../../assets/icons/flags/AQ.svg'),
// },
AR: {
code: '+54',
unicodeFlag: '🇦🇷',
@@ -154,11 +167,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇧🇹',
svgFlag: require('../../assets/icons/flags/BT.svg'),
},
BV: {
code: '+47',
unicodeFlag: '🇧🇻',
svgFlag: require('../../assets/icons/flags/BV.svg'),
},
// same as Norway
// BV: {
// code: '+47',
// unicodeFlag: '🇧🇻',
// svgFlag: require('../../assets/icons/flags/BV.svg'),
// },
BW: {
code: '+267',
unicodeFlag: '🇧🇼',
@@ -379,11 +393,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇬🇷',
svgFlag: require('../../assets/icons/flags/GR.svg'),
},
GS: {
code: '+500',
unicodeFlag: '🇬🇸',
svgFlag: require('../../assets/icons/flags/GS.svg'),
},
// same as Falkland Islands
// GS: {
// code: '+500',
// unicodeFlag: '🇬🇸',
// svgFlag: require('../../assets/icons/flags/GS.svg'),
// },
GT: {
code: '+502',
unicodeFlag: '🇬🇹',
@@ -779,11 +794,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇵🇲',
svgFlag: require('../../assets/icons/flags/PM.svg'),
},
PN: {
code: '+64',
unicodeFlag: '🇵🇳',
svgFlag: require('../../assets/icons/flags/PN.svg'),
},
// same as New Zealand
// PN: {
// code: '+64',
// unicodeFlag: '🇵🇳',
// svgFlag: require('../../assets/icons/flags/PN.svg'),
// },
PR: {
code: '+1',
unicodeFlag: '🇵🇷',
@@ -1199,11 +1215,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇫🇴',
svgFlag: require('../../assets/icons/flags/FO.svg'),
},
HM: {
code: '+672',
unicodeFlag: '🇭🇲',
svgFlag: require('../../assets/icons/flags/HM.svg'),
},
// same as Norfolk Island
// HM: {
// code: '+672',
// unicodeFlag: '🇭🇲',
// svgFlag: require('../../assets/icons/flags/HM.svg'),
// },
KM: {
code: '+269',
unicodeFlag: '🇰🇲',
@@ -1229,16 +1246,18 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇹🇨',
svgFlag: require('../../assets/icons/flags/TC.svg'),
},
TF: {
code: '+672',
unicodeFlag: '🇹🇫',
svgFlag: require('../../assets/icons/flags/TF.svg'),
},
UM: {
code: '+1',
unicodeFlag: '🇺🇲',
svgFlag: require('../../assets/icons/flags/UM.svg'),
},
// same as Norfolk Island
// TF: {
// code: '+672',
// unicodeFlag: '🇹🇫',
// svgFlag: require('../../assets/icons/flags/TF.svg'),
// },
// same as US mainland
// UM: {
// code: '+1',
// unicodeFlag: '🇺🇲',
// svgFlag: require('../../assets/icons/flags/UM.svg'),
// },
VA: {
code: '+39',
unicodeFlag: '🇻🇦',
@@ -1249,25 +1268,33 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇽🇰',
svgFlag: require('../../assets/icons/flags/XK.svg'),
},
}
} satisfies Record<
CountryCode,
{
code: string
unicodeFlag: string
svgFlag: any
}
>
const DEFAULT_PHONE_COUNTRY = 'US'
const DEFAULT_PHONE_COUNTRY = 'US' as const
export function getDefaultCountry(location?: {countryCode?: string}) {
export function getDefaultCountry(location?: {
countryCode?: string
}): CountryCode {
const locationCountryCode = location?.countryCode?.toUpperCase()
if (
location?.countryCode &&
location.countryCode.toUpperCase() in INTERNATIONAL_TELEPHONE_CODES
locationCountryCode &&
locationCountryCode in INTERNATIONAL_TELEPHONE_CODES
) {
return location.countryCode.toUpperCase()
return locationCountryCode as CountryCode
}
return DEFAULT_PHONE_COUNTRY
}
export function getPhoneCodeFromCountryCode(countryCode: string) {
const country =
INTERNATIONAL_TELEPHONE_CODES[
countryCode.toUpperCase() as keyof typeof INTERNATIONAL_TELEPHONE_CODES
]
INTERNATIONAL_TELEPHONE_CODES[countryCode.toUpperCase() as CountryCode]
if (!country) throw new Error(`Country ${countryCode} not found`)
return country.code
}
+5
View File
@@ -14235,6 +14235,11 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libphonenumber-js@^1.12.30:
version "1.12.30"
resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.30.tgz#8f37c6d0546e89a399085d329214a583642e611a"
integrity sha512-KxH7uIJFD6+cR6nhdh+wY6prFiH26A3W/W1gTMXnng2PXSwVfi5MhYkdq3Z2Y7vhBVa1/5VJgpNtI76UM2njGA==
lighthouse-logger@^1.0.0:
version "1.4.2"
resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa"