diff --git a/.github/workflows/contributorChecklists.yml b/.github/workflows/contributorChecklists.yml index 692ba8944956..338ec6ba1e55 100644 --- a/.github/workflows/contributorChecklists.yml +++ b/.github/workflows/contributorChecklists.yml @@ -5,10 +5,10 @@ on: pull_request jobs: checklist: runs-on: ubuntu-latest - if: github.actor != 'OSBotify' && (github.event_name == 'pull_request' && contains(github.event.pull_request.body, '- [')) + if: github.actor != 'OSBotify' steps: - name: contributorChecklist.js - uses: Expensify/App/.github/actions/javascript/contributorChecklist@andrew-checklist-3 + uses: Expensify/App/.github/actions/javascript/contributorChecklist@main with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHECKLIST: 'contributor' diff --git a/.github/workflows/contributorPlusChecklists.yml b/.github/workflows/contributorPlusChecklists.yml index 5acffb511386..76dda02da067 100644 --- a/.github/workflows/contributorPlusChecklists.yml +++ b/.github/workflows/contributorPlusChecklists.yml @@ -1,14 +1,14 @@ name: Contributor+ Checklist -on: issue_comment +on: pull_request_review jobs: checklist: runs-on: ubuntu-latest - if: github.actor != 'OSBotify' && (contains(github.event.issue.pull_request.url, 'http') && github.event_name == 'issue_comment' && contains(github.event.comment.body, '- [')) + if: github.actor != 'OSBotify' steps: - name: contributorChecklist.js - uses: Expensify/App/.github/actions/javascript/contributorChecklist@andrew-checklist-3 + uses: Expensify/App/.github/actions/javascript/contributorChecklist@main with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHECKLIST: 'contributorPlus' diff --git a/README.md b/README.md index 75f7d621bacc..fb3a256efd22 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Alternatively, you can also set up debugger using [Flipper](https://fbflipper.co Our React Native Android app now uses the `Hermes` JS engine which requires your browser for remote debugging. These instructions are specific to Chrome since that's what the Hermes documentation provided. 1. Navigate to `chrome://inspect` 2. Use the `Configure...` button to add the Metro server address (typically `localhost:8081`, check your `Metro` output) -3. You should now see a "Hermes React Native" target with an "inspect" link which can be used to bring up a debugger. If you don't see the "inspect" link, make sure the Metro server is running. +3. You should now see a "Hermes React Native" target with an "inspect" link which can be used to bring up a debugger. If you don't see the "inspect" link, make sure the Metro server is running 4. You can now use the Chrome debug tools. See [React Native Debugging Hermes](https://reactnative.dev/docs/hermes#debugging-hermes-using-google-chromes-devtools) ## Web @@ -221,9 +221,9 @@ created to house a collection of items in plural form and using camelCase (eg: p - components: React native components that are re-used in several places. - libs: Library classes/functions, these are not React native components (ie: they are not UI) - pages: These are components that define pages in the app. The component that defines the page itself should be named -`Page` if there are components used only inside one page, they should live in its own directory named after the ``. +`Page` if there are components used only inside one page, they should live in its own directory named after the `` - styles: These files define styles used among components/pages -- contributingGuides: This is just a set of markdown files providing guides and insights to aid developers in learning how to contribute to this repo. +- contributingGuides: This is just a set of markdown files providing guides and insights to aid developers in learning how to contribute to this repo **Note:** There is also a directory called `/docs`, which houses the Expensify Help site. It's a static site that's built with Jekyll and hosted on GitHub Pages. diff --git a/android/app/build.gradle b/android/app/build.gradle index f8b8bf43b4f5..6d255b30bbd0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -155,8 +155,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001019705 - versionName "1.1.97-5" + versionCode 1001020000 + versionName "1.2.0-0" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 8763678bdb8c..abfba4e7a42e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.97 + 1.2.0 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.1.97.5 + 1.2.0.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index e5880d917383..cee9276b44e3 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.1.97 + 1.2.0 CFBundleSignature ???? CFBundleVersion - 1.1.97.5 + 1.2.0.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6d84eedf9a97..9113be1cf99a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -582,7 +582,7 @@ PODS: - Firebase/Performance (= 8.8.0) - React-Core - RNFBApp - - RNGestureHandler (2.5.0): + - RNGestureHandler (2.6.0): - React-Core - RNPermissions (3.6.1): - React-Core @@ -1000,7 +1000,7 @@ SPEC CHECKSUMS: RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e RNFBCrashlytics: 2061ca863e8e2fa1aae9b12477d7dfa8e88ca0f9 RNFBPerf: 389914cda4000fe0d996a752532a591132cbf3f9 - RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50 + RNGestureHandler: 920eb17f5b1e15dae6e5ed1904045f8f90e0b11e RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: 2cf7451318bb9cc430abeec8d67693f9cf4e039c diff --git a/package-lock.json b/package-lock.json index 819081067bb3..51d098c57b64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.1.97-5", + "version": "1.2.0-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.1.97-5", + "version": "1.2.0-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -44475,7 +44475,7 @@ "@oguzhnatly/react-native-image-manipulator": { "version": "git+ssh://git@github.com/Expensify/react-native-image-manipulator.git#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050", "integrity": "sha512-PvrSoCq5PS1MA5ZWUpB0khfzH6sM8SI6YiVl4i2SItPr7IeRxiWfI4n45VhBCCElc1z5GhAwTZOBaIzXTX7/og==", - "from": "@oguzhnatly/react-native-image-manipulator@https://github.com/Expensify/react-native-image-manipulator#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050" + "from": "@oguzhnatly/react-native-image-manipulator@github:Expensify/react-native-image-manipulator#c5f654fc9d0ad7cc5b89d50b34ecf8b0e3f4d050" }, "@onfido/active-video-capture": { "version": "0.0.1", diff --git a/package.json b/package.json index 47be580e4fc9..952b00a1de18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.97-5", + "version": "1.2.0-0", "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.", diff --git a/src/CONFIG.js b/src/CONFIG.js index a60b17e1e75b..4c03fa63c48e 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.js @@ -9,6 +9,8 @@ import CONST from './CONST'; const ENVIRONMENT = lodashGet(Config, 'ENVIRONMENT', CONST.ENVIRONMENT.DEV); const newExpensifyURL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_EXPENSIFY_URL', 'https://new.expensify.com/')); const expensifyURL = Url.addTrailingForwardSlash(lodashGet(Config, 'EXPENSIFY_URL', 'https://www.expensify.com/')); +const stagingExpensifyURL = Url.addTrailingForwardSlash(lodashGet(Config, 'STAGING_EXPENSIFY_URL', 'https://staging.expensify.com/')); +const stagingSecureExpensifyUrl = Url.addTrailingForwardSlash(lodashGet(Config, 'STAGING_SECURE_EXPENSIFY_URL', 'https://staging-secure.expensify.com/')); const ngrokURL = Url.addTrailingForwardSlash(lodashGet(Config, 'NGROK_URL', '')); const secureNgrokURL = Url.addTrailingForwardSlash(lodashGet(Config, 'SECURE_NGROK_URL', '')); const secureExpensifyUrl = Url.addTrailingForwardSlash(lodashGet( @@ -46,12 +48,15 @@ export default { SECURE_EXPENSIFY_URL: secureURLRoot, NEW_EXPENSIFY_URL: newExpensifyURL, URL_API_ROOT: expensifyURLRoot, + STAGING_EXPENSIFY_URL: stagingExpensifyURL, + STAGING_SECURE_EXPENSIFY_URL: stagingSecureExpensifyUrl, PARTNER_NAME: lodashGet(Config, 'EXPENSIFY_PARTNER_NAME', 'chat-expensify-com'), PARTNER_PASSWORD: lodashGet(Config, 'EXPENSIFY_PARTNER_PASSWORD', 'e21965746fd75f82bb66'), EXPENSIFY_CASH_REFERER: 'ecash', CONCIERGE_URL: conciergeUrl, }, IS_IN_PRODUCTION: Platform.OS === 'web' ? process.env.NODE_ENV === 'production' : !__DEV__, + IS_IN_STAGING: ENVIRONMENT === CONST.ENVIRONMENT.STAGING, IS_USING_LOCAL_WEB: useNgrok || expensifyURLRoot.includes('dev'), PUSHER: { APP_KEY: lodashGet(Config, 'PUSHER_APP_KEY', '268df511a204fbb60884'), diff --git a/src/CONST.js b/src/CONST.js index a974d81dbce2..5d2d735158f9 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -232,7 +232,6 @@ const CONST = { MANAGE_CARDS_URL: 'domain_companycards', FEES_URL: `${USE_EXPENSIFY_URL}/fees`, CFPB_PREPAID_URL: 'https://cfpb.gov/prepaid', - STAGING_SECURE_URL: 'https://staging-secure.expensify.com/', STAGING_NEW_EXPENSIFY_URL: 'https://staging.new.expensify.com', // Use Environment.getEnvironmentURL to get the complete URL with port number @@ -286,6 +285,9 @@ const CONST = { ANNOUNCE: '#announce', ADMINS: '#admins', }, + STATE: { + SUBMITTED: 'SUBMITTED', + }, STATE_NUM: { OPEN: 0, PROCESSING: 1, @@ -349,6 +351,7 @@ const CONST = { SWITCH_REPORT: 'switch_report', SIDEBAR_LOADED: 'sidebar_loaded', PERSONAL_DETAILS_FORMATTED: 'personal_details_formatted', + SIDEBAR_LINKS_FILTER_REPORTS: 'sidebar_links_filter_reports', COLD: 'cold', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, TOOLTIP_SENSE: 1000, @@ -513,6 +516,7 @@ const CONST = { SVFG: 'svfg@expensify.com', INTEGRATION_TESTING_CREDS: 'integrationtestingcreds@expensify.com', ADMIN: 'admin@expensify.com', + GUIDES_DOMAIN: 'team.expensify.com', }, ENVIRONMENT: { @@ -668,6 +672,7 @@ const CONST = { FREE: 'free', PERSONAL: 'personal', CORPORATE: 'corporate', + TEAM: 'team', }, ROLE: { ADMIN: 'admin', diff --git a/src/components/AnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly.js deleted file mode 100644 index c93ecb416b64..000000000000 --- a/src/components/AnchorForCommentsOnly.js +++ /dev/null @@ -1,107 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import {StyleSheet} from 'react-native'; -import lodashGet from 'lodash/get'; -import Str from 'expensify-common/lib/str'; -import PropTypes from 'prop-types'; -import Text from './Text'; -import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; -import * as ReportActionContextMenu from '../pages/home/report/ContextMenu/ReportActionContextMenu'; -import * as ContextMenuActions from '../pages/home/report/ContextMenu/ContextMenuActions'; -import Tooltip from './Tooltip'; -import canUseTouchScreen from '../libs/canUseTouchscreen'; -import styles from '../styles/styles'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; - -const propTypes = { - /** The URL to open */ - href: PropTypes.string, - - /** What headers to send to the linked page (usually noopener and noreferrer) - This is unused in native, but is here for parity with web */ - rel: PropTypes.string, - - /** Used to determine where to open a link ("_blank" is passed for a new tab) - This is unused in native, but is here for parity with web */ - target: PropTypes.string, - - /** Any children to display */ - children: PropTypes.node, - - /** Anchor text of URLs or emails. */ - displayName: PropTypes.string, - - /** Any additional styles to apply */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, - - /** Press handler for the link, when not passed, default href is used to create a link like behaviour */ - onPress: PropTypes.func, - - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - href: '', - rel: '', - target: '', - children: null, - style: {}, - displayName: '', - onPress: undefined, -}; - -/* - * This is a default anchor component for regular links. - */ -const BaseAnchorForCommentsOnly = (props) => { - let linkRef; - const rest = _.omit(props, _.keys(propTypes)); - const linkProps = {}; - if (_.isFunction(props.onPress)) { - linkProps.onPress = props.onPress; - } else { - linkProps.href = props.href; - } - const defaultTextStyle = canUseTouchScreen() || props.isSmallScreenWidth ? {} : styles.userSelectText; - - return ( - { - ReportActionContextMenu.showContextMenu( - Str.isValidEmail(props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, - event, - props.href, - lodashGet(linkRef, 'current'), - ); - } - } - > - - linkRef = el} - style={StyleSheet.flatten([props.style, defaultTextStyle])} - accessibilityRole="link" - hrefAttrs={{ - rel: props.rel, - target: props.target, - }} - // eslint-disable-next-line react/jsx-props-no-spreading - {...linkProps} - // eslint-disable-next-line react/jsx-props-no-spreading - {...rest} - > - {props.children} - - - - ); -}; - -BaseAnchorForCommentsOnly.propTypes = propTypes; -BaseAnchorForCommentsOnly.defaultProps = defaultProps; -BaseAnchorForCommentsOnly.displayName = 'BaseAnchorForCommentsOnly'; - -export default withWindowDimensions(BaseAnchorForCommentsOnly); diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js new file mode 100644 index 000000000000..e65b423fa517 --- /dev/null +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js @@ -0,0 +1,69 @@ +import _ from 'underscore'; +import React from 'react'; +import {StyleSheet} from 'react-native'; +import lodashGet from 'lodash/get'; +import Str from 'expensify-common/lib/str'; +import Text from '../Text'; +import PressableWithSecondaryInteraction from '../PressableWithSecondaryInteraction'; +import * as ReportActionContextMenu from '../../pages/home/report/ContextMenu/ReportActionContextMenu'; +import * as ContextMenuActions from '../../pages/home/report/ContextMenu/ContextMenuActions'; +import Tooltip from '../Tooltip'; +import canUseTouchScreen from '../../libs/canUseTouchscreen'; +import styles from '../../styles/styles'; +import withWindowDimensions from '../withWindowDimensions'; +import {propTypes, defaultProps} from './anchorForCommentsOnlyPropTypes'; + +/* + * This is a default anchor component for regular links. + */ +const BaseAnchorForCommentsOnly = (props) => { + let linkRef; + const rest = _.omit(props, _.keys(propTypes)); + const linkProps = {}; + if (_.isFunction(props.onPress)) { + linkProps.onPress = props.onPress; + } else { + linkProps.href = props.href; + } + const defaultTextStyle = canUseTouchScreen() || props.isSmallScreenWidth ? {} : styles.userSelectText; + + return ( + { + ReportActionContextMenu.showContextMenu( + Str.isValidEmail(props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, + event, + props.href, + lodashGet(linkRef, 'current'), + ); + } + } + > + + linkRef = el} + style={StyleSheet.flatten([props.style, defaultTextStyle])} + accessibilityRole="link" + hrefAttrs={{ + rel: props.rel, + target: props.target, + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...linkProps} + // eslint-disable-next-line react/jsx-props-no-spreading + {...rest} + > + {props.children} + + + + ); +}; + +BaseAnchorForCommentsOnly.propTypes = propTypes; +BaseAnchorForCommentsOnly.defaultProps = defaultProps; +BaseAnchorForCommentsOnly.displayName = 'BaseAnchorForCommentsOnly'; + +export default withWindowDimensions(BaseAnchorForCommentsOnly); diff --git a/src/components/AnchorForCommentsOnly/anchorForCommentsOnlyPropTypes.js b/src/components/AnchorForCommentsOnly/anchorForCommentsOnlyPropTypes.js new file mode 100644 index 000000000000..7661efaf0af3 --- /dev/null +++ b/src/components/AnchorForCommentsOnly/anchorForCommentsOnlyPropTypes.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import stylePropTypes from '../../styles/stylePropTypes'; +import {windowDimensionsPropTypes} from '../withWindowDimensions'; + +const propTypes = { + /** The URL to open */ + href: PropTypes.string, + + /** What headers to send to the linked page (usually noopener and noreferrer) + This is unused in native, but is here for parity with web */ + rel: PropTypes.string, + + /** Used to determine where to open a link ("_blank" is passed for a new tab) + This is unused in native, but is here for parity with web */ + target: PropTypes.string, + + /** Any children to display */ + children: PropTypes.node, + + /** Anchor text of URLs or emails. */ + displayName: PropTypes.string, + + /** Any additional styles to apply */ + style: stylePropTypes, + + /** Press handler for the link, when not passed, default href is used to create a link like behaviour */ + onPress: PropTypes.func, + + ...windowDimensionsPropTypes, +}; + +const defaultProps = { + href: '', + rel: '', + target: '', + children: null, + style: {}, + displayName: '', + onPress: undefined, +}; + +export {propTypes, defaultProps}; diff --git a/src/components/AnchorForCommentsOnly/index.js b/src/components/AnchorForCommentsOnly/index.js new file mode 100644 index 000000000000..1526e78007fe --- /dev/null +++ b/src/components/AnchorForCommentsOnly/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import * as anchorForCommentsOnlyPropTypes from './anchorForCommentsOnlyPropTypes'; +import BaseAnchorForCommentsOnly from './BaseAnchorForCommentsOnly'; + +// eslint-disable-next-line react/jsx-props-no-spreading +const AnchorForCommentsOnly = props => ; +AnchorForCommentsOnly.propTypes = anchorForCommentsOnlyPropTypes.propTypes; +AnchorForCommentsOnly.defaultProps = anchorForCommentsOnlyPropTypes.defaultProps; +AnchorForCommentsOnly.displayName = 'AnchorForCommentsOnly'; + +export default AnchorForCommentsOnly; diff --git a/src/components/AnchorForCommentsOnly/index.native.js b/src/components/AnchorForCommentsOnly/index.native.js new file mode 100644 index 000000000000..e692ea57afbb --- /dev/null +++ b/src/components/AnchorForCommentsOnly/index.native.js @@ -0,0 +1,20 @@ +import React from 'react'; +import {Linking} from 'react-native'; +import _ from 'underscore'; + +import * as anchorForCommentsOnlyPropTypes from './anchorForCommentsOnlyPropTypes'; +import BaseAnchorForCommentsOnly from './BaseAnchorForCommentsOnly'; + +// eslint-disable-next-line react/jsx-props-no-spreading +const AnchorForCommentsOnly = (props) => { + const onPress = () => (_.isFunction(props.onPress) ? props.onPress() : Linking.openURL(props.href)); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; + +AnchorForCommentsOnly.propTypes = anchorForCommentsOnlyPropTypes.propTypes; +AnchorForCommentsOnly.defaultProps = anchorForCommentsOnlyPropTypes.defaultProps; +AnchorForCommentsOnly.displayName = 'AnchorForCommentsOnly'; + +export default AnchorForCommentsOnly; diff --git a/src/components/ArrowKeyFocusManager.js b/src/components/ArrowKeyFocusManager.js index 46cff9371656..84283915aba1 100644 --- a/src/components/ArrowKeyFocusManager.js +++ b/src/components/ArrowKeyFocusManager.js @@ -10,6 +10,9 @@ const propTypes = { PropTypes.node, ]).isRequired, + /** Array of disabled indexes. */ + disabledIndexes: PropTypes.arrayOf(PropTypes.number), + /** The current focused index. */ focusedIndex: PropTypes.number.isRequired, @@ -20,6 +23,10 @@ const propTypes = { onFocusedIndexChanged: PropTypes.func.isRequired, }; +const defaultProps = { + disabledIndexes: [], +}; + class ArrowKeyFocusManager extends Component { componentDidMount() { const arrowUpConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_UP; @@ -30,11 +37,14 @@ class ArrowKeyFocusManager extends Component { return; } - let newFocusedIndex = this.props.focusedIndex - 1; + const currentFocusedIndex = this.props.focusedIndex > 0 ? this.props.focusedIndex - 1 : this.props.maxIndex; + let newFocusedIndex = currentFocusedIndex; - // Wrap around to the bottom of the list - if (newFocusedIndex < 0) { - newFocusedIndex = this.props.maxIndex; + while (this.props.disabledIndexes.includes(newFocusedIndex)) { + newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : this.props.maxIndex; + if (newFocusedIndex === currentFocusedIndex) { // all indexes are disabled + return; // no-op + } } this.props.onFocusedIndexChanged(newFocusedIndex); @@ -45,11 +55,14 @@ class ArrowKeyFocusManager extends Component { return; } - let newFocusedIndex = this.props.focusedIndex + 1; + const currentFocusedIndex = this.props.focusedIndex < this.props.maxIndex ? this.props.focusedIndex + 1 : 0; + let newFocusedIndex = currentFocusedIndex; - // Wrap around to the top of the list - if (newFocusedIndex > this.props.maxIndex) { - newFocusedIndex = 0; + while (this.props.disabledIndexes.includes(newFocusedIndex)) { + newFocusedIndex = newFocusedIndex < this.props.maxIndex ? newFocusedIndex + 1 : 0; + if (newFocusedIndex === currentFocusedIndex) { // all indexes are disabled + return; // no-op + } } this.props.onFocusedIndexChanged(newFocusedIndex); @@ -72,5 +85,6 @@ class ArrowKeyFocusManager extends Component { } ArrowKeyFocusManager.propTypes = propTypes; +ArrowKeyFocusManager.defaultProps = defaultProps; export default ArrowKeyFocusManager; diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index e5235244b719..64999e9005db 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -120,7 +120,7 @@ class AttachmentModal extends PureComponent { const fileName = fullFileName.trim(); const splitFileName = fileName.split('.'); const fileExtension = splitFileName.pop(); - return {fileName, fileExtension}; + return {fileName: splitFileName.join('.'), fileExtension}; } /** diff --git a/src/components/AvatarWithIndicator.js b/src/components/AvatarWithIndicator.js index 55f097588b00..1bd517cfd642 100644 --- a/src/components/AvatarWithIndicator.js +++ b/src/components/AvatarWithIndicator.js @@ -11,7 +11,8 @@ import policyMemberPropType from '../pages/policyMemberPropType'; import bankAccountPropTypes from './bankAccountPropTypes'; import cardPropTypes from './cardPropTypes'; import userWalletPropTypes from '../pages/EnablePayments/userWalletPropTypes'; -import {fullPolicyPropTypes} from '../pages/workspace/withFullPolicy'; +import {policyPropTypes} from '../pages/workspace/withPolicy'; +import walletTermsPropTypes from '../pages/EnablePayments/walletTermsPropTypes'; import * as PolicyUtils from '../libs/PolicyUtils'; import * as PaymentMethods from '../libs/actions/PaymentMethods'; @@ -29,7 +30,7 @@ const propTypes = { policiesMemberList: PropTypes.objectOf(policyMemberPropType), /** All the user's policies (from Onyx via withFullPolicy) */ - policies: PropTypes.objectOf(fullPolicyPropTypes.policy), + policies: PropTypes.objectOf(policyPropTypes.policy), /** List of bank accounts */ bankAccountList: PropTypes.objectOf(bankAccountPropTypes), @@ -39,6 +40,9 @@ const propTypes = { /** The user's wallet (coming from Onyx) */ userWallet: userWalletPropTypes, + + /** Information about the user accepting the terms for payments */ + walletTerms: walletTermsPropTypes, }; const defaultProps = { @@ -49,6 +53,7 @@ const defaultProps = { bankAccountList: {}, cardList: {}, userWallet: {}, + walletTerms: {}, }; const AvatarWithIndicator = (props) => { @@ -73,6 +78,9 @@ const AvatarWithIndicator = (props) => { () => _.some(cleanPolicies, PolicyUtils.hasPolicyError), () => _.some(cleanPolicies, PolicyUtils.hasCustomUnitsError), () => _.some(cleanPolicyMembers, PolicyUtils.hasPolicyMemberError), + + // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead) + () => !_.isEmpty(props.walletTerms.errors) && !props.walletTerms.chatReportID, ]; const shouldShowIndicator = _.some(errorCheckingMethods, errorCheckingMethod => errorCheckingMethod()); @@ -112,4 +120,7 @@ export default withOnyx({ userWallet: { key: ONYXKEYS.USER_WALLET, }, + walletTerms: { + key: ONYXKEYS.WALLET_TERMS, + }, })(AvatarWithIndicator); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index 9d395a8779b9..36adfe30f6b5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -85,7 +85,7 @@ const AnchorRenderer = (props) => { displayName={displayName} // Only pass the press handler for internal links, for public links fallback to default link handling - onPress={internalNewExpensifyPath || internalExpensifyPath ? navigateToLink : undefined} + onPress={(internalNewExpensifyPath || internalExpensifyPath) ? navigateToLink : undefined} > diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js index d8e1527189ad..a5f59ded8036 100644 --- a/src/components/KYCWall/BaseKYCWall.js +++ b/src/components/KYCWall/BaseKYCWall.js @@ -11,6 +11,7 @@ import * as PaymentMethods from '../../libs/actions/PaymentMethods'; import ONYXKEYS from '../../ONYXKEYS'; import Log from '../../libs/Log'; import {propTypes, defaultProps} from './kycWallPropTypes'; +import * as Wallet from '../../libs/actions/Wallet'; // This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow // before continuing to take whatever action they originally intended to take. It requires a button as a child and a native event so we can get the coordinates and use it @@ -35,6 +36,7 @@ class KYCWall extends React.Component { if (this.props.shouldListenForResize) { this.dimensionsSubscription = Dimensions.addEventListener('change', this.setMenuPosition); } + Wallet.setKYCWallSourceChatReportID(this.props.chatReportID); } componentWillUnmount() { diff --git a/src/components/KYCWall/kycWallPropTypes.js b/src/components/KYCWall/kycWallPropTypes.js index d2d5933fbc73..5886001c8c92 100644 --- a/src/components/KYCWall/kycWallPropTypes.js +++ b/src/components/KYCWall/kycWallPropTypes.js @@ -21,7 +21,10 @@ const propTypes = { isDisabled: PropTypes.bool, /** The user's wallet */ - userWallet: PropTypes.objectOf(userWalletPropTypes), + userWallet: userWalletPropTypes, + + /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ + chatReportID: PropTypes.number, }; const defaultProps = { @@ -29,6 +32,7 @@ const defaultProps = { popoverPlacement: 'top', shouldListenForResize: false, isDisabled: false, + chatReportID: 0, }; export {propTypes, defaultProps}; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index ec2588185de6..cbcb0faa415c 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -11,7 +11,7 @@ import Text from '../Text'; import compose from '../../libs/compose'; import CONST from '../../CONST'; import styles from '../../styles/styles'; -import withLocalize from '../withLocalize'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import TextInput from '../TextInput'; import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; import KeyboardShortcut from '../../libs/KeyboardShortcut'; @@ -24,6 +24,7 @@ const propTypes = { shouldDelayFocus: PropTypes.bool, ...optionsSelectorPropTypes, + ...withLocalizePropTypes, }; const defaultProps = { @@ -144,6 +145,8 @@ class BaseOptionsSelector extends Component { */ flattenSections() { const allOptions = []; + this.disabledOptionsIndexes = []; + let index = 0; _.each(this.props.sections, (section, sectionIndex) => { _.each(section.data, (option, optionIndex) => { allOptions.push({ @@ -151,6 +154,10 @@ class BaseOptionsSelector extends Component { sectionIndex, index: optionIndex, }); + if (section.isDisabled || option.isDisabled) { + this.disabledOptionsIndexes.push(index); + } + index += 1; }); }); return allOptions; @@ -265,8 +272,9 @@ class BaseOptionsSelector extends Component { ) : ; return ( {} : this.updateFocusedIndex} > diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index ece04dad7530..8187b608f7ed 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import optionPropTypes from '../optionPropTypes'; -import {withLocalizePropTypes} from '../withLocalize'; import styles from '../../styles/styles'; const propTypes = { @@ -93,8 +92,6 @@ const propTypes = { /** Whether to show options list */ shouldShowOptions: PropTypes.bool, - - ...withLocalizePropTypes, }; const defaultProps = { diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index b9d44dd53e67..ef40ce8be744 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -21,6 +21,9 @@ import Icon from '../Icon'; import CONST from '../../CONST'; import * as Expensicons from '../Icon/Expensicons'; import Text from '../Text'; +import * as PaymentMethods from '../../libs/actions/PaymentMethods'; +import OfflineWithFeedback from '../OfflineWithFeedback'; +import walletTermsPropTypes from '../../pages/EnablePayments/walletTermsPropTypes'; const propTypes = { /** Additional logic for displaying the pay button */ @@ -75,6 +78,9 @@ const propTypes = { email: PropTypes.string, }).isRequired, + /** Information about the user accepting the terms for payments */ + walletTerms: walletTermsPropTypes, + ...withLocalizePropTypes, }; @@ -84,6 +90,7 @@ const defaultProps = { onPayButtonPressed: null, onPreviewPressed: () => {}, containerStyles: [], + walletTerms: {}, }; const IOUPreview = (props) => { @@ -124,10 +131,18 @@ const IOUPreview = (props) => { {reportIsLoading ? : ( - - - - + { + PaymentMethods.clearWalletTermsError(); + Report.clearIOUError(props.chatReportID); + }} + errorRowStyles={[styles.mbn1]} + > + + + {cachedTotal} @@ -137,48 +152,48 @@ const IOUPreview = (props) => { )} + + + - - - - - {isCurrentUserManager - ? ( - - {props.iouReport.hasOutstandingIOU - ? props.translate('iou.youowe', {owner: ownerName}) - : props.translate('iou.youpaid', {owner: ownerName})} - - ) - : ( - - {props.iouReport.hasOutstandingIOU - ? props.translate('iou.owesyou', {manager: managerName}) - : props.translate('iou.paidyou', {manager: managerName})} - - )} - {(isCurrentUserManager - && !props.shouldHidePayButton - && props.iouReport.stateNum === CONST.REPORT.STATE_NUM.PROCESSING && ( - - + {props.iouReport.hasOutstandingIOU + ? props.translate('iou.youowe', {owner: ownerName}) + : props.translate('iou.youpaid', {owner: ownerName})} + + ) + : ( + + {props.iouReport.hasOutstandingIOU + ? props.translate('iou.owesyou', {manager: managerName}) + : props.translate('iou.paidyou', {manager: managerName})} + + )} + {(isCurrentUserManager + && !props.shouldHidePayButton + && props.iouReport.stateNum === CONST.REPORT.STATE_NUM.PROCESSING && ( + - {props.translate('iou.pay')} - - - ))} - + + {props.translate('iou.pay')} + + + ))} + + )} @@ -201,5 +216,8 @@ export default compose( session: { key: ONYXKEYS.SESSION, }, + walletTerms: { + key: ONYXKEYS.WALLET_TERMS, + }, }), )(IOUPreview); diff --git a/src/components/RoomNameInput.js b/src/components/RoomNameInput.js index 8bd14fe9408c..55e1a2296a7a 100644 --- a/src/components/RoomNameInput.js +++ b/src/components/RoomNameInput.js @@ -5,7 +5,6 @@ import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; import compose from '../libs/compose'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import withFullPolicy, {fullPolicyDefaultProps, fullPolicyPropTypes} from '../pages/workspace/withFullPolicy'; import TextInput from './TextInput'; const propTypes = { @@ -22,7 +21,6 @@ const propTypes = { errorText: PropTypes.string, ...withLocalizePropTypes, - ...fullPolicyPropTypes, /* Onyx Props */ @@ -53,7 +51,6 @@ const defaultProps = { initialValue: '', disabled: false, errorText: '', - ...fullPolicyDefaultProps, forwardedRef: () => {}, }; @@ -114,7 +111,6 @@ RoomNameInput.defaultProps = defaultProps; export default compose( withLocalize, - withFullPolicy, withOnyx({ reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 251a64d80c5c..316e356b51e3 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -27,12 +27,16 @@ const propTypes = { /** Information about the network */ network: networkPropTypes.isRequired, + /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ + chatReportID: PropTypes.number, + ...withLocalizePropTypes, }; const defaultProps = { currency: CONST.CURRENCY.USD, shouldShowPaypal: false, + chatReportID: 0, }; class SettlementButton extends React.Component { @@ -80,6 +84,7 @@ class SettlementButton extends React.Component { addBankAccountRoute={this.props.addBankAccountRoute} addDebitCardRoute={this.props.addDebitCardRoute} isDisabled={this.props.network.isOffline} + chatReportID={this.props.chatReportID} > {triggerKYCFlow => ( ( {/* Option to switch from using the staging secure endpoint or the production secure endpoint. This enables QA and internal testers to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. */} - + User.setShouldUseSecureStaging(!props.user.shouldUseSecureStaging)} + isOn={lodashGet(props, 'user.shouldUseStagingServer', true)} + onToggle={() => User.setShouldUseStagingServer(!lodashGet(props, 'user.shouldUseStagingServer', true))} /> diff --git a/src/components/Tooltip/TooltipRenderedOnPageBody.js b/src/components/Tooltip/TooltipRenderedOnPageBody.js index d2c40a74b477..3bfcc71fd4c9 100644 --- a/src/components/Tooltip/TooltipRenderedOnPageBody.js +++ b/src/components/Tooltip/TooltipRenderedOnPageBody.js @@ -27,11 +27,11 @@ const propTypes = { /** Any additional amount to manually adjust the horizontal position of the tooltip. A positive value shifts the tooltip to the right, and a negative value shifts it to the left. */ - shiftHorizontal: PropTypes.number.isRequired, + shiftHorizontal: PropTypes.number, /** Any additional amount to manually adjust the vertical position of the tooltip. A positive value shifts the tooltip down, and a negative value shifts it up. */ - shiftVertical: PropTypes.number.isRequired, + shiftVertical: PropTypes.number, /** Text to be shown in the tooltip */ text: PropTypes.string.isRequired, @@ -43,6 +43,11 @@ const propTypes = { numberOfLines: PropTypes.number.isRequired, }; +const defaultProps = { + shiftHorizontal: 0, + shiftVertical: 0, +}; + // Props will change frequently. // On every tooltip hover, we update the position in state which will result in re-rendering. // We also update the state on layout changes which will be triggered often. @@ -132,5 +137,6 @@ class TooltipRenderedOnPageBody extends React.PureComponent { } TooltipRenderedOnPageBody.propTypes = propTypes; +TooltipRenderedOnPageBody.defaultProps = defaultProps; export default TooltipRenderedOnPageBody; diff --git a/src/languages/en.js b/src/languages/en.js index 00d6f9e6f9d3..c70131508112 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -707,6 +707,8 @@ export default { activatedTitle: 'Wallet activated!', activatedMessage: 'Congrats, your wallet is set up and ready to make payments.', checkBackLater: 'We\'re still reviewing your information. Please check back later.', + continueToPayment: 'Continue to payment', + continueToTransfer: 'Continue to transfer', }, companyStep: { headerTitle: 'Company information', diff --git a/src/languages/es.js b/src/languages/es.js index 75554f7c91e8..68b50e078565 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -709,6 +709,8 @@ export default { activatedTitle: '¡Billetera activada!', activatedMessage: 'Felicidades, tu Billetera está configurada y lista para hacer pagos.', checkBackLater: 'Todavía estamos revisando tu información. Por favor, vuelva más tarde.', + continueToPayment: 'Continuar al pago', + continueToTransfer: 'Continuar a la transferencia', }, companyStep: { headerTitle: 'Información de la empresa', diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js index 3e93e318232e..4c8fe2ee6b30 100644 --- a/src/libs/HttpUtils.js +++ b/src/libs/HttpUtils.js @@ -1,14 +1,15 @@ import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import _ from 'underscore'; import CONFIG from '../CONFIG'; import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; import HttpsError from './Errors/HttpsError'; -let shouldUseSecureStaging = false; +let shouldUseStagingServer = false; Onyx.connect({ key: ONYXKEYS.USER, - callback: val => shouldUseSecureStaging = (val && _.isBoolean(val.shouldUseSecureStaging)) ? val.shouldUseSecureStaging : false, + callback: val => shouldUseStagingServer = lodashGet(val, 'shouldUseStagingServer', true), }); let shouldFailAllRequests = false; @@ -94,10 +95,11 @@ function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = formData.append(key, val); }); + let apiRoot = shouldUseSecure ? CONFIG.EXPENSIFY.SECURE_EXPENSIFY_URL : CONFIG.EXPENSIFY.URL_API_ROOT; - if (shouldUseSecure && shouldUseSecureStaging) { - apiRoot = CONST.STAGING_SECURE_URL; + if (CONFIG.IS_IN_STAGING && shouldUseStagingServer) { + apiRoot = shouldUseSecure ? CONFIG.EXPENSIFY.STAGING_SECURE_EXPENSIFY_URL : CONFIG.EXPENSIFY.STAGING_EXPENSIFY_URL; } return processHTTPRequest(`${apiRoot}api?command=${command}`, type, formData, data.canCancel); diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 3454a6847847..a2f9d082e3c3 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -3,6 +3,7 @@ import Onyx, {withOnyx} from 'react-native-onyx'; import moment from 'moment'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; import * as StyleUtils from '../../../styles/StyleUtils'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import CONST from '../../../CONST'; @@ -86,6 +87,9 @@ const modalScreenListeners = { const propTypes = { ...windowDimensionsPropTypes, + + /** The current path as reported by the NavigationContainer */ + currentPath: PropTypes.string.isRequired, }; class AuthScreens extends React.Component { @@ -112,7 +116,6 @@ class AuthScreens extends React.Component { // Listen for report changes and fetch some data we need on initialization UnreadIndicatorUpdater.listenForReportChanges(); App.openApp(); - App.setUpPoliciesAndNavigate(this.props.session); Timing.end(CONST.TIMING.HOMEPAGE_INITIAL_RENDER); const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; @@ -130,6 +133,10 @@ class AuthScreens extends React.Component { } shouldComponentUpdate(nextProps) { + // we perform this check here instead of componentDidUpdate to skip an unnecessary re-render + if (this.props.currentPath !== nextProps.currentPath) { + App.setUpPoliciesAndNavigate(nextProps.session, nextProps.currentPath); + } return nextProps.isSmallScreenWidth !== this.props.isSmallScreenWidth; } @@ -261,6 +268,7 @@ class AuthScreens extends React.Component { name="Participants" options={modalScreenOptions} component={ModalStackNavigators.ReportParticipantsModalStackNavigator} + listeners={modalScreenListeners} /> ( @@ -13,7 +16,7 @@ const AppNavigator = props => ( ? ( // These are the protected screens and only accessible when an authToken is present - + ) : ( diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index 4002c0101c35..57fd9ccd0305 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -28,6 +28,16 @@ const propTypes = { }; class NavigationRoot extends Component { + constructor(props) { + super(props); + + this.state = { + currentPath: '', + }; + + this.parseAndLogRoute = this.parseAndLogRoute.bind(this); + } + /** * Intercept navigation state changes and log it * @param {NavigationState} state @@ -47,6 +57,8 @@ class NavigationRoot extends Component { } UnreadIndicatorUpdater.throttledUpdatePageTitleAndUnreadCount(); + + this.setState({currentPath}); } render() { @@ -67,7 +79,7 @@ class NavigationRoot extends Component { enabled: false, }} > - + ); } diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 186e54ef8e49..2ab2d89ee9c8 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -3,7 +3,6 @@ import _ from 'underscore'; import Onyx from 'react-native-onyx'; import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; -import memoizeOne from 'memoize-one'; import Str from 'expensify-common/lib/str'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; @@ -143,10 +142,35 @@ function getParticipantNames(personalDetailList) { return participantNames; } +/** + * A very optimized method to remove unique items from an array. + * Taken from https://stackoverflow.com/a/9229821/9114791 + * + * @param {Array} items + * @returns {Array} + */ +function uniqFast(items) { + const seenItems = {}; + const result = []; + let j = 0; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (seenItems[item] !== 1) { + seenItems[item] = 1; + result[j++] = item; + } + } + return result; +} + /** * Returns a string with all relevant search terms. * Default should be serachable by policy/domain name but not by participants. * + * This method must be incredibly performant. It was found to be a big performance bottleneck + * when dealing with accounts that have thousands of reports. For loops are more efficient than _.each + * Array.prototype.push.apply is faster than using the spread operator, and concat() is faster than push(). + * * @param {Object} report * @param {String} reportName * @param {Array} personalDetailList @@ -154,28 +178,28 @@ function getParticipantNames(personalDetailList) { * @return {String} */ function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolicyExpenseChat) { - const searchTerms = []; + let searchTerms = []; if (!isChatRoomOrPolicyExpenseChat) { - _.each(personalDetailList, (personalDetail) => { - searchTerms.push(personalDetail.displayName); - searchTerms.push(personalDetail.login.replace(/\./g, '')); - }); + for (let i = 0; i < personalDetailList.length; i++) { + const personalDetail = personalDetailList[i]; + searchTerms = searchTerms.concat([personalDetail.displayName, personalDetail.login.replace(/\./g, '')]); + } } if (report) { - searchTerms.push(...reportName); - searchTerms.push(..._.map(reportName.split(','), name => name.trim())); + Array.prototype.push.apply(searchTerms, reportName.split('')); + Array.prototype.push.apply(searchTerms, reportName.split(',')); if (isChatRoomOrPolicyExpenseChat) { const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(report, policies); - searchTerms.push(...chatRoomSubtitle); - searchTerms.push(..._.map(chatRoomSubtitle.split(','), name => name.trim())); + Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split('')); + Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(',')); } else { - searchTerms.push(...report.participants); + searchTerms = searchTerms.concat(report.participants); } } - return _.unique(searchTerms).join(' '); + return uniqFast(searchTerms).join(' '); } /** @@ -217,80 +241,118 @@ function createOption(logins, personalDetails, report, reportActions = {}, { showChatPreviewLine = false, forcePolicyNamePreview = false, }) { - const isChatRoom = ReportUtils.isChatRoom(report); - const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); + const result = { + text: null, + alternateText: null, + brickRoadIndicator: null, + icons: null, + tooltipText: null, + ownerEmail: null, + subtitle: null, + participantsList: null, + login: null, + reportID: null, + phoneNumber: null, + payPalMeAddress: null, + isUnread: null, + hasDraftComment: false, + keyForList: null, + searchText: null, + isDefaultRoom: false, + isPinned: false, + hasOutstandingIOU: false, + iouReportID: null, + isIOUReportOwner: null, + iouReportAmount: 0, + isChatRoom: false, + isArchivedRoom: false, + shouldShowSubscript: false, + isPolicyExpenseChat: false, + }; + const personalDetailMap = getPersonalDetailsForLogins(logins, personalDetails); const personalDetailList = _.values(personalDetailMap); - const isArchivedRoom = ReportUtils.isArchivedRoom(report); - const isDefaultRoom = ReportUtils.isDefaultRoom(report); - const hasMultipleParticipants = personalDetailList.length > 1 || isChatRoom || isPolicyExpenseChat; const personalDetail = personalDetailList[0]; - const hasOutstandingIOU = lodashGet(report, 'hasOutstandingIOU', false); - const iouReport = hasOutstandingIOU - ? lodashGet(iouReports, `${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`, {}) - : {}; - - const lastActorDetails = report ? _.find(personalDetailList, {login: report.lastActorEmail}) : null; - const lastMessageTextFromReport = ReportUtils.isReportMessageAttachment({text: lodashGet(report, 'lastMessageText', ''), html: lodashGet(report, 'lastMessageHtml', '')}) - ? `[${Localize.translateLocal('common.attachment')}]` - : Str.htmlDecode(lodashGet(report, 'lastMessageText', '')); - let lastMessageText = report && hasMultipleParticipants && lastActorDetails - ? `${lastActorDetails.displayName}: ` - : ''; - lastMessageText += report ? lastMessageTextFromReport : ''; - - if (isPolicyExpenseChat && isArchivedRoom) { - const archiveReason = lodashGet(lastReportActions[report.reportID], 'originalMessage.reason', CONST.REPORT.ARCHIVE_REASON.DEFAULT); - lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { - displayName: lodashGet(lastActorDetails, 'displayName', report.lastActorEmail), - policyName: ReportUtils.getPolicyName(report, policies), - }); - } + let hasMultipleParticipants = personalDetailList.length > 1; + let subtitle; - const tooltipText = ReportUtils.getReportParticipantsTitle(lodashGet(report, ['participants'], [])); - const subtitle = ReportUtils.getChatRoomSubtitle(report, policies); - const reportName = ReportUtils.getReportName(report, personalDetailMap, policies); - let alternateText; - if (isChatRoom || isPolicyExpenseChat) { - alternateText = (showChatPreviewLine && !forcePolicyNamePreview && lastMessageText) - ? lastMessageText - : subtitle; + if (report) { + result.isChatRoom = ReportUtils.isChatRoom(report); + result.isDefaultRoom = ReportUtils.isDefaultRoom(report); + result.isArchivedRoom = ReportUtils.isArchivedRoom(report); + result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); + result.shouldShowSubscript = result.isPolicyExpenseChat && !report.isOwnPolicyExpenseChat && !result.isArchivedRoom; + result.brickRoadIndicator = getBrickRoadIndicatorStatusForReport(report, reportActions); + result.ownerEmail = report.ownerEmail; + result.reportID = report.reportID; + result.isUnread = ReportUtils.isUnread(report); + result.hasDraftComment = report.hasDraft; + result.isPinned = report.isPinned; + result.iouReportID = report.iouReportID; + result.keyForList = String(report.reportID); + result.tooltipText = ReportUtils.getReportParticipantsTitle(report.participants || []); + result.hasOutstandingIOU = report.hasOutstandingIOU; + + hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; + subtitle = ReportUtils.getChatRoomSubtitle(report, policies); + + let lastMessageTextFromReport = ''; + if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml})) { + lastMessageTextFromReport = `[${Localize.translateLocal('common.attachment')}]`; + } else { + lastMessageTextFromReport = Str.htmlDecode(report ? report.lastMessageText : ''); + } + + const lastActorDetails = personalDetailMap[report.lastActorEmail] || null; + let lastMessageText = hasMultipleParticipants && lastActorDetails + ? `${lastActorDetails.displayName}: ` + : ''; + lastMessageText += report ? lastMessageTextFromReport : ''; + + if (result.isPolicyExpenseChat && result.isArchivedRoom) { + const archiveReason = (lastReportActions[report.reportID] && lastReportActions[report.reportID].originalMessage && lastReportActions[report.reportID].originalMessage.reason) + || CONST.REPORT.ARCHIVE_REASON.DEFAULT; + lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { + displayName: archiveReason.displayName || report.lastActorEmail, + policyName: ReportUtils.getPolicyName(report, policies), + }); + } + + if (result.isChatRoom || result.isPolicyExpenseChat) { + result.alternateText = (showChatPreviewLine && !forcePolicyNamePreview && lastMessageText) + ? lastMessageText + : subtitle; + } else { + result.alternateText = (showChatPreviewLine && lastMessageText) + ? lastMessageText + : Str.removeSMSDomain(personalDetail.login); + } } else { - alternateText = (showChatPreviewLine && lastMessageText) - ? lastMessageText - : Str.removeSMSDomain(personalDetail.login); + result.keyForList = personalDetail.login; } - return { - text: reportName, - alternateText, - brickRoadIndicator: getBrickRoadIndicatorStatusForReport(report, reportActions), - icons: ReportUtils.getIcons(report, personalDetails, policies, lodashGet(personalDetail, ['avatar'])), - tooltipText, - ownerEmail: lodashGet(report, ['ownerEmail']), - subtitle, - participantsList: personalDetailList, - - // It doesn't make sense to provide a login in the case of a report with multiple participants since - // there isn't any one single login to refer to for a report. - login: !hasMultipleParticipants ? personalDetail.login : null, - reportID: report ? report.reportID : null, - phoneNumber: !hasMultipleParticipants ? personalDetail.phoneNumber : null, - payPalMeAddress: !hasMultipleParticipants ? personalDetail.payPalMeAddress : null, - isUnread: ReportUtils.isUnread(report), - hasDraftComment: lodashGet(report, 'hasDraft', false), - keyForList: report ? String(report.reportID) : personalDetail.login, - searchText: getSearchText(report, reportName, personalDetailList, isChatRoom || isPolicyExpenseChat), - isPinned: lodashGet(report, 'isPinned', false), - hasOutstandingIOU, - iouReportID: lodashGet(report, 'iouReportID'), - isIOUReportOwner: lodashGet(iouReport, 'ownerEmail', '') === currentUserLogin, - iouReportAmount: lodashGet(iouReport, 'total', 0), - isChatRoom, - isArchivedRoom, - isDefaultRoom, - shouldShowSubscript: isPolicyExpenseChat && !report.isOwnPolicyExpenseChat && !isArchivedRoom, - isPolicyExpenseChat, - }; + + if (result.hasOutstandingIOU) { + const iouReport = iouReports[`${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`] || null; + if (iouReport) { + result.isIOUReportOwner = iouReport.ownerEmail === currentUserLogin; + result.iouReportAmount = iouReport.total; + } + } + + if (!hasMultipleParticipants) { + result.login = personalDetail.login; + result.phoneNumber = personalDetail.phoneNumber; + result.payPalMeAddress = personalDetail.payPalMeAddress; + } + + const reportName = ReportUtils.getReportName(report, personalDetailMap, policies); + result.text = reportName; + result.subtitle = subtitle; + result.participantsList = personalDetailList; + result.icons = ReportUtils.getIcons(report, personalDetails, policies, personalDetail.avatar); + result.searchText = getSearchText(report, reportName, personalDetailList, result.isChatRoom || result.isPolicyExpenseChat); + + return result; } /** @@ -351,7 +413,7 @@ function isCurrentUser(userDetails) { * * @param {Object} reports * @param {Object} personalDetails - * @param {Number} activeReportID + * @param {String} activeReportID * @param {Object} options * @returns {Object} * @private @@ -404,20 +466,24 @@ function getOptions(reports, personalDetails, activeReportID, { const allReportOptions = []; _.each(orderedReports, (report) => { + if (!report) { + return; + } const isChatRoom = ReportUtils.isChatRoom(report); const isDefaultRoom = ReportUtils.isDefaultRoom(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); - const logins = lodashGet(report, ['participants'], []); + const logins = report.participants || []; // Report data can sometimes be incomplete. If we have no logins or reportID then we will skip this entry. const shouldFilterNoParticipants = _.isEmpty(logins) && !isChatRoom && !isDefaultRoom && !isPolicyExpenseChat; - if (!report || !report.reportID || shouldFilterNoParticipants) { + if (!report.reportID || shouldFilterNoParticipants) { return; } - const hasDraftComment = lodashGet(report, 'hasDraft', false); - const iouReportOwner = lodashGet(report, 'hasOutstandingIOU', false) - ? lodashGet(iouReports, [`${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`, 'ownerEmail'], '') + const hasDraftComment = report.hasDraft || false; + const iouReport = report.iouReportID && iouReports[`${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`]; + const iouReportOwner = report.hasOutstandingIOU && iouReport + ? iouReport.ownerEmail : ''; const reportContainsIOUDebt = iouReportOwner && iouReportOwner !== currentUserLogin; @@ -431,7 +497,7 @@ function getOptions(reports, personalDetails, activeReportID, { const shouldFilterReportIfRead = hideReadReports && !ReportUtils.isUnread(report); const shouldFilterReport = shouldFilterReportIfEmpty || shouldFilterReportIfRead; - if (report.reportID !== activeReportID + if (report.reportID.toString() !== activeReportID && (!report.isPinned || isDefaultRoom) && !hasDraftComment && shouldFilterReport @@ -443,8 +509,14 @@ function getOptions(reports, personalDetails, activeReportID, { return; } - // We let Free Plan default rooms to be shown in the App - it's the one exception to the beta, otherwise do not show policy rooms in product - if (ReportUtils.isDefaultRoom(report) && !Permissions.canUseDefaultRooms(betas) && ReportUtils.getPolicyType(report, policies) !== CONST.POLICY.TYPE.FREE) { + // We create policy rooms for all policies, however we don't show them unless + // - It's a free plan workspace + // - The report includes guides participants (@team.expensify.com) for 1:1 Assigned + if (!Permissions.canUseDefaultRooms(betas) + && ReportUtils.isDefaultRoom(report) + && ReportUtils.getPolicyType(report, policies) !== CONST.POLICY.TYPE.FREE + && !ReportUtils.hasExpensifyGuidesEmails(logins) + ) { return; } @@ -762,7 +834,7 @@ function getMemberInviteOptions( * @param {Object} reportActions * @returns {Object} */ -function calculateSidebarOptions(reports, personalDetails, activeReportID, priorityMode, betas, reportActions) { +function getSidebarOptions(reports, personalDetails, activeReportID, priorityMode, betas, reportActions) { let sideBarOptions = { prioritizeIOUDebts: true, prioritizeReportsWithDraftComments: true, @@ -786,8 +858,6 @@ function calculateSidebarOptions(reports, personalDetails, activeReportID, prior }); } -const getSidebarOptions = memoizeOne(calculateSidebarOptions); - /** * Helper method that returns the text to be used for the header's message and title (if any) * diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 8fd7ec3af880..375a4be558f5 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -12,6 +12,7 @@ import md5 from './md5'; import Navigation from './Navigation/Navigation'; import ROUTES from '../ROUTES'; import * as NumberUtils from './NumberUtils'; +import * as NumberFormatUtils from './NumberFormatUtils'; let sessionEmail; Onyx.connect({ @@ -203,6 +204,15 @@ function getPolicyType(report, policies) { return lodashGet(policies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'type'], ''); } +/** + * Returns true if there are any guides accounts (team.expensify.com) in emails + * @param {Array} emails + * @returns {Boolean} + */ +function hasExpensifyGuidesEmails(emails) { + return _.some(emails, email => Str.extractEmailDomain(email) === CONST.EMAIL.GUIDES_DOMAIN); +} + /** * Given a collection of reports returns the most recently accessed one * @@ -215,7 +225,9 @@ function findLastAccessedReport(reports, ignoreDefaultRooms, policies) { let sortedReports = sortReportsByLastVisited(reports); if (ignoreDefaultRooms) { - sortedReports = _.filter(sortedReports, report => !isDefaultRoom(report) || getPolicyType(report, policies) === CONST.POLICY.TYPE.FREE); + sortedReports = _.filter(sortedReports, report => !isDefaultRoom(report) + || getPolicyType(report, policies) === CONST.POLICY.TYPE.FREE + || hasExpensifyGuidesEmails(lodashGet(report, ['participants'], []))); } return _.last(sortedReports); @@ -246,8 +258,11 @@ function isArchivedRoom(report) { * @returns {String} */ function getPolicyName(report, policies) { - const defaultValue = report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable'); - return lodashGet(policies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'name'], defaultValue); + const policyName = ( + policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] + && policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`].name + ) || ''; + return policyName || report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable'); } /** @@ -497,7 +512,7 @@ function getReportName(report, personalDetailsForParticipants = {}, policies = { } if (isPolicyExpenseChat(report)) { - const reportOwnerPersonalDetails = lodashGet(personalDetailsForParticipants, report.ownerEmail); + const reportOwnerPersonalDetails = personalDetailsForParticipants[report.ownerEmail]; const reportOwnerDisplayName = getDisplayNameForParticipant(reportOwnerPersonalDetails) || report.ownerEmail || report.reportName; formattedName = report.isOwnPolicyExpenseChat ? getPolicyName(report, policies) : reportOwnerDisplayName; } @@ -512,11 +527,9 @@ function getReportName(report, personalDetailsForParticipants = {}, policies = { // Not a room or PolicyExpenseChat, generate title from participants const participants = _.without(lodashGet(report, 'participants', []), sessionEmail); - const displayNamesWithTooltips = getDisplayNamesWithTooltips( - _.isEmpty(personalDetailsForParticipants) ? participants : personalDetailsForParticipants, - participants.length > 1, - ); - return _.map(displayNamesWithTooltips, ({displayName}) => displayName).join(', '); + const isMultipleParticipantReport = participants.length > 1; + const participantsToGetTheNamesOf = _.isEmpty(personalDetailsForParticipants) ? participants : personalDetailsForParticipants; + return _.map(participantsToGetTheNamesOf, participant => getDisplayNameForParticipant(participant, isMultipleParticipantReport)).join(', '); } /** @@ -560,6 +573,29 @@ function hasReportNameError(report) { return !_.isEmpty(lodashGet(report, 'errorFields.reportName', {})); } +/* + * Builds an optimistic IOU report with a randomly generated reportID + */ +function buildOptimisticIOUReport(ownerEmail, recipientEmail, total, chatReportID, currency, locale) { + const formattedTotal = NumberFormatUtils.format(locale, + total, { + style: 'currency', + currency, + }); + return { + cachedTotal: formattedTotal, + chatReportID, + currency, + hasOutstandingIOU: true, + managerEmail: recipientEmail, + ownerEmail, + reportID: generateReportID(), + state: CONST.REPORT.STATE.SUBMITTED, + stateNum: 1, + total, + }; +} + /** * Builds an optimistic IOU reportAction object * @@ -625,8 +661,8 @@ function buildOptimisticIOUReportAction(type, amount, comment, paymentType = '', * @returns {Boolean} */ function isUnread(report) { - const lastReadSequenceNumber = lodashGet(report, 'lastReadSequenceNumber', 0); - const maxSequenceNumber = lodashGet(report, 'maxSequenceNumber', 0); + const lastReadSequenceNumber = report.lastReadSequenceNumber || 0; + const maxSequenceNumber = report.maxSequenceNumber || 0; return lastReadSequenceNumber < maxSequenceNumber; } @@ -648,6 +684,7 @@ export { isArchivedRoom, isConciergeChatReport, hasExpensifyEmails, + hasExpensifyGuidesEmails, canShowReportRecipientLocalTime, formatReportLastMessageText, chatIncludesConcierge, @@ -660,6 +697,7 @@ export { navigateToDetailsPage, generateReportID, hasReportNameError, + buildOptimisticIOUReport, buildOptimisticIOUReportAction, isUnread, }; diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index 43b8b7d2c056..d2242b028962 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -3,6 +3,7 @@ import CONST from '../../CONST'; import * as API from '../API'; import ONYXKEYS from '../../ONYXKEYS'; import * as Localize from '../Localize'; +import DateUtils from '../DateUtils'; export { setupWithdrawalAccount, @@ -41,6 +42,50 @@ function clearPlaid() { Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, ''); } +/** + * Helper method to build the Onyx data required during setup of a Verified Business Bank Account + * + * @returns {Object} + */ +// We'll remove the below once this function is used by the VBBA commands that are yet to be implemented +/* eslint-disable no-unused-vars */ +function getVBBADataForOnyx() { + return { + optimisticData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: true, + errors: null, + }, + }, + ], + successData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, + errors: null, + }, + }, + ], + failureData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + isLoading: false, + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('paymentsPage.addBankAccountFailure'), + }, + }, + }, + ], + }; +} + /** * Adds a bank account via Plaid * diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js index 9d648b489815..dceb2e87243d 100644 --- a/src/libs/actions/PaymentMethods.js +++ b/src/libs/actions/PaymentMethods.js @@ -321,6 +321,13 @@ function clearWalletError() { Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null}); } +/** + * Clear any error(s) related to the user's wallet terms + */ +function clearWalletTermsError() { + Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null}); +} + function deletePaymentCard(fundID) { API.write('DeletePaymentCard', { fundID, @@ -355,4 +362,5 @@ export { clearDeletePaymentMethodError, clearAddPaymentMethodError, clearWalletError, + clearWalletTermsError, }; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 64f190168720..d8af8be7d7f1 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -129,7 +129,8 @@ function deletePolicy(policyID) { Growl.show(Localize.translateLocal('workspace.common.growlMessageOnDelete'), CONST.GROWL.SUCCESS, 3000); - // Removing the workspace data from Onyx as well + // Removing the workspace data from Onyx and local array as well + delete allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; return Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); }) .then(() => Report.fetchAllReports(false)) @@ -810,7 +811,7 @@ function createWorkspace() { expenseChatReportID, expenseChatData, expenseReportActionData, - } = Report.createOptimisticWorkspaceChats(policyID, workspaceName); + } = Report.buildOptimisticWorkspaceChats(policyID, workspaceName); // We need to use makeRequestWithSideEffects as we try to redirect to the policy right after creation // The policy hasn't been merged in Onyx data at this point, leading to an intermittent Not Found screen @@ -887,15 +888,42 @@ function createWorkspace() { key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, value: {pendingAction: null}, }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: { + 0: { + pendingAction: null, + }, + }, + }, { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, value: {pendingAction: null}, }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + 0: { + pendingAction: null, + }, + }, + }, { onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${expenseChatReportID}`, value: {pendingAction: null}, + }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, + value: { + 0: { + pendingAction: null, + }, + }, }], failureData: [{ onyxMethod: CONST.ONYX.METHOD.SET, @@ -913,7 +941,7 @@ function createWorkspace() { value: null, }, { - onyxMethod: CONST.ONYX.METHOD.MERGE, + onyxMethod: CONST.ONYX.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, value: null, }, @@ -923,7 +951,7 @@ function createWorkspace() { value: null, }, { - onyxMethod: CONST.ONYX.METHOD.MERGE, + onyxMethod: CONST.ONYX.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, value: null, }, @@ -933,7 +961,7 @@ function createWorkspace() { value: null, }, { - onyxMethod: CONST.ONYX.METHOD.MERGE, + onyxMethod: CONST.ONYX.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`, value: null, }], diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 85e380feaf54..ad89ee7968d5 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -585,7 +585,7 @@ function fetchAllReports( } /** - * Creates an optimistic chat report with a randomly generated reportID and as much information as we currently have + * Builds an optimistic chat report with a randomly generated reportID and as much information as we currently have * * @param {Array} participantList * @param {String} reportName @@ -597,7 +597,7 @@ function fetchAllReports( * @param {String} visibility * @returns {Object} */ -function createOptimisticChatReport( +function buildOptimisticChatReport( participantList, reportName = 'Chat Report', chatType = '', @@ -637,7 +637,7 @@ function createOptimisticChatReport( * @param {String} ownerEmail * @returns {Object} */ -function createOptimisticCreatedReportAction(ownerEmail) { +function buildOptimisticCreatedReportAction(ownerEmail) { return { 0: { actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, @@ -676,8 +676,8 @@ function createOptimisticCreatedReportAction(ownerEmail) { * @param {String} policyName * @returns {Object} */ -function createOptimisticWorkspaceChats(policyID, policyName) { - const announceChatData = createOptimisticChatReport( +function buildOptimisticWorkspaceChats(policyID, policyName) { + const announceChatData = buildOptimisticChatReport( [currentUserEmail], CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE, CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, @@ -687,15 +687,15 @@ function createOptimisticWorkspaceChats(policyID, policyName) { policyName, ); const announceChatReportID = announceChatData.reportID; - const announceReportActionData = createOptimisticCreatedReportAction(announceChatData.ownerEmail); + const announceReportActionData = buildOptimisticCreatedReportAction(announceChatData.ownerEmail); - const adminsChatData = createOptimisticChatReport([currentUserEmail], CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID, null, false, policyName); + const adminsChatData = buildOptimisticChatReport([currentUserEmail], CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, policyID, null, false, policyName); const adminsChatReportID = adminsChatData.reportID; - const adminsReportActionData = createOptimisticCreatedReportAction(adminsChatData.ownerEmail); + const adminsReportActionData = buildOptimisticCreatedReportAction(adminsChatData.ownerEmail); - const expenseChatData = createOptimisticChatReport([currentUserEmail], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, currentUserEmail, true, policyName); + const expenseChatData = buildOptimisticChatReport([currentUserEmail], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, currentUserEmail, true, policyName); const expenseChatReportID = expenseChatData.reportID; - const expenseReportActionData = createOptimisticCreatedReportAction(expenseChatData.ownerEmail); + const expenseReportActionData = buildOptimisticCreatedReportAction(expenseChatData.ownerEmail); return { announceChatReportID, @@ -1641,6 +1641,15 @@ function viewNewReportAction(reportID, action) { }); } +/** + * Clear the errors associated with the IOUs of a given report. + * + * @param {Number} reportID + */ +function clearIOUError(reportID) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {errorFields: {iou: null}}); +} + // We are using this map to ensure actions are only handled once const handledReportActions = {}; Onyx.connect({ @@ -1714,9 +1723,10 @@ export { readOldestAction, openReport, openPaymentDetailsPage, - createOptimisticWorkspaceChats, - createOptimisticChatReport, - createOptimisticCreatedReportAction, + buildOptimisticWorkspaceChats, + buildOptimisticChatReport, + buildOptimisticCreatedReportAction, updatePolicyRoomName, clearPolicyRoomNameErrors, + clearIOUError, }; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index a7b71d9fd3b5..0d61d0ae0330 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -426,10 +426,10 @@ function updateChatPriorityMode(mode) { } /** - * @param {Boolean} shouldUseSecureStaging + * @param {Boolean} shouldUseStagingServer */ -function setShouldUseSecureStaging(shouldUseSecureStaging) { - Onyx.merge(ONYXKEYS.USER, {shouldUseSecureStaging}); +function setShouldUseStagingServer(shouldUseStagingServer) { + Onyx.merge(ONYXKEYS.USER, {shouldUseStagingServer}); } function clearUserErrorMessage() { @@ -484,7 +484,7 @@ export { isBlockedFromConcierge, subscribeToUserEvents, updatePreferredSkinTone, - setShouldUseSecureStaging, + setShouldUseStagingServer, clearUserErrorMessage, subscribeToExpensifyCardUpdates, updateFrequentlyUsedEmojis, diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js index 838f2e3b06ab..62e4a5da7592 100644 --- a/src/libs/actions/Wallet.js +++ b/src/libs/actions/Wallet.js @@ -96,6 +96,15 @@ function setWalletShouldShowFailedKYC(shouldShowFailedKYC) { Onyx.merge(ONYXKEYS.USER_WALLET, {shouldShowFailedKYC}); } +/** + * Save the ID of the chat whose IOU triggered showing the KYC wall. + * + * @param {Number} chatReportID + */ +function setKYCWallSourceChatReportID(chatReportID) { + Onyx.merge(ONYXKEYS.WALLET_TERMS, {chatReportID}); +} + /** * Transforms a list of Idology errors to a translated displayable error string. * @param {Array} idologyErrors @@ -452,6 +461,7 @@ function verifyIdentity(parameters) { * * @param {Object} parameters * @param {Boolean} parameters.hasAcceptedTerms + * @param {Number} parameters.chatReportID When accepting the terms of wallet to pay an IOU, indicates the parent chat ID of the IOU */ function acceptWalletTerms(parameters) { const optimisticData = [ @@ -485,7 +495,7 @@ function acceptWalletTerms(parameters) { }, ]; - API.write('AcceptWalletTerms', {hasAcceptedTerms: parameters.hasAcceptedTerms}, {optimisticData, successData, failureData}); + API.write('AcceptWalletTerms', {hasAcceptedTerms: parameters.hasAcceptedTerms, reportID: parameters.chatReportID}, {optimisticData, successData, failureData}); } /** @@ -542,4 +552,5 @@ export { updatePersonalDetails, verifyIdentity, acceptWalletTerms, + setKYCWallSourceChatReportID, }; diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 525b20760d75..eed475161531 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -164,7 +164,7 @@ class AddPersonalBankAccountPage extends React.Component { ) : ( - + { this.setState({ diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index a84eccb01108..24cb7370a48a 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -3,6 +3,7 @@ import {View, ScrollView} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; +import lodashGet from 'lodash/get'; import styles from '../styles/styles'; import Text from '../components/Text'; import ONYXKEYS from '../ONYXKEYS'; @@ -65,132 +66,142 @@ const getPhoneNumber = (details) => { return Str.removeSMSDomain(details.login); }; -const DetailsPage = (props) => { - const details = props.personalDetails[props.route.params.login]; - if (!details) { - // Personal details have not loaded yet - return ; +class DetailsPage extends React.PureComponent { + componentDidMount() { + if (lodashGet(this.props.route.params, 'login')) { + return; + } + + // Leave the page when the login information is not available + Navigation.dismissModal(); } - const isSMSLogin = Str.isSMSLogin(details.login); - // If we have a reportID param this means that we - // arrived here via the ParticipantsPage and should be allowed to navigate back to it - const shouldShowBackButton = Boolean(props.route.params.reportID); - const timezone = DateUtils.getLocalMomentFromTimestamp(props.preferredLocale, null, details.timezone.selected); - const GMTTime = `${timezone.toString().split(/[+-]/)[0].slice(-3)} ${timezone.zoneAbbr()}`; - const currentTime = Number.isNaN(Number(timezone.zoneAbbr())) ? timezone.zoneAbbr() : GMTTime; - const shouldShowLocalTime = !ReportUtils.hasExpensifyEmails([details.login]); + render() { + const details = this.props.personalDetails[lodashGet(this.props.route.params, 'login')]; + if (!details) { + // Personal details have not loaded yet + return ; + } + const isSMSLogin = Str.isSMSLogin(details.login); - let pronouns = details.pronouns; + // If we have a reportID param this means that we + // arrived here via the ParticipantsPage and should be allowed to navigate back to it + const shouldShowBackButton = Boolean(this.props.route.params.reportID); + const timezone = DateUtils.getLocalMomentFromTimestamp(this.props.preferredLocale, null, details.timezone.selected); + const GMTTime = `${timezone.toString().split(/[+-]/)[0].slice(-3)} ${timezone.zoneAbbr()}`; + const currentTime = Number.isNaN(Number(timezone.zoneAbbr())) ? timezone.zoneAbbr() : GMTTime; + const shouldShowLocalTime = !ReportUtils.hasExpensifyEmails([details.login]); - if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { - const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); - pronouns = props.translate(`pronouns.${localeKey}`); - } + let pronouns = details.pronouns; + + if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { + const localeKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); + pronouns = this.props.translate(`pronouns.${localeKey}`); + } - return ( - - Navigation.goBack()} - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - {details ? ( - - - - {({show}) => ( - - - + return ( + + Navigation.goBack()} + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + {details ? ( + + + + {({show}) => ( + + + + )} + + {details.displayName && ( + + {isSMSLogin ? this.props.toLocalPhone(details.displayName) : details.displayName} + )} - - {details.displayName && ( - - {isSMSLogin ? props.toLocalPhone(details.displayName) : details.displayName} - + {details.login ? ( + + + {this.props.translate(isSMSLogin + ? 'common.phoneNumber' + : 'common.email')} + + + + + {isSMSLogin + ? this.props.toLocalPhone(getPhoneNumber(details)) + : details.login} + + + + + ) : null} + {pronouns ? ( + + + {this.props.translate('profilePage.preferredPronouns')} + + + {pronouns} + + + ) : null} + {shouldShowLocalTime && details.timezone ? ( + + + {this.props.translate('detailsPage.localTime')} + + + {timezone.format('LT')} + {' '} + {currentTime} + + + ) : null} + + {details.login !== this.props.session.email && ( + Report.fetchOrCreateChatReport([this.props.session.email, details.login])} + wrapperStyle={styles.breakAll} + shouldShowRightIcon + /> )} - {details.login ? ( - - - {props.translate(isSMSLogin - ? 'common.phoneNumber' - : 'common.email')} - - - - - {isSMSLogin - ? props.toLocalPhone(getPhoneNumber(details)) - : details.login} - - - - - ) : null} - {pronouns ? ( - - - {props.translate('profilePage.preferredPronouns')} - - - {pronouns} - - - ) : null} - {shouldShowLocalTime && details.timezone ? ( - - - {props.translate('detailsPage.localTime')} - - - {timezone.format('LT')} - {' '} - {currentTime} - - - ) : null} - - {details.login !== props.session.email && ( - Report.fetchOrCreateChatReport([props.session.email, details.login])} - wrapperStyle={styles.breakAll} - shouldShowRightIcon - /> - )} - - ) : null} - - - ); -}; + + ) : null} + + + ); + } +} DetailsPage.propTypes = propTypes; -DetailsPage.displayName = 'DetailsPage'; export default compose( withLocalize, diff --git a/src/pages/EnablePayments/ActivateStep.js b/src/pages/EnablePayments/ActivateStep.js index 94454e8ce78a..5178e7ef5b03 100644 --- a/src/pages/EnablePayments/ActivateStep.js +++ b/src/pages/EnablePayments/ActivateStep.js @@ -1,6 +1,6 @@ import React from 'react'; import {View} from 'react-native'; -import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import Navigation from '../../libs/Navigation/Navigation'; @@ -15,16 +15,25 @@ import defaultTheme from '../../styles/themes/default'; import FixedFooter from '../../components/FixedFooter'; import Button from '../../components/Button'; import * as PaymentMethods from '../../libs/actions/PaymentMethods'; +import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; +import walletTermsPropTypes from './walletTermsPropTypes'; const propTypes = { ...withLocalizePropTypes, /** The user's wallet */ - userWallet: PropTypes.objectOf(userWalletPropTypes), + userWallet: userWalletPropTypes, + + /** Information about the user accepting the terms for payments */ + walletTerms: walletTermsPropTypes, }; const defaultProps = { userWallet: {}, + walletTerms: { + chatReportID: 0, + }, }; class ActivateStep extends React.Component { @@ -35,6 +44,8 @@ class ActivateStep extends React.Component { } renderGoldWalletActivationStep() { + // The text of the "Continue" button depends on whether the action comes from an IOU (i.e. with an attached chat), or a balance transfer + const continueButtonText = this.props.walletTerms.chatReportID ? this.props.translate('activateStep.continueToPayment') : this.props.translate('activateStep.continueToTransfer'); return ( <> @@ -55,7 +66,7 @@ class ActivateStep extends React.Component {