From 11c8b06a193fbc72ab5fa3f79c04cab393664749 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 2 Dec 2025 19:54:43 +0200 Subject: [PATCH] add a bunch of number parsing logic with libphonenumber --- package.json | 1 + .../InternationalPhoneCodeSelect.tsx | 5 +- .../contacts/SyncContactsFlow.web.tsx | 3 + src/components/contacts/phone-number.ts | 91 ++++++++++++++ .../contacts/screens/PhoneInput.tsx | 85 ++++++++++--- src/components/contacts/state.ts | 7 +- src/lib/international-telephone-codes.ts | 115 +++++++++++------- yarn.lock | 5 + 8 files changed, 243 insertions(+), 69 deletions(-) create mode 100644 src/components/contacts/SyncContactsFlow.web.tsx create mode 100644 src/components/contacts/phone-number.ts diff --git a/package.json b/package.json index e3876b9ad2..c4b9f89d53 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/InternationalPhoneCodeSelect.tsx b/src/components/InternationalPhoneCodeSelect.tsx index f42f4a21a3..2a51e2ccff 100644 --- a/src/components/InternationalPhoneCodeSelect.tsx +++ b/src/components/InternationalPhoneCodeSelect.tsx @@ -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() diff --git a/src/components/contacts/SyncContactsFlow.web.tsx b/src/components/contacts/SyncContactsFlow.web.tsx new file mode 100644 index 0000000000..d40435f6fa --- /dev/null +++ b/src/components/contacts/SyncContactsFlow.web.tsx @@ -0,0 +1,3 @@ +export function SyncContactsFlow() { + throw new Error('SyncContactsFlow is not available on web') +} diff --git a/src/components/contacts/phone-number.ts b/src/components/contacts/phone-number.ts new file mode 100644 index 0000000000..df22aa2429 --- /dev/null +++ b/src/components/contacts/phone-number.ts @@ -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 + } +} diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx index a265e40263..295858eaf8 100644 --- a/src/components/contacts/screens/PhoneInput.tsx +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -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({ /> - + { + 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} /> - {error && ( - - {error} - - )} + + {error && {error}} + {formatError && {formatError}} + @@ -165,7 +195,7 @@ export function PhoneInput({ label={_(msg`Next`)} size="large" color="primary" - onPress={() => submit({countryCode, phoneNumber})}> + onPress={onSubmitNumber}> Next @@ -217,3 +247,18 @@ function LegalDisclaimer() { ) } + +function ErrorText({children}: {children: string}) { + const t = useTheme() + return ( + + {children} + + ) +} diff --git a/src/components/contacts/state.ts b/src/components/contacts/state.ts index 5a2909445f..b35483c2f1 100644 --- a/src/components/contacts/state.ts +++ b/src/components/contacts/state.ts @@ -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 } } diff --git a/src/lib/international-telephone-codes.ts b/src/lib/international-telephone-codes.ts index 355769b47b..b2a7d3d4c0 100644 --- a/src/lib/international-telephone-codes.ts +++ b/src/lib/international-telephone-codes.ts @@ -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 + +/** + * 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 } diff --git a/yarn.lock b/yarn.lock index 3b320fcae0..4ddb35f011 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"