diff --git a/app/soapbox/components/ui/phone-input/phone-input.tsx b/app/soapbox/components/ui/phone-input/phone-input.tsx index 2b140fc62..a3efc5791 100644 --- a/app/soapbox/components/ui/phone-input/phone-input.tsx +++ b/app/soapbox/components/ui/phone-input/phone-input.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import { parsePhoneNumber } from 'libphonenumber-js'; +import React, { useState, useEffect } from 'react'; -import { CountryCode, formatPhoneNumber } from 'soapbox/utils/phone'; +import { CountryCode } from 'soapbox/utils/phone'; import HStack from '../hstack/hstack'; import Input from '../input/input'; @@ -8,31 +9,49 @@ import Input from '../input/input'; import CountryCodeDropdown from './country-code-dropdown'; interface IPhoneInput extends Pick, 'required' | 'autoFocus'> { - /** Input phone number. */ + /** E164 phone number. */ value?: string, - /** E164 country code. */ - countryCode?: CountryCode, - /** Change event handler taking the formatted input. */ - onChange?: (phone: string) => void, + /** Change handler which receives the E164 phone string. */ + onChange?: (phone: string | undefined) => void, + /** Country code that's selected on mount. */ + defaultCountryCode?: CountryCode, } /** Internationalized phone input with country code picker. */ const PhoneInput: React.FC = (props) => { - const { countryCode = 1, value = '', onChange, ...rest } = props; + const { value, onChange, defaultCountryCode = '1', ...rest } = props; + + const [countryCode, setCountryCode] = useState(defaultCountryCode); + const [nationalNumber, setNationalNumber] = useState(''); const handleCountryChange = (code: CountryCode) => { - if (onChange) { - onChange(formatPhoneNumber(countryCode, value)); - } + setCountryCode(code); }; - /** Pass the formatted phone to the handler. */ const handleChange: React.ChangeEventHandler = ({ target }) => { - if (onChange) { - onChange(formatPhoneNumber(countryCode, target.value)); - } + setNationalNumber(target.value); }; + // When the internal state changes, update the external state. + useEffect(() => { + if (onChange) { + try { + const opts = { defaultCallingCode: countryCode, extract: false } as any; + const result = parsePhoneNumber(nationalNumber, opts); + + if (!result.isPossible()) { + throw result; + } + + onChange(result.format('E.164')); + } catch (e) { + // The value returned is always a valid E164 string. + // If it's not valid, it'll return undefined. + onChange(undefined); + } + } + }, [countryCode, nationalNumber]); + return ( = (props) => { diff --git a/app/soapbox/features/ui/components/modals/verify-sms-modal.tsx b/app/soapbox/features/ui/components/modals/verify-sms-modal.tsx index 10e97d3ca..5b9d1deea 100644 --- a/app/soapbox/features/ui/components/modals/verify-sms-modal.tsx +++ b/app/soapbox/features/ui/components/modals/verify-sms-modal.tsx @@ -31,13 +31,13 @@ const VerifySmsModal: React.FC = ({ onClose }) => { const isLoading = useAppSelector((state) => state.verification.isLoading); const [status, setStatus] = useState(Statuses.IDLE); - const [phone, setPhone] = useState(''); + const [phone, setPhone] = useState(); const [verificationCode, setVerificationCode] = useState(''); const [requestedAnother, setAlreadyRequestedAnother] = useState(false); - const isValid = validPhoneNumberRegex.test(phone); + const isValid = phone ? validPhoneNumberRegex.test(phone) : false; - const onChange = useCallback((phone: string) => { + const onChange = useCallback((phone?: string) => { setPhone(phone); }, []); @@ -57,7 +57,7 @@ const VerifySmsModal: React.FC = ({ onClose }) => { return; } - dispatch(reRequestPhoneVerification(phone)).then(() => { + dispatch(reRequestPhoneVerification(phone!)).then(() => { dispatch( snackbar.success( intl.formatMessage({ diff --git a/app/soapbox/features/verification/steps/sms-verification.tsx b/app/soapbox/features/verification/steps/sms-verification.tsx index 87e8e49f2..066d4332a 100644 --- a/app/soapbox/features/verification/steps/sms-verification.tsx +++ b/app/soapbox/features/verification/steps/sms-verification.tsx @@ -22,14 +22,14 @@ const SmsVerification = () => { const isLoading = useAppSelector((state) => state.verification.isLoading) as boolean; - const [phone, setPhone] = React.useState(''); + const [phone, setPhone] = React.useState(); const [status, setStatus] = React.useState(Statuses.IDLE); const [verificationCode, setVerificationCode] = React.useState(''); const [requestedAnother, setAlreadyRequestedAnother] = React.useState(false); - const isValid = validPhoneNumberRegex.test(phone); + const isValid = phone ? validPhoneNumberRegex.test(phone) : false; - const onChange = React.useCallback((phone: string) => { + const onChange = React.useCallback((phone?: string) => { setPhone(phone); }, []); @@ -49,7 +49,7 @@ const SmsVerification = () => { return; } - dispatch(requestPhoneVerification(phone)).then(() => { + dispatch(requestPhoneVerification(phone!)).then(() => { dispatch( snackbar.success( intl.formatMessage({ diff --git a/app/soapbox/utils/__tests__/phone.test.ts b/app/soapbox/utils/__tests__/phone.test.ts index db45729a6..2908601b8 100644 --- a/app/soapbox/utils/__tests__/phone.test.ts +++ b/app/soapbox/utils/__tests__/phone.test.ts @@ -3,27 +3,27 @@ import { formatPhoneNumber } from '../phone'; describe('Phone unit tests', () => { it('Properly formats', () => { let number = ''; - expect(formatPhoneNumber(1, number)).toEqual(''); + expect(formatPhoneNumber('1', number)).toEqual(''); number = '5'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (5'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (5'); number = '55'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (55'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (55'); number = '555'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (555'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (555'); number = '55513'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (555) 13'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (555) 13'); number = '555135'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (555) 135'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (555) 135'); number = '5551350'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (555) 135-0'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (555) 135-0'); number = '5551350123'; - expect(formatPhoneNumber(1, number)).toEqual('+1 (555) 135-0123'); + expect(formatPhoneNumber('1', number)).toEqual('+1 (555) 135-0123'); }); }); diff --git a/app/soapbox/utils/phone.ts b/app/soapbox/utils/phone.ts index 913042a6d..39dfdbcd7 100644 --- a/app/soapbox/utils/phone.ts +++ b/app/soapbox/utils/phone.ts @@ -1,7 +1,7 @@ /** List of supported E164 country codes. */ const COUNTRY_CODES = [ - 1, - 44, + '1', + '44', ] as const; /** Supported E164 country code. */ diff --git a/package.json b/package.json index 92f8a1600..bcd0a208c 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "intl-pluralrules": "^1.3.1", "is-nan": "^1.2.1", "jsdoc": "~3.6.7", + "libphonenumber-js": "^1.10.8", "line-awesome": "^1.3.0", "localforage": "^1.10.0", "lodash": "^4.7.11", diff --git a/yarn.lock b/yarn.lock index 6d6828864..83bf71b1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7756,6 +7756,11 @@ li@^1.3.0: resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" integrity sha512-z34TU6GlMram52Tss5mt1m//ifRIpKH5Dqm7yUVOdHI+BQCs9qGPHFaCUTIzsWX7edN30aa2WrPwR7IO10FHaw== +libphonenumber-js@^1.10.8: + version "1.10.8" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.8.tgz#21925db0f16d4f1553dff2bbc62afdaeb03f21f0" + integrity sha512-MGgHrKRGE7sg7y0DikHybRDgTXcYv4HL+WwhDm5UAiChCNb5tcy5OEaU8XTTt5bDBwhZGCJNxoGMVBpZ4RfhIg== + lie@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"