diff --git a/android/app/build.gradle b/android/app/build.gradle index aee47932ef02..76e6e8fbba5b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001030101 - versionName "1.3.1-1" + versionCode 1001030202 + versionName "1.3.2-2" } splits { diff --git a/assets/images/task.svg b/assets/images/task.svg new file mode 100644 index 000000000000..20412f771b69 --- /dev/null +++ b/assets/images/task.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index e4264a5cb56e..3c6bcd30e6bc 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -111,7 +111,7 @@ Additionally if you want to discuss an idea with the open source community witho 3. If you cannot reproduce the problem, pause on this step and add a comment to the issue explaining where you are stuck or that you don't think the issue can be reproduced. #### Propose a solution for the job -4. Do not propose solutions to jobs without the `Help Wanted` label applied. Any proposals submitted when that label is not present will not be reviewed. +4. You can propose solutions on any issue at at any time, but if you propose solutions to jobs before the `Help Wanted` label is applied, you do so at your own risk. Proposals will not be reviewed until the label is added and there is always a chance that we might not add the label or hire an external contributor for the job. 5. After you reproduce the issue, complete the [proposal template here](./PROPOSAL_TEMPLATE.md) and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). - Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. ALL NEW PROPOSALS MUST BE DIFFERENT FROM EXISTING PROPOSALS. The *difference* should be important, meaningful or considerable. 6. Refrain from leaving additional comments until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4d69d9105585..374c409a088a 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.1 + 1.3.2 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.1.1 + 1.3.2.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index eb369c6b82e8..eb4f6d0326a7 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.1 + 1.3.2 CFBundleSignature ???? CFBundleVersion - 1.3.1.1 + 1.3.2.2 diff --git a/package-lock.json b/package-lock.json index ca90e80bd9cf..b4291421f19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.1-1", + "version": "1.3.2-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.1-1", + "version": "1.3.2-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -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.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", @@ -16726,6 +16727,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/awesome-phonenumber": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.4.0.tgz", + "integrity": "sha512-jf6E+GHKRIMobCKygQhZ9kHmdxZ8hvXUlVhLyesP/k8JVpmWAyNa5TzWDS0hKe480tmT419yRtGLmTpD0k8pUQ==", + "engines": { + "node": ">=14" + } + }, "node_modules/axe-core": { "version": "4.4.3", "dev": true, @@ -52421,6 +52430,11 @@ "version": "1.0.5", "dev": true }, + "awesome-phonenumber": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/awesome-phonenumber/-/awesome-phonenumber-5.4.0.tgz", + "integrity": "sha512-jf6E+GHKRIMobCKygQhZ9kHmdxZ8hvXUlVhLyesP/k8JVpmWAyNa5TzWDS0hKe480tmT419yRtGLmTpD0k8pUQ==" + }, "axe-core": { "version": "4.4.3", "dev": true diff --git a/package.json b/package.json index 1320678f43db..2315146aed39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.1-1", + "version": "1.3.2-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -65,6 +65,7 @@ "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", "@ua/react-native-airship": "^15.2.0", + "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", diff --git a/src/CONST.js b/src/CONST.js index 54bfc6c53fbf..5ee576483cff 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -191,6 +191,7 @@ const CONST = { POLICY_ROOMS: 'policyRooms', POLICY_EXPENSE_CHAT: 'policyExpenseChat', PASSWORDLESS: 'passwordless', + TASKS: 'tasks', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 49d5435c8ebd..b0526ae4b61a 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -188,6 +188,7 @@ export default { HOME_ADDRESS_FORM: 'homeAddressForm', NEW_ROOM_FORM: 'newRoomForm', ROOM_SETTINGS_FORM: 'roomSettingsForm', + NEW_TASK_FORM: 'newTaskForm', MONEY_REQUEST_DESCRIPTION_FORM: 'moneyRequestDescriptionForm', }, diff --git a/src/ROUTES.js b/src/ROUTES.js index 423169d6dd3e..117cd0e6c85a 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -13,6 +13,7 @@ const IOU_DETAILS = 'iou/details'; const IOU_REQUEST_CURRENCY = `${IOU_REQUEST}/currency`; const IOU_BILL_CURRENCY = `${IOU_BILL}/currency`; const IOU_SEND_CURRENCY = `${IOU_SEND}/currency`; +const NEW_TASK = 'new/task'; const SETTINGS_PERSONAL_DETAILS = 'settings/profile/personal-details'; const SETTINGS_CONTACT_METHODS = 'settings/profile/contact-methods'; @@ -55,6 +56,7 @@ export default { SETTINGS_NEW_CONTACT_METHOD: `${SETTINGS_CONTACT_METHODS}/new`, NEW_GROUP: 'new/group', NEW_CHAT: 'new/chat', + NEW_TASK, REPORT, REPORT_WITH_ID: 'r/:reportID', getReportRoute: reportID => `r/${reportID}`, @@ -89,6 +91,9 @@ export default { IOU_DETAILS_ENABLE_PAYMENTS: `${IOU_DETAILS}/enable-payments`, IOU_DETAILS_WITH_IOU_REPORT_ID: `${IOU_DETAILS}/:chatReportID/:iouReportID/`, getIouDetailsRoute: (chatReportID, iouReportID) => `iou/details/${chatReportID}/${iouReportID}`, + getNewTaskRoute: reportID => `${NEW_TASK}/${reportID}`, + NEW_TASK_WITH_REPORT_ID: `${NEW_TASK}/:reportID?`, + getTaskDetailsRoute: taskID => `task/details/${taskID}`, SEARCH: 'search', SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:accountID/:validateCode', DETAILS: 'details', diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index cb0f71cd1d7c..112ad8ffa7cc 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -494,7 +494,7 @@ class EmojiPickerMenu extends Component { pointerEvents={this.state.arePointerEventsDisabled ? 'none' : 'auto'} > {!this.props.isSmallScreenWidth && ( - + diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 976d846dcb81..d3c7c2f4bc57 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -109,6 +109,7 @@ import Podcast from '../../../assets/images/social-podcast.svg'; import Linkedin from '../../../assets/images/social-linkedin.svg'; import Instagram from '../../../assets/images/social-instagram.svg'; import AddReaction from '../../../assets/images/add-reaction.svg'; +import Task from '../../../assets/images/task.svg'; export { ActiveRoomAvatar, @@ -204,6 +205,7 @@ export { Send, Shield, Sync, + Task, ThreeDots, Transfer, Trashcan, diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 070b639c2796..c8dc826a316c 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -1,6 +1,8 @@ import _ from 'underscore'; import React from 'react'; -import {View} from 'react-native'; +import { + View, Pressable, +} from 'react-native'; import Text from './Text'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; @@ -16,14 +18,9 @@ import colors from '../styles/colors'; import variables from '../styles/variables'; import MultipleAvatars from './MultipleAvatars'; import * as defaultWorkspaceAvatars from './Icon/WorkspaceDefaultAvatars'; -import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; -import * as DeviceCapabilities from '../libs/DeviceCapabilities'; -import ControlSelection from '../libs/ControlSelection'; const propTypes = { ...menuItemPropTypes, - ...windowDimensionsPropTypes, }; const defaultProps = { @@ -49,13 +46,11 @@ const defaultProps = { subtitle: undefined, iconType: CONST.ICON_TYPE_ICON, onPress: () => {}, - onSecondaryInteraction: undefined, interactive: true, fallbackIcon: Expensicons.FallbackAvatar, brickRoadIndicator: '', floatRightAvatars: [], shouldStackHorizontally: false, - shouldBlockSelection: false, }; const MenuItem = (props) => { @@ -76,7 +71,7 @@ const MenuItem = (props) => { ]); return ( - { if (props.disabled) { return; @@ -88,9 +83,6 @@ const MenuItem = (props) => { props.onPress(e); }} - onPressIn={() => props.shouldBlockSelection && props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={ControlSelection.unblock} - onSecondaryInteraction={props.onSecondaryInteraction} style={({hovered, pressed}) => ([ props.style, StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || hovered, pressed, props.success, props.disabled, props.interactive), true), @@ -98,7 +90,6 @@ const MenuItem = (props) => { styles.popoverMaxWidth, ])} disabled={props.disabled} - ref={props.forwardedRef} > {({hovered, pressed}) => ( <> @@ -221,14 +212,12 @@ const MenuItem = (props) => { )} - + ); }; MenuItem.propTypes = propTypes; MenuItem.defaultProps = defaultProps; MenuItem.displayName = 'MenuItem'; -export default withWindowDimensions(React.forwardRef((props, ref) => ( - // eslint-disable-next-line react/jsx-props-no-spreading - -))); + +export default MenuItem; diff --git a/src/components/MenuItemList.js b/src/components/MenuItemList.js index fe7d40b582f8..e442d3766893 100644 --- a/src/components/MenuItemList.js +++ b/src/components/MenuItemList.js @@ -3,8 +3,6 @@ import _ from 'underscore'; import PropTypes from 'prop-types'; import MenuItem from './MenuItem'; import menuItemPropTypes from './menuItemPropTypes'; -import * as ReportActionContextMenu from '../pages/home/report/ContextMenu/ReportActionContextMenu'; -import {CONTEXT_MENU_TYPES} from '../pages/home/report/ContextMenu/ContextMenuActions'; const propTypes = { /** An array of props that are pass to individual MenuItem components */ @@ -14,38 +12,17 @@ const defaultProps = { menuItems: [], }; -const MenuItemList = (props) => { - let popoverAnchor; - - /** - * Handle the secondary interaction for a menu item. - * - * @param {*} link the menu item link or function to get the link - * @param {Event} e the interaction event - */ - const secondaryInteraction = (link, e) => { - if (typeof link === 'function') { - link().then(url => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, url, popoverAnchor)); - } else if (!_.isEmpty(link)) { - ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, link, popoverAnchor); - } - }; - - return ( - <> - {_.map(props.menuItems, menuItemProps => ( - secondaryInteraction(menuItemProps.link, e) : undefined} - ref={el => popoverAnchor = el} - shouldBlockSelection={Boolean(menuItemProps.link)} - // eslint-disable-next-line react/jsx-props-no-spreading - {...menuItemProps} - /> - ))} - - ); -}; +const MenuItemList = props => ( + <> + {_.map(props.menuItems, menuItemProps => ( + + ))} + +); MenuItemList.displayName = 'MenuItemList'; MenuItemList.propTypes = propTypes; diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index 1e314654bba3..7d44127f1c91 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -155,6 +155,7 @@ class BaseModal extends PureComponent { modalContainerStyleMarginBottom: modalContainerStyle.marginBottom, modalContainerStylePaddingTop: modalContainerStyle.paddingTop, modalContainerStylePaddingBottom: modalContainerStyle.paddingBottom, + insets, }); return ( diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 09732a485ce7..ea4be4cb4985 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -135,9 +135,11 @@ class MoneyRequestConfirmationList extends Component { * @returns {Array} */ getParticipantsWithAmount(participants) { + const iouAmount = IOUUtils.calculateAmount(participants, this.props.iouAmount, this.props.iou.selectedCurrencyCode); + return OptionsListUtils.getIOUConfirmationOptionsFromParticipants( participants, - this.props.numberFormat(IOUUtils.calculateAmount(participants, this.props.iouAmount) / 100, { + this.props.numberFormat(iouAmount / 100, { style: 'currency', currency: this.props.iou.selectedCurrencyCode, }), @@ -169,9 +171,10 @@ class MoneyRequestConfirmationList extends Component { const formattedUnselectedParticipants = this.getParticipantsWithoutAmount(unselectedParticipants); const formattedParticipants = _.union(formattedSelectedParticipants, formattedUnselectedParticipants); + const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants, this.props.iouAmount, this.props.iou.selectedCurrencyCode, true); const formattedMyPersonalDetails = OptionsListUtils.getIOUConfirmationOptionsFromMyPersonalDetail( this.props.currentUserPersonalDetails, - this.props.numberFormat(IOUUtils.calculateAmount(selectedParticipants, this.props.iouAmount, true) / 100, { + this.props.numberFormat(myIOUAmount / 100, { style: 'currency', currency: this.props.iou.selectedCurrencyCode, }), diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index 412755a4ccef..f3ea25a471fd 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -4,7 +4,6 @@ import {Pressable} from 'react-native'; import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes'; import styles from '../../styles/styles'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; -import * as StyleUtils from '../../styles/StyleUtils'; /** * This is a special Pressable that calls onSecondaryInteraction when LongPressed, or right-clicked. @@ -12,7 +11,6 @@ import * as StyleUtils from '../../styles/StyleUtils'; class PressableWithSecondaryInteraction extends Component { constructor(props) { super(props); - this.executeSecondaryInteraction = this.executeSecondaryInteraction.bind(this); this.executeSecondaryInteractionOnContextMenu = this.executeSecondaryInteractionOnContextMenu.bind(this); } @@ -27,28 +25,11 @@ class PressableWithSecondaryInteraction extends Component { this.pressableRef.removeEventListener('contextmenu', this.executeSecondaryInteractionOnContextMenu); } - /** - * @param {Event} e - the secondary interaction event - */ - executeSecondaryInteraction(e) { - if (DeviceCapabilities.hasHoverSupport()) { - return; - } - if (this.props.withoutFocusOnSecondaryInteraction && this.pressableRef) { - this.pressableRef.blur(); - } - this.props.onSecondaryInteraction(e); - } - /** * @param {contextmenu} e - A right-click MouseEvent. * https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event */ executeSecondaryInteractionOnContextMenu(e) { - if (!this.props.onSecondaryInteraction) { - return; - } - e.stopPropagation(); if (this.props.preventDefaultContentMenu) { e.preventDefault(); @@ -73,9 +54,17 @@ class PressableWithSecondaryInteraction extends Component { // On Web, Text does not support LongPress events thus manage inline mode with styling instead of using Text. return ( { + if (DeviceCapabilities.hasHoverSupport()) { + return; + } + if (this.props.withoutFocusOnSecondaryInteraction && this.pressableRef) { + this.pressableRef.blur(); + } + this.props.onSecondaryInteraction(e); + }} onPressOut={this.props.onPressOut} onPress={this.props.onPress} ref={el => this.pressableRef = el} diff --git a/src/components/PressableWithSecondaryInteraction/index.native.js b/src/components/PressableWithSecondaryInteraction/index.native.js index 744c92e4f81b..c51671ba835c 100644 --- a/src/components/PressableWithSecondaryInteraction/index.native.js +++ b/src/components/PressableWithSecondaryInteraction/index.native.js @@ -19,9 +19,6 @@ const PressableWithSecondaryInteraction = (props) => { ref={props.forwardedRef} onPress={props.onPress} onLongPress={(e) => { - if (!props.onSecondaryInteraction) { - return; - } e.preventDefault(); HapticFeedback.longPress(); props.onSecondaryInteraction(e); diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index 0964edaa9506..bffe11bc4cd8 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import stylePropTypes from '../../styles/stylePropTypes'; const propTypes = { /** The function that should be called when this pressable is pressed */ @@ -12,19 +11,13 @@ const propTypes = { onPressOut: PropTypes.func, /** The function that should be called when this pressable is LongPressed or right-clicked. */ - onSecondaryInteraction: PropTypes.func, + onSecondaryInteraction: PropTypes.func.isRequired, /** The children which should be contained in this wrapper component. */ - children: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.node, - ]).isRequired, + children: PropTypes.node.isRequired, /** The ref to the search input (may be null on small screen widths) */ - forwardedRef: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.object, - ]), + forwardedRef: PropTypes.func, /** Prevent the default ContextMenu on web/Desktop */ preventDefaultContentMenu: PropTypes.bool, @@ -41,9 +34,6 @@ const propTypes = { /** Disable focus trap for the element on secondary interaction */ withoutFocusOnSecondaryInteraction: PropTypes.bool, - - /** Used to apply styles to the Pressable */ - style: stylePropTypes, }; const defaultProps = { diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js index f7ca07c7c543..0363f939cf77 100644 --- a/src/components/ValidateCode/ValidateCodeModal.js +++ b/src/components/ValidateCode/ValidateCodeModal.js @@ -1,4 +1,4 @@ -import React, {PureComponent} from 'react'; +import React, {useCallback} from 'react'; import PropTypes from 'prop-types'; import {compose} from 'underscore'; import {withOnyx} from 'react-native-onyx'; @@ -39,64 +39,54 @@ const defaultProps = { }, }; -class ValidateCodeModal extends PureComponent { - constructor(props) { - super(props); +function ValidateCodeModal(props) { + const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code), [props.accountID, props.code]); - this.signInHere = this.signInHere.bind(this); - } - - signInHere() { - Session.signInWithValidateCode(this.props.accountID, this.props.code); - } - - render() { - return ( - - - - - - - {this.props.translate('validateCodeModal.title')} - - - - {this.props.translate('validateCodeModal.description')} - {!lodashGet(this.props, 'session.authToken', null) - && ( - <> - {this.props.translate('validateCodeModal.or')} - {' '} - - {this.props.translate('validateCodeModal.signInHere')} - - - )} - {this.props.shouldShowSignInHere ? '!' : '.'} - - - - - {this.props.code} - - - - + return ( + + + + + {props.translate('validateCodeModal.title')} + + + + {props.translate('validateCodeModal.description')} + {!lodashGet(props, 'session.authToken', null) + && ( + <> + {props.translate('validateCodeModal.or')} + {' '} + + {props.translate('validateCodeModal.signInHere')} + + + )} + {props.shouldShowSignInHere ? '!' : '.'} + + + + + {props.code} + + + + + - ); - } + + ); } ValidateCodeModal.propTypes = propTypes; diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 78486cb5a848..c1759d127cfe 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -88,18 +88,6 @@ const propTypes = { /** Prop to identify if we should load avatars vertically instead of diagonally */ shouldStackHorizontally: PropTypes.bool, - - /** The function that should be called when this component is LongPressed or right-clicked. */ - onSecondaryInteraction: PropTypes.func, - - /** Flag to indicate whether or not text selection should be disabled from long-pressing the menu item. */ - shouldBlockSelection: PropTypes.bool, - - /** The ref to the menu item */ - forwardedRef: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.object, - ]), }; export default propTypes; diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js index eae8796efa56..422bff30c87d 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 * as LocalePhoneNumber from '../libs/LocalePhoneNumber'; 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 LocalePhoneNumber.formatPhoneNumber(phoneNumber); } /** diff --git a/src/languages/en.js b/src/languages/en.js index 913d4669aac1..a2527dddbac9 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -192,6 +192,7 @@ export default { }, optionsSelector: { nameEmailOrPhoneNumber: 'Name, email, or phone number', + findMember: 'Find a member', }, videoChatButtonAndMenu: { tooltip: 'Start a Call', @@ -970,6 +971,7 @@ export default { growlMessageOnDelete: 'Workspace deleted', growlMessageOnDeleteError: 'This workspace cannot be deleted right now because reports are actively being processed', unavailable: 'Unavailable workspace', + memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.', }, emptyWorkspace: { title: 'Create a new workspace', @@ -1137,6 +1139,13 @@ export default { public_announce: 'Public Announce', }, }, + newTaskPage: { + assignTask: 'Assign task', + title: 'Title', + description: 'Description', + shareIn: 'Share in', + pleaseEnterTaskName: 'Please enter a title', + }, statementPage: { generatingPDF: 'We\'re generating your PDF right now. Please come back later!', }, diff --git a/src/languages/es.js b/src/languages/es.js index ba6e38232047..f5d713b5ce5a 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -191,6 +191,7 @@ export default { }, optionsSelector: { nameEmailOrPhoneNumber: 'Nombre, email o número de teléfono', + findMember: 'Encuentra un miembro', }, videoChatButtonAndMenu: { tooltip: 'Iniciar una llamada', @@ -971,6 +972,7 @@ export default { deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', growlMessageOnDeleteError: 'No se puede eliminar el espacio de trabajo porque tiene informes que están siendo procesados', unavailable: 'Espacio de trabajo no disponible', + memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.', }, emptyWorkspace: { title: 'Crear un nuevo espacio de trabajo', @@ -1138,6 +1140,13 @@ export default { public_announce: 'Anuncio Público', }, }, + newTaskPage: { + assignTask: 'Asignar tarea', + title: 'Título', + description: 'Descripción', + shareIn: 'Compartir en', + pleaseEnterTaskName: 'Por favor introduce un título', + }, statementPage: { generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!', }, @@ -1449,7 +1458,7 @@ export default { }, DC: { stateISO: 'DC', - stateName: 'District Of Columbia', + stateName: 'Distrito de Columbia', }, }, allCountries: { diff --git a/src/libs/IOUUtils.js b/src/libs/IOUUtils.js index b4e8f1ff46ce..1151f9c77225 100644 --- a/src/libs/IOUUtils.js +++ b/src/libs/IOUUtils.js @@ -1,31 +1,75 @@ import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import CONST from '../CONST'; +import ONYXKEYS from '../ONYXKEYS'; + +let currencyList = {}; +Onyx.connect({ + key: ONYXKEYS.CURRENCY_LIST, + callback: (val) => { + if (_.isEmpty(val)) { + return; + } + + currencyList = val; + }, +}); + +/** + * Returns the number of digits after the decimal separator for a specific currency. + * For currencies that have decimal places > 2, floor to 2 instead: + * https://github.com/Expensify/App/issues/15878#issuecomment-1496291464 + * + * @param {String} currency - IOU currency + * @returns {Number} + */ +function getCurrencyDecimals(currency = CONST.CURRENCY.USD) { + const decimals = lodashGet(currencyList, [currency, 'decimals']); + return _.isUndefined(decimals) ? 2 : Math.min(decimals, 2); +} + +/** + * Returns the currency's minor unit quantity + * e.g. Cent in USD + * + * @param {String} currency - IOU currency + * @returns {Number} + */ +function getCurrencyUnit(currency = CONST.CURRENCY.USD) { + return 10 ** getCurrencyDecimals(currency); +} /** * Calculates the amount per user given a list of participants * @param {Array} participants - List of logins for the participants in the chat. It should not include the current user's login. * @param {Number} total - IOU total amount + * @param {String} currency - IOU currency * @param {Boolean} isDefaultUser - Whether we are calculating the amount for the current user * @returns {Number} */ -function calculateAmount(participants, total, isDefaultUser = false) { +function calculateAmount(participants, total, currency, isDefaultUser = false) { // Convert to cents before working with iouAmount to avoid // javascript subtraction with decimal problem -- when dealing with decimals, // because they are encoded as IEEE 754 floating point numbers, some of the decimal // numbers cannot be represented with perfect accuracy. - // Cents is temporary and there must be support for other currencies in the future - const iouAmount = Math.round(parseFloat(total * 100)); + // Currencies that do not have minor units (i.e. no decimal place) are also supported. + // https://github.com/Expensify/App/issues/15878 + const currencyUnit = getCurrencyUnit(currency); + const iouAmount = Math.round(parseFloat(total * currencyUnit)); + const totalParticipants = participants.length + 1; const amountPerPerson = Math.round(iouAmount / totalParticipants); - if (!isDefaultUser) { - return amountPerPerson; - } + let finalAmount = amountPerPerson; - const sumAmount = amountPerPerson * totalParticipants; - const difference = iouAmount - sumAmount; + if (isDefaultUser) { + const sumAmount = amountPerPerson * totalParticipants; + const difference = iouAmount - sumAmount; + finalAmount = iouAmount !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; + } - return iouAmount !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; + return (finalAmount * 100) / currencyUnit; } /** @@ -139,4 +183,6 @@ export { updateIOUOwnerAndTotal, getIOUReportActions, isIOUReportPendingCurrencyConversion, + getCurrencyUnit, + getCurrencyDecimals, }; diff --git a/src/libs/LocalePhoneNumber.js b/src/libs/LocalePhoneNumber.js index 3d47e7c6a5a9..dbe7e39f5941 100644 --- a/src/libs/LocalePhoneNumber.js +++ b/src/libs/LocalePhoneNumber.js @@ -1,53 +1,73 @@ import lodashGet from 'lodash/get'; -import lodashTrim from 'lodash/trim'; -import lodashIncludes from 'lodash/includes'; -import lodashStartsWith from 'lodash/startsWith'; +import Onyx from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; -import translations from '../languages/translations'; +import {parsePhoneNumber} from 'awesome-phonenumber'; +import ONYXKEYS from '../ONYXKEYS'; -/** - * 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); +let currentUserEmail; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (val) => { + // When signed out, val is undefined + if (!val) { + return; } - return numString; - } - return number; -} + + 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 an internationally converted phone number with the country code + * 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} 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}`; +function formatPhoneNumber(number) { + const parsedPhoneNumber = parsePhoneNumber(Str.removeSMSDomain(number)); + + // return the string untouched if it's not a phone number + if (!parsedPhoneNumber.valid) { + return number; + } + + let signedInUserCountryCode; + + /** + * if there is a phone number saved in the user's personal details we format the other numbers depending on + * the phone number's country code, otherwise we use country code based on the user's IP + */ + if (currentUserPersonalDetails.phoneNumber) { + signedInUserCountryCode = parsePhoneNumber(currentUserPersonalDetails.phoneNumber).countryCode; + } else { + signedInUserCountryCode = countryCodeByIP; } - return number; + + const regionCode = parsedPhoneNumber.countryCode; + + if (regionCode === signedInUserCountryCode) { + return parsedPhoneNumber.number.national; + } + + return parsedPhoneNumber.number.international; } export { - toLocalPhone, - fromLocalPhone, + // eslint-disable-next-line import/prefer-default-export + formatPhoneNumber, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 776508f27864..f033f5a8a243 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -288,6 +288,12 @@ class AuthScreens extends React.Component { component={ModalStackNavigators.IOURequestModalStackNavigator} listeners={modalScreenListeners} /> + { + const NewTaskPage = require('../../../pages/NewTaskPage').default; + return NewTaskPage; + }, + name: 'NewTask_Root', +}]); + const SettingsModalStackNavigator = createModalStackNavigator([ { getComponent: () => { @@ -545,6 +553,7 @@ export { SearchModalStackNavigator, NewGroupModalStackNavigator, NewChatModalStackNavigator, + NewTaskModalStackNavigator, SettingsModalStackNavigator, EnablePaymentsStackNavigator, AddPersonalBankAccountModalStackNavigator, diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 71caa196abd0..f9c1361538ed 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -206,6 +206,11 @@ export default { NewChat_Root: ROUTES.NEW_CHAT, }, }, + NewTask: { + screens: { + NewTask_Root: ROUTES.NEW_TASK_WITH_REPORT_ID, + }, + }, Search: { screens: { Search_Root: ROUTES.SEARCH, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 0ddac02b814c..37103054493c 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -13,6 +13,7 @@ import Permissions from './Permissions'; import * as CollectionUtils from './CollectionUtils'; import Navigation from './Navigation/Navigation'; import * as LoginUtils from './LoginUtils'; +import * as LocalePhoneNumber from './LocalePhoneNumber'; /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can @@ -162,7 +163,7 @@ function getPersonalDetailsForLogins(logins, personalDetails) { if (!personalDetail) { personalDetail = { login, - displayName: Str.removeSMSDomain(login), + displayName: LocalePhoneNumber.formatPhoneNumber(login), avatar: ReportUtils.getDefaultAvatar(login), }; } @@ -190,7 +191,7 @@ function getParticipantsOptions(report, personalDetails) { text: details.displayName, firstName: lodashGet(details, 'firstName', ''), lastName: lodashGet(details, 'lastName', ''), - alternateText: Str.isSMSLogin(details.login) ? Str.removeSMSDomain(details.login) : details.login, + alternateText: Str.isSMSLogin(details.login) ? LocalePhoneNumber.formatPhoneNumber(details.login) : details.login, icons: [{ source: ReportUtils.getAvatar(details.avatar, details.login), name: details.login, @@ -432,13 +433,13 @@ function createOption(logins, personalDetails, report, reportActions = {}, { } else { result.alternateText = (showChatPreviewLine && lastMessageText) ? lastMessageText - : Str.removeSMSDomain(personalDetail.login); + : LocalePhoneNumber.formatPhoneNumber(personalDetail.login); } reportName = ReportUtils.getReportName(report, policies); } else { reportName = ReportUtils.getDisplayNameForParticipant(logins[0]); result.keyForList = personalDetail.login; - result.alternateText = Str.removeSMSDomain(personalDetail.login); + result.alternateText = LocalePhoneNumber.formatPhoneNumber(personalDetail.login); } result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result, iouReports); diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index 68f1a493f158..d3e407260e20 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -94,6 +94,14 @@ function canUsePasswordlessLogins(betas) { return _.contains(betas, CONST.BETAS.PASSWORDLESS) || _.contains(betas, CONST.BETAS.ALL); } +/** + * @param {Array} betas + * @returns {Boolean} + */ +function canUseTasks(betas) { + return _.contains(betas, CONST.BETAS.TASKS) || _.contains(betas, CONST.BETAS.ALL); +} + export default { canUseChronos, canUseIOU, @@ -105,4 +113,5 @@ export default { canUsePolicyRooms, canUsePolicyExpenseChat, canUsePasswordlessLogins, + canUseTasks, }; diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 75c0cccf0dff..2e9ea66f7fd4 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -134,6 +134,11 @@ function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) { function getLastVisibleAction(reportID, actionsToMerge = {}) { const actions = _.toArray(lodashMerge({}, allReportActions[reportID], actionsToMerge)); const visibleActions = _.filter(actions, action => (!isDeletedAction(action))); + + if (_.isEmpty(visibleActions)) { + return {}; + } + return _.max(visibleActions, action => moment.utc(action.created).valueOf()); } @@ -144,7 +149,8 @@ function getLastVisibleAction(reportID, actionsToMerge = {}) { */ function getLastVisibleMessageText(reportID, actionsToMerge = {}) { const lastVisibleAction = getLastVisibleAction(reportID, actionsToMerge); - const message = lodashGet(lastVisibleAction, ['message', 0]); + const message = lodashGet(lastVisibleAction, ['message', 0], {}); + if (isReportMessageAttachment(message)) { return CONST.ATTACHMENT_MESSAGE_TEXT; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 8de21e5eee18..677e842f6855 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 * as LocalePhoneNumber from './LocalePhoneNumber'; let sessionEmail; Onyx.connect({ @@ -766,7 +766,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 = LocalePhoneNumber.formatPhoneNumber(longName); } const shortName = personalDetails.firstName || longName; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 7ca56320904b..5fc03ab77e67 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -10,6 +10,7 @@ import * as Localize from './Localize'; import CONST from '../CONST'; import * as OptionsListUtils from './OptionsListUtils'; import * as CollectionUtils from './CollectionUtils'; +import * as LocalePhoneNumber from './LocalePhoneNumber'; // 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 @@ -251,6 +252,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) ? LocalePhoneNumber.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); @@ -302,7 +306,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/Visibility/index.js b/src/libs/Visibility/index.js index 2d6c7d2f0906..c86d4a1e6965 100644 --- a/src/libs/Visibility/index.js +++ b/src/libs/Visibility/index.js @@ -6,7 +6,7 @@ import {AppState} from 'react-native'; * @returns {Boolean} */ function isVisible() { - return document.visibilityState === 'visible'; + return document.visibilityState === 'visible' && document.hasFocus(); } /** diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 6e739253dd27..4b6415bae599 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -311,8 +311,8 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment ]; // Loop through participants creating individual chats, iouReports and reportActionIDs as needed - const splitAmount = IOUUtils.calculateAmount(participants, amount); - const splits = [{email: currentUserEmail, amount: IOUUtils.calculateAmount(participants, amount, true)}]; + const splitAmount = IOUUtils.calculateAmount(participants, amount, currency, false); + const splits = [{email: currentUserEmail, amount: IOUUtils.calculateAmount(participants, amount, currency, true)}]; const hasMultipleParticipants = participants.length > 1; _.each(participants, (participant) => { diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js index 2cccfc447f6b..c38f901bb391 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.js @@ -34,35 +34,33 @@ function showGrowlIfOffline() { } /** - * @param {String} [url] the url path - * @param {String} [shortLivedAuthToken] - * - * @returns {Promise} + * @param {String} url */ -function buildOldDotURL(url, shortLivedAuthToken) { - const hasHashParams = url.indexOf('#') !== -1; - const hasURLParams = url.indexOf('?') !== -1; +function openOldDotLink(url) { + /** + * @param {String} [shortLivedAuthToken] + * @returns {Promise} + */ + function buildOldDotURL(shortLivedAuthToken) { + const hasHashParams = url.indexOf('#') !== -1; + const hasURLParams = url.indexOf('?') !== -1; - const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; - const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; + const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; + const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; - const params = _.compact([authTokenParam, emailParam]).join('&'); + const params = _.compact([authTokenParam, emailParam]).join('&'); - return Environment.getOldDotEnvironmentURL() - .then((environmentURL) => { - const oldDotDomain = Url.addTrailingForwardSlash(environmentURL); + return Environment.getOldDotEnvironmentURL() + .then((environmentURL) => { + const oldDotDomain = Url.addTrailingForwardSlash(environmentURL); - // If the URL contains # or ?, we can assume they don't need to have the `?` token to start listing url parameters. - return `${oldDotDomain}${url}${hasHashParams || hasURLParams ? '&' : '?'}${params}`; - }); -} + // If the URL contains # or ?, we can assume they don't need to have the `?` token to start listing url parameters. + return `${oldDotDomain}${url}${hasHashParams || hasURLParams ? '&' : '?'}${params}`; + }); + } -/** - * @param {String} url the url path - */ -function openOldDotLink(url) { if (isNetworkOffline) { - buildOldDotURL(url).then(oldDotURL => Linking.openURL(oldDotURL)); + buildOldDotURL().then(oldDotURL => Linking.openURL(oldDotURL)); return; } @@ -71,11 +69,11 @@ function openOldDotLink(url) { API.makeRequestWithSideEffects( 'OpenOldDotLink', {}, {}, ).then((response) => { - buildOldDotURL(url, response.shortLivedAuthToken).then((oldDotUrl) => { + buildOldDotURL(response.shortLivedAuthToken).then((oldDotUrl) => { Linking.openURL(oldDotUrl); }); }).catch(() => { - buildOldDotURL(url).then((oldDotUrl) => { + buildOldDotURL().then((oldDotUrl) => { Linking.openURL(oldDotUrl); }); }); @@ -94,7 +92,6 @@ function openExternalLink(url, shouldSkipCustomSafariLogic = false) { } export { - buildOldDotURL, openOldDotLink, openExternalLink, }; diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index f23b246280e1..f8ed8a839929 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -6,6 +6,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; import * as API from '../API'; import * as ReportUtils from '../ReportUtils'; +import * as LocalePhoneNumber from '../LocalePhoneNumber'; import ROUTES from '../../ROUTES'; import Navigation from '../Navigation/Navigation'; @@ -29,9 +30,9 @@ Onyx.connect({ * @returns {String} */ function getDisplayName(login, personalDetail) { - // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms + // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms and format it // so that the option looks cleaner in our UI. - const userLogin = Str.removeSMSDomain(login); + const userLogin = LocalePhoneNumber.formatPhoneNumber(login); const userDetails = personalDetail || lodashGet(personalDetails, login); if (!userDetails) { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index f99403e83911..0890b6e5720e 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -921,11 +921,10 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, optimisticReportActions); if (reportActionID === lastVisibleAction.reportActionID) { + const reportComment = parser.htmlToText(htmlForNewComment); + const lastMessageText = ReportUtils.formatReportLastMessageText(reportComment); const optimisticReport = { - lastMessageHtml: lodashGet(lastVisibleAction, 'message[0].html'), - lastMessageText: lodashGet(lastVisibleAction, 'message[0].text'), - lastVisibleActionCreated: lastVisibleAction.created, - lastActorEmail: lastVisibleAction.actorEmail, + lastMessageText: Str.htmlDecode(lastMessageText), }; optimisticData.push({ onyxMethod: CONST.ONYX.METHOD.MERGE, diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index fd8b5f961d41..df926a1ae8e1 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 phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : details.login; + return ( @@ -127,7 +131,7 @@ class DetailsPage extends React.PureComponent { @@ -151,7 +155,7 @@ class DetailsPage extends React.PureComponent { {Boolean(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/GetAssistancePage.js b/src/pages/GetAssistancePage.js index 89544b25dcfe..e2115c6d7814 100644 --- a/src/pages/GetAssistancePage.js +++ b/src/pages/GetAssistancePage.js @@ -57,7 +57,6 @@ const GetAssistancePage = (props) => { shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: CONST.NEWHELP_URL, }]; // If the user is eligible for calls with their Guide, add the 'Schedule a setup call' item at the second position in the list diff --git a/src/pages/NewTaskPage.js b/src/pages/NewTaskPage.js new file mode 100644 index 000000000000..a93025dfd5a6 --- /dev/null +++ b/src/pages/NewTaskPage.js @@ -0,0 +1,96 @@ +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import compose from '../libs/compose'; +import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; +import Navigation from '../libs/Navigation/Navigation'; +import ScreenWrapper from '../components/ScreenWrapper'; +import styles from '../styles/styles'; +import ONYXKEYS from '../ONYXKEYS'; +import * as ErrorUtils from '../libs/ErrorUtils'; +import Form from '../components/Form'; +import TextInput from '../components/TextInput'; +import Permissions from '../libs/Permissions'; + +const propTypes = { + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + + ...withLocalizePropTypes, +}; +const defaultProps = { + betas: [], +}; + +// NOTE: This page is going to be updated in https://github.com/Expensify/App/issues/16855, this is just a placeholder for now +const NewTaskPage = (props) => { + /** + * @param {Object} values - form input values passed by the Form component + * @returns {Boolean} + */ + function validate(values) { + const errors = {}; + + if (!values.taskTitle) { + // We error if the user doesn't enter a room name + ErrorUtils.addErrorMessage(errors, 'taskTitle', props.translate('newTaskPage.pleaseEnterTaskName')); + } + + return errors; + } + + function onSubmit() { + + } + + if (!Permissions.canUseTasks(props.betas)) { + Navigation.dismissModal(); + return null; + } + + return ( + + Navigation.dismissModal()} + /> +
validate(values)} + onSubmit={() => onSubmit()} + enabledWhenOffline + > + + + + + + +
+
+ ); +}; + +NewTaskPage.displayName = 'NewTaskPage'; +NewTaskPage.propTypes = propTypes; +NewTaskPage.defaultProps = defaultProps; + +export default compose( + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + }), + withLocalize, +)(NewTaskPage); diff --git a/src/pages/ReimbursementAccount/Enable2FAPrompt.js b/src/pages/ReimbursementAccount/Enable2FAPrompt.js index cb7bc1f004d3..51c62b8957c1 100644 --- a/src/pages/ReimbursementAccount/Enable2FAPrompt.js +++ b/src/pages/ReimbursementAccount/Enable2FAPrompt.js @@ -13,36 +13,31 @@ import themeColors from '../../styles/themes/default'; const propTypes = { ...withLocalizePropTypes, }; -const Enable2FAPrompt = (props) => { - const secureYourAccountUrl = encodeURI(`settings?param={"section":"account","action":"enableTwoFactorAuth","exitTo":"${ROUTES.getBankAccountRoute()}","isFromNewDot":"true"}`); - - return ( -
{ - Link.openOldDotLink(secureYourAccountUrl); - }, - icon: Expensicons.Shield, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - iconFill: themeColors.success, - wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(secureYourAccountUrl), +const Enable2FAPrompt = props => ( +
{ + Link.openOldDotLink(encodeURI(`settings?param={"section":"account","action":"enableTwoFactorAuth","exitTo":"${ROUTES.getBankAccountRoute()}","isFromNewDot":"true"}`)); }, - ]} - > - - - {props.translate('validationStep.enable2FAText')} - - -
- ); -}; + icon: Expensicons.Shield, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + iconFill: themeColors.success, + wrapperStyle: [styles.cardMenuItem], + }, + ]} + > + + + {props.translate('validationStep.enable2FAText')} + + +
+); Enable2FAPrompt.propTypes = propTypes; Enable2FAPrompt.displayName = 'Enable2FAPrompt'; diff --git a/src/pages/YearPickerPage.js b/src/pages/YearPickerPage.js index f2a4cdee3024..54b5d2c3c4f0 100644 --- a/src/pages/YearPickerPage.js +++ b/src/pages/YearPickerPage.js @@ -70,9 +70,15 @@ class YearPickerPage extends React.Component { * @param {String} text */ filterYearList(text) { - this.setState({ - inputText: text, - yearOptions: _.filter(this.yearList, year => year.text.includes(text.trim())), + const searchText = text.replace(CONST.REGEX.NON_NUMERIC, ''); + this.setState((prevState) => { + if (searchText === prevState.inputText) { + return {}; + } + return { + inputText: searchText, + yearOptions: _.filter(this.yearList, year => year.text.includes(searchText)), + }; }); } diff --git a/src/pages/home/report/ParticipantLocalTime.js b/src/pages/home/report/ParticipantLocalTime.js index ab1b3dcc4b5d..fb494afb05e9 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(this.props.participant.displayName) : this.props.participant.displayName); return ( diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index b2bd5bf853ab..1fa0874824d8 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -52,6 +52,7 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../components/wit import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import KeyboardShortcut from '../../../libs/KeyboardShortcut'; +import Permissions from '../../../libs/Permissions'; const propTypes = { /** Beta features list */ @@ -173,6 +174,7 @@ class ReportActionCompose extends React.Component { this.setTextInputRef = this.setTextInputRef.bind(this); this.getInputPlaceholder = this.getInputPlaceholder.bind(this); this.getMoneyRequestOptions = this.getMoneyRequestOptions.bind(this); + this.getTaskOption = this.getTaskOption.bind(this); this.addAttachment = this.addAttachment.bind(this); this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); @@ -391,6 +393,29 @@ class ReportActionCompose extends React.Component { } } + /** + * Determines if we can show the task option + * @param {Array} reportParticipants + * @returns {Boolean} + */ + getTaskOption(reportParticipants) { + // We only prevent the task option from showing if it's a DM and the other user is an Expensify default email + if (!Permissions.canUseTasks(this.props.betas) || (lodashGet(this.props.report, 'participants', []).length === 1 && _.some(reportParticipants, email => _.contains( + CONST.EXPENSIFY_EMAILS, + email, + )))) { + return []; + } + + return [ + { + icon: Expensicons.Task, + text: this.props.translate('newTaskPage.assignTask'), + onSelected: () => Navigation.navigate(ROUTES.getNewTaskRoute(this.props.reportID)), + }, + ]; + } + /** * Clean data related to EmojiSuggestions */ @@ -798,7 +823,7 @@ class ReportActionCompose extends React.Component { onClose={() => this.setMenuVisibility(false)} onItemSelected={() => this.setMenuVisibility(false)} anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...this.getMoneyRequestOptions(reportParticipants), + menuItems={[...this.getMoneyRequestOptions(reportParticipants), ...this.getTaskOption(reportParticipants), { icon: Expensicons.Paperclip, text: this.props.translate('reportActionCompose.addAttachment'), diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 19d2cc05f58c..ed208a7db999 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(displayName) : displayName, + }] : props.action.person; return ( diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 426a50e68285..ebff5f4e6a51 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -211,6 +211,13 @@ class FloatingActionButtonAndPopover extends React.Component { onSelected: () => Navigation.navigate(ROUTES.IOU_BILL), }, ] : []), + ...(Permissions.canUseTasks(this.props.betas) ? [ + { + icon: Expensicons.Task, + text: this.props.translate('newTaskPage.assignTask'), + onSelected: () => Navigation.navigate(ROUTES.NEW_TASK), + }, + ] : []), ...(!this.props.isLoading && !Policy.hasActiveFreePolicy(this.props.allPolicies) ? [ { icon: Expensicons.NewWorkspace, diff --git a/src/pages/iou/ModalHeader.js b/src/pages/iou/ModalHeader.js index a89b184fb897..990a8ccecceb 100644 --- a/src/pages/iou/ModalHeader.js +++ b/src/pages/iou/ModalHeader.js @@ -28,7 +28,7 @@ const defaultProps = { }; const ModalHeader = props => ( - + ( > {props.shouldShowBackButton && ( - - - - - - - + + + + + )}
diff --git a/src/pages/iou/steps/MoneyRequestAmountPage.js b/src/pages/iou/steps/MoneyRequestAmountPage.js index 10c260895490..7fb169fa3d84 100755 --- a/src/pages/iou/steps/MoneyRequestAmountPage.js +++ b/src/pages/iou/steps/MoneyRequestAmountPage.js @@ -59,6 +59,7 @@ class MoneyRequestAmountPage extends React.Component { this.updateLongPressHandlerState = this.updateLongPressHandlerState.bind(this); this.updateAmount = this.updateAmount.bind(this); this.stripCommaFromAmount = this.stripCommaFromAmount.bind(this); + this.stripSpacesFromAmount = this.stripSpacesFromAmount.bind(this); this.focusTextInput = this.focusTextInput.bind(this); this.navigateToCurrencySelectionPage = this.navigateToCurrencySelectionPage.bind(this); this.amountViewID = 'amountView'; @@ -126,13 +127,16 @@ class MoneyRequestAmountPage extends React.Component { * @returns {Object} */ getNewState(prevState, newAmount) { - if (!this.validateAmount(newAmount)) { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = this.stripSpacesFromAmount(newAmount); + if (!this.validateAmount(newAmountWithoutSpaces)) { // Use a shallow copy of selection to trigger setSelection // More info: https://github.com/Expensify/App/issues/16385 return {amount: prevState.amount, selection: {...prevState.selection}}; } - const selection = this.getNewSelection(prevState.selection, prevState.amount.length, newAmount.length); - return {amount: this.stripCommaFromAmount(newAmount), selection}; + const selection = this.getNewSelection(prevState.selection, prevState.amount.length, newAmountWithoutSpaces.length); + return {amount: this.stripCommaFromAmount(newAmountWithoutSpaces), selection}; } /** @@ -194,6 +198,16 @@ class MoneyRequestAmountPage extends React.Component { return amount.replace(/,/g, ''); } + /** + * Strip spaces from the amount + * + * @param {String} amount + * @returns {String} + */ + stripSpacesFromAmount(amount) { + return amount.replace(/\s+/g, ''); + } + /** * Adds a leading zero to the amount if user entered just the decimal separator * diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index b4a816ea5740..7c7840fdbbdb 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -19,8 +19,6 @@ import * as Report from '../../../libs/actions/Report'; import * as Link from '../../../libs/actions/Link'; import getPlatformSpecificMenuItems from './getPlatformSpecificMenuItems'; import compose from '../../../libs/compose'; -import * as ReportActionContextMenu from '../../home/report/ContextMenu/ReportActionContextMenu'; -import {CONTEXT_MENU_TYPES} from '../../home/report/ContextMenu/ContextMenuActions'; const propTypes = { ...withLocalizePropTypes, @@ -28,8 +26,6 @@ const propTypes = { }; const AboutPage = (props) => { - let popoverAnchor; - const platformSpecificMenuItems = getPlatformSpecificMenuItems(props.isSmallScreenWidth); const menuItems = [ @@ -48,7 +44,6 @@ const AboutPage = (props) => { action: () => { Link.openExternalLink(CONST.GITHUB_URL); }, - link: CONST.GITHUB_URL, }, { translationKey: 'initialSettingsPage.aboutPage.viewOpenJobs', @@ -57,7 +52,6 @@ const AboutPage = (props) => { action: () => { Link.openExternalLink(CONST.UPWORK_URL); }, - link: CONST.UPWORK_URL, }, { translationKey: 'initialSettingsPage.aboutPage.reportABug', @@ -113,10 +107,6 @@ const AboutPage = (props) => { icon={item.icon} iconRight={item.iconRight} onPress={() => item.action()} - shouldBlockSelection={Boolean(item.link)} - onSecondaryInteraction={!_.isEmpty(item.link) - ? e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor) : undefined} - ref={el => popoverAnchor = el} shouldShowRightIcon /> ))} diff --git a/src/pages/settings/AppDownloadLinks.js b/src/pages/settings/AppDownloadLinks.js index 8acd2e95dafb..9b47bdb644ef 100644 --- a/src/pages/settings/AppDownloadLinks.js +++ b/src/pages/settings/AppDownloadLinks.js @@ -11,9 +11,12 @@ import compose from '../../libs/compose'; import MenuItem from '../../components/MenuItem'; import styles from '../../styles/styles'; import * as Link from '../../libs/actions/Link'; +import PressableWithSecondaryInteraction from '../../components/PressableWithSecondaryInteraction'; +import ControlSelection from '../../libs/ControlSelection'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; +import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import * as ReportActionContextMenu from '../home/report/ContextMenu/ReportActionContextMenu'; -import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions'; +import * as ContextMenuActions from '../home/report/ContextMenu/ContextMenuActions'; const propTypes = { ...withLocalizePropTypes, @@ -53,6 +56,21 @@ const AppDownloadLinksPage = (props) => { }, ]; + /** + * Show the ReportActionContextMenu modal popover. + * + * @param {Object} [event] - A press event. + * @param {String} [selection] - Copied content. + */ + const showPopover = (event, selection) => { + ReportActionContextMenu.showContextMenu( + ContextMenuActions.CONTEXT_MENU_TYPES.LINK, + event, + selection, + popoverAnchor, + ); + }; + return ( { /> {_.map(menuItems, item => ( - item.action()} - onSecondaryInteraction={e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor)} + onPressIn={() => props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onSecondaryInteraction={e => showPopover(e, item.link)} + ref={el => popoverAnchor = el} onKeyDown={(event) => { event.target.blur(); }} - ref={el => popoverAnchor = el} - title={props.translate(item.translationKey)} - icon={item.icon} - iconRight={item.iconRight} - shouldBlockSelection - shouldShowRightIcon - /> + > + item.action()} + shouldShowRightIcon + /> + ))} diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index ef001593a7c0..66a130697b9d 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -4,7 +4,6 @@ import {View, ScrollView, Pressable} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; -import Str from 'expensify-common/lib/str'; import {withNetwork} from '../../components/OnyxProvider'; import styles from '../../styles/styles'; import Text from '../../components/Text'; @@ -36,8 +35,6 @@ import * as Link from '../../libs/actions/Link'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import * as UserUtils from '../../libs/UserUtils'; import policyMemberPropType from '../policyMemberPropType'; -import * as ReportActionContextMenu from '../home/report/ContextMenu/ReportActionContextMenu'; -import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions'; const propTypes = { /* Onyx Props */ @@ -119,8 +116,6 @@ class InitialSettingsPage extends React.Component { constructor(props) { super(props); - this.popoverAnchor = React.createRef(); - this.getWalletBalance = this.getWalletBalance.bind(this); this.getDefaultMenuItems = this.getDefaultMenuItems.bind(this); this.getMenuItem = this.getMenuItem.bind(this); @@ -207,7 +202,6 @@ class InitialSettingsPage extends React.Component { action: () => { Link.openExternalLink(CONST.NEWHELP_URL); }, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, - link: CONST.NEWHELP_URL, }, { translationKey: 'initialSettingsPage.about', @@ -241,9 +235,6 @@ class InitialSettingsPage extends React.Component { brickRoadIndicator={item.brickRoadIndicator} floatRightAvatars={item.floatRightAvatars} shouldStackHorizontally={item.shouldStackHorizontally} - ref={this.popoverAnchor} - shouldBlockSelection={Boolean(item.link)} - onSecondaryInteraction={!_.isEmpty(item.link) ? e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, this.popoverAnchor.current) : undefined} /> ); } @@ -304,7 +295,7 @@ class InitialSettingsPage extends React.Component { {this.props.currentUserPersonalDetails.displayName ? this.props.currentUserPersonalDetails.displayName - : Str.removeSMSDomain(this.props.session.email)} + : this.props.formatPhoneNumber(this.props.session.email)} @@ -313,7 +304,7 @@ class InitialSettingsPage extends React.Component { style={[styles.textLabelSupporting, styles.mt1]} numberOfLines={1} > - {Str.removeSMSDomain(this.props.session.email)} + {this.props.formatPhoneNumber(this.props.session.email)} )} diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index ba5c0e5fbb5b..a2aa35f6beff 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -156,6 +156,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(contactMethod).replace(/ /g, '\u00A0') + : contactMethod; + const loginData = this.props.loginList[contactMethod]; if (!contactMethod || !loginData) { return ; @@ -169,7 +175,7 @@ class ContactMethodDetailsPage extends Component { return ( Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} onCloseButtonPress={() => Navigation.dismissModal(true)} @@ -191,7 +197,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 7d8e362a438e..ce7711e58de2 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js @@ -83,13 +83,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(partnerUserID) : partnerUserID; + return ( Navigation.navigate(ROUTES.getEditContactMethodRoute(partnerUserID))} brickRoadIndicator={indicator} diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 3ea4f9abb9f7..88890bcd0f99 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -1,4 +1,3 @@ -import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import React from 'react'; import {View} from 'react-native'; @@ -65,7 +64,7 @@ const ProfilePage = (props) => { }, { description: props.translate('contacts.contactMethod'), - title: Str.removeSMSDomain(lodashGet(currentUserDetails, 'login', '')), + title: props.formatPhoneNumber(lodashGet(currentUserDetails, 'login', '')), pageRoute: ROUTES.SETTINGS_CONTACT_METHODS, brickRoadIndicator: contactMethodBrickRoadIndicator, }, diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js index 56a5c27e12a0..b80ab2687bac 100644 --- a/src/pages/settings/Security/CloseAccountPage.js +++ b/src/pages/settings/Security/CloseAccountPage.js @@ -85,7 +85,7 @@ class CloseAccountPage extends Component { } render() { - const userEmailOrPhone = Str.removeSMSDomain(this.props.session.email); + const userEmailOrPhone = this.props.formatPhoneNumber(this.props.session.email); return ( ( {!_.isEmpty(props.credentials.login) && ( - {props.translate('loginForm.notYou', {user: Str.removeSMSDomain(props.credentials.login)})} + {props.translate('loginForm.notYou', {user: props.formatPhoneNumber(props.credentials.login)})} )} { const isSMSLogin = Str.isSMSLogin(props.credentials.login); - const login = isSMSLogin ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login)) : props.credentials.login; + + // replacing spaces with "hard spaces" to prevent breaking the number + const login = isSMSLogin + ? props.formatPhoneNumber(props.credentials.login).replace(/ /g, '\u00A0') + : props.credentials.login; + const loginType = (isSMSLogin ? props.translate('common.phone') : props.translate('common.email')).toLowerCase(); return ( diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index f83f467106ee..3282e16d50b2 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -102,16 +102,18 @@ class SignInPage extends Component { } else { const userLogin = Str.removeSMSDomain(lodashGet(this.props, 'credentials.login', '')); + // replacing spaces with "hard spaces" to prevent breaking the number + const userLoginToDisplay = Str.isSMSLogin(userLogin) ? this.props.formatPhoneNumber(userLogin).replace(/ /g, '\u00A0') : userLogin; if (this.props.account.validated) { welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); welcomeText = this.props.isSmallScreenWidth - ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLogin})}` - : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLogin}); + ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay})}` + : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay}); } else { welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcome'); welcomeText = this.props.isSmallScreenWidth - ? `${this.props.translate('welcomeText.welcome')} ${this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLogin})}` - : this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLogin}); + ? `${this.props.translate('welcomeText.welcome')} ${this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay})}` + : this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay}); } } } else if (showPasswordForm) { diff --git a/src/pages/signin/SignInPageHero.js b/src/pages/signin/SignInPageHero.js index 36edb388283a..633a709758d1 100644 --- a/src/pages/signin/SignInPageHero.js +++ b/src/pages/signin/SignInPageHero.js @@ -17,7 +17,7 @@ const SignInPageHero = props => ( StyleUtils.getMinimumHeight(variables.signInContentMinHeight), props.windowWidth <= variables.tabletResponsiveWidthBreakpoint ? styles.flexColumn : styles.flexColumn, styles.pt20, - StyleUtils.getMaximumWidth(variables.signInContextMaxWidth), + StyleUtils.getMaximumWidth(variables.signInHeroContextMaxWidth), styles.alignSelfCenter, ]} > diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index a10e39b03293..132b848af0e5 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -6,7 +6,6 @@ import { } from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; -import Str from 'expensify-common/lib/str'; import styles from '../../styles/styles'; import ONYXKEYS from '../../ONYXKEYS'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; @@ -289,8 +288,8 @@ class WorkspaceMembersPage extends React.Component { onSelectRow={() => this.toggleUser(item.login, item.pendingAction)} boldStyle option={{ - text: Str.removeSMSDomain(item.displayName), - alternateText: Str.removeSMSDomain(item.login), + text: this.props.formatPhoneNumber(item.displayName), + alternateText: this.props.formatPhoneNumber(item.login), participantsList: [item], icons: [{ source: ReportUtils.getAvatar(item.avatar, item.login), @@ -409,7 +408,7 @@ class WorkspaceMembersPage extends React.Component { {data.length > 0 ? ( @@ -439,7 +438,7 @@ class WorkspaceMembersPage extends React.Component { ) : ( - {this.props.translate('common.noResultsFound')} + {this.props.translate('workspace.common.memberNotFound')} )} diff --git a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js index 15397ea96776..f97b6ba946ae 100644 --- a/src/pages/workspace/bills/WorkspaceBillsFirstSection.js +++ b/src/pages/workspace/bills/WorkspaceBillsFirstSection.js @@ -42,7 +42,6 @@ const defaultProps = { const WorkspaceBillsFirstSection = (props) => { const emailDomain = Str.extractEmailDomain(props.session.email); - const manageYourBillsUrl = `reports?policyID=${props.policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`; return (
{ { title: props.translate('workspace.bills.viewAllBills'), onPress: () => ( - Link.openOldDotLink(manageYourBillsUrl) + Link.openOldDotLink(`reports?policyID=${props.policyID}&from=all&type=bill&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`) ), icon: Expensicons.Bill, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(manageYourBillsUrl), }, ]} containerStyles={[styles.cardSection]} diff --git a/src/pages/workspace/bills/WorkspaceBillsVBAView.js b/src/pages/workspace/bills/WorkspaceBillsVBAView.js index dcb625777c7d..3731a9cc7b8f 100644 --- a/src/pages/workspace/bills/WorkspaceBillsVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsVBAView.js @@ -17,35 +17,30 @@ const propTypes = { ...withLocalizePropTypes, }; -const WorkspaceBillsVBAView = (props) => { - const reportsUrl = `reports?policyID=${props.policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`; +const WorkspaceBillsVBAView = props => ( + <> + - return ( - <> - - -
Link.openOldDotLink(reportsUrl), - icon: Expensicons.Bill, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(reportsUrl), - }, - ]} - > - - {props.translate('workspace.bills.VBACopy')} - -
- - ); -}; +
Link.openOldDotLink(`reports?policyID=${props.policyID}&from=all&type=bill&showStates=Processing,Approved&isAdvancedFilterMode=true`), + icon: Expensicons.Bill, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + wrapperStyle: [styles.cardMenuItem], + }, + ]} + > + + {props.translate('workspace.bills.VBACopy')} + +
+ +); WorkspaceBillsVBAView.propTypes = propTypes; WorkspaceBillsVBAView.displayName = 'WorkspaceBillsVBAView'; diff --git a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js index 4c1213800bac..a9a551f23131 100644 --- a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js +++ b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js @@ -13,40 +13,31 @@ const propTypes = { ...withLocalizePropTypes, }; -const MENU_LINKS = { - ISSUE_AND_MANAGE_CARDS: 'domain_companycards', - RECONCILE_CARDS: encodeURI('domain_companycards?param={"section":"cardReconciliation"}'), - SETTLEMENT_FREQUENCY: encodeURI('domain_companycards?param={"section":"configureSettings"}'), -}; - const WorkspaceCardVBAWithECardView = (props) => { const menuItems = [ { title: props.translate('workspace.common.issueAndManageCards'), - onPress: () => Link.openOldDotLink(MENU_LINKS.ISSUE_AND_MANAGE_CARDS), + onPress: () => Link.openOldDotLink('domain_companycards'), icon: Expensicons.ExpensifyCard, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(MENU_LINKS.ISSUE_AND_MANAGE_CARDS), }, { title: props.translate('workspace.common.reconcileCards'), - onPress: () => Link.openOldDotLink(MENU_LINKS.RECONCILE_CARDS), + onPress: () => Link.openOldDotLink(encodeURI('domain_companycards?param={"section":"cardReconciliation"}')), icon: Expensicons.ReceiptSearch, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(MENU_LINKS.RECONCILE_CARDS), }, { title: props.translate('workspace.common.settlementFrequency'), - onPress: () => Link.openOldDotLink(MENU_LINKS.SETTLEMENT_FREQUENCY), + onPress: () => Link.openOldDotLink(encodeURI('domain_companycards?param={"section":"configureSettings"}')), icon: Expensicons.Gear, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(MENU_LINKS.SETTLEMENT_FREQUENCY), }, ]; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js b/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js index 9f3c84d28ff1..4d027542b155 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js +++ b/src/pages/workspace/invoices/WorkspaceInvoicesFirstSection.js @@ -16,46 +16,39 @@ const propTypes = { ...withLocalizePropTypes, }; -const WorkspaceInvoicesFirstSection = (props) => { - const sendInvoiceUrl = encodeURI('reports?param={"createInvoice":true}'); - const viewAllInvoicesUrl = `reports?policyID=${props.policyID}&from=all&type=invoice&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`; - - return ( -
Link.openOldDotLink(sendInvoiceUrl), - icon: Expensicons.Send, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(sendInvoiceUrl), - }, - { - title: props.translate('workspace.invoices.viewAllInvoices'), - onPress: () => ( - Link.openOldDotLink(viewAllInvoicesUrl) - ), - icon: Expensicons.Invoice, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(viewAllInvoicesUrl), - }, - ]} - containerStyles={[styles.cardSection]} - > - - - {props.translate('workspace.invoices.invoiceFirstSectionCopy')} - - -
- ); -}; +const WorkspaceInvoicesFirstSection = props => ( +
Link.openOldDotLink(encodeURI('reports?param={"createInvoice":true}')), + icon: Expensicons.Send, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + wrapperStyle: [styles.cardMenuItem], + }, + { + title: props.translate('workspace.invoices.viewAllInvoices'), + onPress: () => ( + Link.openOldDotLink(`reports?policyID=${props.policyID}&from=all&type=invoice&showStates=Open,Processing,Approved,Reimbursed,Archived&isAdvancedFilterMode=true`) + ), + icon: Expensicons.Invoice, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + wrapperStyle: [styles.cardMenuItem], + }, + ]} + containerStyles={[styles.cardSection]} + > + + + {props.translate('workspace.invoices.invoiceFirstSectionCopy')} + + +
+); WorkspaceInvoicesFirstSection.propTypes = propTypes; WorkspaceInvoicesFirstSection.displayName = 'WorkspaceInvoicesFirstSection'; diff --git a/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js b/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js index c4f50bb9d49d..c88a61eef18b 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js +++ b/src/pages/workspace/invoices/WorkspaceInvoicesVBAView.js @@ -17,35 +17,30 @@ const propTypes = { ...withLocalizePropTypes, }; -const WorkspaceInvoicesVBAView = (props) => { - const viewUnpaidInvoicesUrl = `reports?policyID=${props.policyID}&from=all&type=invoice&showStates=Processing&isAdvancedFilterMode=true`; +const WorkspaceInvoicesVBAView = props => ( + <> + - return ( - <> - - -
Link.openOldDotLink(viewUnpaidInvoicesUrl), - icon: Expensicons.Hourglass, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(viewUnpaidInvoicesUrl), - }, - ]} - > - - {props.translate('workspace.invoices.unlockVBACopy')} - -
- - ); -}; +
Link.openOldDotLink(`reports?policyID=${props.policyID}&from=all&type=invoice&showStates=Processing&isAdvancedFilterMode=true`), + icon: Expensicons.Hourglass, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + wrapperStyle: [styles.cardMenuItem], + }, + ]} + > + + {props.translate('workspace.invoices.unlockVBACopy')} + +
+ +); WorkspaceInvoicesVBAView.propTypes = propTypes; WorkspaceInvoicesVBAView.displayName = 'WorkspaceInvoicesVBAView'; diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseSection.js b/src/pages/workspace/reimburse/WorkspaceReimburseSection.js index 3928816fb686..20f443bf5671 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseSection.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseSection.js @@ -57,7 +57,6 @@ class WorkspaceReimburseSection extends React.Component { render() { const achState = lodashGet(this.props.reimbursementAccount, 'achData.state', ''); const hasVBA = achState === BankAccount.STATE.OPEN; - const reimburseReceiptsUrl = `reports?policyID=${this.props.policy.id}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`; if (this.props.network.isOffline) { return ( @@ -94,12 +93,11 @@ class WorkspaceReimburseSection extends React.Component { menuItems={[ { title: this.props.translate('workspace.reimburse.reimburseReceipts'), - onPress: () => Link.openOldDotLink(reimburseReceiptsUrl), + onPress: () => Link.openOldDotLink(`reports?policyID=${this.props.policy.id}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`), icon: Expensicons.Bank, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(reimburseReceiptsUrl), }, ]} > diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js index ccb40d053e7f..d103121b9e2a 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js @@ -214,8 +214,6 @@ class WorkspaceReimburseView extends React.Component { } render() { - const viewAllReceiptsUrl = `expenses?policyIDList=${this.props.policy.id}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`; - return ( <>
Link.openOldDotLink(viewAllReceiptsUrl), + onPress: () => Link.openOldDotLink(`expenses?policyIDList=${this.props.policy.id}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`), icon: Expensicons.Receipt, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL(viewAllReceiptsUrl), }, ]} > diff --git a/src/pages/workspace/travel/WorkspaceTravelVBAView.js b/src/pages/workspace/travel/WorkspaceTravelVBAView.js index 79b256136028..9c79b55c90b4 100644 --- a/src/pages/workspace/travel/WorkspaceTravelVBAView.js +++ b/src/pages/workspace/travel/WorkspaceTravelVBAView.js @@ -8,7 +8,6 @@ import * as Illustrations from '../../../components/Icon/Illustrations'; import Section from '../../../components/Section'; import * as Link from '../../../libs/actions/Link'; import * as Report from '../../../libs/actions/Report'; -import CONST from '../../../CONST'; const propTypes = { ...withLocalizePropTypes, @@ -26,7 +25,6 @@ const WorkspaceTravelVBAView = props => ( shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: () => Link.buildOldDotURL('domain_companycards'), }, { title: props.translate('workspace.travel.bookTravelWithConcierge'), @@ -39,12 +37,11 @@ const WorkspaceTravelVBAView = props => ( }, { title: props.translate('requestorStep.learnMore'), - onPress: () => Link.openExternalLink(CONST.CONCIERGE_TRAVEL_URL), + onPress: () => Link.openExternalLink('https://community.expensify.com/discussion/7066/introducing-concierge-travel'), icon: Expensicons.Info, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], - link: CONST.CONCIERGE_TRAVEL_URL, }, ]} > diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index f614ab69185d..f78e56839e5b 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -440,15 +440,19 @@ function getModalPaddingStyles({ modalContainerStyleMarginBottom, modalContainerStylePaddingTop, modalContainerStylePaddingBottom, + insets, }) { + // use fallback value for safeAreaPaddingBottom to keep padding bottom consistent with padding top. + // More info: issue #17376 + const safeAreaPaddingBottomWithFallback = insets.bottom === 0 ? (modalContainerStylePaddingTop || 0) : safeAreaPaddingBottom; return { marginTop: (modalContainerStyleMarginTop || 0) + (shouldAddTopSafeAreaMargin ? safeAreaPaddingTop : 0), - marginBottom: (modalContainerStyleMarginBottom || 0) + (shouldAddBottomSafeAreaMargin ? safeAreaPaddingBottom : 0), + marginBottom: (modalContainerStyleMarginBottom || 0) + (shouldAddBottomSafeAreaMargin ? safeAreaPaddingBottomWithFallback : 0), paddingTop: shouldAddTopSafeAreaPadding ? (modalContainerStylePaddingTop || 0) + safeAreaPaddingTop : modalContainerStylePaddingTop || 0, paddingBottom: shouldAddBottomSafeAreaPadding - ? (modalContainerStylePaddingBottom || 0) + safeAreaPaddingBottom + ? (modalContainerStylePaddingBottom || 0) + safeAreaPaddingBottomWithFallback : modalContainerStylePaddingBottom || 0, paddingLeft: safeAreaPaddingLeft || 0, paddingRight: safeAreaPaddingRight || 0, diff --git a/src/styles/stylePropTypes.js b/src/styles/stylePropTypes.js index edc5d0383a75..4c7e825a8848 100644 --- a/src/styles/stylePropTypes.js +++ b/src/styles/stylePropTypes.js @@ -3,5 +3,4 @@ import PropTypes from 'prop-types'; export default PropTypes.oneOfType([ PropTypes.object, PropTypes.arrayOf(PropTypes.object), - PropTypes.func, ]); diff --git a/src/styles/variables.js b/src/styles/variables.js index b97aa3e0e1bc..97fb157020fc 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -118,7 +118,7 @@ export default { signInHeroBackgroundWidth: 2000, signInHeroBackgroundWidthMobile: 800, signInContentMaxWidth: 1360, - signInHeroContextMaxWidth: 740, + signInHeroContextMaxWidth: 680, signInContentMinHeight: 800, signInLogoHeightSmallScreen: 28, signInLogoHeight: 34, diff --git a/tests/unit/IOUUtilsTest.js b/tests/unit/IOUUtilsTest.js index 989b33a42359..4ae4f3c5bc4b 100644 --- a/tests/unit/IOUUtilsTest.js +++ b/tests/unit/IOUUtilsTest.js @@ -1,6 +1,10 @@ +import Onyx from 'react-native-onyx'; import CONST from '../../src/CONST'; import * as IOUUtils from '../../src/libs/IOUUtils'; import * as ReportUtils from '../../src/libs/ReportUtils'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import currencyList from './currencyList.json'; let iouReport; let reportActions; @@ -38,27 +42,37 @@ function cancelMoneyRequest(moneyRequestAction, {isOnline} = {}) { ); } -beforeEach(() => { - reportActions = []; - const chatReportID = ReportUtils.generateReportID(); - const amount = 1000; - const currency = 'USD'; - - iouReport = ReportUtils.buildOptimisticIOUReport( - ownerEmail, - managerEmail, - amount, - chatReportID, - currency, - CONST.LOCALES.EN, - ); - - // The starting point of all tests is the IOUReport containing a single non-pending transaction in USD - // All requests in the tests are assumed to be offline, unless isOnline is specified - createIOUReportAction('create', amount, currency, {IOUTransactionID: '', isOnline: true}); -}); +function initCurrencyList() { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + return waitForPromisesToResolve(); +} describe('isIOUReportPendingCurrencyConversion', () => { + beforeEach(() => { + reportActions = []; + const chatReportID = ReportUtils.generateReportID(); + const amount = 1000; + const currency = 'USD'; + + iouReport = ReportUtils.buildOptimisticIOUReport( + ownerEmail, + managerEmail, + amount, + chatReportID, + currency, + CONST.LOCALES.EN, + ); + + // The starting point of all tests is the IOUReport containing a single non-pending transaction in USD + // All requests in the tests are assumed to be offline, unless isOnline is specified + createIOUReportAction('create', amount, currency, {IOUTransactionID: '', isOnline: true}); + }); + test('Requesting money offline in a different currency will show the pending conversion message', () => { // Request money offline in AED createIOUReportAction('create', 100, 'AED'); @@ -132,3 +146,57 @@ describe('isIOUReportPendingCurrencyConversion', () => { }); }); +describe('getCurrencyDecimals', () => { + beforeAll(() => initCurrencyList()); + test('Currency decimals smaller than or equal 2', () => { + expect(IOUUtils.getCurrencyDecimals('JPY')).toBe(0); + expect(IOUUtils.getCurrencyDecimals('USD')).toBe(2); + }); + + test('Currency decimals larger than 2 should return 2', () => { + // Actual: 3 + expect(IOUUtils.getCurrencyDecimals('LYD')).toBe(2); + + // Actual: 4 + expect(IOUUtils.getCurrencyDecimals('UYW')).toBe(2); + }); +}); + +describe('getCurrencyUnit', () => { + beforeAll(() => initCurrencyList()); + test('Currency with decimals smaller than or equal 2', () => { + expect(IOUUtils.getCurrencyUnit('JPY')).toBe(1); + expect(IOUUtils.getCurrencyUnit('USD')).toBe(100); + }); + + test('Currency with decimals larger than 2 should be floor to 2', () => { + expect(IOUUtils.getCurrencyUnit('LYD')).toBe(100); + }); +}); + +describe('calculateAmount', () => { + beforeAll(() => initCurrencyList()); + test('103 JPY split among 3 participants including the default user should be [35, 34, 34]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 103, 'JPY', true)).toBe(3500); + expect(IOUUtils.calculateAmount(participants, 103, 'JPY')).toBe(3400); + }); + + test('10 AFN split among 4 participants including the default user should be [1, 3, 3, 3]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com', 'suestorm@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 10, 'AFN', true)).toBe(100); + expect(IOUUtils.calculateAmount(participants, 10, 'AFN')).toBe(300); + }); + + test('10 BHD split among 3 participants including the default user should be [334, 333, 333]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 10, 'BHD', true)).toBe(334); + expect(IOUUtils.calculateAmount(participants, 10, 'BHD')).toBe(333); + }); + + test('0.02 USD split among 4 participants including the default user should be [-1, 1, 1, 1]', () => { + const participants = ['tonystark@expensify.com', 'reedrichards@expensify.com', 'suestorm@expensify.com']; + expect(IOUUtils.calculateAmount(participants, 0.02, 'USD', true)).toBe(-1); + expect(IOUUtils.calculateAmount(participants, 0.02, 'USD')).toBe(1); + }); +}); diff --git a/tests/unit/LocalePhoneNumberTest.js b/tests/unit/LocalePhoneNumberTest.js index bb2c250205ad..40d6bcc7bf69 100644 --- a/tests/unit/LocalePhoneNumberTest.js +++ b/tests/unit/LocalePhoneNumberTest.js @@ -1,89 +1,49 @@ -const localePhoneNumber = require('../../src/libs/LocalePhoneNumber'); -const CONST = require('../../src/CONST').default; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import * as LocalePhoneNumber from '../../src/libs/LocalePhoneNumber'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; -describe('localePhoneNumber', () => { - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.ES_ES, '34547474747474')).toBe('547474747474'); - }); - - // Failing due to the use of Trimstart. - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.ES_ES, '343434343434')).toBe('3434343434'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.ES_ES, '+34547474747474')).toBe('547474747474'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.ES_ES, '547474747474')).toBe('547474747474'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.ES_ES, '+17474747474')).toBe('+17474747474'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.EN, '+1547474747474')).toBe('547474747474'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.EN, '1547474747474')).toBe('547474747474'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.EN, '547474747474')).toBe('547474747474'); - }); +const ES_NUMBER = '+34702474537'; +const US_NUMBER = '+18332403627'; - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.EN, '+347474747474')).toBe('+347474747474'); - }); - - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone(CONST.LOCALES.EN, '+34 747 474 7474')).toBe('+34 747 474 7474'); - }); +describe('LocalePhoneNumber utils', () => { + beforeAll(() => Onyx.init({ + keys: ONYXKEYS, + })); - it('Test to local Number Conversion by locale', () => { - expect(localePhoneNumber.toLocalPhone('en-EN', '+17474747474')).toBe('+17474747474'); - }); + describe('formatPhoneNumber function - when the current user has a phone number', () => { + beforeEach(() => Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: 'current@user.com'}, + [ONYXKEYS.COUNTRY_CODE]: 34, + [ONYXKEYS.PERSONAL_DETAILS]: {'current@user.com': {phoneNumber: US_NUMBER}}, + }).then(waitForPromisesToResolve)); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.ES_ES, '34547474747474')).toBe('+34547474747474'); - }); + afterEach(() => Onyx.clear()); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.ES_ES, '+34547474747474')).toBe('+34547474747474'); - }); + it('should display a number from the same region formatted locally', () => { + expect(LocalePhoneNumber.formatPhoneNumber(US_NUMBER)).toBe('(833) 240-3627'); + }); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.ES_ES, '547474747474')).toBe('+34547474747474'); + it('should display a number from another region formatted internationally', () => { + expect(LocalePhoneNumber.formatPhoneNumber(ES_NUMBER)).toBe('+34 702 47 45 37'); + }); }); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.ES_ES, '+17474747474')).toBe('+3417474747474'); - }); + describe('formatPhoneNumber function - when the current user does not have a phone number', () => { + beforeEach(() => Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: 'current@user.com'}, + [ONYXKEYS.COUNTRY_CODE]: 34, + [ONYXKEYS.PERSONAL_DETAILS]: {'current@user.com': {phoneNumber: ''}}, + }).then(waitForPromisesToResolve)); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.EN, '+1547474747474')).toBe('+1547474747474'); - }); + afterEach(() => Onyx.clear()); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.EN, '1547474747474')).toBe('+1547474747474'); - }); - - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.EN, '547474747474')).toBe('+1547474747474'); - }); - - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.EN, '+347474747474')).toBe('+1347474747474'); - }); - - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone(CONST.LOCALES.EN, ' + 34 747 474 7474 ')).toBe('+1 34 747 474 7474'); - }); + it('should display a number from the same region formatted locally', () => { + expect(LocalePhoneNumber.formatPhoneNumber(ES_NUMBER)).toBe('702 47 45 37'); + }); - it('Test to international Number Conversion by locale', () => { - expect(localePhoneNumber.fromLocalPhone('en-EN', '+17474747474')).toBe('+17474747474'); + it('should display a number from another region formatted internationally', () => { + expect(LocalePhoneNumber.formatPhoneNumber(US_NUMBER)).toBe('+1 833-240-3627'); + }); }); }); diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js index c9fcca5a6a7d..d610b740135f 100644 --- a/tests/unit/ReportUtilsTest.js +++ b/tests/unit/ReportUtilsTest.js @@ -25,8 +25,8 @@ const participantsPersonalDetails = { login: 'lagertha@vikings.net', pronouns: 'She/her', }, - '+12223334444@expensify.sms': { - login: '+12223334444@expensify.sms', + '+18332403627@expensify.sms': { + login: '+18332403627@expensify.sms', }, }; const policy = { @@ -64,8 +64,8 @@ describe('ReportUtils', () => { pronouns: 'She/her', }, { - displayName: '2223334444', - tooltip: '+12223334444', + displayName: '(833) 240-3627', + tooltip: '+18332403627', pronouns: undefined, }, ]); @@ -89,8 +89,8 @@ describe('ReportUtils', () => { pronouns: 'She/her', }, { - displayName: '2223334444', - tooltip: '+12223334444', + displayName: '(833) 240-3627', + tooltip: '+18332403627', pronouns: undefined, }, ]); @@ -113,15 +113,15 @@ describe('ReportUtils', () => { test('SMS', () => { expect(ReportUtils.getReportName({ - participants: [currentUserEmail, '+12223334444@expensify.sms'], - })).toBe('2223334444'); + participants: [currentUserEmail, '+18332403627@expensify.sms'], + })).toBe('(833) 240-3627'); }); }); test('Group DM', () => { expect(ReportUtils.getReportName({ - participants: [currentUserEmail, 'ragnar@vikings.net', 'floki@vikings.net', 'lagertha@vikings.net', '+12223334444@expensify.sms'], - })).toBe('Ragnar, floki@vikings.net, Lagertha, 2223334444'); + participants: [currentUserEmail, 'ragnar@vikings.net', 'floki@vikings.net', 'lagertha@vikings.net', '+18332403627@expensify.sms'], + })).toBe('Ragnar, floki@vikings.net, Lagertha, (833) 240-3627'); }); describe('Default Policy Room', () => { diff --git a/tests/unit/currencyList.json b/tests/unit/currencyList.json index 740b3caf2b28..c6eda7bdd766 100644 --- a/tests/unit/currencyList.json +++ b/tests/unit/currencyList.json @@ -7,11 +7,13 @@ "AFN": { "symbol": "Af", "name": "Afghan Afghani", + "decimals": 0, "ISO4217": "971" }, "ALL": { "symbol": "ALL", "name": "Albanian Lek", + "decimals": 0, "ISO4217": "008" }, "AMD": { @@ -123,6 +125,7 @@ "BYR": { "symbol": "BR", "name": "Belarus Ruble", + "decimals": 0, "retired": true, "retirementDate": "2016-07-01", "ISO4217": "974" @@ -330,11 +333,13 @@ "IQD": { "symbol": "IQD", "name": "Iraqi Dinar", + "decimals": 0, "ISO4217": "368" }, "IRR": { "symbol": "﷼", "name": "Iran Rial", + "decimals": 0, "ISO4217": "364" }, "ISK": { @@ -377,16 +382,19 @@ "KMF": { "symbol": "CF", "name": "Comoros Franc", + "decimals": 0, "ISO4217": "174" }, "KPW": { "symbol": "KP₩", "name": "North Korean Won", + "decimals": 0, "ISO4217": "408" }, "KRW": { "symbol": "₩", "name": "Korean Won", + "decimals": 0, "ISO4217": "410" }, "KWD": { @@ -407,11 +415,13 @@ "LAK": { "symbol": "₭", "name": "Lao Kip", + "decimals": 0, "ISO4217": "418" }, "LBP": { "symbol": "LBP", "name": "Lebanese Pound", + "decimals": 0, "ISO4217": "422" }, "LKR": { @@ -460,6 +470,7 @@ "MGA": { "symbol": "MGA", "name": "Malagasy Ariary", + "decimals": 0, "ISO4217": "969" }, "MKD": { @@ -470,6 +481,7 @@ "MMK": { "symbol": "Ks", "name": "Myanmar Kyat", + "decimals": 0, "ISO4217": "104" }, "MNT": { @@ -594,6 +606,7 @@ "PYG": { "symbol": "₲", "name": "Paraguayan Guarani", + "decimals": 0, "ISO4217": "600" }, "QAR": { @@ -609,6 +622,7 @@ "RSD": { "symbol": "РСД", "name": "Serbian Dinar", + "decimals": 0, "ISO4217": "941" }, "RUB": { @@ -660,11 +674,13 @@ "SLL": { "symbol": "Le", "name": "Sierra Leone Leone", + "decimals": 0, "ISO4217": "694" }, "SOS": { "symbol": "So.", "name": "Somali Shilling", + "decimals": 0, "ISO4217": "706" }, "SRD": { @@ -675,6 +691,7 @@ "STD": { "symbol": "Db", "name": "Sao Tome Dobra", + "decimals": 0, "retired": true, "retirementDate": "2018-07-11", "ISO4217": "678" @@ -692,6 +709,7 @@ "SYP": { "symbol": "SYP", "name": "Syrian Pound", + "decimals": 0, "ISO4217": "760" }, "SZL": { @@ -798,6 +816,7 @@ "VUV": { "symbol": "Vt", "name": "Vanuatu Vatu", + "decimals": 0, "ISO4217": "548" }, "WST": { @@ -830,7 +849,8 @@ }, "YER": { "symbol": "YER", - "name": "Yemen Riyal", + "name": "Yemen Riyal", + "decimals": 0, "ISO4217": "886" }, "ZAR": { @@ -841,6 +861,7 @@ "ZMK": { "symbol": "ZK", "name": "Zambian Kwacha", + "decimals": 0, "retired": true, "retirementDate": "2013-01-01", "ISO4217": "894" @@ -852,4 +873,3 @@ "ISO4217": "967" } } - \ No newline at end of file