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 7b45f4790e
commit 56549e7dd0
8 changed files with 243 additions and 69 deletions
+1
View File
@@ -171,6 +171,7 @@
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"libphonenumber-js": "^1.12.30",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@@ -4,6 +4,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import { import {
type CountryCode,
getDefaultCountry, getDefaultCountry,
INTERNATIONAL_TELEPHONE_CODES, INTERNATIONAL_TELEPHONE_CODES,
} from '#/lib/international-telephone-codes' } from '#/lib/international-telephone-codes'
@@ -23,8 +24,8 @@ export function InternationalPhoneCodeSelect({
value, value,
onChange, onChange,
}: { }: {
value?: string value?: CountryCode
onChange: (value: string) => void onChange: (value: CountryCode) => void
}) { }) {
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const location = useGeolocation() 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 {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' 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 {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useGeolocationStatus} from '#/state/geolocation' import {useGeolocationStatus} from '#/state/geolocation'
@@ -18,6 +21,10 @@ import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {
getCountryCodeFromPastedNumber,
processPhoneNumber,
} from '../phone-number'
import {type Action, type State} from '../state' import {type Action, type State} from '../state'
export function PhoneInput({ export function PhoneInput({
@@ -40,24 +47,31 @@ export function PhoneInput({
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '') const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
const gutters = useGutters([0, 'wide']) const gutters = useGutters([0, 'wide'])
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
// for API/generic errors
const [error, setError] = useState('') const [error, setError] = useState('')
// for issues with parsing the number
const [formatError, setFormatError] = useState('')
const {mutate: submit, isPending} = useMutation({ const {mutate: submit, isPending} = useMutation({
mutationFn: async ({}: {countryCode: string; phoneNumber: string}) => { mutationFn: async ({}: {
phoneCountryCode: CountryCode
phoneNumber: string
}) => {
// get otp // get otp
await new Promise(resolve => { await new Promise(resolve => {
setTimeout(resolve, 500) setTimeout(resolve, 500)
}) })
}, },
onSuccess: (_data, {countryCode, phoneNumber}) => { onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
dispatch({ dispatch({
type: 'SUBMIT_PHONE_NUMBER', type: 'SUBMIT_PHONE_NUMBER',
payload: {phoneCountryCode: countryCode, phoneNumber}, payload: {phoneCountryCode, phoneNumber},
}) })
}, },
onMutate: () => { onMutate: () => {
Keyboard.dismiss() Keyboard.dismiss()
setError('') setError('')
setFormatError('')
}, },
onError: err => { onError: err => {
if (isNetworkError(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) const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
return ( return (
@@ -126,32 +150,38 @@ export function PhoneInput({
/> />
</View> </View>
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<TextField.Root> <TextField.Root isInvalid={!!formatError}>
<TextField.Input <TextField.Input
label={_(msg`Phone number`)} label={_(msg`Phone number`)}
value={phoneNumber} 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} placeholder={null}
keyboardType="phone-pad" keyboardType="number-pad" // we don't want people entering +() etc
autoComplete="tel" autoComplete="tel"
returnKeyType={android('next')} returnKeyType={android('next')}
onSubmitEditing={() => submit({countryCode, phoneNumber})} onSubmitEditing={onSubmitNumber}
/> />
</TextField.Root> </TextField.Root>
</View> </View>
</View> </View>
</View> </View>
{error && (
<Text {error && <ErrorText>{error}</ErrorText>}
style={[ {formatError && <ErrorText>{formatError}</ErrorText>}
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_xl,
]}>
{error}
</Text>
)}
<View style={[a.mt_auto, a.py_xl]}> <View style={[a.mt_auto, a.py_xl]}>
<LegalDisclaimer /> <LegalDisclaimer />
</View> </View>
@@ -165,7 +195,7 @@ export function PhoneInput({
label={_(msg`Next`)} label={_(msg`Next`)}
size="large" size="large"
color="primary" color="primary"
onPress={() => submit({countryCode, phoneNumber})}> onPress={onSubmitNumber}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
</ButtonText> </ButtonText>
@@ -217,3 +247,18 @@ function LegalDisclaimer() {
</View> </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 {useReducer} from 'react'
import {type ExistingContact} from 'expo-contacts' import {type ExistingContact} from 'expo-contacts'
import {type CountryCode} from '#/lib/international-telephone-codes'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export type Contact = ExistingContact export type Contact = ExistingContact
@@ -14,12 +15,12 @@ export type Match = {
export type State = export type State =
| { | {
step: '1: phone input' step: '1: phone input'
phoneCountryCode?: string phoneCountryCode?: CountryCode
phoneNumber?: string phoneNumber?: string
} }
| { | {
step: '2: verify number' step: '2: verify number'
phoneCountryCode: string phoneCountryCode: CountryCode
phoneNumber: string phoneNumber: string
lastSentAt: Date lastSentAt: Date
} }
@@ -41,7 +42,7 @@ export type Action =
| { | {
type: 'SUBMIT_PHONE_NUMBER' type: 'SUBMIT_PHONE_NUMBER'
payload: { payload: {
phoneCountryCode: string phoneCountryCode: CountryCode
phoneNumber: string 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 = { export const INTERNATIONAL_TELEPHONE_CODES = {
AD: { AD: {
code: '+376', code: '+376',
@@ -34,11 +45,13 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇦🇴', unicodeFlag: '🇦🇴',
svgFlag: require('../../assets/icons/flags/AO.svg'), svgFlag: require('../../assets/icons/flags/AO.svg'),
}, },
AQ: { // sorry penguins :(
code: '+672', // same as Norfolk Island
unicodeFlag: '🇦🇶', // AQ: {
svgFlag: require('../../assets/icons/flags/AQ.svg'), // code: '+672',
}, // unicodeFlag: '🇦🇶',
// svgFlag: require('../../assets/icons/flags/AQ.svg'),
// },
AR: { AR: {
code: '+54', code: '+54',
unicodeFlag: '🇦🇷', unicodeFlag: '🇦🇷',
@@ -154,11 +167,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇧🇹', unicodeFlag: '🇧🇹',
svgFlag: require('../../assets/icons/flags/BT.svg'), svgFlag: require('../../assets/icons/flags/BT.svg'),
}, },
BV: { // same as Norway
code: '+47', // BV: {
unicodeFlag: '🇧🇻', // code: '+47',
svgFlag: require('../../assets/icons/flags/BV.svg'), // unicodeFlag: '🇧🇻',
}, // svgFlag: require('../../assets/icons/flags/BV.svg'),
// },
BW: { BW: {
code: '+267', code: '+267',
unicodeFlag: '🇧🇼', unicodeFlag: '🇧🇼',
@@ -379,11 +393,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇬🇷', unicodeFlag: '🇬🇷',
svgFlag: require('../../assets/icons/flags/GR.svg'), svgFlag: require('../../assets/icons/flags/GR.svg'),
}, },
GS: { // same as Falkland Islands
code: '+500', // GS: {
unicodeFlag: '🇬🇸', // code: '+500',
svgFlag: require('../../assets/icons/flags/GS.svg'), // unicodeFlag: '🇬🇸',
}, // svgFlag: require('../../assets/icons/flags/GS.svg'),
// },
GT: { GT: {
code: '+502', code: '+502',
unicodeFlag: '🇬🇹', unicodeFlag: '🇬🇹',
@@ -779,11 +794,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇵🇲', unicodeFlag: '🇵🇲',
svgFlag: require('../../assets/icons/flags/PM.svg'), svgFlag: require('../../assets/icons/flags/PM.svg'),
}, },
PN: { // same as New Zealand
code: '+64', // PN: {
unicodeFlag: '🇵🇳', // code: '+64',
svgFlag: require('../../assets/icons/flags/PN.svg'), // unicodeFlag: '🇵🇳',
}, // svgFlag: require('../../assets/icons/flags/PN.svg'),
// },
PR: { PR: {
code: '+1', code: '+1',
unicodeFlag: '🇵🇷', unicodeFlag: '🇵🇷',
@@ -1199,11 +1215,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇫🇴', unicodeFlag: '🇫🇴',
svgFlag: require('../../assets/icons/flags/FO.svg'), svgFlag: require('../../assets/icons/flags/FO.svg'),
}, },
HM: { // same as Norfolk Island
code: '+672', // HM: {
unicodeFlag: '🇭🇲', // code: '+672',
svgFlag: require('../../assets/icons/flags/HM.svg'), // unicodeFlag: '🇭🇲',
}, // svgFlag: require('../../assets/icons/flags/HM.svg'),
// },
KM: { KM: {
code: '+269', code: '+269',
unicodeFlag: '🇰🇲', unicodeFlag: '🇰🇲',
@@ -1229,16 +1246,18 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇹🇨', unicodeFlag: '🇹🇨',
svgFlag: require('../../assets/icons/flags/TC.svg'), svgFlag: require('../../assets/icons/flags/TC.svg'),
}, },
TF: { // same as Norfolk Island
code: '+672', // TF: {
unicodeFlag: '🇹🇫', // code: '+672',
svgFlag: require('../../assets/icons/flags/TF.svg'), // unicodeFlag: '🇹🇫',
}, // svgFlag: require('../../assets/icons/flags/TF.svg'),
UM: { // },
code: '+1', // same as US mainland
unicodeFlag: '🇺🇲', // UM: {
svgFlag: require('../../assets/icons/flags/UM.svg'), // code: '+1',
}, // unicodeFlag: '🇺🇲',
// svgFlag: require('../../assets/icons/flags/UM.svg'),
// },
VA: { VA: {
code: '+39', code: '+39',
unicodeFlag: '🇻🇦', unicodeFlag: '🇻🇦',
@@ -1249,25 +1268,33 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇽🇰', unicodeFlag: '🇽🇰',
svgFlag: require('../../assets/icons/flags/XK.svg'), 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 ( if (
location?.countryCode && locationCountryCode &&
location.countryCode.toUpperCase() in INTERNATIONAL_TELEPHONE_CODES locationCountryCode in INTERNATIONAL_TELEPHONE_CODES
) { ) {
return location.countryCode.toUpperCase() return locationCountryCode as CountryCode
} }
return DEFAULT_PHONE_COUNTRY return DEFAULT_PHONE_COUNTRY
} }
export function getPhoneCodeFromCountryCode(countryCode: string) { export function getPhoneCodeFromCountryCode(countryCode: string) {
const country = const country =
INTERNATIONAL_TELEPHONE_CODES[ INTERNATIONAL_TELEPHONE_CODES[countryCode.toUpperCase() as CountryCode]
countryCode.toUpperCase() as keyof typeof INTERNATIONAL_TELEPHONE_CODES
]
if (!country) throw new Error(`Country ${countryCode} not found`) if (!country) throw new Error(`Country ${countryCode} not found`)
return country.code return country.code
} }
+5
View File
@@ -14361,6 +14361,11 @@ levn@^0.4.1:
prelude-ls "^1.2.1" prelude-ls "^1.2.1"
type-check "~0.4.0" 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: lighthouse-logger@^1.0.0:
version "1.4.2" version "1.4.2"
resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa" resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa"