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"