From b74f71852152d99500c00b62af790c838ff68cad Mon Sep 17 00:00:00 2001 From: Agata Kosior Date: Fri, 31 Mar 2023 14:35:04 +0200 Subject: [PATCH 01/12] feat: create formatPhoneNumber util function --- package-lock.json | 14 +++ package.json | 1 + src/components/withLocalize.js | 27 ++---- src/libs/LocalePhoneNumber.js | 53 ----------- src/libs/ReportUtils.js | 4 +- src/libs/SidebarUtils.js | 6 +- src/libs/formatPhoneNumber.js | 61 +++++++++++++ src/pages/DetailsPage.js | 18 ++-- src/pages/home/report/ParticipantLocalTime.js | 2 +- .../home/report/ReportActionItemSingle.js | 7 +- src/pages/iou/IOUModal.js | 2 +- .../Contacts/ContactMethodDetailsPage.js | 10 ++- .../Profile/Contacts/ContactMethodsPage.js | 4 +- .../settings/Profile/Contacts/LoginField.js | 4 +- src/pages/signin/ResendValidationForm.js | 7 +- src/pages/signin/SignInPage.js | 8 +- tests/unit/LocalePhoneNumberTest.js | 88 ------------------- tests/unit/ReportUtilsTest.js | 8 +- tests/unit/formatPhoneNumberTest.js | 49 +++++++++++ 19 files changed, 188 insertions(+), 185 deletions(-) delete mode 100644 src/libs/LocalePhoneNumber.js create mode 100644 src/libs/formatPhoneNumber.js delete mode 100644 tests/unit/LocalePhoneNumberTest.js create mode 100644 tests/unit/formatPhoneNumberTest.js diff --git a/package-lock.json b/package-lock.json index 02f2d2a9d4fd..6af3a1cd0f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", "@ua/react-native-airship": "^15.2.0", + "awesome-phonenumber": "^5.3.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", @@ -17876,6 +17877,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/awesome-phonenumber": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.3.0.tgz", + "integrity": "sha512-Inioq+5cDHcbSdy400FnXs1D68fZujU5Nbm3tg+qU+j/iPFW0U5CBI/rlrjI2mGCI31GvErSMk2oiNKOR2sX5w==", + "engines": { + "node": ">=14" + } + }, "node_modules/axe-core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz", @@ -58267,6 +58276,11 @@ "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", "dev": true }, + "awesome-phonenumber": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.3.0.tgz", + "integrity": "sha512-Inioq+5cDHcbSdy400FnXs1D68fZujU5Nbm3tg+qU+j/iPFW0U5CBI/rlrjI2mGCI31GvErSMk2oiNKOR2sX5w==" + }, "axe-core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz", diff --git a/package.json b/package.json index 4583c48916c1..0fb6b5cbbc8f 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@react-navigation/drawer": "github:Expensify/react-navigation#react-navigation-drawer-v6.5.0-alpha1-gitpkg", "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", + "awesome-phonenumber": "^5.3.0", "@ua/react-native-airship": "^15.2.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js index a188e413ddf6..e0a207e871b4 100755 --- a/src/components/withLocalize.js +++ b/src/components/withLocalize.js @@ -7,12 +7,12 @@ import getComponentDisplayName from '../libs/getComponentDisplayName'; import ONYXKEYS from '../ONYXKEYS'; import * as Localize from '../libs/Localize'; import DateUtils from '../libs/DateUtils'; -import * as LocalePhoneNumber from '../libs/LocalePhoneNumber'; import * as NumberFormatUtils from '../libs/NumberFormatUtils'; import * as LocaleDigitUtils from '../libs/LocaleDigitUtils'; import CONST from '../CONST'; import compose from '../libs/compose'; import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails'; +import formatPhoneNumber from '../libs/formatPhoneNumber'; const LocaleContext = createContext(null); @@ -29,11 +29,9 @@ const withLocalizePropTypes = { /** Formats a datetime to local date and time string */ datetimeToCalendarTime: PropTypes.func.isRequired, - /** Returns a locally converted phone number without the country code */ - toLocalPhone: PropTypes.func.isRequired, - - /** Returns an internationally converted phone number with the country code */ - fromLocalPhone: PropTypes.func.isRequired, + /** Returns a locally converted phone number for numbers from the same region + * and an internationally converted phone number with the country code for numbers from other regions */ + formatPhoneNumber: PropTypes.func.isRequired, /** Gets the standard digit corresponding to a locale digit */ fromLocaleDigit: PropTypes.func.isRequired, @@ -77,8 +75,7 @@ class LocaleContextProvider extends React.Component { numberFormat: this.numberFormat.bind(this), datetimeToRelative: this.datetimeToRelative.bind(this), datetimeToCalendarTime: this.datetimeToCalendarTime.bind(this), - fromLocalPhone: this.fromLocalPhone.bind(this), - toLocalPhone: this.toLocalPhone.bind(this), + formatPhoneNumber: this.formatPhoneNumber.bind(this), fromLocaleDigit: this.fromLocaleDigit.bind(this), toLocaleDigit: this.toLocaleDigit.bind(this), preferredLocale: this.props.preferredLocale, @@ -126,19 +123,11 @@ class LocaleContextProvider extends React.Component { } /** - * @param {Number} number - * @returns {String} - */ - toLocalPhone(number) { - return LocalePhoneNumber.toLocalPhone(this.props.preferredLocale, number); - } - - /** - * @param {Number} number + * @param {String} phoneNumber * @returns {String} */ - fromLocalPhone(number) { - return LocalePhoneNumber.fromLocalPhone(this.props.preferredLocale, number); + formatPhoneNumber(phoneNumber) { + return formatPhoneNumber(phoneNumber); } /** diff --git a/src/libs/LocalePhoneNumber.js b/src/libs/LocalePhoneNumber.js deleted file mode 100644 index 3d47e7c6a5a9..000000000000 --- a/src/libs/LocalePhoneNumber.js +++ /dev/null @@ -1,53 +0,0 @@ -import lodashGet from 'lodash/get'; -import lodashTrim from 'lodash/trim'; -import lodashIncludes from 'lodash/includes'; -import lodashStartsWith from 'lodash/startsWith'; -import Str from 'expensify-common/lib/str'; -import translations from '../languages/translations'; - -/** - * Returns a locally converted phone number without the country code - * - * @param {String} locale eg 'en', 'es-ES' - * @param {String} number - * @returns {String} - */ -function toLocalPhone(locale, number) { - const numString = lodashTrim(number); - const withoutPlusNum = lodashIncludes(numString, '+') ? Str.cutBefore(numString, '+') : numString; - const country = lodashGet(translations, [locale, 'phoneCountryCode']); - - if (country) { - if (lodashStartsWith(withoutPlusNum, country)) { - return Str.cutBefore(withoutPlusNum, country); - } - return numString; - } - return number; -} - -/** - * Returns an internationally converted phone number with the country code - * - * @param {String} locale eg 'en', 'es-ES' - * @param {String} number - * @returns {String} - */ -function fromLocalPhone(locale, number) { - const numString = lodashTrim(number); - const withoutPlusNum = lodashIncludes(numString, '+') ? Str.cutBefore(numString, '+') : numString; - const country = lodashGet(translations, [locale, 'phoneCountryCode']); - - if (country) { - if (lodashStartsWith(withoutPlusNum, country)) { - return `+${withoutPlusNum}`; - } - return `+${country}${withoutPlusNum}`; - } - return number; -} - -export { - toLocalPhone, - fromLocalPhone, -}; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 1b637537f2f5..b4f8e884d1e4 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -7,7 +7,6 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import * as Localize from './Localize'; -import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Expensicons from '../components/Icon/Expensicons'; import hashCode from './hashCode'; import Navigation from './Navigation/Navigation'; @@ -21,6 +20,7 @@ import linkingConfig from './Navigation/linkingConfig'; import * as defaultAvatars from '../components/Icon/DefaultAvatars'; import isReportMessageAttachment from './isReportMessageAttachment'; import * as defaultWorkspaceAvatars from '../components/Icon/WorkspaceDefaultAvatars'; +import formatPhoneNumber from './formatPhoneNumber'; let sessionEmail; Onyx.connect({ @@ -748,7 +748,7 @@ function getDisplayNameForParticipant(login, shouldUseShortForm = false) { const loginWithoutSMSDomain = Str.removeSMSDomain(personalDetails.login); let longName = personalDetails.displayName || loginWithoutSMSDomain; if (longName === loginWithoutSMSDomain && Str.isSMSLogin(longName)) { - longName = LocalePhoneNumber.toLocalPhone(preferredLocale, longName); + longName = formatPhoneNumber(longName); } const shortName = personalDetails.firstName || longName; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 99429caa7e04..3bbd8f9a5a6e 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -9,6 +9,7 @@ import * as Localize from './Localize'; import CONST from '../CONST'; import * as OptionsListUtils from './OptionsListUtils'; import * as CollectionUtils from './CollectionUtils'; +import formatPhoneNumber from './formatPhoneNumber'; // Note: It is very important that the keys subscribed to here are the same // keys that are connected to SidebarLinks withOnyx(). If there was a key missing from SidebarLinks and it's data was updated @@ -240,6 +241,9 @@ function getOptionData(reportID) { const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; const subtitle = ReportUtils.getChatRoomSubtitle(report, policies); + const login = Str.removeSMSDomain(personalDetail.login); + const formattedLogin = Str.isSMSLogin(login) ? formatPhoneNumber(login) : login; + // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((participantPersonalDetailList || []).slice(0, 10), hasMultipleParticipants); @@ -293,7 +297,7 @@ function getOptionData(reportID) { }).join(' '); } - result.alternateText = lastMessageText || Str.removeSMSDomain(personalDetail.login); + result.alternateText = lastMessageText || formattedLogin; } result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result, iouReports); diff --git a/src/libs/formatPhoneNumber.js b/src/libs/formatPhoneNumber.js new file mode 100644 index 000000000000..026065b38e2b --- /dev/null +++ b/src/libs/formatPhoneNumber.js @@ -0,0 +1,61 @@ +import lodashGet from 'lodash/get'; +import Onyx from 'react-native-onyx'; +import {parsePhoneNumber} from 'awesome-phonenumber'; +import ONYXKEYS from '../ONYXKEYS'; + +let currentUserEmail; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (val) => { + // When signed out, val is undefined + if (!val) { + return; + } + + currentUserEmail = val.email; + }, +}); + +let currentUserPersonalDetails; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS, + callback: (val) => { + currentUserPersonalDetails = lodashGet(val, currentUserEmail, {}); + }, +}); + +let countryCodeByIP; +Onyx.connect({ + key: ONYXKEYS.COUNTRY_CODE, + callback: val => countryCodeByIP = val || 1, +}); + +/** + * Returns a locally converted phone number for numbers from the same region + * and an internationally converted phone number with the country code for numbers from other regions + * + * @param {String} number + * @returns {String} + */ + +function formatPhoneNumber(number) { + const parsed = parsePhoneNumber(number); + let locale; + + if (currentUserPersonalDetails.phoneNumber) { + locale = parsePhoneNumber(currentUserPersonalDetails.phoneNumber).countryCode; + } else { + locale = countryCodeByIP; + } + + const regionCode = parsed.countryCode; + + if (regionCode === locale) { + // replacing regular spaces for so-called "hard spaces" to avoid breaking the line on whitespace + return parsed.number.national; + } + + return parsed.number.international; +} + +export default formatPhoneNumber; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 3b5b83796d43..9d59dbcf0009 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -108,6 +108,10 @@ class DetailsPage extends React.PureComponent { pronouns = this.props.translate(`pronouns.${localeKey}`); } + const phoneNumber = getPhoneNumber(details); + const displayName = isSMSLogin ? this.props.formatPhoneNumber(phoneNumber) : details.displayName; + const communicationsLinkValue = isSMSLogin ? getPhoneNumber(details) : details.login; + return ( @@ -127,7 +131,7 @@ class DetailsPage extends React.PureComponent { @@ -151,7 +155,7 @@ class DetailsPage extends React.PureComponent { {details.displayName && ( - {isSMSLogin ? this.props.toLocalPhone(details.displayName) : details.displayName} + {displayName} )} {details.login ? ( @@ -161,11 +165,13 @@ class DetailsPage extends React.PureComponent { ? 'common.phoneNumber' : 'common.email')} - - + + {isSMSLogin - ? this.props.toLocalPhone(getPhoneNumber(details)) + ? this.props.formatPhoneNumber(phoneNumber) : details.login} @@ -186,7 +192,7 @@ class DetailsPage extends React.PureComponent { {details.login !== this.props.session.email && ( Report.navigateToAndOpenReport([details.login])} wrapperStyle={styles.breakAll} diff --git a/src/pages/home/report/ParticipantLocalTime.js b/src/pages/home/report/ParticipantLocalTime.js index ab1b3dcc4b5d..4043acd54692 100644 --- a/src/pages/home/report/ParticipantLocalTime.js +++ b/src/pages/home/report/ParticipantLocalTime.js @@ -56,7 +56,7 @@ class ParticipantLocalTime extends PureComponent { render() { const reportRecipientDisplayName = this.props.participant.firstName || (Str.isSMSLogin(this.props.participant.login) - ? this.props.toLocalPhone(this.props.participant.displayName) + ? this.props.formatPhoneNumber(Str.removeSMSDomain(this.props.participant.displayName)) : this.props.participant.displayName); return ( diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 19d2cc05f58c..927bc0be52a0 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -65,7 +65,10 @@ const ReportActionItemSingle = (props) => { // we'll need to take the displayName from personal details and have it be in the same format for now. Eventually, // we should stop referring to the report history items entirely for this information. const personArray = displayName - ? [{type: 'TEXT', text: Str.isSMSLogin(login) ? props.toLocalPhone(displayName) : displayName}] + ? [{ + type: 'TEXT', + text: Str.isSMSLogin(login) ? props.formatPhoneNumber(Str.removeSMSDomain(displayName)) : displayName + }] : props.action.person; return ( @@ -107,7 +110,7 @@ const ReportActionItemSingle = (props) => { /> ))} - + ) : null} {props.children} diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js index 069ff51c7e57..0ecbb081e31f 100755 --- a/src/pages/iou/IOUModal.js +++ b/src/pages/iou/IOUModal.js @@ -121,7 +121,7 @@ class IOUModal extends Component { text: personalDetails.displayName, firstName: lodashGet(personalDetails, 'firstName', ''), lastName: lodashGet(personalDetails, 'lastName', ''), - alternateText: Str.isSMSLogin(personalDetails.login) ? Str.removeSMSDomain(personalDetails.login) : personalDetails.login, + alternateText: Str.isSMSLogin(personalDetails.login) ? this.props.formatPhoneNumber(Str.removeSMSDomain(personalDetails.login)) : personalDetails.login, icons: [{ source: ReportUtils.getAvatar(personalDetails.avatar, personalDetails.login), name: personalDetails.login, diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index eca350a4e691..7785ad6bc2e7 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -142,6 +142,12 @@ class ContactMethodDetailsPage extends Component { render() { const contactMethod = this.getContactMethod(); + + // replacing spaces with "hard spaces" to prevent breaking the number + const formattedContactMethod = Str.isSMSLogin(contactMethod) + ? this.props.formatPhoneNumber(Str.removeSMSDomain(contactMethod)).replace(/ /g, '\u00A0') + : contactMethod; + const loginData = this.props.loginList[contactMethod]; if (!contactMethod || !loginData) { return ; @@ -154,7 +160,7 @@ class ContactMethodDetailsPage extends Component { return ( Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} onCloseButtonPress={() => Navigation.dismissModal(true)} @@ -175,7 +181,7 @@ class ContactMethodDetailsPage extends Component { - {this.props.translate('contacts.enterMagicCode', {contactMethod})} + {this.props.translate('contacts.enterMagicCode', {contactMethod: formattedContactMethod})} diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js index a205eb7da709..c5b494569164 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js @@ -96,13 +96,15 @@ const ContactMethodsPage = (props) => { // Default to using login key if we deleted login.partnerUserID optimistically // but still need to show the pending login being deleted while offline. const partnerUserID = login.partnerUserID || loginName; + const menuItemTitle = Str.isSMSLogin(partnerUserID) ? props.formatPhoneNumber(Str.removeSMSDomain(partnerUserID)) : partnerUserID; + return ( Navigation.navigate(ROUTES.getEditContactMethodRoute(partnerUserID))} brickRoadIndicator={indicator} diff --git a/src/pages/settings/Profile/Contacts/LoginField.js b/src/pages/settings/Profile/Contacts/LoginField.js index 1fff1838ba9c..337522d68cb4 100755 --- a/src/pages/settings/Profile/Contacts/LoginField.js +++ b/src/pages/settings/Profile/Contacts/LoginField.js @@ -69,7 +69,7 @@ class LoginField extends Component { return this.props.label; } if (this.props.type === CONST.LOGIN_TYPE.PHONE) { - return this.props.toLocalPhone(this.props.login.partnerUserID); + return this.props.formatPhoneNumber(this.props.login.partnerUserID); } return this.props.login.partnerUserID; } @@ -106,7 +106,7 @@ class LoginField extends Component { {this.props.type === CONST.LOGIN_TYPE.PHONE - ? this.props.toLocalPhone(this.props.login.partnerUserID) + ? this.props.formatPhoneNumber(this.props.login.partnerUserID) : this.props.login.partnerUserID}