diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index cd48426b65..042b268389 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -27,6 +27,7 @@ export { MenuList, } from './menu/menu'; export { default as Modal } from './modal/modal'; +export { default as PhoneInput } from './phone-input/phone-input'; export { default as ProgressBar } from './progress-bar/progress-bar'; export { default as Select } from './select/select'; export { default as Spinner } from './spinner/spinner'; diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 488f2733e6..9a4785c7ba 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -20,7 +20,7 @@ interface IInput extends Pick, 'maxL className?: string, /** Extra class names for the outer
element. */ outerClassName?: string, - /** URL to the svg icon. */ + /** URL to the svg icon. Cannot be used with addon. */ icon?: string, /** Internal input name. */ name?: string, @@ -31,9 +31,11 @@ interface IInput extends Pick, 'maxL /** Change event handler for the input. */ onChange?: (event: React.ChangeEvent) => void, /** HTML input type. */ - type: 'text' | 'number' | 'email' | 'tel' | 'password', + type?: 'text' | 'number' | 'email' | 'tel' | 'password', /** Whether to display the input in red. */ hasError?: boolean, + /** An element to display as prefix to input. Cannot be used with icon. */ + addon?: React.ReactElement, } /** Form input element. */ @@ -41,7 +43,7 @@ const Input = React.forwardRef( (props, ref) => { const intl = useIntl(); - const { type = 'text', icon, className, outerClassName, hasError, ...filteredProps } = props; + const { type = 'text', icon, className, outerClassName, hasError, addon, ...filteredProps } = props; const [revealed, setRevealed] = React.useState(false); @@ -59,6 +61,12 @@ const Input = React.forwardRef(
) : null} + {addon ? ( +
+ {addon} +
+ ) : null} + ( 'pr-7': isPassword, 'text-red-600 border-red-600': hasError, 'pl-8': typeof icon !== 'undefined', + 'pl-16': typeof addon !== 'undefined', }, className)} /> diff --git a/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx b/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx new file mode 100644 index 0000000000..aaf2b4ac53 --- /dev/null +++ b/app/soapbox/components/ui/phone-input/country-code-dropdown.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { COUNTRY_CODES, CountryCode } from 'soapbox/utils/phone'; + +interface ICountryCodeDropdown { + countryCode: CountryCode, + onChange(countryCode: CountryCode): void, +} + +/** Dropdown menu to select a country code. */ +const CountryCodeDropdown: React.FC = ({ countryCode, onChange }) => { + return ( + + ); +}; + +export default CountryCodeDropdown; diff --git a/app/soapbox/components/ui/phone-input/phone-input.tsx b/app/soapbox/components/ui/phone-input/phone-input.tsx new file mode 100644 index 0000000000..81d2723c42 --- /dev/null +++ b/app/soapbox/components/ui/phone-input/phone-input.tsx @@ -0,0 +1,81 @@ +import { parsePhoneNumber, AsYouType } from 'libphonenumber-js'; +import React, { useState, useEffect } from 'react'; + +import { CountryCode } from 'soapbox/utils/phone'; + +import Input from '../input/input'; + +import CountryCodeDropdown from './country-code-dropdown'; + +interface IPhoneInput extends Pick, 'required' | 'autoFocus'> { + /** E164 phone number. */ + value?: string, + /** 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 { value, onChange, defaultCountryCode = '1', ...rest } = props; + + const [countryCode, setCountryCode] = useState(defaultCountryCode); + const [nationalNumber, setNationalNumber] = useState(''); + + const handleChange: React.ChangeEventHandler = ({ target }) => { + // HACK: AsYouType is not meant to be used this way. But it works! + const asYouType = new AsYouType({ defaultCallingCode: countryCode }); + const formatted = asYouType.input(target.value); + + // If the new value is the same as before, we might be backspacing, + // so use the actual event value instead of the formatted value. + if (formatted === nationalNumber && target.value !== nationalNumber) { + setNationalNumber(target.value); + } else { + setNationalNumber(formatted); + } + }; + + // 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); + + // Throw if the number is invalid, but catch it below. + // We'll only ever call `onChange` with a valid E164 string or `undefined`. + 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]); + + useEffect(() => { + handleChange({ target: { value: nationalNumber } } as any); + }, [countryCode, nationalNumber]); + + return ( + + } + {...rest} + /> + ); +}; + +export default PhoneInput; 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 b897334865..ccc9d72214 100644 --- a/app/soapbox/features/ui/components/modals/verify-sms-modal.tsx +++ b/app/soapbox/features/ui/components/modals/verify-sms-modal.tsx @@ -6,11 +6,9 @@ import { verifyCredentials } from 'soapbox/actions/auth'; import { closeModal } from 'soapbox/actions/modals'; import snackbar from 'soapbox/actions/snackbar'; import { reConfirmPhoneVerification, reRequestPhoneVerification } from 'soapbox/actions/verification'; -import { FormGroup, Input, Modal, Stack, Text } from 'soapbox/components/ui'; -import { validPhoneNumberRegex } from 'soapbox/features/verification/steps/sms-verification'; +import { FormGroup, PhoneInput, Modal, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { getAccessToken } from 'soapbox/utils/auth'; -import { formatPhoneNumber } from 'soapbox/utils/phone'; interface IVerifySmsModal { onClose: (type: string) => void, @@ -32,16 +30,14 @@ 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; - const onChange = useCallback((event: React.ChangeEvent) => { - const formattedPhone = formatPhoneNumber(event.target.value); - - setPhone(formattedPhone); + const onChange = useCallback((phone?: string) => { + setPhone(phone); }, []); const handleSubmit = (event: React.MouseEvent) => { @@ -60,7 +56,7 @@ const VerifySmsModal: React.FC = ({ onClose }) => { return; } - dispatch(reRequestPhoneVerification(phone)).then(() => { + dispatch(reRequestPhoneVerification(phone!)).then(() => { dispatch( snackbar.success( intl.formatMessage({ @@ -141,8 +137,7 @@ const VerifySmsModal: React.FC = ({ onClose }) => { case Statuses.READY: return ( - ', () => { await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555'); await waitFor(() => { fireEvent.submit( - screen.getByRole('button'), { + screen.getByRole('button', { name: 'Next' }), { preventDefault: () => {}, }, ); @@ -56,7 +56,7 @@ describe('', () => { await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555'); await waitFor(() => { fireEvent.submit( - screen.getByRole('button'), { + screen.getByRole('button', { name: 'Next' }), { preventDefault: () => {}, }, ); @@ -90,7 +90,7 @@ describe('', () => { await userEvent.type(screen.getByLabelText('Phone Number'), '+1 (555) 555-5555'); await waitFor(() => { fireEvent.submit( - screen.getByRole('button'), { + screen.getByRole('button', { name: 'Next' }), { preventDefault: () => {}, }, ); diff --git a/app/soapbox/features/verification/steps/sms-verification.tsx b/app/soapbox/features/verification/steps/sms-verification.tsx index 4e24c8b24c..0388964c31 100644 --- a/app/soapbox/features/verification/steps/sms-verification.tsx +++ b/app/soapbox/features/verification/steps/sms-verification.tsx @@ -5,9 +5,8 @@ import OtpInput from 'react-otp-input'; import snackbar from 'soapbox/actions/snackbar'; import { confirmPhoneVerification, requestPhoneVerification } from 'soapbox/actions/verification'; -import { Button, Form, FormGroup, Input, Text } from 'soapbox/components/ui'; +import { Button, Form, FormGroup, PhoneInput, Text } from 'soapbox/components/ui'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import { formatPhoneNumber } from 'soapbox/utils/phone'; const Statuses = { IDLE: 'IDLE', @@ -15,25 +14,21 @@ const Statuses = { FAIL: 'FAIL', }; -const validPhoneNumberRegex = /^\+1\s\(\d{3}\)\s\d{3}-\d{4}/; - const SmsVerification = () => { const intl = useIntl(); const dispatch = useAppDispatch(); 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; - const onChange = React.useCallback((event) => { - const formattedPhone = formatPhoneNumber(event.target.value); - - setPhone(formattedPhone); + const onChange = React.useCallback((phone?: string) => { + setPhone(phone); }, []); const handleSubmit = React.useCallback((event) => { @@ -52,7 +47,7 @@ const SmsVerification = () => { return; } - dispatch(requestPhoneVerification(phone)).then(() => { + dispatch(requestPhoneVerification(phone!)).then(() => { dispatch( snackbar.success( intl.formatMessage({ @@ -147,8 +142,7 @@ const SmsVerification = () => {
- { ); }; -export { SmsVerification as default, validPhoneNumberRegex }; +export { SmsVerification as default }; diff --git a/app/soapbox/utils/__tests__/phone.test.ts b/app/soapbox/utils/__tests__/phone.test.ts deleted file mode 100644 index 67e130773f..0000000000 --- a/app/soapbox/utils/__tests__/phone.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { formatPhoneNumber } from '../phone'; - -describe('Phone unit tests', () => { - it('Properly formats', () => { - let number = ''; - expect(formatPhoneNumber(number)).toEqual(''); - - number = '5'; - expect(formatPhoneNumber(number)).toEqual('+1 (5'); - - number = '55'; - expect(formatPhoneNumber(number)).toEqual('+1 (55'); - - number = '555'; - expect(formatPhoneNumber(number)).toEqual('+1 (555'); - - number = '55513'; - expect(formatPhoneNumber(number)).toEqual('+1 (555) 13'); - - number = '555135'; - expect(formatPhoneNumber(number)).toEqual('+1 (555) 135'); - - number = '5551350'; - expect(formatPhoneNumber(number)).toEqual('+1 (555) 135-0'); - - number = '5551350123'; - expect(formatPhoneNumber(number)).toEqual('+1 (555) 135-0123'); - }); -}); diff --git a/app/soapbox/utils/phone.ts b/app/soapbox/utils/phone.ts index d112e66eb5..9cc175f5d5 100644 --- a/app/soapbox/utils/phone.ts +++ b/app/soapbox/utils/phone.ts @@ -1,33 +1,17 @@ +/** List of supported E164 country codes. */ +const COUNTRY_CODES = [ + '1', + '44', +] as const; -function removeFormattingFromNumber(number = '') { - if (number) { - return number.toString().replace(/\D/g, ''); - } +/** Supported E164 country code. */ +type CountryCode = typeof COUNTRY_CODES[number]; - return number; -} +/** Check whether a given value is a country code. */ +const isCountryCode = (value: any): value is CountryCode => COUNTRY_CODES.includes(value); -function formatPhoneNumber(phoneNumber = '') { - let formattedPhoneNumber = ''; - let strippedPhone = removeFormattingFromNumber(phoneNumber); - if (strippedPhone.slice(0, 1) === '1') { - strippedPhone = strippedPhone.slice(1); - } - - for (let i = 0; i < strippedPhone.length && i < 10; i++) { - const character = strippedPhone.charAt(i); - if (i === 0) { - const prefix = '+1 ('; - formattedPhoneNumber += prefix + character; - } else if (i === 3) { - formattedPhoneNumber += `) ${character}`; - } else if (i === 6) { - formattedPhoneNumber += `-${character}`; - } else { - formattedPhoneNumber += character; - } - } - return formattedPhoneNumber; -} - -export { formatPhoneNumber }; +export { + COUNTRY_CODES, + CountryCode, + isCountryCode, +}; diff --git a/package.json b/package.json index 060b867bdf..5d4bf3fb19 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 c8f4c5f60b..fcc324bc12 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"