diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md index 5e0e3633f3bc..5c96d8736bcd 100644 --- a/.github/ISSUE_TEMPLATE/Standard.md +++ b/.github/ISSUE_TEMPLATE/Standard.md @@ -42,45 +42,8 @@ Which of our officially supported platforms is this issue occurring on? - [ ] MacOS: Desktop ## Screenshots/Videos -
-Android: Native - - -
- -
-Android: mWeb Chrome - - - -
- -
-iOS: Native - - - -
- -
-iOS: mWeb Safari - - - -
- -
-MacOS: Chrome / Safari - - - -
- -
-MacOS: Desktop - - +Add any screenshot/video evidence
diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index 679f6cf9508b..c2d486b325bb 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -35,12 +35,12 @@ function getTestBuildMessage() { const iOSQRCode = iOSSuccess ? `![iOS](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${iOSLink})` : "The QR code can't be generated, because the iOS build failed"; const webQRCode = webSuccess ? `![Web](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${webLink})` : "The QR code can't be generated, because the web build failed"; - const message = `:test_tube::test_tube: Use the links below to test this build in android and iOS. Happy testing! :test_tube::test_tube: -| android :robot: | iOS :apple: | + const message = `:test_tube::test_tube: Use the links below to test this adhoc build on Android, iOS, Desktop, and Web. Happy testing! :test_tube::test_tube: +| Android :robot: | iOS :apple: | | ------------- | ------------- | | ${androidLink} | ${iOSLink} | | ${androidQRCode} | ${iOSQRCode} | -| desktop :computer: | web :spider_web: | +| Desktop :computer: | Web :spider_web: | | ${desktopLink} | ${webLink} | | ${desktopQRCode} | ${webQRCode} |`; diff --git a/.github/actions/javascript/postTestBuildComment/postTestBuildComment.js b/.github/actions/javascript/postTestBuildComment/postTestBuildComment.js index ea086e9657ac..4bb43f6e5900 100644 --- a/.github/actions/javascript/postTestBuildComment/postTestBuildComment.js +++ b/.github/actions/javascript/postTestBuildComment/postTestBuildComment.js @@ -26,12 +26,12 @@ function getTestBuildMessage() { const iOSQRCode = iOSSuccess ? `![iOS](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${iOSLink})` : "The QR code can't be generated, because the iOS build failed"; const webQRCode = webSuccess ? `![Web](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${webLink})` : "The QR code can't be generated, because the web build failed"; - const message = `:test_tube::test_tube: Use the links below to test this build in android and iOS. Happy testing! :test_tube::test_tube: -| android :robot: | iOS :apple: | + const message = `:test_tube::test_tube: Use the links below to test this adhoc build on Android, iOS, Desktop, and Web. Happy testing! :test_tube::test_tube: +| Android :robot: | iOS :apple: | | ------------- | ------------- | | ${androidLink} | ${iOSLink} | | ${androidQRCode} | ${iOSQRCode} | -| desktop :computer: | web :spider_web: | +| Desktop :computer: | Web :spider_web: | | ${desktopLink} | ${webLink} | | ${desktopQRCode} | ${webQRCode} |`; diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index ff888c135be9..e1bb286179cf 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -183,6 +183,7 @@ jobs: file_artifacts: Customer Artifacts.zip log_artifacts: debug.log cleanup: true + timeout: 5400 - name: Print logs if run failed if: failure() @@ -213,6 +214,7 @@ jobs: remote_src: false file_artifacts: Customer Artifacts.zip cleanup: true + timeout: 5400 - name: Unzip AWS Device Farm delta results run: unzip "Customer Artifacts.zip" -d deltaResults diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index d18a0a383ed6..f30d55c4dff4 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -154,7 +154,7 @@ jobs: - name: Build production desktop app if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run desktop-build -- --publish always + run: npm run desktop-build env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -165,7 +165,7 @@ jobs: - name: Build staging desktop app if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: npm run desktop-build-staging -- --publish always + run: npm run desktop-build-staging env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index beb5d4e2f530..b79b687e638e 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -246,7 +246,7 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Build desktop app for testing - run: npm run desktop-build-adhoc -- --publish always + run: npm run desktop-build-adhoc env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -339,7 +339,7 @@ jobs: if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} with: token: ${{ secrets.OS_BOTIFY_TOKEN }} - body-include: 'Use the links below to test this build in android and iOS. Happy testing!' + body-include: 'Use the links below to test this adhoc build in Android, iOS, Desktop, and Web. Happy testing!' number: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} delete: true diff --git a/android/app/build.gradle b/android/app/build.gradle index b31abc3e278a..b07c66308609 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001039301 - versionName "1.3.93-1" + versionCode 1001039505 + versionName "1.3.95-5" } flavorDimensions "default" diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 7834a0ebb861..078cec114c31 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -54,6 +54,11 @@ src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'), url('/fonts/ExpensifyNewKansas-MediumItalic.woff') format('woff'); } +@font-face { + font-family: Windows Segoe UI Emoji; + src: url('/fonts/seguiemj.ttf'); +} + * { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/assets/fonts/web/seguiemj.ttf b/assets/fonts/web/seguiemj.ttf new file mode 100644 index 000000000000..3a455801aa0c Binary files /dev/null and b/assets/fonts/web/seguiemj.ttf differ diff --git a/config/electronBuilder.config.js b/config/electronBuilder.config.js index da87c93ee367..e4ed685f65fe 100644 --- a/config/electronBuilder.config.js +++ b/config/electronBuilder.config.js @@ -1,6 +1,5 @@ const {version} = require('../package.json'); -const isPublishing = process.argv.includes('--publish'); const pullRequestNumber = process.env.PULL_REQUEST_NUMBER; const s3Bucket = { @@ -28,8 +27,7 @@ if (!isCorrectElectronEnv) { } /** - * The configuration for the production and staging Electron builds. - * It can be used to create local builds of the same, by omitting the `--publish` flag + * The configuration for the debug, production and staging Electron builds. */ module.exports = { appId: 'com.expensifyreactnative.chat', @@ -44,6 +42,9 @@ module.exports = { entitlements: 'desktop/entitlements.mac.plist', entitlementsInherit: 'desktop/entitlements.mac.plist', type: 'distribution', + notarize: { + teamId: '368M544MTT', + }, }, dmg: { title: 'New Expensify', @@ -58,7 +59,6 @@ module.exports = { path: s3Path[process.env.ELECTRON_ENV], }, ], - afterSign: isPublishing ? './desktop/notarize.js' : undefined, files: ['dist', '!dist/www/{.well-known,favicon*}'], directories: { app: 'desktop', diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 8d423dbc4213..d12f602260e1 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -187,7 +187,6 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'react-native-config': 'react-web-config', 'react-native$': '@expensify/react-native-web', 'react-native-web': '@expensify/react-native-web', - 'lottie-react-native': 'react-native-web-lottie', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md index 3ade13554bd6..43485a28b353 100644 --- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md +++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md @@ -233,7 +233,7 @@ If developing on MacOS, the development desktop app can't handle deeplinks corre 1. Create a "real" build of the desktop app, which can handle deep links, open the build folder, and install the dmg there: ``` -npm run desktop-build --publish=never +npm run desktop-build open desktop-build # Then double-click "NewExpensify.dmg" in Finder window ``` diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 5c51f16ffc4d..24e0d1878237 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -32,12 +32,9 @@ This project and everyone participating in it is governed by the Expensify [Code At this time, we are not hiring contractors in Crimea, North Korea, Russia, Iran, Cuba, or Syria. ## Slack channels -All contributors should be a member of **two** Slack channels: +All contributors should be a member of a shared Slack channel called [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- this channel is used to ask **general questions**, facilitate **discussions**, and make **feature requests**. -1. [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- used to ask **general questions**, facilitate **discussions**, and make **feature requests**. -2. [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) -- used to discuss or report **bugs** specifically. - -Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to these two Slack channels, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite! +Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to Slack, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite! Note: Do not send direct messages to the Expensify team in Slack or Expensify Chat, they will not be able to respond. @@ -47,30 +44,21 @@ Note: if you are hired for an Upwork job and have any job-specific questions, pl If you've found a vulnerability, please email security@expensify.com with the subject `Vulnerability Report` instead of creating an issue. ## Payment for Contributions -We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing or reporting a bug, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. +We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. -Payment for your contributions and bug reports will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is). +Payment for your contributions will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is). New contributors are limited to working on one job at a time, however experienced contributors may work on numerous jobs simultaneously. Please be aware that compensation for any support in solving an issue is provided **entirely at Expensify’s discretion**. Personal time or resources applied towards investigating a proposal **will not guarantee compensation**. Compensation is only guaranteed to those who **[propose a solution and get hired for that job](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#propose-a-solution-for-the-job)**. We understand there may be cases where a selected proposal may take inspiration from a previous proposal. Unfortunately, it’s not possible for us to evaluate every individual case and we have no process that can efficiently do so. Issues with higher rewards come with higher risk factors so try to keep things civil and make the best proposal you can. Once again, **any information provided may not necessarily lead to you getting hired for that issue or compensated in any way.** -**Important:** Payment amounts are variable, dependent on when your PR is merged and if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md). team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge. - -**Payment timelines** are based on the day and timestamp the contributor is assigned to the Github issue by an Expensify employee: -- Merged PR within 3 business days (72 hours) - 50% **bonus** -- Merged PR within 6 business days (144 hours) - 0% bonus -- Merged PR within 9 business days (216 hours) - 50% **penalty** -- No PR within 12 business days - **Contract terminated** - -We specify exact hours to make sure we can clearly decide what is eligible for the bonus given our team is global and contributors span across all the timezones. +**Important:** Payment amounts are variable, dependent on if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge. ### Regressions If a PR causes a regression at any point within the regression period (starting when the code is merged and ending 168 hours (that's 7 days) after being deployed to production): - payments will be issued 7 days after all regressions are fixed (ie: deployed to production) - a 50% penalty will be applied to the Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) for each regression on an issue -- the assigned Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) are not eligible for the 50% urgency bonus The 168 hours (aka 7 days) will be measured by calculating the time between when the PR is merged, and when a bug is posted to the #expensify-bugs Slack channel. @@ -80,25 +68,6 @@ A job could be fixing a bug or working on a new feature. There are two ways you #### Finding a job that Expensify posted This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted. -#### Raising jobs and bugs -It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to raise it and claim the bug bounty. If it's a valid bug that we choose to resolve by deploying it to production — either internally or via an external contributor — then we will compensate you $50 for identifying the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first. -- Note: If you get assigned the job you proposed **and** you complete the job, this $50 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. -- Note about proposed bugs: Expensify has the right not to pay the $50 reward if the suggested bug has already been reported. Following, if more than one contributor proposes the same bug, the contributor who posted it first in the [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) Slack channel is the one who is eligible for the bonus. -- Note: whilst you may optionally propose a solution for that job on Slack, solutions are ultimately reviewed in GitHub. The onus is on you to propose the solution on GitHub, and/or ensure the issue creator will include a link to your proposal. - -Please follow these steps to propose a job or raise a bug: - -1. Check to ensure a GH issue does not already exist for this job in the [New Expensify Issue list](https://github.com/Expensify/App/issues). -2. Check to ensure the `Bug:` or `Feature Request:` was not already posted in Slack (specifically the #expensify-bugs or #expensify-open-source [Slack channels](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#slack-channels)). Use your best judgement by searching for similar titles, words and issue descriptions. -3. If your bug or new feature matches with an existing issue, please comment on that Slack thread or GitHub issue with your findings if you think it will help solve the issue. -4. If there is no existing GitHub issue or Upwork job, check if the issue is happening on prod (as opposed to only happening on dev) -5. If the issue is just in dev then it means it's a new issue and has not been deployed to production. In this case, you should try to find the offending PR and comment in the issue tied to the PR and ask the assigned users to add the `DeployBlockerCash` label. If you can't find it, follow the reporting instructions in the next item, but note that the issue is a regression only found in dev and not in prod. -6. If the issue happens in main, staging, or production then report the issue(s) in the #expensify-bugs Slack channel, using the report bug workflow. You can do this by clicking 'Workflow > report Bug', or typing `/Report bug`. View [this guide](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_CREATE_A_PLAN.md) for help creating a plan when proposing a feature request. Please verify the bug's presence on **every** platform mentioned in the bug report template, and confirm this with a screen recording.. - - **Important note/reminder**: never share any information pertaining to a customer of Expensify when describing the bug. This includes, and is not limited to, a customer's name, email, and contact information. -7. The Applause team will review your job proposal in the appropriate slack channel. If you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:` -8. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork to receive your payout. No additional work is required. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed. -9. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed. - >**Note:** Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. Here's an example of a good problem/solution: > >**Problem:** The app start up time has regressed because we introduced "New Feature" in PR #12345 and is now 1042ms slower because `SomeComponent` is re-rendering 42 times. diff --git a/desktop/notarize.js b/desktop/notarize.js deleted file mode 100644 index f6f803700a19..000000000000 --- a/desktop/notarize.js +++ /dev/null @@ -1,20 +0,0 @@ -const {notarize} = require('@electron/notarize'); -const electron = require('../config/electronBuilder.config'); - -exports.default = function notarizing(context) { - const {electronPlatformName, appOutDir} = context; - if (electronPlatformName !== 'darwin') { - return; - } - - const appName = context.packager.appInfo.productFilename; - - return notarize({ - tool: 'notarytool', - appBundleId: electron.appId, - appPath: `${appOutDir}/${appName}.app`, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, - teamId: '368M544MTT', - }); -}; diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html index 0183da296bff..d18ca2199e33 100644 --- a/docs/_includes/footer.html +++ b/docs/_includes/footer.html @@ -25,9 +25,6 @@

Features

  • Invoicing
  • -
  • - CPA Card -
  • Payroll
  • diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 825b681c8871..46434787d6df 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -35,8 +35,8 @@ html { } table { - border-collapse: collapse; border-spacing: 0; + border-collapse: collapse; } caption, @@ -365,6 +365,43 @@ button { } } + table { + margin-bottom: 20px; + border-radius: 8px; + + // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders. + // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use + border-style: hidden; + box-shadow: 0 0 0 1px $color-green-borders; + } + + th:first-child { + border-top-left-radius: 8px; + } + + th:last-child { + border-top-right-radius: 8px; + } + + tr:last-child > td:first-child { + border-bottom-left-radius: 8px; + } + + tr:last-child > td:last-child { + border-bottom-right-radius: 8px; + } + + th, + td { + padding: 6px 13px; + border: 1px solid $color-green-borders; + } + + thead tr th { + font-weight: bold; + background-color: $color-green-highlightBG; + } + .img-wrap { display: flex; justify-content: space-around; diff --git a/docs/articles/expensify-classic/account-settings/Copilot.md b/docs/articles/expensify-classic/account-settings/Copilot.md new file mode 100644 index 000000000000..4fac402b7ced --- /dev/null +++ b/docs/articles/expensify-classic/account-settings/Copilot.md @@ -0,0 +1,69 @@ +--- +title: Copilot +description: Safely delegate tasks without sharing login information. +--- + +# About +The Copilot feature allows you to safely delegate tasks without sharing login information. Your chosen user can access your account through their own Expensify account, with customizable permissions to manage expenses, create reports, and more. This can even be extended to users outside your policy or domain. + +# How-to +# How to add a Copilot +1. Log into the Expensify desktop website. +2. Navigate to *Settings > Account > Account Details > _Copilot: Delegated Access_*. +3. Enter the email address or phone number of your Copilot and select whether you want to give them Full Access or the ability to Submit Only. + - *Full Access Copilot*: Your Copilot will have full access to your account. Nearly every action you can do and everything you can see in your account will also be available to your Copilot. They *will not* have the ability to add or remove other Copilots from your account. + - *Submit Only Copilot*: Your Copilot will have the same limitations as a Full Access Copilot, with the added restriction of not being able to approve reports on your behalf. +4. Click Invite Copilot. + +If your Copilot already has an Expensify account, they will get an email notifying them that they can now access your account from within their account as well. +If they do not already have an Expensify account, they will be provided with a link to create one. Once they have created their Expensify account, they will be able to access your account from within their own account. + +# How to use Copilot +A designated copilot can access another account via the Expensify website or the mobile app. + +## How to switch to Copilot mode (on the Expensify website): +1. Click your profile icon in the upper left side of the page. +2. In the “Copilot Access” section of the dropdown, choose the account you wish to access. +3. When you Copilot into someone else’s account, the Expensify header will change color and an airplane icon will appear. +4. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”. + +## How to switch to Copilot Mode (on the mobile app): +1. Tap on the menu icon on the top left-hand side of the screen, then tap your profile icon. +2. Tap “Switch to Copilot Mode”, then choose the account you wish to access. +3. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”. + +# How to remove a Copilot +If you ever need to remove a Copilot, you can do so by following the below steps: +1. Log into the Expensify desktop website +2. Navigate to *Settings > Your Account > Account Details > _Copilot: Delegated Access_* +3. Click the red X next to the Copilot you'd like to remove + + +# Deep Dive +## Copilot Permissions +A Copilot can do the following actions in your account: +- Prepare expenses on your behalf +- Approve and reimburse others' expenses on your behalf (Note: this applies only to **Full Access** Copilots) +- View and make changes to your account/domain/policy settings +- View all expenses you can see within your own account + +## Copilot restrictions +A Copilot cannot do the following actions in your account: +- Change or reset your password +- Add/remove other Copilots + +## Forwarding receipts to receipts@expensify.com as a Copilot +To ensure a receipt is routed to the Expensify account in which you are a copilot rather than your own you’ll need to do the following: +1. Forward the email to receipts@expensify.com +2. Put the email of the account in which you are a copilot in the subject line +3. Send + + +# FAQ +## Can a Copilot's Secondary Login be used to forward receipts? +Yes! A Copilot can use any of the email addresses tied to their account to forward receipts into the account of the person they're assisting. + +## I'm in Copilot mode for an account; Can I add another Copilot to that account on their behalf? +No, only the original account holder can add another Copilot to the account. +## Is there a restriction on the number of Copilots I can have or the number of users for whom I can act as a Copilot? +There is no limit! You can have as many Copilots as you like, and you can be a Copilot for as many users as you need. diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md deleted file mode 100644 index a060e37146a5..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Brex -description: Brex ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index 6debce6240ff..fc1e83701caf 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -1,5 +1,101 @@ --- -title: CSV Import -description: CSV Import +title: Import and assign company cards from CSV file +description: uploading a CSV file containing your company card transactions --- -## Resource Coming Soon! + +# Overview +Expensify offers a convenient CSV import feature for managing company card expenses when direct connections or commercial card feeds aren't available. This feature allows you to upload a CSV file containing your company card transactions and assign them to cardholders within your Expensify domain. +This feature is available on Group Workspaces and requires Domain Admin access. + +# How to import company cards via CSV +1. Download a CSV of transactions from your bank by logging into their website and finding the relevant statement. +2. Format the CSV for upload using [this template](https://s3-us-west-1.amazonaws.com/concierge-responses-expensify-com/uploads%2F1594908368712-Best+Example+CSV+for+Domains.csv) as a guide. +- At a minimum, your file must include the following columns: + - **Card Number** - each number in this column should display at least the last four digits, and you can obscure up to 12 characters +(e.g., 543212XXXXXX12334). + - **Posted Date** - use the YYYY-MM-DD format in this column (and any other date column in your spreadsheet). + - **Merchant** - the name of the individual or business that provided goods or services for the transaction. This is a free-text field. + - **Posted Amount** - use the number format in this column, and indicate negative amounts with parentheses (e.g., (335.98) for -$335.98). + - **Posted Currency** - use currency codes (e.g., USD, GBP, EUR) to indicate the currency of the posted transactions. +- You can also add mapping for Categories and Tags, but those parameters are optional. +3. Log into Expensify on your web browser. +4. Head to Settings > Domains > Domain Name > Company Cards +5. Click Manage/Import CSV +6. Create a Company Card Layout Name for your spreadsheet +7. Click Upload CSV +8. Review the mapping of your spreadsheet to ensure that the Card Number, Date, Merchant, Amount, and Currency match your data. +9. Double-check the Output Preview for any errors and, if needed, refer to the common error solutions listed in the FAQ below. +10. Once the mapping is correct, click Submit Spreadsheet to complete the import. +11. After submitting the spreadsheet, click I'll wait a minute. Then, wait about 1-2 minutes for the import to process. The domain page will refresh once the upload is complete. + +# How to assign new cards +If you're assigning cards via CSV upload for the first time: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Find the new CSV feed in the drop-down list underneath **Imported Cards** +3. Click **Assign New Cards** +4. Under **Assign a Card**, enter the relevant info +5. Click **Assign** +From there, transactions will be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval. + +# How to upload new expenses for existing assigned cards +There's no need to create a new upload layout for subsequent CSV uploads. Instead, add new expenses to the existing CSV: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Click **Manage/Import CSV** +3. Select the saved layout from the drop-down list +4. Click **Upload CSV** +5. After uploading the more recent CSV, click **Update All Cards** to retrieve the new expenses for the assigned cards. + +# Deep dive +If the CSV upload isn't formatted correctly, it will cause issues when you try to import or assign cards. Let's go over some common issues and how to fix them. + +## Error: "Attribute value mapping is missing" +If you encounter an error that says "Attribute-value mapping is missing," the spreadsheet likely lacks critical details like Card Number, Date, Merchant, Amount, or Currency. To resolve: +1. Click the **X** at the top of the page to close the mapping window +2. Confirm what's missing from the spreadsheet +3. Add a new column to your spreadsheet and add the missing detail +4. Upload the revised spreadsheet by clicking **Manage Spreadsheet** +5. Enter a **Company Card Layout Name** for the contents of your spreadsheet +6. Click **Upload CSV** + +## Error: "We've detected an error while processing your spreadsheet feed" +This error usually occurs when there's an upload issue. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + +## Error: "An unexpected error occurred, and we could not retrieve the list of cards" +This error occurs when there's an issue uploading the spreadsheet or the upload fails. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + + +## I added a new parameter to an existing spreadsheet, but the data isn't showing in Expensify after the upload completes. What's going on? +If you added a new card to an existing spreadsheet and imported it via a saved layout, but it isn't showing up for assignment, this suggests that the modification may have caused an issue. +The next step in troubleshooting this issue is to compare the number of rows on the revised spreadsheet to the Output Preview to ensure the row count matches the revised spreadsheet. +To check this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. Select your saved layout in the dropdown list +3. Click **Upload CSV** and select the revised spreadsheet +4. Compare the Output Preview row count to your revised spreadsheet to ensure they match + + +If they don't match, you'll need to revise the spreadsheet by following the CSV formatting guidelines in step 2 of "How to import company cards via CSV" above. +Once you do that, save the revised spreadsheet with a new layout name. +Then, try to upload the revised spreadsheet again: + +1. Click **Upload CSV** +2. Upload the revised file +3. Check the row count again on the Output Preview to confirm it matches the spreadsheet +4. Click **Submit Spreadsheet** + +# FAQ +## Why can't I see my CSV transactions immediately after uploading them? +Don't worry! You'll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!** + +## I'm trying to import a credit. Why isn't it uploading? +Negative expenses shouldn't include a minus sign. Instead, they should just be wrapped in parentheses. For example, to indicate "-335.98," you'll want to make sure it's formatted as "(335.98)." diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md similarity index 99% rename from docs/articles/expensify-classic/billing-and-subscriptions/Overview.md rename to docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md index b835db54cbf2..30a507a1f9df 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md @@ -1,5 +1,5 @@ --- -title: Billing in Expensify +title: Billing Overview description: An overview of how billing works in Expensify. --- # Overview diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md index ae367d25891e..7f3d83af1e6e 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md @@ -11,12 +11,14 @@ Every expense has an Attendees field and will list the expense creator’s name ## How to Add Additional Attendees to an Expense * Go to the attendees field * Search for the names of the attendees - * The default list will be of internal attendees belonging to your workspace and domain. + * The default list will be of internal attendees belonging to your workspace and domain * External attendees are not part of your workspace or domain, so you will need to enter their name or email * Select the attendees you would like to add * Save the expense -* Once added, the list of attendees for each expense will be visible on the expense line. -* An amount per employee expense will also be displayed on the report for easy viewing +* Once added, the list of attendees for each expense will be visible on the expense line +* An amount per employee expense will also be displayed on the report for easy viewing + +![image of an expense with attendee tracking]({{site.url}}/assets/images/attendee-tracking.png){:width="100%"} # FAQ diff --git a/docs/articles/expensify-classic/expensify-card/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md similarity index 98% rename from docs/articles/expensify-classic/expensify-card/Card-Settings.md rename to docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md index a8d56f267757..3e2eb2deec46 100644 --- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md +++ b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md @@ -1,6 +1,6 @@ --- -title: Expensify Card Settings -description: Admin Card Settings and Features +title: Admin Card Settings and Features +description: An in-depth look into the Expensify Card program's admin controls and settings. --- # Overview diff --git a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md index 9de47d6e5beb..5f5ecca13b2f 100644 --- a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md +++ b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md @@ -35,6 +35,8 @@ To set up your auto-reconciliation account with the Expensify Card, follow these 5. Head to the "Settings" tab. 6. Select the account in your accounting solution that you want to use for reconciliation. Make sure this account matches the settlement business bank account. +![Company Card Settings section](https://help.expensify.com/assets/images/Auto-Reconciliaton_Image1.png){:width="100%"} + That's it! You've successfully set up your auto-reconciliation account. ## How does Auto-Reconciliation work @@ -44,9 +46,11 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s **What happens**: When an Expensify Card is used to make purchases, the amount spent is automatically deducted from your company’s 'Settlement Account' (your business checking account). This deduction happens on a daily or monthly basis, depending on your chosen settlement frequency. Don't worry; this settlement account is pre-defined when you apply for the Expensify Card, and you can't accidentally change it. **Accounting treatment**: After your card balance is settled each day, we update your accounting system with a journal entry. This entry credits your bank account (referred to as the GL account) and debits the Expensify Card Clearing Account. To ensure accuracy, please make sure that the 'bank account' in your Expensify Card settings matches your real-life settlement account. You can easily verify this by navigating to **Settings > Account > Payments**, where you'll see 'Settlement Account' next to your business bank account. To keep track of settlement figures by date, use the Company Card Reconciliation Dashboard's Settlements tab: +![Company Card Reconciliation Dashboard](https://help.expensify.com/assets/images/Auto-Reconciliation_Image2.png){:width="100%"} + ### Submitting, Approving, and Exporting Expenses **What happens**: Users submit their expenses on a report, which might occur after some time has passed since the initial purchase. Once the report is approved, it's then exported to your accounting software. -**Accounting treatment**: When the report is exported, we create a journal entry in your accounting system. This entry credits the Clearing Account and debits the Liability Account for the purchase amount. The Liability Account functions as a bank account in your ledger, specifically for Expensify Card expenses: +**Accounting treatment**: When the report is exported, we create a journal entry in your accounting system. This entry credits the Clearing Account and debits the Liability Account for the purchase amount. The Liability Account functions as a bank account in your ledger, specifically for Expensify Card expenses. # Deep Dive ## QuickBooks Online diff --git a/docs/articles/expensify-classic/expensify-card/CPA-Card.md b/docs/articles/expensify-classic/expensify-card/CPA-Card.md deleted file mode 100644 index dfc1e71192db..000000000000 --- a/docs/articles/expensify-classic/expensify-card/CPA-Card.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: CPA Card -description: CPA Card ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md b/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md deleted file mode 100644 index 9888edd139ac..000000000000 --- a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Connect to Indirect Integration -description: Connect to Indirect Integration ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md b/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md similarity index 100% rename from docs/articles/expensify-classic/expensify-card/File-A-Dispute.md rename to docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md diff --git a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md b/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md deleted file mode 100644 index a8cddcdfdd42..000000000000 --- a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Third Party Payments -description: Third Party Payments ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Best-Practices.md b/docs/articles/expensify-classic/getting-started/Best-Practices.md deleted file mode 100644 index b02ea9d68fe6..000000000000 --- a/docs/articles/expensify-classic/getting-started/Best-Practices.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Best Practices -description: Best Practices ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md index e01450a730cf..2314fbeb7178 100644 --- a/docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md +++ b/docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md @@ -2,4 +2,200 @@ title: Custom Templates description: Custom Templates --- -## Resource Coming Soon! +# Overview + +If you don't have a direct connection to your accounting system, as long as the system accepts a CSV file, you can easily export your expense data for upload to the system. Custom templates are great if you want to analyze the data in your favorite spreadsheet program. + +# How to use custom templates +If you are a Group workspace admin you can create a custom template that will be available to all Workspace Admins on the workspace from **Settings > Workspaces > Group > _Workspace Name_ > Export Formats**. + +If you are using a free account you can create a custom template from **Settings > Account > Preferences > CSV Export Formats**. + +You can use your custom templates from the **Reports** page. +1. Select the checkbox next to the report you’d like to export +3. Click **Export to** at the top of the page +4. Select your template from the dropdown + +# Formulas +## Report level + +| Formula | Description | +| -- | -- | +| **Report title** | **the title of the report the expense is part of** | +| {report:title} | would output "Expense Expenses to 2019-11-05" assuming that is the report's title.| +| **Report ID** | **number is a unique number per report and can be used to identify specific reports**| +| {report:id} | would output R00I7J3xs5fn assuming that is the report's ID.| +| **Old Report ID** | **a unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62.** | +| {report:oldID} | would output R3513250790654885 assuming that is the report's old ID.| +| **Reimbursement ID** | **the unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.**| +| {report:reimbursementid} | would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.| +| **Report Total** | **the total amount of the expense report.**| +| {report:total} | would output $325.34 assuming that is the report's total.| +| **Type** | **is the type of report (either Expense Report, Invoice or Bill)**| +| {report:type} | would output "Expense Report" assuming that is the report's type| +| **Reimbursable Total** | **is the total amount that is reimbursable on the report.**| +| {report:reimbursable} | would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.| +| **Currency** | **is the currency to which all expenses on the report are being converted.**| +| {report:currency} | would output USD assuming that the report total was calculated in US Dollars| +|| Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.| +| **Report Field** | **formula will output the value for a given Report Field which is created in the workspace settings.**| +| {field:Employee ID} | would output 12456 , assuming "Employee ID" is the name of the Report Field and "123456" is the value of that field on the report.| +| **Created date** | **the expense report was originally created by the user.**| +| {report:created} | would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.| +| {report:created:yyyy-MM-dd} | would output 2010-09-15 assuming the expense report was created on September 15, 2010.| +| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).| +| **StartDate** | **is the date of the earliest expense on the report.**| +| {report:startdate} | would output 2010-09-15 assuming that is the date of the earliest expense on the report.| +| **EndDate**| **is the date of the last expense on the report.**| +| {report:enddate} | would output 2010-09-26 assuming that is the date of the last expense on the report.| +| **Scheduled Submit Dates** | **the start and end dates of the Scheduled Submit reporting cycle.**| +| {report:autoReporting:start} | would output 2010-09-15 assuming that is the start date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.| +| {report:autoReporting:end} | would output 2010-09-26 assuming that is the end date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.| +| **Submission Date** | **is the date that the report was submitted.**| +| {report:submit:date} | would output 1986-09-15 12:00:00 assuming that the report was submitted on September 15, 1986, at noon.| +| {report:submit:date:yyyy-MM-dd} | would output 1986-09-15 assuming that the report was submitted on September 15, 1986.| +| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting | +| **Approval Date** | **the date the report was approved. This formula can be used for export templates, but not for report titles.**| +| {report:approve:date} | would output 2011-09-25 12:00:00 assuming that the report was approved on September 25, 2011, at noon.| +| {report:approve:date:yyyy-MM-dd} | would output 2011-09-25 assuming that the report was approved on September 25, 2011.| +| **Reimbursement Date** | **the date an expense report was reimbursed. This formula can be used for export templates, but not for report titles.**| +| {report:achreimburse} | would output 2011-09-25 assuming that is the date the report was reimbursed via ACH Direct Deposit.| +| {report:manualreimburse} | would output 2011-09-25 assuming that is the date the report was marked as reimbursed. | +| **Export Date** | **is the date when the report is exported. This formula can be used for export templates, but not for report titles.**| +| {report:dateexported} | would output 2013-09-15 12:00 assuming that the report was exported on September 15, 2013, at noon.| +| {report:dateexported:yyyy-MM-dd} | would output 2013-09-15 assuming that the report was exported on September 15, 2013.| +| **Expenses Count** | **is the number of total expenses on the report of this specific expense.**| +| {report:expensescount} | would output 10 assuming that there were 10 expenses on the given report for this expense.| +| **Workspace Name** | **is the name of the workspace applied to the report.**| +| {report:policyname} | would output Sales assuming that the given report was under a workspace named Sales.| +| **Status** | **is the current state of the report when it was exported**.| +| {report:status} | would output Approved assuming that the report has been approved and not yet reimbursed.| +| **Custom Fields** | | +| {report:submit:from:customfield1} | would output the custom field 1 entry associated with the user who submitted the report. If John Smith’s Custom Field 1 contains 100, then this formula would output 100.| +| {report:submit:from:customfield2} | would output the custom field 2 entry associated with the user who submitted the report. If John Smith’s Custom Field 2 contains 1234, then this formula would output 1234. | +| **To** | **is the email address of the last person who the report was submitted to.**| +| {report:submit:to} | would output alice@email.com if they are the current approver| +| {report:submit:to:email\|frontPart} | would output alice.| +| **Current user** | **To export the email of the currently logged in Expensify user**| +| {user:email} | would output bob@example.com assuming that is the currently logged in Expensify user's email.| +| **Submitter** | **"Sally Ride" with email "sride@email.com" is the submitter for the following examples**| +| {report:submit:from:email}| sride@email.com| +| {report:submit:from}| Sally Ride| +| {report:submit:from:firstname}| Sally| +| {report:submit:from:lastname}| Ride| +| {report:submit:from:fullname}| Sally Ride | +| | Note - If user's name is blank, then {report:submit:from} and {report:submit:from:email\|frontPart} will print the user's whole email.| + +`{report:submit:from:email|frontPart}` sride + +`{report:submit:from:email|domain}` email.com + +`{user:email|frontPart}` would output bob assuming that is the currently logged in Expensify user's email. + +## Expense level + +| Formula | Description | +| -- | -- | +| **Merchant** | **Merchant of the expense** | +| {expense:merchant} | would output Sharons Coffee Shop and Grill assuming the expense is from Sharons Coffee Shop | +| {expense:distance:count} | would output the total miles/kilometers of the expense.| +| {expense:distance:rate} | would output the monetary rate allowed per mile/kilometer. | +| {expense:distance:unit} | would output either mi or km depending on which unit is applied in the workspace settings. | +| **Date** | **Related to the date listed on the expense** | +| {expense:created:yyyy-MM-dd} | would output 2019-11-05 assuming the expense was created on November 5th, 2019 | +| {expense:posted:yyyy-MM-dd} | would output 2023-07-24 assuming the expense was posted on July 24th, 2023 | +| **Tax** | **The tax type and amount applied to the expense line item** | +| {expense:tax:field} | would output VAT assuming this is the name of the tax field.| +| {expense:tax:ratename} | would output the name of the tax rate that was used (ex: Standard). This will show custom if the chosen tax amount is manually entered and not chosen from the list of given options.| +| {expense:tax:amount} | would output $2.00 assuming that is the amount of the tax on the expense.| +| {expense:tax:percentage} | would output 20% assuming this is the amount of tax that was applied to the subtotal.| +| {expense:tax:net} | would output $18.66 assuming this is the amount of the expense before tax was applied.| +| {expense:tax:code} | would output the tax code that was set in the workspace settings.| +| **Expense Amount** | **Related to the currency type and amount of the expense** | +| {expense:amount} | would output $3.95 assuming the expense was for three dollars and ninety-five cents| +| {expense:amount:isk} | would output Íkr3.95 assuming the expense was for 3.95 Icelandic króna.| +| {expense:amount:nosymbol} | would output 3.95. Notice that there is no currency symbol in front of the expense amount because we designated none.| +| {expense:exchrate} | would output the currency conversion rate used to convert the expense amount| +| | Add an optional extra input that is either a three-letter currency code or nosymbol to denote the output's currency. The default if one isn't provided is USD.| +| {expense:amount:originalcurrency} | This gives the amount of the expense in the currency in which it occurred before currency conversion | +| {expense:amount:originalcurrency:nosymbol} | will export the expense in its original currency without the currency symbol. | +| {expense:amount:negsign} | displays negative expenses with a minus sign in front rather wrapped in parenthesis. It would output -$3.95 assuming the expense was already a negative expense for three dollars and ninety-five cents. This formula does not convert a positive expense to a negative value.| +| {expense:amount:unformatted} | displays expense amounts without commas. This removes commas from expenses that have an amount of more than 1000. It would output $10000 assuming the expense was for ten thousand dollars.| +| {expense:debitamount} | displays the amount of the expense if the expense is positive. Nothing will be displayed in this column if the expense is negative. It would output $3.95 assuming the expense was for three dollars and ninety-five cents.| +| {expense:creditamount} | displays the amount of the expense if the expense is negative. Nothing will be displayed in this column if the expense is positive. It would output -$3.95 assuming the expense was for negative three dollars and ninety-five cents.| +| **For expenses imported via CDF/VCF feed only** || +| {expense:purchaseamount} | is the amount of the original purchase in the currency it was purchased in. Control plan users only.| +| {expense:purchaseamount} | would output Irk 3.95 assuming the expense was for 3.95 Icelandic krónur, no matter what currency your bank has translated it to.| +| {expense:purchasecurrency} | would output Irk assuming the expense was incurred in Icelandic krónur (before your bank converted it back to your home currency)| +| **Original Amount** | **when import with a connected bank**| +| {expense:originalamount} | is the amount of the expense imported from your bank or credit card feed. It would output $3.95 assuming the expense equated to $3.95 and you use US-based bank. You may add an optional extra input that is either a three-letter currency code or NONE to denote the output's currency.| +| **Category** | **The category of the expense** | +| {expense:category} | would output Employee Moral assuming that is the expenses' category.| +| {expense:category:glcode} | would output the category gl code of the category selected.| +| {expense:category:payrollcode} | outputs the payroll code information entered for the category that is applied to the expense. If the payroll code for the Mileage category was 39847, this would output simply 39847.| +| **Attendees** | **Persons listed as attendees on the expense**| +| {expense:attendees} | would output the name or email address entered in the Attendee field within the expense (ex. guest@domain.com). | +| {expense:attendees:count} | would output the number of attendees that were added to the expense (ex. 2).8.  Attendees - persons listed as attendees on the expense.| +| **Tags** | Tags of the expense - in this example the name of the tag is "Department" | +| {expense:tag} | would output Henry at Example Co. assuming that is the expenses' tag. | +| **Multiple Tags** | Tags for companies that have multiple tags setup. | +| {expense:tag:ntag-1} | outputs the first tag on the expense, if one is selected | +| {expense:tag:ntag-3} | outputs the third tag on the expense, if one is selected | +| **Description** | The description on the expense | +| {expense:comment} |would output "office lunch" assuming that is the expenses' description.| +| **Receipt** | | +| {expense:receipt:type} | would output eReceipt if the receipt is an Expensify Guaranteed eReceipt.| +| {expense:receipt:url} | would output a link to the receipt image page that anyone with access to the receipt in Expensify could view.| +| {expense:receipt:url:direct} | would show the direct receipt image url for download. | +| {expense:mcc} | would output 3351 assuming that is the expenses' MCC (Merchant Category Code of the expense).| +| | Note, we only have the MCC for expenses that are automatically imported or imported from an OFX/QFX file. For those we don't have an MCC for the output would be (an empty string).| +| **Card name/number expense type** | | +| {expense:card} | Manual/Cash Expenses — would output Cash assuming the expense was manually entered using either the website or the mobile app.| +| {expense:card} | Bank Card Expenses — would output user@company.com – 1234 assuming the expense was imported from a credit card feed.| +| | Note - If you do not have access to the card that the expense was created on 'Unknown' will be displayed. If cards are assigned to users under Domain, then you'll need to be a Domain Admin to export the card number.| +| **Expense ID** | | +| {expense:id} | would output the unique number associated with each individual expense "4294967579".| +| **Reimbursable state** | | +| {expense:reimbursable} | would output "yes" or "no" depending on whether the expense is reimbursable or not.| +| **Billable state** | | +| {expense:billable} | would output "yes" or "no" depending on whether the expense is billable or not. +| **Expense Number** | **is the ordinal number of the expense on its expense report.**| +| {report:expense:number} | would output 2 assuming that the given expense was the second expense on its report.| +| **GL codes** | | +| {expense:category:glcode} | would output the GL code associated with the category of the expense. If the GL code for Meals is 45256 this would output simply 45256.| +| {expense:tag:glcode} | would output the GL code associated with the tag of the expense. If the GL code for Client X is 08294 this would output simply 08294.| +| {expense:tag:ntag-3:glcode} | would output the GL code associated with the third tag the user chooses. This is only for companies that have multiple tags setup.| + +## Date formats + +| Formula | Description | +| -- | -- | +| M/dd/yyyy | 5/23/2019| +|MMMM dd, yyyy| May 23, 2019| +|dd MMM yyyy| 23 May 2019| +|yyyy/MM/dd| 2019/05/23| +|dd MMM yyyy| 23 May 2019| +|yyyy/MM/dd| 2019/05/23| +|MMMM, yyyy| May, 2019| +|yy/MM/dd| 19/05/23| +|dd/MM/yy| 23/05/19| +|yyyy| 2019| + +## Math formulas + +| Formula | Description | +| -- | -- | +| * | Multiplication {math: 3 * 4} output 12| +| / | Division {math: 3 / 4 }output 0.75| +| + | Addition {math: 3 + 4 }output | +| - | Subtraction {math: 3 - 4 }output -1| +| ^ | Exponent {math: 3 ^ 4 } output 81| +| sqrt | The square root of a number. {sqrt:64} output 8| +|| Note - You can also combine the value of any two numeric fields. For example, you can use {math: {expense:tag:glcode} + {expense:category:glcode}} to add the value of the Tag GL code with the Category GL code.| + +## Substring formulas +This formula will output a subset of the string in question. It is important to remember that the count starts at 0 not 1. + +`{expense:merchant|substr:0:4}` would output "Star" for a merchant named Starbucks. This is because we are telling it to start at position 0 and be of 4 character length. + +`{expense:merchant|substr:4:5}` would output "bucks" for a merchant named Starbucks. This is because we are telling it to start at position 4 and be of 5 character length. diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md new file mode 100644 index 000000000000..267c938a3edf --- /dev/null +++ b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md @@ -0,0 +1,43 @@ +--- +title: Fringe Benefits +description: How to track your Fringe Benefits +--- +# Overview +If you’re looking to track and report expense data to calculate Fringe Benefits Tax (FBT), you can use Expensify’s special workflow that allows you to capture extra information and use a template to export to a spreadsheet. + +# How to set up Fringe Benefit Tax + +## Add Attendee Count Tags +First, you’ll need to add these two tags to your Workspace: +1) Number of Internal Attendees +2) Number of External Attendees + +These tags must be named exactly as written above, ensuring there are no extra spaces at the beginning or at the end. You’ll need to set the tags to be numbers 00 - 10 or whatever number you wish to go up to (up to the maximum number of attendees you would expect at any one time), one tag per number i.e. “01”, “02”, “03” etc. These tags can be added in addition to those that are pulled in from your accounting solution. Follow these [instructions](https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags#gsc.tab=0) to add tags. + +## Add Payroll Code +Go to **Settings > Workspaces > Group > _Workspace Name_ > Categories** and within the categories you wish to track FBT against, select **Edit Category** and add the code “TAG”: + +## Enable Workflow +Once you’ve added both tags (Internal Attendees and External Attendees) and added the payroll code “TAG” to FBT categories, you can send a request to Expensify at concierge@expensify.com to enable the FBT workflow. Please send the following request: +>“Can you please add the custom workflow/DEW named FRINGE_BENEFIT_TAX to my company workspace named ?” +Once the FBT workflow is enabled, it will require anything with the code “TAG” to include the two attendee count tags in order to be submitted. + + +# For Users +Once these steps are completed, users who create expenses coded with any category that has the payroll code “TAG” (e.g. Entertainment Expenses) but don’t add the internal and external attendee counts, will not be able to submit their expenses. +# For Admins +You are now able to create and run a report, which shows all expenses under these categories and also shows the number of internal and external attendees. Because we don’t presume to know all of the data points you wish to capture, you’ll need to create a Custom CSV export. +Here are a couple of examples of Excel formulas to use to report on attendees: +- `{expense:tag:ntag-1}` outputs the first tag the user chooses. +- `{expense:tag:ntag-3}` outputs the third tag the user chooses. + +Your expenses may have multiple levels of coding, i.e.: +- GL Code (Category) +- Department (Tag 1) +- Location (Tag 2) +- Number of Internal Attendees (Tag 3) +- Number of External Attendees (Tag 4) + +In the above case, you’ll want to use `{expense:tag:ntag-3}` and `{expense:tag:ntag-4}` as formulas to report on the number of internal and external attendees. + +Our article on [Custom Templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates#gsc.tab=0) shows how to create a custom CSV. diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index e9077fc40a50..ecdea4699ee0 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -64,6 +64,8 @@ Before completing the steps below, you will need Workday Report Writer access to - Note: _if there is field data you want to import that is not listed above, or you have any special requests, let your Expensify Account Manager know and we will work with you to accommodate the request._ 4. Rename the columns so they match Expensify's API key names (The full list of names are found here): - employeeID + - customField1 + - customField2 - firstName - lastName - employeeEmail diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md similarity index 61% rename from docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md index 3ee1c8656b4b..f2978434959b 100644 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md @@ -1,5 +1,5 @@ --- -title: Coming Soon +title: Add Members to your Workspace description: Coming Soon --- ## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md index 1f69c1eee8f4..4c64ab1cefe4 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md @@ -52,28 +52,28 @@ This document explains how to manage employee expense reports and approval workf - *Final Approver (Finance/Accountant):* This is the person listed as the 'Approves to' in the Settings of the Second Approver. - This is what this setup looks like in the Workspace Members table. - Bryan submits his reports to Jim for 1st level approval. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot showing the People section of the workspace]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png){:width="100%"} - All of the reports Jim approves are submitted to Kevin. Kevin is the 'approves to' in Jim's Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png){:width="100%"} - All of the reports Kevin approves are submitted to Lucy. Lucy is the 'approves to' in Kevin's Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png){:width="100%"} - Lucy is the final approver, so she doesn't submit her reports to anyone for review. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Final Approver]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png){:width="100%"} - The final outcome: The member in the Submits To line is different than the person noted as the Approves To. ### Adding additional approver levels - You can also set a specific approver for Reports Totals in Settings. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png){:width="100%"} - An example: The submitter's manager can approve any report up to a certain limit, let's say $500, and forward it to accounting. However, if a report is over that $500 limit, it has to be also approved by the department head before being forwarded to accounting. - To configure, click on Edit Settings next to the approving manager's email address and set the "If Report Total is Over" and "Then Approves to" fields. -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"} +![Screenshot of Workspace Member Settings]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png){:width="100%"} +![Screenshot of Policy Member Editor Configure]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png){:width="100%"} ### Setting category approvals @@ -89,7 +89,7 @@ This document explains how to manage employee expense reports and approval workf - To add a category approver in your Workspace: - Navigate to *Settings > Policies > Group > [Workspace Name] > Categories* - Click *"Edit Settings"* next to the category that requires the additional approver - - Select an approver and click *“Save”* + - Select an approver and click *"Save"* #### Tag approver @@ -106,4 +106,4 @@ Category and Tag approvers are inserted at the beginning of the approval workflo ### Workflow enforcement -- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement by following the steps below. As a Workspace Admin, you can choose to enforce your approval workflow by going. \ No newline at end of file +- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement. As a Workspace Admin, you can choose to enforce your approval workflow by going to Settings > Workspaces > Group > [Workspace Name] > People > Approval Mode. When enabled (which is the default setting for a new workspace), submitters and approvers must adhere to the set approval workflow (recommended). This setting does not apply to Workspace Admins, who are free to submit outside of this workflow diff --git a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md b/docs/articles/expensify-classic/send-payments/Pay-Invoices.md deleted file mode 100644 index e5e6799c268c..000000000000 --- a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Pay Invoices -description: Pay Invoices ---- -## Resource Coming Soon! diff --git a/docs/assets/images/attendee-tracking.png b/docs/assets/images/attendee-tracking.png new file mode 100644 index 000000000000..1888851b2a13 Binary files /dev/null and b/docs/assets/images/attendee-tracking.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 25a144298c6b..1966f3862d59 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.93 + 1.3.95 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.93.1 + 1.3.95.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 8a462daeeff1..387687a2beaa 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.93 + 1.3.95 CFBundleSignature ???? CFBundleVersion - 1.3.93.1 + 1.3.95.5 diff --git a/package-lock.json b/package-lock.json index 03b0fdd0df7c..a80022853a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "new.expensify", - "version": "1.3.93-1", + "version": "1.3.95-5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.93-1", + "version": "1.3.95-5", "hasInstallScript": true, "license": "MIT", "dependencies": { + "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-web": "0.18.15", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -21,6 +22,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -51,13 +53,13 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#2adc24c4e889b3a15f199a6b273e343c7d9cff78", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#82bfcd1cb077afd03d1c8c069618c7dd5bd405d8", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^6.3.1", + "lottie-react-native": "^6.4.0", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -94,7 +96,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.100", + "react-native-onyx": "1.0.111", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -113,7 +115,6 @@ "react-native-view-shot": "^3.6.0", "react-native-vision-camera": "^2.16.2", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", @@ -137,7 +138,7 @@ "@babel/preset-typescript": "^7.21.5", "@babel/runtime": "^7.20.0", "@dword-design/eslint-plugin-import-alias": "^4.0.8", - "@electron/notarize": "^1.2.3", + "@electron/notarize": "^2.1.0", "@jest/globals": "^29.5.0", "@octokit/core": "4.0.4", "@octokit/plugin-paginate-rest": "3.1.0", @@ -2942,6 +2943,14 @@ "node": ">=10.0.0" } }, + "node_modules/@dotlottie/react-player": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz", + "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@dword-design/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", @@ -3104,13 +3113,14 @@ } }, "node_modules/@electron/notarize": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-1.2.3.tgz", - "integrity": "sha512-9oRzT56rKh5bspk3KpAVF8lPKHYQrBnRwcgiOeR0hdilVEQmszDaAu0IPCPrwwzJN0ugNs0rRboTreHMt/6mBQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.1.0.tgz", + "integrity": "sha512-Q02xem1D0sg4v437xHgmBLxI2iz/fc0D4K7fiVWHa/AnW8o7D751xyKNXgziA6HrTOme9ul1JfWN5ark8WH1xA==", "dev": true, "dependencies": { "debug": "^4.1.1", - "fs-extra": "^9.0.1" + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" }, "engines": { "node": ">= 10.0.0" @@ -5634,6 +5644,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@lottiefiles/react-lottie-player": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz", + "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "16 - 18" + } + }, "node_modules/@lwc/eslint-plugin-lwc": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz", @@ -21725,35 +21746,6 @@ "node": ">=14.0.0" } }, - "node_modules/app-builder-lib/node_modules/@electron/notarize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.1.0.tgz", - "integrity": "sha512-Q02xem1D0sg4v437xHgmBLxI2iz/fc0D4K7fiVWHa/AnW8o7D751xyKNXgziA6HrTOme9ul1JfWN5ark8WH1xA==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/app-builder-lib/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -30354,8 +30346,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#2adc24c4e889b3a15f199a6b273e343c7d9cff78", - "integrity": "sha512-O7XTAfJoCHiFof+X5oFcCgAZAVVJbdIZ+ANA3WBlvabxcPqN0c+dGxIroV8HlRBbTNAkD3CoDVoF61YBUOxCUg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#82bfcd1cb077afd03d1c8c069618c7dd5bd405d8", + "integrity": "sha512-Pvji3XqRyCbhgaKLVPT0HfRl/cazGStQeo8V6tWcU1n3UNiG/6Qey4jAdfN8WQZlfslrSzFiZTWrD7UT0JeRrQ==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -38142,15 +38134,23 @@ } }, "node_modules/lottie-react-native": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", - "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz", + "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==", "peerDependencies": { + "@dotlottie/react-player": "^1.6.1", + "@lottiefiles/react-lottie-player": "^3.5.3", "react": "*", "react-native": ">=0.46", "react-native-windows": ">=0.63.x" }, "peerDependenciesMeta": { + "@dotlottie/react-player": { + "optional": true + }, + "@lottiefiles/react-lottie-player": { + "optional": true + }, "react-native-windows": { "optional": true } @@ -44802,17 +44802,17 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.100", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz", - "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==", + "version": "1.0.111", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.111.tgz", + "integrity": "sha512-6drd5Grhkyq4oyt2+Bu6t7JYK5tqaARc0YP7taEHK9jLbhjdC4E9MPLJR2FVXiORkQCPOoyy1Gqmb4AUVIsvxg==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.1" }, "engines": { - "node": "16.15.1", - "npm": "8.11.0" + "node": ">=16.15.1 <=18.17.1", + "npm": ">=8.11.0 <=9.6.7" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -45130,18 +45130,6 @@ "react-native-web": "*" } }, - "node_modules/react-native-web-lottie": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz", - "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==", - "license": "MIT", - "dependencies": { - "lottie-web": "^5.7.1" - }, - "peerDependencies": { - "react-native-web": "*" - } - }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", @@ -55231,6 +55219,12 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@dotlottie/react-player": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz", + "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==", + "requires": {} + }, "@dword-design/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz", @@ -55354,13 +55348,14 @@ } }, "@electron/notarize": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-1.2.3.tgz", - "integrity": "sha512-9oRzT56rKh5bspk3KpAVF8lPKHYQrBnRwcgiOeR0hdilVEQmszDaAu0IPCPrwwzJN0ugNs0rRboTreHMt/6mBQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.1.0.tgz", + "integrity": "sha512-Q02xem1D0sg4v437xHgmBLxI2iz/fc0D4K7fiVWHa/AnW8o7D751xyKNXgziA6HrTOme9ul1JfWN5ark8WH1xA==", "dev": true, "requires": { "debug": "^4.1.1", - "fs-extra": "^9.0.1" + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" } }, "@electron/osx-sign": { @@ -57142,6 +57137,14 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "@lottiefiles/react-lottie-player": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz", + "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==", + "requires": { + "lottie-web": "^5.10.2" + } + }, "@lwc/eslint-plugin-lwc": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz", @@ -68793,31 +68796,6 @@ "temp-file": "^3.4.0" }, "dependencies": { - "@electron/notarize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.1.0.tgz", - "integrity": "sha512-Q02xem1D0sg4v437xHgmBLxI2iz/fc0D4K7fiVWHa/AnW8o7D751xyKNXgziA6HrTOme9ul1JfWN5ark8WH1xA==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, - "dependencies": { - "fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - } - } - } - }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -75082,9 +75060,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#2adc24c4e889b3a15f199a6b273e343c7d9cff78", - "integrity": "sha512-O7XTAfJoCHiFof+X5oFcCgAZAVVJbdIZ+ANA3WBlvabxcPqN0c+dGxIroV8HlRBbTNAkD3CoDVoF61YBUOxCUg==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#2adc24c4e889b3a15f199a6b273e343c7d9cff78", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#82bfcd1cb077afd03d1c8c069618c7dd5bd405d8", + "integrity": "sha512-Pvji3XqRyCbhgaKLVPT0HfRl/cazGStQeo8V6tWcU1n3UNiG/6Qey4jAdfN8WQZlfslrSzFiZTWrD7UT0JeRrQ==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#82bfcd1cb077afd03d1c8c069618c7dd5bd405d8", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -80562,9 +80540,9 @@ } }, "lottie-react-native": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz", - "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz", + "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==", "requires": {} }, "lottie-web": { @@ -85438,9 +85416,9 @@ } }, "react-native-onyx": { - "version": "1.0.100", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz", - "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==", + "version": "1.0.111", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.111.tgz", + "integrity": "sha512-6drd5Grhkyq4oyt2+Bu6t7JYK5tqaARc0YP7taEHK9jLbhjdC4E9MPLJR2FVXiORkQCPOoyy1Gqmb4AUVIsvxg==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -85651,14 +85629,6 @@ "integrity": "sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==", "requires": {} }, - "react-native-web-lottie": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz", - "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==", - "requires": { - "lottie-web": "^5.7.1" - } - }, "react-native-webview": { "version": "11.23.0", "requires": { diff --git a/package.json b/package.json index abbfed725a41..f3462a2b63bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.93-1", + "version": "1.3.95-5", "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.", @@ -58,6 +58,7 @@ "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { + "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-web": "0.18.15", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -69,6 +70,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -99,13 +101,13 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#2adc24c4e889b3a15f199a6b273e343c7d9cff78", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#82bfcd1cb077afd03d1c8c069618c7dd5bd405d8", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "jest-when": "^3.5.2", "lodash": "4.17.21", - "lottie-react-native": "^6.3.1", + "lottie-react-native": "^6.4.0", "mapbox-gl": "^2.15.0", "moment": "^2.29.4", "moment-timezone": "^0.5.31", @@ -142,7 +144,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.100", + "react-native-onyx": "1.0.111", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -161,7 +163,6 @@ "react-native-view-shot": "^3.6.0", "react-native-vision-camera": "^2.16.2", "react-native-web-linear-gradient": "^1.1.2", - "react-native-web-lottie": "^1.4.4", "react-native-webview": "^11.17.2", "react-pdf": "^6.2.2", "react-plaid-link": "3.3.2", @@ -174,8 +175,6 @@ "underscore": "^1.13.1" }, "devDependencies": { - "@dword-design/eslint-plugin-import-alias": "^4.0.8", - "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@actions/core": "1.10.0", "@actions/github": "5.1.1", "@babel/core": "^7.20.0", @@ -186,7 +185,8 @@ "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.21.5", "@babel/runtime": "^7.20.0", - "@electron/notarize": "^1.2.3", + "@dword-design/eslint-plugin-import-alias": "^4.0.8", + "@electron/notarize": "^2.1.0", "@jest/globals": "^29.5.0", "@octokit/core": "4.0.4", "@octokit/plugin-paginate-rest": "3.1.0", @@ -205,6 +205,7 @@ "@svgr/webpack": "^6.0.0", "@testing-library/jest-native": "5.4.1", "@testing-library/react-native": "11.5.1", + "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/concurrently": "^7.0.0", "@types/jest": "^29.5.2", "@types/jest-when": "^3.5.2", diff --git a/patches/react-native-web-lottie+1.4.4.patch b/patches/react-native-web-lottie+1.4.4.patch deleted file mode 100644 index c82c33b5a7fe..000000000000 --- a/patches/react-native-web-lottie+1.4.4.patch +++ /dev/null @@ -1,9 +0,0 @@ -diff --git a/node_modules/react-native-web-lottie/dist/index.js b/node_modules/react-native-web-lottie/dist/index.js -index 7cd6b42..9c2b356 100644 ---- a/node_modules/react-native-web-lottie/dist/index.js -+++ b/node_modules/react-native-web-lottie/dist/index.js -@@ -1 +1 @@ --var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _extends2=_interopRequireDefault(require("@babel/runtime/helpers/extends"));var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var _possibleConstructorReturn2=_interopRequireDefault(require("@babel/runtime/helpers/possibleConstructorReturn"));var _getPrototypeOf3=_interopRequireDefault(require("@babel/runtime/helpers/getPrototypeOf"));var _inherits2=_interopRequireDefault(require("@babel/runtime/helpers/inherits"));var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/louislagrange/Documents/Projets/react-native-web-community/react-native-web-lottie/src/index.js";var Animation=function(_PureComponent){(0,_inherits2.default)(Animation,_PureComponent);function Animation(){var _getPrototypeOf2;var _this;(0,_classCallCheck2.default)(this,Animation);for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++){args[_key]=arguments[_key];}_this=(0,_possibleConstructorReturn2.default)(this,(_getPrototypeOf2=(0,_getPrototypeOf3.default)(Animation)).call.apply(_getPrototypeOf2,[this].concat(args)));_this.animationDOMNode=null;_this.loadAnimation=function(props){if(_this.anim){_this.anim.destroy();}_this.anim=_lottieWeb.default.loadAnimation({container:_this.animationDOMNode,animationData:props.source,renderer:'svg',loop:props.loop||false,autoplay:props.autoPlay,rendererSettings:props.rendererSettings||{}});if(props.onAnimationFinish){_this.anim.addEventListener('complete',props.onAnimationFinish);}};_this.setAnimationDOMNode=function(ref){return _this.animationDOMNode=_reactDom.default.findDOMNode(ref);};_this.play=function(){if(!_this.anim){return;}for(var _len2=arguments.length,frames=new Array(_len2),_key2=0;_key2<_len2;_key2++){frames[_key2]=arguments[_key2];}_this.anim.playSegments(frames,true);};_this.reset=function(){if(!_this.anim){return;}_this.anim.stop();};return _this;}(0,_createClass2.default)(Animation,[{key:"componentDidMount",value:function componentDidMount(){var _this2=this;this.loadAnimation(this.props);if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.addListener(function(progress){var value=progress.value;var frame=value/(1/_this2.anim.getDuration(true));_this2.anim.goToAndStop(frame,true);});}}},{key:"componentWillUnmount",value:function componentWillUnmount(){if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.removeAllListeners();}}},{key:"UNSAFE_componentWillReceiveProps",value:function UNSAFE_componentWillReceiveProps(nextProps){if(this.props.source&&nextProps.source&&this.props.source.nm!==nextProps.source.nm){this.loadAnimation(nextProps);}}},{key:"render",value:function render(){return _react.default.createElement(_View.default,{style:this.props.style,ref:this.setAnimationDOMNode,__source:{fileName:_jsxFileName,lineNumber:71}});}}]);return Animation;}(_react.PureComponent);var _default=_react.default.forwardRef(function(props,ref){return _react.default.createElement(Animation,(0,_extends2.default)({},props,{ref:typeof ref=='function'?function(c){return ref(c&&c.anim);}:ref,__source:{fileName:_jsxFileName,lineNumber:76}}));});exports.default=_default; -\ No newline at end of file -+var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/roryabraham/react-native-web-lottie/src/index.js";function Animation(_ref){var source=_ref.source,_ref$renderer=_ref.renderer,renderer=_ref$renderer===void 0?'svg':_ref$renderer,_ref$loop=_ref.loop,loop=_ref$loop===void 0?false:_ref$loop,_ref$autoPlay=_ref.autoPlay,autoPlay=_ref$autoPlay===void 0?false:_ref$autoPlay,_ref$rendererSettings=_ref.rendererSettings,rendererSettings=_ref$rendererSettings===void 0?{}:_ref$rendererSettings,_ref$style=_ref.style,style=_ref$style===void 0?{}:_ref$style;var nm=source.nm;var anim=(0,_react.useRef)(null);var animationDOMNode=(0,_react.useRef)(null);(0,_react.useEffect)(function(){var _anim$current;(_anim$current=anim.current)==null?void 0:_anim$current.destroy();anim.current=_lottieWeb.default.loadAnimation({container:animationDOMNode.current,animationData:source,renderer:renderer,loop:loop,autoPlay:autoPlay,rendererSettings:rendererSettings});return function(){var _anim$current2;(_anim$current2=anim.current)==null?void 0:_anim$current2.destroy();};},[nm]);return _react.default.createElement(_View.default,{style:style,ref:function ref(r){return animationDOMNode.current=_reactDom.default.findDOMNode(r);},__source:{fileName:_jsxFileName,lineNumber:36}});}var _default=_react.default.memo(Animation);exports.default=_default; -\ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index 8507072da5c8..9e7c1f007335 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -144,7 +144,6 @@ const CONST = { DESKTOP: `${ACTIVE_EXPENSIFY_URL}NewExpensify.dmg`, }, DATE: { - MOMENT_FORMAT_STRING: 'YYYY-MM-DD', SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss', FNS_FORMAT_STRING: 'yyyy-MM-dd', LOCAL_TIME_FORMAT: 'h:mm a', @@ -260,6 +259,7 @@ const CONST = { CUSTOM_STATUS: 'customStatus', NEW_DOT_TAGS: 'newDotTags', NEW_DOT_SAML: 'newDotSAML', + VIOLATIONS: 'violations', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1312,6 +1312,7 @@ const CONST = { TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, + ANY_SPACE: /\s/g, // Extract attachment's source from the data's html string ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, @@ -1479,7 +1480,6 @@ const CONST = { RECIEPT_SCANNING_URL: `${USE_EXPENSIFY_URL}/receipt-scanning-app`, BILL_PAY_URL: `${USE_EXPENSIFY_URL}/bills`, INVOICES_URL: `${USE_EXPENSIFY_URL}/invoices`, - CPA_CARD_URL: `${USE_EXPENSIFY_URL}/cpa-card`, PAYROLL_URL: `${USE_EXPENSIFY_URL}/payroll`, TRAVEL_URL: `${USE_EXPENSIFY_URL}/travel`, EXPENSIFY_APPROVED_URL: `${USE_EXPENSIFY_URL}/accountants`, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9cd43badac6b..11c2318672d8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -386,9 +386,11 @@ type OnyxValues = { // Collections [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download; [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; + [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategory; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTags; [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMember; + [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record; diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index f9667807106b..566b6c709423 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -166,7 +166,7 @@ function AddPlaidBankAccount({ value: account.plaidAccountID, label: `${account.addressName} ${account.mask}`, })); - const {icon, iconSize} = getBankIcon(); + const {icon, iconSize, iconStyles} = getBankIcon(); const plaidErrors = lodashGet(plaidData, 'errors'); const plaidDataErrorMessage = !_.isEmpty(plaidErrors) ? _.chain(plaidErrors).values().first().value() : ''; const bankName = lodashGet(plaidData, 'bankName'); @@ -236,10 +236,11 @@ function AddPlaidBankAccount({ src={icon} height={iconSize} width={iconSize} + additionalStyles={iconStyles} /> {bankName} - + ({ - language: preferredLocale, - types: resultTypes, - components: isLimitedToUSA ? 'country:us' : undefined, + language: props.preferredLocale, + types: props.resultTypes, + components: props.isLimitedToUSA ? 'country:us' : undefined, }), - [preferredLocale, resultTypes, isLimitedToUSA], + [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], ); - const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; + const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; const saveLocationDetails = (autocompleteData, details) => { const addressComponents = details.address_components; @@ -188,7 +169,7 @@ function AddressSearch({ // to this component which don't match the usual properties coming from auto-complete. In that case, only a limited // amount of data massaging needs to happen for what the parent expects to get from this function. if (_.size(details)) { - onPress({ + props.onPress({ address: lodashGet(details, 'description'), lat: lodashGet(details, 'geometry.location.lat', 0), lng: lodashGet(details, 'geometry.location.lng', 0), @@ -275,7 +256,7 @@ function AddressSearch({ // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 - if (subpremise && typeof renamedInputKeys.street2 === 'undefined') { + if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') { values.street += `, ${subpremise}`; } @@ -284,19 +265,19 @@ function AddressSearch({ values.country = country; } - if (inputID) { - _.each(values, (inputValue, key) => { - const inputKey = lodashGet(renamedInputKeys, key, key); + if (props.inputID) { + _.each(values, (value, key) => { + const inputKey = lodashGet(props.renamedInputKeys, key, key); if (!inputKey) { return; } - onInputChange(inputValue, inputKey); + props.onInputChange(value, inputKey); }); } else { - onInputChange(values); + props.onInputChange(values); } - onPress(values); + props.onPress(values); }; /** Gets the user's current location and registers success/error callbacks */ @@ -326,7 +307,7 @@ function AddressSearch({ lng: successData.coords.longitude, address: CONST.YOUR_LOCATION_TEXT, }; - onPress(location); + props.onPress(location); }, (errorData) => { if (!shouldTriggerGeolocationCallbacks.current) { @@ -344,16 +325,16 @@ function AddressSearch({ }; const renderHeaderComponent = () => - predefinedPlaces.length > 0 && ( + props.predefinedPlaces.length > 0 && ( <> {/* This will show current location button in list if there are some recent destinations */} {shouldShowCurrentLocationButton && ( )} - {!value && {translate('common.recentDestinations')}} + {!props.value && {props.translate('common.recentDestinations')}} ); @@ -365,26 +346,6 @@ function AddressSearch({ }; }, []); - const listEmptyComponent = useCallback( - () => - network.isOffline || !isTyping ? null : ( - {translate('common.noResultsFound')} - ), - [isTyping, translate, network.isOffline], - ); - - const listLoader = useCallback( - () => ( - - - - ), - [], - ); - return ( /* * The GooglePlacesAutocomplete component uses a VirtualizedList internally, @@ -411,10 +372,20 @@ function AddressSearch({ fetchDetails suppressDefaultStyles enablePoweredByContainer={false} - predefinedPlaces={predefinedPlaces} - listEmptyComponent={listEmptyComponent} - listLoaderComponent={listLoader} - renderHeaderComponent={renderHeaderComponent} + predefinedPlaces={props.predefinedPlaces} + listEmptyComponent={ + props.network.isOffline || !isTyping ? null : ( + {props.translate('common.noResultsFound')} + ) + } + listLoaderComponent={ + + + + } renderRow={(data) => { const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text; const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text; @@ -425,6 +396,7 @@ function AddressSearch({ ); }} + renderHeaderComponent={renderHeaderComponent} onPress={(data, details) => { saveLocationDetails(data, details); setIsTyping(false); @@ -439,31 +411,34 @@ function AddressSearch({ query={query} requestUrl={{ useOnPlatform: 'all', - url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, ref: (node) => { - if (!innerRef) { + if (!props.innerRef) { return; } - if (_.isFunction(innerRef)) { - innerRef(node); + if (_.isFunction(props.innerRef)) { + props.innerRef(node); return; } // eslint-disable-next-line no-param-reassign - innerRef.current = node; + props.innerRef.current = node; }, - label, - containerStyles, - errorText, - hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, - value, - defaultValue, - inputID, - shouldSaveDraft, + label: props.label, + containerStyles: props.containerStyles, + errorText: props.errorText, + hint: + displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping) + ? undefined + : props.hint, + value: props.value, + defaultValue: props.defaultValue, + inputID: props.inputID, + shouldSaveDraft: props.shouldSaveDraft, onFocus: () => { setIsFocused(true); }, @@ -473,24 +448,24 @@ function AddressSearch({ setIsFocused(false); setIsTyping(false); } - onBlur(); + props.onBlur(); }, autoComplete: 'off', onInputChange: (text) => { setSearchValue(text); setIsTyping(true); - if (inputID) { - onInputChange(text); + if (props.inputID) { + props.onInputChange(text); } else { - onInputChange({street: text}); + props.onInputChange({street: text}); } // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) { + if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) { setDisplayListViewBorder(false); } }, - maxLength: maxInputLength, + maxLength: props.maxInputLength, spellCheck: false, }} styles={{ @@ -511,18 +486,17 @@ function AddressSearch({ }} inbetweenCompo={ // We want to show the current location button even if there are no recent destinations - predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( ) : ( <> ) } - placeholder="" /> setLocationErrorCode(null)} diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js index 9bef889e61a1..f11bbcc9b187 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js @@ -82,5 +82,6 @@ function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward CarouselButtons.propTypes = propTypes; CarouselButtons.defaultProps = defaultProps; +CarouselButtons.displayName = 'CarouselButtons'; export default CarouselButtons; diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index 2d271aa6d4c4..53a8606c927f 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -116,5 +116,6 @@ function CarouselItem({item, isFocused, onPress}) { CarouselItem.propTypes = propTypes; CarouselItem.defaultProps = defaultProps; +CarouselItem.displayName = 'CarouselItem'; export default CarouselItem; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js index 2ded34829a08..7a083d71b591 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js @@ -181,7 +181,9 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI ); } + AttachmentCarouselPage.propTypes = pagePropTypes; AttachmentCarouselPage.defaultProps = defaultProps; +AttachmentCarouselPage.displayName = 'AttachmentCarouselPage'; export default AttachmentCarouselPage; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js index 5bf8b79dae77..0839462d4f23 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js @@ -574,5 +574,6 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc } ImageTransformer.propTypes = imageTransformerPropTypes; ImageTransformer.defaultProps = imageTransformerDefaultProps; +ImageTransformer.displayName = 'ImageTransformer'; export default ImageTransformer; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js index 10f2ae94340a..3a27d80c5509 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js @@ -1,4 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React from 'react'; import {StyleSheet} from 'react-native'; @@ -19,6 +18,8 @@ function ImageWrapper({children}) { ); } + ImageWrapper.propTypes = imageWrapperPropTypes; +ImageWrapper.displayName = 'ImageWrapper'; export default ImageWrapper; diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js index e4659caf24f0..59fd7596f0ad 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js @@ -1,4 +1,3 @@ -/* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; import React, {useImperativeHandle, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -168,8 +167,10 @@ function AttachmentCarouselPager({ ); } + AttachmentCarouselPager.propTypes = pagerPropTypes; AttachmentCarouselPager.defaultProps = pagerDefaultProps; +AttachmentCarouselPager.displayName = 'AttachmentCarouselPager'; const AttachmentCarouselPagerWithRef = React.forwardRef((props, ref) => ( { - if (attachment.isReceipt) { + if (attachment.isReceipt && isReceipt) { const action = ReportActionsUtils.getParentReportAction(report); const transactionID = _.get(action, ['originalMessage', 'IOUTransactionID']); return attachment.transactionID === transactionID; } return attachment.source === source; }, - [source, report], + [source, report, isReceipt], ); useEffect(() => { @@ -86,10 +87,12 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl // to get the index of the current page const entry = _.first(viewableItems); if (!entry) { + setIsReceipt(false); setActiveSource(null); return; } + setIsReceipt(entry.item.isReceipt); setPage(entry.index); setActiveSource(entry.item.source); @@ -217,8 +220,10 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl ); } + AttachmentCarousel.propTypes = propTypes; AttachmentCarousel.defaultProps = defaultProps; +AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( withOnyx({ diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index 8c6957c9371a..b86c9b1c786e 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -27,17 +27,18 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, const [activeSource, setActiveSource] = useState(source); const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(true); const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows(); + const [isReceipt, setIsReceipt] = useState(false); const compareImage = useCallback( (attachment) => { - if (attachment.isReceipt) { + if (attachment.isReceipt && isReceipt) { const action = ReportActionsUtils.getParentReportAction(report); const transactionID = _.get(action, ['originalMessage', 'IOUTransactionID']); return attachment.transactionID === transactionID; } return attachment.source === source; }, - [source, report], + [source, report, isReceipt], ); useEffect(() => { @@ -76,6 +77,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, const item = attachments[newPageIndex]; setPage(newPageIndex); + setIsReceipt(item.isReceipt); setActiveSource(item.source); onNavigate(item); @@ -167,6 +169,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, } AttachmentCarousel.propTypes = propTypes; AttachmentCarousel.defaultProps = defaultProps; +AttachmentCarousel.displayName = 'AttachmentCarousel'; export default compose( withOnyx({ diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js index 23049915a8d9..307dbe8e9ddb 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js @@ -39,5 +39,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, o AttachmentViewImage.propTypes = propTypes; AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps; +AttachmentViewImage.displayName = 'AttachmentViewImage'; export default compose(memo, withLocalize)(AttachmentViewImage); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js index faf2f21c133d..cb1190fa1fdd 100755 --- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js @@ -47,5 +47,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUs AttachmentViewImage.propTypes = propTypes; AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps; +AttachmentViewImage.displayName = 'AttachmentViewImage'; export default compose(memo, withLocalize)(AttachmentViewImage); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js similarity index 78% rename from src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js rename to src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js index 9ab0b45f8c8f..40887ddee697 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js @@ -3,7 +3,18 @@ import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCa import PDFView from '@components/PDFView'; import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; -function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) { +function BaseAttachmentViewPdf({ + file, + encryptedSourceUrl, + isFocused, + isUsedInCarousel, + onPress, + onScaleChanged: onScaleChangedProp, + onToggleKeyboard, + onLoadComplete, + errorLabelStyles, + style, +}) { const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); useEffect(() => { @@ -16,7 +27,7 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse const onScaleChanged = useCallback( (scale) => { - onScaleChangedProp(); + onScaleChangedProp(scale); // When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in if (isUsedInCarousel) { @@ -49,7 +60,8 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse ); } -AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; -AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +BaseAttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; +BaseAttachmentViewPdf.displayName = 'BaseAttachmentViewPdf'; -export default memo(AttachmentViewPdf); +export default memo(BaseAttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js new file mode 100644 index 000000000000..46afd23daa4c --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js @@ -0,0 +1,68 @@ +import React, {memo, useCallback, useContext} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import Animated, {useSharedValue} from 'react-native-reanimated'; +import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; +import styles from '@styles/styles'; +import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; +import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; + +function AttachmentViewPdf(props) { + const {onScaleChanged, ...restProps} = props; + const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); + const scaleRef = useSharedValue(1); + const offsetX = useSharedValue(0); + const offsetY = useSharedValue(0); + + const Pan = Gesture.Pan() + .manualActivation(true) + .onTouchesMove((evt) => { + if (offsetX.value !== 0 && offsetY.value !== 0) { + // if the value of X is greater than Y and the pdf is not zoomed in, + // enable the pager scroll so that the user + // can swipe to the next attachment otherwise disable it. + if (Math.abs(evt.allTouches[0].absoluteX - offsetX.value) > Math.abs(evt.allTouches[0].absoluteY - offsetY.value) && scaleRef.value === 1) { + attachmentCarouselPagerContext.shouldPagerScroll.value = true; + } else { + attachmentCarouselPagerContext.shouldPagerScroll.value = false; + } + } + offsetX.value = evt.allTouches[0].absoluteX; + offsetY.value = evt.allTouches[0].absoluteY; + }); + + const updateScale = useCallback( + (scale) => { + scaleRef.value = scale; + }, + [scaleRef], + ); + + return ( + + + + { + updateScale(scale); + onScaleChanged(); + }} + /> + + + + ); +} + +AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; + +export default memo(AttachmentViewPdf); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js new file mode 100644 index 000000000000..103ff292760f --- /dev/null +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js @@ -0,0 +1,17 @@ +import React, {memo} from 'react'; +import BaseAttachmentViewPdf from './BaseAttachmentViewPdf'; +import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes'; + +function AttachmentViewPdf(props) { + return ( + + ); +} + +AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes; +AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps; + +export default memo(AttachmentViewPdf); diff --git a/src/components/Avatar.js b/src/components/Avatar.js index 546387031643..4b8ddd45aa95 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -119,6 +119,9 @@ function Avatar(props) { ); } + Avatar.defaultProps = defaultProps; Avatar.propTypes = propTypes; +Avatar.displayName = 'Avatar'; + export default Avatar; diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index f3127131b9b2..156007aea76e 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -69,6 +69,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC return ( { updateNumberOfLines(); @@ -491,6 +490,7 @@ function Composer({ Composer.propTypes = propTypes; Composer.defaultProps = defaultProps; +Composer.displayName = 'Composer'; const ComposerWithRef = React.forwardRef((props, ref) => ( ( ComposerWithRef.displayName = 'ComposerWithRef'; -export default compose(withLocalize, withWindowDimensions, withNavigation)(ComposerWithRef); +export default compose(withLocalize, withNavigation)(ComposerWithRef); diff --git a/src/components/ConfirmationPage.js b/src/components/ConfirmationPage.js index bc154923e926..09dd8ae3da38 100644 --- a/src/components/ConfirmationPage.js +++ b/src/components/ConfirmationPage.js @@ -47,6 +47,7 @@ function ConfirmationPage(props) { autoPlay loop style={styles.confirmationAnimation} + webStyle={styles.confirmationAnimationWeb} /> {props.heading} {props.description} diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js index c895d919cd33..26424f2d8283 100644 --- a/src/components/DatePicker/datepickerPropTypes.js +++ b/src/components/DatePicker/datepickerPropTypes.js @@ -6,13 +6,13 @@ const propTypes = { ...fieldPropTypes, /** - * The datepicker supports any value that `moment` can parse. + * The datepicker supports any value that `new Date()` can parse. * `onInputChange` would always be called with a Date (or null) */ value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), /** - * The datepicker supports any defaultValue that `moment` can parse. + * The datepicker supports any defaultValue that `new Date()` can parse. * `onInputChange` would always be called with a Date (or null) */ defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js index 002cf5587e44..561fc700b6a5 100644 --- a/src/components/DatePicker/index.android.js +++ b/src/components/DatePicker/index.android.js @@ -1,5 +1,5 @@ import RNDatePicker from '@react-native-community/datetimepicker'; -import moment from 'moment'; +import {format, parseISO} from 'date-fns'; import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import TextInput from '@components/TextInput'; @@ -20,8 +20,7 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain setIsPickerVisible(false); if (event.type === 'set') { - const asMoment = moment(selectedDate, true); - onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); + onInputChange(format(selectedDate, CONST.DATE.FNS_FORMAT_STRING)); } }; @@ -39,7 +38,8 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain [showPicker], ); - const dateAsText = value || defaultValue ? moment(value || defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; + const date = value || defaultValue; + const dateAsText = date ? format(parseISO(date), CONST.DATE.FNS_FORMAT_STRING) : ''; return ( <> @@ -61,7 +61,7 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain /> {isPickerVisible && ( { setIsPickerVisible(false); - const asMoment = moment(selectedDate, true); - onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); + onInputChange(format(selectedDate, CONST.DATE.FNS_FORMAT_STRING)); }; /** @@ -77,7 +77,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca setSelectedDate(date); }; - const dateAsText = value || defaultValue ? moment(value || defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; + const dateAsText = dateValue ? format(parseISO(dateValue), CONST.DATE.FNS_FORMAT_STRING) : ''; return ( <> diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a5b282b22c73..33266242c5db 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,4 +1,4 @@ -import moment from 'moment'; +import {format, isValid, parseISO} from 'date-fns'; import React, {useEffect, useRef} from 'react'; import _ from 'underscore'; import TextInput from '@components/TextInput'; @@ -13,8 +13,8 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl useEffect(() => { // Adds nice native datepicker on web/desktop. Not possible to set this through props inputRef.current.setAttribute('type', 'date'); - inputRef.current.setAttribute('max', moment(maxDate).format(CONST.DATE.MOMENT_FORMAT_STRING)); - inputRef.current.setAttribute('min', moment(minDate).format(CONST.DATE.MOMENT_FORMAT_STRING)); + inputRef.current.setAttribute('max', format(new Date(maxDate), CONST.DATE.FNS_FORMAT_STRING)); + inputRef.current.setAttribute('min', format(new Date(minDate), CONST.DATE.FNS_FORMAT_STRING)); inputRef.current.classList.add('expensify-datepicker'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -29,9 +29,9 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl return; } - const asMoment = moment(text, true); - if (asMoment.isValid()) { - onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); + const date = parseISO(text); + if (isValid(date)) { + onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING)); } }; diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js new file mode 100644 index 000000000000..69c6b6767dae --- /dev/null +++ b/src/components/FlatList/MVCPFlatList.js @@ -0,0 +1,205 @@ +/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */ +import PropTypes from 'prop-types'; +import React from 'react'; +import {FlatList} from 'react-native'; + +function mergeRefs(...args) { + return function forwardRef(node) { + args.forEach((ref) => { + if (ref == null) { + return; + } + if (typeof ref === 'function') { + ref(node); + return; + } + if (typeof ref === 'object') { + // eslint-disable-next-line no-param-reassign + ref.current = node; + return; + } + console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`); + }); + }; +} + +function useMergeRefs(...args) { + return React.useMemo( + () => mergeRefs(...args), + // eslint-disable-next-line + [...args], + ); +} + +const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, inverted, onScroll, ...props}, forwardedRef) => { + const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {}; + const scrollRef = React.useRef(null); + const prevFirstVisibleOffsetRef = React.useRef(null); + const firstVisibleViewRef = React.useRef(null); + const mutationObserverRef = React.useRef(null); + const lastScrollOffsetRef = React.useRef(0); + + const getScrollOffset = React.useCallback(() => { + if (scrollRef.current == null) { + return 0; + } + return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop; + }, [horizontal]); + + const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []); + + const scrollToOffset = React.useCallback( + (offset, animated) => { + const behavior = animated ? 'smooth' : 'instant'; + scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + }, + [horizontal], + ); + + const prepareForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const contentView = getContentView(); + if (contentView == null) { + return; + } + + const scrollOffset = getScrollOffset(); + + const contentViewLength = contentView.childNodes.length; + for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { + const subview = contentView.childNodes[inverted ? contentViewLength - i - 1 : i]; + const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; + if (subviewOffset > scrollOffset || i === contentViewLength - 1) { + prevFirstVisibleOffsetRef.current = subviewOffset; + firstVisibleViewRef.current = subview; + break; + } + } + }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal, inverted]); + + const adjustForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const firstVisibleView = firstVisibleViewRef.current; + const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current; + if (firstVisibleView == null || prevFirstVisibleOffset == null) { + return; + } + + const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop; + const delta = firstVisibleViewOffset - prevFirstVisibleOffset; + if (Math.abs(delta) > 0.5) { + const scrollOffset = getScrollOffset(); + prevFirstVisibleOffsetRef.current = firstVisibleViewOffset; + scrollToOffset(scrollOffset + delta, false); + if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) { + scrollToOffset(0, true); + } + } + }, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]); + + const setupMutationObserver = React.useCallback(() => { + const contentView = getContentView(); + if (contentView == null) { + return; + } + + mutationObserverRef.current?.disconnect(); + + const mutationObserver = new MutationObserver(() => { + // Chrome adjusts scroll position when elements are added at the top of the + // view. We want to have the same behavior as react-native / Safari so we + // reset the scroll position to the last value we got from an event. + const lastScrollOffset = lastScrollOffsetRef.current; + const scrollOffset = getScrollOffset(); + if (lastScrollOffset !== scrollOffset) { + scrollToOffset(lastScrollOffset, false); + } + + // This needs to execute after scroll events are dispatched, but + // in the same tick to avoid flickering. rAF provides the right timing. + requestAnimationFrame(() => { + adjustForMaintainVisibleContentPosition(); + }); + }); + mutationObserver.observe(contentView, { + attributes: true, + childList: true, + subtree: true, + }); + + mutationObserverRef.current = mutationObserver; + }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); + + React.useEffect(() => { + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); + + const setMergedRef = useMergeRefs(scrollRef, forwardedRef); + + const onRef = React.useCallback( + (newRef) => { + // Make sure to only call refs and re-attach listeners if the node changed. + if (newRef == null || newRef === scrollRef.current) { + return; + } + + setMergedRef(newRef); + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, + [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver], + ); + + React.useEffect(() => { + const mutationObserver = mutationObserverRef.current; + return () => { + mutationObserver?.disconnect(); + }; + }, []); + + const onScrollInternal = React.useCallback( + (ev) => { + lastScrollOffsetRef.current = getScrollOffset(); + + prepareForMaintainVisibleContentPosition(); + + onScroll?.(ev); + }, + [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll], + ); + + return ( + + ); +}); + +MVCPFlatList.displayName = 'MVCPFlatList'; +MVCPFlatList.propTypes = { + maintainVisibleContentPosition: PropTypes.shape({ + minIndexForVisible: PropTypes.number.isRequired, + autoscrollToTopThreshold: PropTypes.number, + }), + horizontal: PropTypes.bool, +}; + +MVCPFlatList.defaultProps = { + maintainVisibleContentPosition: null, + horizontal: false, +}; + +export default MVCPFlatList; diff --git a/src/components/FlatList/index.web.js b/src/components/FlatList/index.web.js new file mode 100644 index 000000000000..7299776db9bc --- /dev/null +++ b/src/components/FlatList/index.web.js @@ -0,0 +1,3 @@ +import MVCPFlatList from './MVCPFlatList'; + +export default MVCPFlatList; diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 85408323c9f2..92baa9727832 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -71,8 +71,6 @@ const propTypes = { shouldValidateOnChange: PropTypes.bool, }; -const VALIDATE_DELAY = 200; - const defaultProps = { isSubmitButtonVisible: true, formState: { @@ -248,28 +246,19 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC // as this is already happening by the value prop. defaultValue: undefined, onTouched: (event) => { - setTimeout(() => { - setTouchedInput(inputID); - }, VALIDATE_DELAY); + setTouchedInput(inputID); if (_.isFunction(propsToParse.onTouched)) { propsToParse.onTouched(event); } }, onPress: (event) => { - setTimeout(() => { - setTouchedInput(inputID); - }, VALIDATE_DELAY); + setTouchedInput(inputID); if (_.isFunction(propsToParse.onPress)) { propsToParse.onPress(event); } }, - onPressOut: (event) => { - // To prevent validating just pressed inputs, we need to set the touched input right after - // onValidate and to do so, we need to delays setTouchedInput of the same amount of time - // as the onValidate is delayed - setTimeout(() => { - setTouchedInput(inputID); - }, VALIDATE_DELAY); + onPressIn: (event) => { + setTouchedInput(inputID); if (_.isFunction(propsToParse.onPressIn)) { propsToParse.onPressIn(event); } @@ -285,7 +274,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC if (shouldValidateOnBlur) { onValidate(inputValues, !hasServerError); } - }, VALIDATE_DELAY); + }, 200); } if (_.isFunction(propsToParse.onBlur)) { diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js index b2e6f4477e89..99237fd8db43 100644 --- a/src/components/Form/InputWrapper.js +++ b/src/components/Form/InputWrapper.js @@ -1,13 +1,12 @@ import PropTypes from 'prop-types'; import React, {forwardRef, useContext} from 'react'; -import refPropTypes from '@components/refPropTypes'; import FormContext from './FormContext'; const propTypes = { InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired, inputID: PropTypes.string.isRequired, valueType: PropTypes.string, - forwardedRef: refPropTypes, + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), }; const defaultProps = { diff --git a/src/components/FullscreenLoadingIndicator.js b/src/components/FullscreenLoadingIndicator.js deleted file mode 100644 index 42be33ef3843..000000000000 --- a/src/components/FullscreenLoadingIndicator.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {ActivityIndicator, StyleSheet, View} from 'react-native'; -import _ from 'underscore'; -import stylePropTypes from '@styles/stylePropTypes'; -import styles from '@styles/styles'; -import themeColors from '@styles/themes/default'; - -const propTypes = { - /** Additional style props */ - style: stylePropTypes, -}; - -const defaultProps = { - style: [], -}; - -function FullScreenLoadingIndicator(props) { - const additionalStyles = _.isArray(props.style) ? props.style : [props.style]; - return ( - - - - ); -} - -FullScreenLoadingIndicator.propTypes = propTypes; -FullScreenLoadingIndicator.defaultProps = defaultProps; -FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; - -export default FullScreenLoadingIndicator; diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx new file mode 100644 index 000000000000..b4483d2e0113 --- /dev/null +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {ActivityIndicator, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; + +type FullScreenLoadingIndicatorProps = { + style?: StyleProp; +}; + +function FullScreenLoadingIndicator({style}: FullScreenLoadingIndicatorProps) { + return ( + + + + ); +} + +FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; + +export default FullScreenLoadingIndicator; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index be70af0adb4f..9079a7f3c091 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -11,6 +11,7 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import * as Url from '@libs/Url'; import styles from '@styles/styles'; import * as Link from '@userActions/Link'; +import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -52,6 +53,10 @@ function AnchorRenderer(props) { // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) if (internalNewExpensifyPath && hasSameOrigin) { + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(internalNewExpensifyPath)) { + Session.signOutAndRedirectToSignIn(); + return; + } Navigation.navigate(internalNewExpensifyPath); return; } diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js index 54a3b0e7b07c..bece92e8fdfc 100644 --- a/src/components/IllustratedHeaderPageLayout.js +++ b/src/components/IllustratedHeaderPageLayout.js @@ -41,6 +41,7 @@ function IllustratedHeaderPageLayout({backgroundColor, children, illustration, f diff --git a/src/components/InlineErrorText.js b/src/components/InlineErrorText.js deleted file mode 100644 index 80438eea8b5f..000000000000 --- a/src/components/InlineErrorText.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import _ from 'underscore'; -import styles from '@styles/styles'; -import Text from './Text'; - -const propTypes = { - /** Text to display */ - children: PropTypes.string.isRequired, - - /** Styling for inline error text */ - // eslint-disable-next-line react/forbid-prop-types - styles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - styles: [], -}; - -function InlineErrorText(props) { - if (_.isEmpty(props.children)) { - return null; - } - - return {props.children}; -} - -InlineErrorText.propTypes = propTypes; -InlineErrorText.defaultProps = defaultProps; -InlineErrorText.displayName = 'InlineErrorText'; -export default InlineErrorText; diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index 14b781759904..7cfa72d9c712 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -130,6 +130,7 @@ InvertedFlatList.defaultProps = { contentContainerStyle: {}, onScroll: () => {}, }; +InvertedFlatList.displayName = 'InvertedFlatList'; const InvertedFlatListWithRef = forwardRef((props, ref) => ( 2; diff --git a/src/components/Lottie/Lottie.tsx b/src/components/Lottie/Lottie.tsx index cf689224278f..6ee3bb544ed7 100644 --- a/src/components/Lottie/Lottie.tsx +++ b/src/components/Lottie/Lottie.tsx @@ -2,13 +2,18 @@ import LottieView, {LottieViewProps} from 'lottie-react-native'; import React, {forwardRef} from 'react'; import styles from '@styles/styles'; -const Lottie = forwardRef((props: LottieViewProps, ref) => ( - -)); +const Lottie = forwardRef((props: LottieViewProps, ref) => { + const aspectRatioStyle = styles.aspectRatioLottie(props.source); + + return ( + + ); +}); export default Lottie; diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 585b7005ab1e..8119248c760d 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -355,6 +355,7 @@ function MagicCodeInput(props) { MagicCodeInput.propTypes = propTypes; MagicCodeInput.defaultProps = defaultProps; +MagicCodeInput.displayName = 'MagicCodeInput'; const MagicCodeInputWithRef = forwardRef((props, ref) => ( Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} @@ -712,6 +713,7 @@ function MoneyRequestConfirmationList(props) { shouldShowRightIcon={!props.isReadOnly} title={props.iouTag} description={policyTagListName} + numberOfLinesTitle={2} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem]} disabled={didConfirm} @@ -736,6 +738,7 @@ function MoneyRequestConfirmationList(props) { MoneyRequestConfirmationList.propTypes = propTypes; MoneyRequestConfirmationList.defaultProps = defaultProps; +MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList'; export default compose( withCurrentUserPersonalDetails, diff --git a/src/components/NewDatePicker/CalendarPicker/index.js b/src/components/NewDatePicker/CalendarPicker/index.js index 0300b4bf476f..4b17766feb17 100644 --- a/src/components/NewDatePicker/CalendarPicker/index.js +++ b/src/components/NewDatePicker/CalendarPicker/index.js @@ -1,5 +1,5 @@ +import {addMonths, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, subMonths} from 'date-fns'; import Str from 'expensify-common/lib/str'; -import moment from 'moment'; import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; @@ -8,6 +8,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import DateUtils from '@libs/DateUtils'; import getButtonState from '@libs/getButtonState'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; @@ -34,8 +35,8 @@ const propTypes = { const defaultProps = { value: new Date(), - minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(), - maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(), + minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), + maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), onSelected: () => {}, }; @@ -46,16 +47,15 @@ class CalendarPicker extends React.PureComponent { if (props.minDate >= props.maxDate) { throw new Error('Minimum date cannot be greater than the maximum date.'); } - - let currentDateView = moment(props.value, CONST.DATE.MOMENT_FORMAT_STRING).toDate(); + let currentDateView = new Date(props.value); if (props.maxDate < currentDateView) { currentDateView = props.maxDate; } else if (props.minDate > currentDateView) { currentDateView = props.minDate; } - const minYear = moment(this.props.minDate).year(); - const maxYear = moment(this.props.maxDate).year(); + const minYear = getYear(new Date(this.props.minDate)); + const maxYear = getYear(new Date(this.props.maxDate)); this.state = { currentDateView, @@ -79,7 +79,7 @@ class CalendarPicker extends React.PureComponent { onYearSelected(year) { this.setState((prev) => { - const newCurrentDateView = moment(prev.currentDateView).set('year', year).toDate(); + const newCurrentDateView = setYear(new Date(prev.currentDateView), year); return { currentDateView: newCurrentDateView, @@ -99,9 +99,9 @@ class CalendarPicker extends React.PureComponent { onDayPressed(day) { this.setState( (prev) => ({ - currentDateView: moment(prev.currentDateView).set('date', day).toDate(), + currentDateView: setDate(new Date(prev.currentDateView), day), }), - () => this.props.onSelected(moment(this.state.currentDateView).format('YYYY-MM-DD')), + () => this.props.onSelected(format(new Date(this.state.currentDateView), CONST.DATE.FNS_FORMAT_STRING)), ); } @@ -109,24 +109,24 @@ class CalendarPicker extends React.PureComponent { * Handles the user pressing the previous month arrow of the calendar picker. */ moveToPrevMonth() { - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).subtract(1, 'months').toDate()})); + this.setState((prev) => ({currentDateView: subMonths(new Date(prev.currentDateView), 1)})); } /** * Handles the user pressing the next month arrow of the calendar picker. */ moveToNextMonth() { - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).add(1, 'months').toDate()})); + this.setState((prev) => ({currentDateView: addMonths(new Date(prev.currentDateView), 1)})); } render() { - const monthNames = _.map(moment.localeData(this.props.preferredLocale).months(), Str.recapitalize); - const daysOfWeek = _.map(moment.localeData(this.props.preferredLocale).weekdays(), (day) => day.toUpperCase()); + const monthNames = _.map(DateUtils.getMonthNames(this.props.preferredLocale), Str.recapitalize); + const daysOfWeek = _.map(DateUtils.getDaysOfWeek(this.props.preferredLocale), (day) => day.toUpperCase()); const currentMonthView = this.state.currentDateView.getMonth(); const currentYearView = this.state.currentDateView.getFullYear(); const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); - const hasAvailableDatesNextMonth = moment(this.props.maxDate).endOf('month').endOf('day') >= moment(this.state.currentDateView).add(1, 'months'); - const hasAvailableDatesPrevMonth = moment(this.props.minDate).startOf('month').startOf('day') <= moment(this.state.currentDateView).subtract(1, 'months'); + const hasAvailableDatesNextMonth = startOfDay(endOfMonth(new Date(this.props.maxDate))) > addMonths(new Date(this.state.currentDateView), 1); + const hasAvailableDatesPrevMonth = startOfDay(new Date(this.props.minDate)) < endOfMonth(subMonths(new Date(this.state.currentDateView), 1)); return ( @@ -201,12 +201,11 @@ class CalendarPicker extends React.PureComponent { style={styles.flexRow} > {_.map(week, (day, index) => { - const currentDate = moment([currentYearView, currentMonthView, day]); - const isBeforeMinDate = currentDate < moment(this.props.minDate).startOf('day'); - const isAfterMaxDate = currentDate > moment(this.props.maxDate).startOf('day'); + const currentDate = new Date(currentYearView, currentMonthView, day); + const isBeforeMinDate = currentDate < startOfDay(new Date(this.props.minDate)); + const isAfterMaxDate = currentDate > startOfDay(new Date(this.props.maxDate)); const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; - const isSelected = moment(this.props.value).isSame(moment([currentYearView, currentMonthView, day]), 'day'); - + const isSelected = isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day)); return ( ({ - opacity: opacity.value, - })); - - React.useEffect(() => { - if (props.shouldDim) { - opacity.value = withTiming(props.dimmingValue, {duration: 50}); - } else { - opacity.value = withTiming(1, {duration: 50}); - } - }, [props.shouldDim, props.dimmingValue, opacity]); - - return ( - - {props.children} - - ); -} - -OpacityView.displayName = 'OpacityView'; -OpacityView.propTypes = propTypes; -OpacityView.defaultProps = defaultProps; -export default OpacityView; diff --git a/src/components/OpacityView.tsx b/src/components/OpacityView.tsx new file mode 100644 index 000000000000..6f82658bcac1 --- /dev/null +++ b/src/components/OpacityView.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; +import Animated, {AnimatedStyle, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import shouldRenderOffscreen from '@libs/shouldRenderOffscreen'; +import variables from '@styles/variables'; + +type OpacityViewProps = { + /** Should we dim the view */ + shouldDim: boolean; + + /** Content to render */ + children: React.ReactNode; + + /** + * Array of style objects + * @default [] + */ + style?: StyleProp>; + + /** + * The value to use for the opacity when the view is dimmed + * @default variables.hoverDimValue + */ + dimmingValue?: number; + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing?: boolean; +}; + +function OpacityView({shouldDim, children, style = [], dimmingValue = variables.hoverDimValue, needsOffscreenAlphaCompositing = false}: OpacityViewProps) { + const opacity = useSharedValue(1); + const opacityStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + + React.useEffect(() => { + if (shouldDim) { + opacity.value = withTiming(dimmingValue, {duration: 50}); + } else { + opacity.value = withTiming(1, {duration: 50}); + } + }, [shouldDim, dimmingValue, opacity]); + + return ( + + {children} + + ); +} + +OpacityView.displayName = 'OpacityView'; +export default OpacityView; diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 4939c5bac431..c2f272663c20 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -109,9 +109,20 @@ function OptionRow(props) { setIsDisabled(props.isDisabled); }, [props.isDisabled]); + const text = lodashGet(props.option, 'text', ''); + const fullTitle = props.isMultilineSupported ? text.trimStart() : text; + const indentsLength = text.length - fullTitle.length; + const paddingLeft = Math.floor(indentsLength / CONST.INDENTS.length) * styles.ml3.marginLeft; const textStyle = props.optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = props.boldStyle || props.option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; - const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, textUnreadStyle, props.style, styles.pre, isDisabled ? styles.optionRowDisabled : {}); + const displayNameStyle = StyleUtils.combineStyles( + styles.optionDisplayName, + textUnreadStyle, + props.style, + styles.pre, + isDisabled ? styles.optionRowDisabled : {}, + props.isMultilineSupported ? {paddingLeft} : {}, + ); const alternateTextStyle = StyleUtils.combineStyles( textStyle, styles.optionAlternateText, @@ -204,7 +215,7 @@ function OptionRow(props) { { + if (!title && shouldShow && !hideSectionHeaders && sectionHeaderStyle) { + return ; + } + if (title && shouldShow && !hideSectionHeaders) { return ( // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. // We do this so that we can reference the height in `getItemLayout` – // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. - + {title} ); diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 18b63b83b865..b841943f2402 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import optionPropTypes from '@components/optionPropTypes'; import SectionList from '@components/SectionList'; +import stylePropTypes from '@styles/stylePropTypes'; import styles from '@styles/styles'; const propTypes = { @@ -14,6 +15,9 @@ const propTypes = { /** Extra styles for the section list container */ contentContainerStyles: PropTypes.arrayOf(PropTypes.object), + /** Style for section headers */ + sectionHeaderStyle: stylePropTypes, + /** Sections for the section list */ sections: PropTypes.arrayOf( PropTypes.shape({ @@ -101,6 +105,7 @@ const propTypes = { const defaultProps = { optionHoveredStyle: undefined, contentContainerStyles: [], + sectionHeaderStyle: undefined, listContainerStyles: [styles.flex1], sections: [], focusedIndex: 0, diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 1fe443e2beab..fb312125efc0 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -427,6 +427,7 @@ class BaseOptionsSelector extends Component { } }} contentContainerStyles={[safeAreaPaddingBottomStyle, ...this.props.contentContainerStyles]} + sectionHeaderStyle={this.props.sectionHeaderStyle} listContainerStyles={this.props.listContainerStyles} listStyles={this.props.listStyles} isLoading={!this.props.shouldShowOptions} diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 5dab2f6016ae..37c15b6e3ae2 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import optionPropTypes from '@components/optionPropTypes'; +import stylePropTypes from '@styles/stylePropTypes'; import styles from '@styles/styles'; import CONST from '@src/CONST'; @@ -108,6 +109,9 @@ const propTypes = { /** Hover style for options in the OptionsList */ optionHoveredStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + /** Style for section headers */ + sectionHeaderStyle: stylePropTypes, + /** Whether to show options list */ shouldShowOptions: PropTypes.bool, @@ -159,6 +163,7 @@ const defaultProps = { shouldTextInputAppearBelowOptions: false, footerContent: undefined, optionHoveredStyle: styles.hoveredComponentBG, + sectionHeaderStyle: undefined, shouldShowOptions: true, disableArrowKeysActions: false, isDisabled: false, diff --git a/src/components/PopoverProvider/index.native.js b/src/components/PopoverProvider/index.native.tsx similarity index 63% rename from src/components/PopoverProvider/index.native.js rename to src/components/PopoverProvider/index.native.tsx index 400b42ddea20..a87036c61808 100644 --- a/src/components/PopoverProvider/index.native.js +++ b/src/components/PopoverProvider/index.native.tsx @@ -1,20 +1,14 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import {PopoverContextProps, PopoverContextValue} from './types'; -const propTypes = { - children: PropTypes.node.isRequired, -}; - -const defaultProps = {}; - -const PopoverContext = React.createContext({ +const PopoverContext = React.createContext({ onOpen: () => {}, popover: {}, close: () => {}, isOpen: false, }); -function PopoverContextProvider(props) { +function PopoverContextProvider(props: PopoverContextProps) { const contextValue = React.useMemo( () => ({ onOpen: () => {}, @@ -28,8 +22,6 @@ function PopoverContextProvider(props) { return {props.children}; } -PopoverContextProvider.defaultProps = defaultProps; -PopoverContextProvider.propTypes = propTypes; PopoverContextProvider.displayName = 'PopoverContextProvider'; export default PopoverContextProvider; diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.tsx similarity index 66% rename from src/components/PopoverProvider/index.js rename to src/components/PopoverProvider/index.tsx index 3e245faceeef..06345ebdbc1c 100644 --- a/src/components/PopoverProvider/index.js +++ b/src/components/PopoverProvider/index.tsx @@ -1,24 +1,18 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'; -const propTypes = { - children: PropTypes.node.isRequired, -}; - -const defaultProps = {}; - -const PopoverContext = React.createContext({ +const PopoverContext = React.createContext({ onOpen: () => {}, popover: {}, close: () => {}, isOpen: false, }); -function PopoverContextProvider(props) { +function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = React.useState(false); - const activePopoverRef = React.useRef(null); + const activePopoverRef = React.useRef(null); - const closePopover = React.useCallback((anchorRef) => { + const closePopover = React.useCallback((anchorRef?: React.RefObject) => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return; } @@ -32,17 +26,12 @@ function PopoverContextProvider(props) { }, []); React.useEffect(() => { - const listener = (e) => { - if ( - !activePopoverRef.current || - !activePopoverRef.current.ref || - !activePopoverRef.current.ref.current || - activePopoverRef.current.ref.current.contains(e.target) || - (activePopoverRef.current.anchorRef && activePopoverRef.current.anchorRef.current && activePopoverRef.current.anchorRef.current.contains(e.target)) - ) { + const listener = (e: Event) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (activePopoverRef.current?.ref?.current?.contains(e.target as Node) || activePopoverRef.current?.anchorRef?.current?.contains(e.target as Node)) { return; } - const ref = activePopoverRef.current.anchorRef; + const ref = activePopoverRef.current?.anchorRef; closePopover(ref); }; document.addEventListener('click', listener, true); @@ -52,8 +41,8 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = (e) => { - if (!activePopoverRef.current || !activePopoverRef.current.ref || !activePopoverRef.current.ref.current || activePopoverRef.current.ref.current.contains(e.target)) { + const listener = (e: Event) => { + if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { return; } closePopover(); @@ -65,7 +54,7 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = (e) => { + const listener = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; } @@ -91,8 +80,8 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = (e) => { - if (activePopoverRef.current && activePopoverRef.current.ref && activePopoverRef.current.ref.current && activePopoverRef.current.ref.current.contains(e.target)) { + const listener = (e: Event) => { + if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { return; } @@ -105,12 +94,12 @@ function PopoverContextProvider(props) { }, [closePopover]); const onOpen = React.useCallback( - (popoverParams) => { - if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams.ref) { + (popoverParams: AnchorRef) => { + if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) { closePopover(activePopoverRef.current.anchorRef); } activePopoverRef.current = popoverParams; - if (popoverParams && popoverParams.onOpenCallback) { + if (popoverParams?.onOpenCallback) { popoverParams.onOpenCallback(); } setIsOpen(true); @@ -131,8 +120,6 @@ function PopoverContextProvider(props) { return {props.children}; } -PopoverContextProvider.defaultProps = defaultProps; -PopoverContextProvider.propTypes = propTypes; PopoverContextProvider.displayName = 'PopoverContextProvider'; export default PopoverContextProvider; diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts new file mode 100644 index 000000000000..ffd0087cd5ff --- /dev/null +++ b/src/components/PopoverProvider/types.ts @@ -0,0 +1,20 @@ +type PopoverContextProps = { + children: React.ReactNode; +}; + +type PopoverContextValue = { + onOpen?: (popoverParams: AnchorRef) => void; + popover?: AnchorRef | Record | null; + close: (anchorRef?: React.RefObject) => void; + isOpen: boolean; +}; + +type AnchorRef = { + ref: React.RefObject; + close: (anchorRef?: React.RefObject) => void; + anchorRef: React.RefObject; + onOpenCallback?: () => void; + onCloseCallback?: () => void; +}; + +export type {PopoverContextProps, PopoverContextValue, AnchorRef}; diff --git a/src/components/Pressable/PressableWithDelayToggle.js b/src/components/Pressable/PressableWithDelayToggle.js index 7113afff8bdc..c9f05e5adfee 100644 --- a/src/components/Pressable/PressableWithDelayToggle.js +++ b/src/components/Pressable/PressableWithDelayToggle.js @@ -140,6 +140,7 @@ function PressableWithDelayToggle(props) { PressableWithDelayToggle.propTypes = propTypes; PressableWithDelayToggle.defaultProps = defaultProps; +PressableWithDelayToggle.displayName = 'PressableWithDelayToggle'; const PressableWithDelayToggleWithRef = React.forwardRef((props, ref) => ( {translate('reimbursementAccountLoadingAnimation.explanationLine')} diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 97043fbd055d..3d696747de3d 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -274,7 +274,9 @@ function MoneyRequestPreview(props) { ) : ( - {getPreviewHeaderText() + (isSettled ? ` • ${getSettledMessage()}` : '')} + + {getPreviewHeaderText() + (isSettled ? ` • ${getSettledMessage()}` : '')} + {hasFieldErrors && ( {_.map(shownImages, ({thumbnail, image, transaction}, index) => { @@ -89,7 +93,16 @@ function ReportActionItemImages({images, size, total, isHovered}) { {isLastImage && remaining > 0 && ( - + + + {remaining > MAX_REMAINING ? `${MAX_REMAINING}+` : `+${remaining}`} )} diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 63ece9fcb3e1..af5b1e25f2a9 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -7,17 +7,21 @@ import _ from 'underscore'; import Checkbox from '@components/Checkbox'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import {usePersonalDetails} from '@components/OnyxProvider'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import refPropTypes from '@components/refPropTypes'; import RenderHTML from '@components/RenderHTML'; +import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; +import ControlSelection from '@libs/ControlSelection'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; import * as Session from '@userActions/Session'; @@ -27,9 +31,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; const propTypes = { - /** All personal details asssociated with user */ - personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - /** The ID of the associated taskReport */ taskReportID: PropTypes.string.isRequired, @@ -52,6 +53,16 @@ const propTypes = { ownerAccountID: PropTypes.number, }), + /** The chat report associated with taskReport */ + chatReportID: PropTypes.string.isRequired, + + /** Popover context menu anchor, used for showing context menu */ + contextMenuAnchor: refPropTypes, + + /** Callback for updating context menu active state, used for showing context menu */ + checkIfContextMenuActive: PropTypes.func, + + /* Onyx Props */ ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -59,12 +70,12 @@ const propTypes = { const defaultProps = { ...withCurrentUserPersonalDetailsDefaultProps, - personalDetailsList: {}, taskReport: {}, isHovered: false, }; function TaskPreview(props) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; // The reportAction might not contain details regarding the taskReport // Only the direct parent reportAction will contain details about the taskReport // Other linked reportActions will only contain the taskReportID and we will grab the details from there @@ -73,8 +84,8 @@ function TaskPreview(props) { : props.action.childStateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.action.childStatusNum === CONST.REPORT.STATUS.APPROVED; const taskTitle = props.taskReport.reportName || props.action.childReportName; const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(props.taskReport) || props.action.childManagerAccountID; - const assigneeLogin = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'login'], ''); - const assigneeDisplayName = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'displayName'], ''); + const assigneeLogin = lodashGet(personalDetails, [taskAssigneeAccountID, 'login'], ''); + const assigneeDisplayName = lodashGet(personalDetails, [taskAssigneeAccountID, 'displayName'], ''); const taskAssignee = assigneeDisplayName || LocalePhoneNumber.formatPhoneNumber(assigneeLogin); const htmlForTaskPreview = taskAssignee && taskAssigneeAccountID !== 0 @@ -90,6 +101,9 @@ function TaskPreview(props) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.taskReportID))} + onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onLongPress={(event) => showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive)} style={[styles.flexRow, styles.justifyContentBetween]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={props.translate('task.task')} @@ -132,9 +146,5 @@ export default compose( key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`, initialValue: {}, }, - personalDetailsList: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - initialValue: {}, - }, }), )(TaskPreview); diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index e1a6d4d4f6c1..213d41c94a3c 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -82,11 +82,7 @@ function ReportWelcomeText(props) { {isPolicyExpenseChat && ( <> {props.translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartOne')} - - {/* Use the policyExpenseChat owner's first name or their display name if it's undefined or an empty string */} - {lodashGet(props.personalDetails, [props.report.ownerAccountID, 'firstName']) || - lodashGet(props.personalDetails, [props.report.ownerAccountID, 'displayName'], '')} - + {ReportUtils.getDisplayNameForParticipant(props.report.ownerAccountID)} {props.translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartTwo')} {ReportUtils.getPolicyName(props.report)} {props.translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartThree')} diff --git a/src/components/SVGImage/index.js b/src/components/SVGImage/index.js deleted file mode 100644 index de915007cc29..000000000000 --- a/src/components/SVGImage/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import {Image} from 'react-native'; -import * as StyleUtils from '@styles/StyleUtils'; -import propTypes from './propTypes'; - -function SVGImage(props) { - return ( - - ); -} - -SVGImage.propTypes = propTypes; -SVGImage.displayName = 'SVGImage'; - -export default SVGImage; diff --git a/src/components/SVGImage/index.native.js b/src/components/SVGImage/index.native.js deleted file mode 100644 index 78b1f8ef7e78..000000000000 --- a/src/components/SVGImage/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import {SvgCssUri} from 'react-native-svg'; -import propTypes from './propTypes'; - -function SVGImage(props) { - return ( - - ); -} - -SVGImage.propTypes = propTypes; -SVGImage.displayName = 'SVGImage'; - -export default SVGImage; diff --git a/src/components/SVGImage/propTypes.js b/src/components/SVGImage/propTypes.js deleted file mode 100644 index 4e02ad42fde9..000000000000 --- a/src/components/SVGImage/propTypes.js +++ /dev/null @@ -1,17 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** The asset to render. */ - src: PropTypes.string.isRequired, - - /** The width of the image. */ - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - - /** The height of the image. */ - height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - - /** The resize mode of the image. */ - resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'repeat', 'center']), -}; - -export default propTypes; diff --git a/src/components/SafeAreaConsumer.js b/src/components/SafeAreaConsumer.tsx similarity index 55% rename from src/components/SafeAreaConsumer.js rename to src/components/SafeAreaConsumer.tsx index 25f22ed61ec4..7df73dbdb65f 100644 --- a/src/components/SafeAreaConsumer.js +++ b/src/components/SafeAreaConsumer.tsx @@ -1,29 +1,34 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import type {DimensionValue} from 'react-native'; +import {EdgeInsets, SafeAreaInsetsContext} from 'react-native-safe-area-context'; import * as StyleUtils from '@styles/StyleUtils'; -const propTypes = { - /** Children to render. */ - children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, +type ChildrenProps = { + paddingTop?: DimensionValue; + paddingBottom?: DimensionValue; + insets?: EdgeInsets; + safeAreaPaddingBottomStyle: { + paddingBottom?: DimensionValue; + }; +}; + +type SafeAreaConsumerProps = { + children: React.FC; }; /** * This component is a light wrapper around the SafeAreaInsetsContext.Consumer. There are several places where we * may need not just the insets, but the computed styles so we save a few lines of code with this. - * - * @param {Object} props - * @returns {React.Component} */ -function SafeAreaConsumer(props) { +function SafeAreaConsumer({children}: SafeAreaConsumerProps) { return ( {(insets) => { - const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets); - return props.children({ + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + return children({ paddingTop, paddingBottom, - insets, + insets: insets ?? undefined, safeAreaPaddingBottomStyle: {paddingBottom}, }); }} @@ -32,5 +37,5 @@ function SafeAreaConsumer(props) { } SafeAreaConsumer.displayName = 'SafeAreaConsumer'; -SafeAreaConsumer.propTypes = propTypes; + export default SafeAreaConsumer; diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index 0ade615423b8..4563c7149e97 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -11,6 +11,7 @@ import OfflineIndicator from '@components/OfflineIndicator'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import TestToolsModal from '@components/TestToolsModal'; import useEnvironment from '@hooks/useEnvironment'; +import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useKeyboardState from '@hooks/useKeyboardState'; import useNetwork from '@hooks/useNetwork'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -22,6 +23,7 @@ import {defaultProps, propTypes} from './propTypes'; function ScreenWrapper({ shouldEnableMaxHeight, + shouldEnableMinHeight, includePaddingTop, keyboardAvoidingViewBehavior, includeSafeAreaPaddingBottom, @@ -37,12 +39,14 @@ function ScreenWrapper({ testID, }) { const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {initialHeight} = useInitialDimensions(); const keyboardState = useKeyboardState(); const {isDevelopment} = useEnvironment(); const {isOffline} = useNetwork(); const navigation = useNavigation(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined; + const minHeight = shouldEnableMinHeight ? initialHeight : undefined; const isKeyboardShown = lodashGet(keyboardState, 'isKeyboardShown', false); const panResponder = useRef( @@ -125,7 +129,7 @@ function ScreenWrapper({ {...keyboardDissmissPanResponder.panHandlers} > diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js index 76c0f81975e4..c98968bb112b 100644 --- a/src/components/ScreenWrapper/propTypes.js +++ b/src/components/ScreenWrapper/propTypes.js @@ -37,6 +37,9 @@ const propTypes = { /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ shouldEnableMaxHeight: PropTypes.bool, + /** Whether to use the minHeight. Use true for screens where the window height are changing because of Virtual Keyboard */ + shouldEnableMinHeight: PropTypes.bool, + /** Array of additional styles for header gap */ headerGapStyles: PropTypes.arrayOf(PropTypes.object), diff --git a/src/components/SplashScreenHider/index.js b/src/components/SplashScreenHider/index.js deleted file mode 100644 index 9bbddd2a0891..000000000000 --- a/src/components/SplashScreenHider/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import {useEffect} from 'react'; -import BootSplash from '@libs/BootSplash'; - -const propTypes = { - /** Splash screen has been hidden */ - onHide: PropTypes.func, -}; - -const defaultProps = { - onHide: () => {}, -}; - -function SplashScreenHider(props) { - const {onHide} = props; - - useEffect(() => { - BootSplash.hide().then(() => onHide()); - }, [onHide]); - - return null; -} - -SplashScreenHider.displayName = 'SplashScreenHider'; -SplashScreenHider.propTypes = propTypes; -SplashScreenHider.defaultProps = defaultProps; - -export default SplashScreenHider; diff --git a/src/components/SplashScreenHider/index.native.js b/src/components/SplashScreenHider/index.native.tsx similarity index 81% rename from src/components/SplashScreenHider/index.native.js rename to src/components/SplashScreenHider/index.native.tsx index 711ce9f6fb80..8c8fa2a4013f 100644 --- a/src/components/SplashScreenHider/index.native.js +++ b/src/components/SplashScreenHider/index.native.tsx @@ -1,33 +1,22 @@ -import PropTypes from 'prop-types'; import {useCallback, useRef} from 'react'; -import {StyleSheet} from 'react-native'; +import {StyleSheet, ViewStyle} from 'react-native'; import Reanimated, {Easing, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Logo from '@assets/images/new-expensify-dark.svg'; import BootSplash from '@libs/BootSplash'; import styles from '@styles/styles'; +import type SplashScreenHiderProps from './types'; -const propTypes = { - /** Splash screen has been hidden */ - onHide: PropTypes.func, -}; - -const defaultProps = { - onHide: () => {}, -}; - -function SplashScreenHider(props) { - const {onHide} = props; - +function SplashScreenHider({onHide = () => {}}: SplashScreenHiderProps) { const logoSizeRatio = BootSplash.logoSizeRatio || 1; const navigationBarHeight = BootSplash.navigationBarHeight || 0; const opacity = useSharedValue(1); const scale = useSharedValue(1); - const opacityStyle = useAnimatedStyle(() => ({ + const opacityStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); - const scaleStyle = useAnimatedStyle(() => ({ + const scaleStyle = useAnimatedStyle(() => ({ transform: [{scale: scale.value}], })); @@ -83,7 +72,5 @@ function SplashScreenHider(props) { } SplashScreenHider.displayName = 'SplashScreenHider'; -SplashScreenHider.propTypes = propTypes; -SplashScreenHider.defaultProps = defaultProps; export default SplashScreenHider; diff --git a/src/components/SplashScreenHider/index.tsx b/src/components/SplashScreenHider/index.tsx new file mode 100644 index 000000000000..d3f5c52c1e3e --- /dev/null +++ b/src/components/SplashScreenHider/index.tsx @@ -0,0 +1,15 @@ +import {useEffect} from 'react'; +import BootSplash from '@libs/BootSplash'; +import type SplashScreenHiderProps from './types'; + +function SplashScreenHider({onHide = () => {}}: SplashScreenHiderProps) { + useEffect(() => { + BootSplash.hide().then(() => onHide()); + }, [onHide]); + + return null; +} + +SplashScreenHider.displayName = 'SplashScreenHider'; + +export default SplashScreenHider; diff --git a/src/components/SplashScreenHider/types.ts b/src/components/SplashScreenHider/types.ts new file mode 100644 index 000000000000..4ea25da93290 --- /dev/null +++ b/src/components/SplashScreenHider/types.ts @@ -0,0 +1,6 @@ +type SplashScreenHiderProps = { + /** Splash screen has been hidden */ + onHide: () => void; +}; + +export default SplashScreenHiderProps; diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index d680cea15c8f..65aa71c7ac06 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -58,6 +58,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm return ( ; type OnyxProps = { - /** Personal details of all the users, including current user */ - personalDetails: OnyxEntry>; - /** Session of the current user */ session: OnyxEntry; }; @@ -34,8 +33,9 @@ export default function ( WrappedComponent: ComponentType>, ): ComponentType & RefAttributes, keyof OnyxProps>> { function WithCurrentUserPersonalDetails(props: Omit, ref: ForwardedRef) { + const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; const accountID = props.session?.accountID ?? 0; - const accountPersonalDetails = props.personalDetails?.[accountID]; + const accountPersonalDetails = personalDetails?.[accountID]; const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo( () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}), [accountPersonalDetails, accountID], @@ -55,9 +55,6 @@ export default function ( const withCurrentUserPersonalDetails = React.forwardRef(WithCurrentUserPersonalDetails); return withOnyx & RefAttributes, OnyxProps>({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, session: { key: ONYXKEYS.SESSION, }, diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js deleted file mode 100644 index a03c1155fa46..000000000000 --- a/src/components/withNavigationFallback.js +++ /dev/null @@ -1,47 +0,0 @@ -import {NavigationContext} from '@react-navigation/core'; -import React, {forwardRef, useContext, useMemo} from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -export default function (WrappedComponent) { - function WithNavigationFallback(props) { - const context = useContext(NavigationContext); - - const navigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []); - - return context ? ( - - ) : ( - - - - ); - } - WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; - WithNavigationFallback.propTypes = { - forwardedRef: refPropTypes, - }; - WithNavigationFallback.defaultProps = { - forwardedRef: undefined, - }; - - const WithNavigationFallbackWithRef = forwardRef((props, ref) => ( - - )); - - WithNavigationFallbackWithRef.displayName = `WithNavigationFallbackWithRef`; - - return WithNavigationFallbackWithRef; -} diff --git a/src/components/withNavigationFallback.tsx b/src/components/withNavigationFallback.tsx new file mode 100644 index 000000000000..aa58b12d4b01 --- /dev/null +++ b/src/components/withNavigationFallback.tsx @@ -0,0 +1,49 @@ +import {NavigationContext} from '@react-navigation/core'; +import {NavigationProp} from '@react-navigation/native'; +import {ParamListBase} from '@react-navigation/routers'; +import React, {ComponentType, ForwardedRef, forwardRef, ReactElement, RefAttributes, useContext, useMemo} from 'react'; + +type AddListenerCallback = () => void; + +type RemoveListenerCallback = () => void; + +type NavigationContextValue = { + isFocused: () => boolean; + addListener: () => AddListenerCallback; + removeListener: () => RemoveListenerCallback; +}; + +export default function (WrappedComponent: ComponentType>): (props: TProps & RefAttributes) => ReactElement | null { + function WithNavigationFallback(props: TProps, ref: ForwardedRef) { + const context = useContext(NavigationContext); + + const navigationContextValue: NavigationContextValue = useMemo( + () => ({ + isFocused: () => true, + addListener: () => () => {}, + removeListener: () => () => {}, + }), + [], + ); + + return context ? ( + + ) : ( + }> + + + ); + } + + WithNavigationFallback.displayName = 'WithNavigationFocusWithFallback'; + + return forwardRef(WithNavigationFallback); +} diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js deleted file mode 100644 index 04c6ab8e8481..000000000000 --- a/src/components/withToggleVisibilityView.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import styles from '@styles/styles'; -import refPropTypes from './refPropTypes'; - -const toggleVisibilityViewPropTypes = { - /** Whether the content is visible. */ - isVisible: PropTypes.bool, -}; - -export default function (WrappedComponent) { - function WithToggleVisibilityView(props) { - return ( - - - - ); - } - - WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; - WithToggleVisibilityView.propTypes = { - forwardedRef: refPropTypes, - - /** Whether the content is visible. */ - isVisible: PropTypes.bool, - }; - WithToggleVisibilityView.defaultProps = { - forwardedRef: undefined, - isVisible: false, - }; - - const WithToggleVisibilityViewWithRef = React.forwardRef((props, ref) => ( - - )); - - WithToggleVisibilityViewWithRef.displayName = `WithToggleVisibilityViewWithRef`; - - return WithToggleVisibilityViewWithRef; -} - -export {toggleVisibilityViewPropTypes}; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx new file mode 100644 index 000000000000..5e0204f6e06f --- /dev/null +++ b/src/components/withToggleVisibilityView.tsx @@ -0,0 +1,30 @@ +import React, {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react'; +import {View} from 'react-native'; +import {SetOptional} from 'type-fest'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import styles from '@styles/styles'; + +type ToggleVisibilityViewProps = { + /** Whether the content is visible. */ + isVisible: boolean; +}; + +export default function withToggleVisibilityView( + WrappedComponent: ComponentType>, +): (props: TProps & RefAttributes) => ReactElement | null { + function WithToggleVisibilityView({isVisible = false, ...rest}: SetOptional, ref: ForwardedRef) { + return ( + + + + ); + } + + WithToggleVisibilityView.displayName = `WithToggleVisibilityViewWithRef(${getComponentDisplayName(WrappedComponent)})`; + return React.forwardRef(WithToggleVisibilityView); +} diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js index 874f9d72b276..62a919925a53 100644 --- a/src/hooks/useDebounce.js +++ b/src/hooks/useDebounce.js @@ -1,5 +1,5 @@ import lodashDebounce from 'lodash/debounce'; -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; /** * Create and return a debounced function. @@ -27,11 +27,13 @@ export default function useDebounce(func, wait, options) { return debouncedFn.cancel; }, [func, wait, leading, maxWait, trailing]); - return (...args) => { + const debounceCallback = useCallback((...args) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }; + }, []); + + return debounceCallback; } diff --git a/src/hooks/useInitialWindowDimensions/index.js b/src/hooks/useInitialWindowDimensions/index.js new file mode 100644 index 000000000000..487b4e498228 --- /dev/null +++ b/src/hooks/useInitialWindowDimensions/index.js @@ -0,0 +1,59 @@ +// eslint-disable-next-line no-restricted-imports +import {useEffect, useState} from 'react'; +import {Dimensions} from 'react-native'; +import {initialWindowMetrics} from 'react-native-safe-area-context'; + +/** + * A convenience hook that provides initial size (width and height). + * An initial height allows to know the real height of window, + * while the standard useWindowDimensions hook return the height minus Virtual keyboard height + * @returns {Object} with information about initial width and height + */ +export default function () { + const [dimensions, setDimensions] = useState(() => { + const window = Dimensions.get('window'); + const screen = Dimensions.get('screen'); + + return { + screenHeight: screen.height, + screenWidth: screen.width, + initialHeight: window.height, + initialWidth: window.width, + }; + }); + + useEffect(() => { + const onDimensionChange = (newDimensions) => { + const {window, screen} = newDimensions; + + setDimensions((oldState) => { + if (screen.width !== oldState.screenWidth || screen.height !== oldState.screenHeight || window.height > oldState.initialHeight) { + return { + initialHeight: window.height, + initialWidth: window.width, + screenHeight: screen.height, + screenWidth: screen.width, + }; + } + + return oldState; + }); + }; + + const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChange); + + return () => { + if (!dimensionsEventListener) { + return; + } + dimensionsEventListener.remove(); + }; + }, []); + + const bottomInset = initialWindowMetrics && initialWindowMetrics.insets && initialWindowMetrics.insets.bottom ? initialWindowMetrics.insets.bottom : 0; + + return { + initialWidth: dimensions.initialWidth, + initialHeight: dimensions.initialHeight - bottomInset, + }; +} diff --git a/src/languages/en.ts b/src/languages/en.ts index 30b8b36ecbdf..c186a1fffedf 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -594,6 +594,7 @@ export default { genericSmartscanFailureMessage: 'Transaction is missing fields', duplicateWaypointsErrorMessage: 'Please remove duplicate waypoints', emptyWaypointsErrorMessage: 'Please enter at least two waypoints', + splitBillMultipleParticipantsErrorMessage: 'Split bill is only allowed between a single workspace or individual users. Please update your selection.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`, enableWallet: 'Enable Wallet', @@ -640,11 +641,8 @@ export default { }, loungeAccessPage: { loungeAccess: 'Lounge access', - headline: 'You qualify for access to our exclusive lounges.', - description: 'The Expensify Lounge is where a "high-end airport lounge" meets a vibrant "co-working space" optimized for like-minded individuals.', - coffeePromo: 'Great coffee and cocktails', - networkingPromo: 'Network with other members', - viewsPromo: 'Incredible views of San Francisco', + headline: 'The Expensify Lounge is closed.', + description: "The Expensify Lounge in San Francisco is closed for the time being, but we'll update this page when it reopens!", }, pronounsPage: { pronouns: 'Pronouns', @@ -1846,7 +1844,7 @@ export default { levelThreeResult: 'Message removed from channel plus anonymous warning and message is reported for review.', }, teachersUnitePage: { - teachersUnite: 'Teachers unite!', + teachersUnite: 'Teachers Unite', joinExpensifyOrg: 'Join Expensify.org in eliminating injustice around the world and help teachers split their expenses for classrooms in need!', iKnowATeacher: 'I know a teacher', iAmATeacher: 'I am a teacher', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0c33b93079d2..a0a30bcf4141 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -479,7 +479,7 @@ export default { buttonSearch: 'Buscar', buttonMySettings: 'Mi configuración', fabNewChat: 'Iniciar chat', - fabNewChatExplained: 'Iniciar chat', + fabNewChatExplained: 'Iniciar chat (Acción flotante)', chatPinned: 'Chat fijado', draftedMessage: 'Mensaje borrador', listOfChatMessages: 'Lista de mensajes del chat', @@ -588,6 +588,7 @@ export default { genericSmartscanFailureMessage: 'La transacción tiene campos vacíos', duplicateWaypointsErrorMessage: 'Por favor elimina los puntos de ruta duplicados', emptyWaypointsErrorMessage: 'Por favor introduce al menos dos puntos de ruta', + splitBillMultipleParticipantsErrorMessage: 'Solo puedes dividir una cuenta entre un único espacio de trabajo o con usuarios individuales. Por favor actualiza tu selección.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`, enableWallet: 'Habilitar Billetera', @@ -634,12 +635,8 @@ export default { }, loungeAccessPage: { loungeAccess: 'Acceso a la sala vip', - headline: 'Podrás acceder a nuestras salas vip exclusivas.', - description: - 'La sala vip Expensify es el punto de encuentro entre una "sala vip de aeropuerto de alta gama" y un vibrante "espacio de co-working" optimizado para personas con ideas afines.', - coffeePromo: 'Buen café y buenos cócteles', - networkingPromo: 'Conecta con otros miembros', - viewsPromo: 'Increíbles vistas de San Francisco', + headline: 'La sala vip de Expensify está cerrada.', + description: 'La sala vip de Expensify está actualmente cerrada, pero actualizaremos esta página cuando vuelva a abrir.', }, pronounsPage: { pronouns: 'Pronombres', @@ -2330,7 +2327,7 @@ export default { levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.', }, teachersUnitePage: { - teachersUnite: '¡Profesores unidos!', + teachersUnite: 'Profesores Unidos', joinExpensifyOrg: 'Únete a Expensify.org para eliminar la injusticia en todo el mundo y ayuda a los profesores a dividir sus gastos para las aulas más necesitadas.', iKnowATeacher: 'Yo conozco a un profesor', iAmATeacher: 'Soy profesor', diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js deleted file mode 100644 index fe79e38585c4..000000000000 --- a/src/libs/Clipboard/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -import Clipboard from '@react-native-clipboard/clipboard'; - -/** - * Sets a string on the Clipboard object via @react-native-clipboard/clipboard - * - * @param {String} text - */ -const setString = (text) => { - Clipboard.setString(text); -}; - -export default { - setString, - - // We don't want to set HTML on native platforms so noop them. - canSetHtml: () => false, - setHtml: () => {}, -}; diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts new file mode 100644 index 000000000000..f78c5e4ab230 --- /dev/null +++ b/src/libs/Clipboard/index.native.ts @@ -0,0 +1,19 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import {CanSetHtml, SetHtml, SetString} from './types'; + +/** + * Sets a string on the Clipboard object via @react-native-clipboard/clipboard + */ +const setString: SetString = (text) => { + Clipboard.setString(text); +}; + +// We don't want to set HTML on native platforms so noop them. +const canSetHtml: CanSetHtml = () => false; +const setHtml: SetHtml = () => {}; + +export default { + setString, + canSetHtml, + setHtml, +}; diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.ts similarity index 60% rename from src/libs/Clipboard/index.js rename to src/libs/Clipboard/index.ts index ff05fcb45d0a..b703b0b4d7f5 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.ts @@ -1,16 +1,34 @@ import Clipboard from '@react-native-clipboard/clipboard'; -import lodashGet from 'lodash/get'; import * as Browser from '@libs/Browser'; import CONST from '@src/CONST'; +import {CanSetHtml, SetHtml, SetString} from './types'; -const canSetHtml = () => lodashGet(navigator, 'clipboard.write'); +type ComposerSelection = { + start: number; + end: number; + direction: 'forward' | 'backward' | 'none'; +}; + +type AnchorSelection = { + anchorOffset: number; + focusOffset: number; + anchorNode: Node; + focusNode: Node; +}; + +type NullableObject = {[K in keyof T]: T[K] | null}; + +type OriginalSelection = ComposerSelection | NullableObject; + +const canSetHtml: CanSetHtml = + () => + (...args: ClipboardItems) => + navigator?.clipboard?.write([...args]); /** * Deprecated method to write the content as HTML to clipboard. - * @param {String} html HTML representation - * @param {String} text Plain text representation */ -function setHTMLSync(html, text) { +function setHTMLSync(html: string, text: string) { const node = document.createElement('span'); node.textContent = html; node.style.all = 'unset'; @@ -21,16 +39,21 @@ function setHTMLSync(html, text) { node.addEventListener('copy', (e) => { e.stopPropagation(); e.preventDefault(); - e.clipboardData.clearData(); - e.clipboardData.setData('text/html', html); - e.clipboardData.setData('text/plain', text); + e.clipboardData?.clearData(); + e.clipboardData?.setData('text/html', html); + e.clipboardData?.setData('text/plain', text); }); document.body.appendChild(node); - const selection = window.getSelection(); - const firstAnchorChild = selection.anchorNode && selection.anchorNode.firstChild; + const selection = window?.getSelection(); + + if (selection === null) { + return; + } + + const firstAnchorChild = selection.anchorNode?.firstChild; const isComposer = firstAnchorChild instanceof HTMLTextAreaElement; - let originalSelection = null; + let originalSelection: OriginalSelection | null = null; if (isComposer) { originalSelection = { start: firstAnchorChild.selectionStart, @@ -60,10 +83,14 @@ function setHTMLSync(html, text) { selection.removeAllRanges(); - if (isComposer) { + const anchorSelection = originalSelection as AnchorSelection; + + if (isComposer && 'start' in originalSelection) { firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction); - } else { - selection.setBaseAndExtent(originalSelection.anchorNode, originalSelection.anchorOffset, originalSelection.focusNode, originalSelection.focusOffset); + } else if (anchorSelection.anchorNode && anchorSelection.focusNode) { + // When copying to the clipboard here, the original values of anchorNode and focusNode will be null since there will be no user selection. + // We are adding a check to prevent null values from being passed to setBaseAndExtent, in accordance with the standards of the Selection API as outlined here: https://w3c.github.io/selection-api/#dom-selection-setbaseandextent. + selection.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset); } document.body.removeChild(node); @@ -71,10 +98,8 @@ function setHTMLSync(html, text) { /** * Writes the content as HTML if the web client supports it. - * @param {String} html HTML representation - * @param {String} text Plain text representation */ -const setHtml = (html, text) => { +const setHtml: SetHtml = (html: string, text: string) => { if (!html || !text) { return; } @@ -91,8 +116,8 @@ const setHtml = (html, text) => { setHTMLSync(html, text); } else { navigator.clipboard.write([ - // eslint-disable-next-line no-undef new ClipboardItem({ + /* eslint-disable @typescript-eslint/naming-convention */ 'text/html': new Blob([html], {type: 'text/html'}), 'text/plain': new Blob([text], {type: 'text/plain'}), }), @@ -102,10 +127,8 @@ const setHtml = (html, text) => { /** * Sets a string on the Clipboard object via react-native-web - * - * @param {String} text */ -const setString = (text) => { +const setString: SetString = (text) => { Clipboard.setString(text); }; diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts new file mode 100644 index 000000000000..1d899144a2ba --- /dev/null +++ b/src/libs/Clipboard/types.ts @@ -0,0 +1,5 @@ +type SetString = (text: string) => void; +type SetHtml = (html: string, text: string) => void; +type CanSetHtml = (() => (...args: ClipboardItems) => Promise) | (() => boolean); + +export type {SetString, CanSetHtml, SetHtml}; diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 5a7da7ca08cf..58e1efa7aa65 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -32,7 +32,11 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo */ function getCommonSuffixLength(str1: string, str2: string): number { let i = 0; - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { + if (str1.length === 0 || str2.length === 0) { + return 0; + } + const minLen = Math.min(str1.length, str2.length); + while (i < minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { i++; } return i; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 13853189ed26..965d85134968 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1,5 +1,7 @@ import { addDays, + eachDayOfInterval, + eachMonthOfInterval, endOfDay, endOfWeek, format, @@ -255,6 +257,38 @@ function getCurrentTimezone(): Required { return timezone; } +/** + * @returns [January, Fabruary, March, April, May, June, July, August, ...] + */ +function getMonthNames(preferredLocale: string): string[] { + if (preferredLocale) { + setLocale(preferredLocale); + } + const fullYear = new Date().getFullYear(); + const monthsArray = eachMonthOfInterval({ + start: new Date(fullYear, 0, 1), // January 1st of the current year + end: new Date(fullYear, 11, 31), // December 31st of the current year + }); + + // eslint-disable-next-line rulesdir/prefer-underscore-method + return monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT)); +} + +/** + * @returns [Monday, Thuesday, Wednesday, ...] + */ +function getDaysOfWeek(preferredLocale: string): string[] { + if (preferredLocale) { + setLocale(preferredLocale); + } + const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week + const endOfCurrentWeek = endOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week + const daysOfWeek = eachDayOfInterval({start: startOfCurrentWeek, end: endOfCurrentWeek}); + + // eslint-disable-next-line rulesdir/prefer-underscore-method + return daysOfWeek.map((date) => format(date, 'eeee')); +} + // Used to throttle updates to the timezone when necessary let lastUpdatedTimezoneTime = new Date(); @@ -373,6 +407,8 @@ const DateUtils = { isToday, isTomorrow, isYesterday, + getMonthNames, + getDaysOfWeek, formatWithUTCTimeZone, }; diff --git a/src/libs/DistanceRequestUtils.js b/src/libs/DistanceRequestUtils.js index 0cc4e39d83af..0f994cc54f93 100644 --- a/src/libs/DistanceRequestUtils.js +++ b/src/libs/DistanceRequestUtils.js @@ -90,7 +90,7 @@ const getDistanceMerchant = (hasRoute, distanceInMeters, unit, rate, currency, t const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); const unitString = distanceInUnits === 1 ? singularDistanceUnit : distanceUnit; const ratePerUnit = rate ? PolicyUtils.getUnitRateValue({rate}, toLocaleDigit) : translate('common.tbd'); - const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; + const currencySymbol = rate ? CurrencyUtils.getCurrencySymbol(currency) || `${currency} ` : ''; return `${distanceInUnits} ${unitString} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; }; diff --git a/src/libs/E2E/apiMocks/openApp.js b/src/libs/E2E/apiMocks/openApp.js index d50f4462cfd9..5e77d3912441 100644 --- a/src/libs/E2E/apiMocks/openApp.js +++ b/src/libs/E2E/apiMocks/openApp.js @@ -2131,7 +2131,7 @@ export default () => ({ report_2543745284790730: { reportID: '2543745284790730', ownerAccountID: 17, - managerEmail: 'fake6@gmail.com', + managerID: 16, currency: 'USD', chatReportID: '98817646', state: 'SUBMITTED', @@ -2143,7 +2143,7 @@ export default () => ({ report_4249286573496381: { reportID: '4249286573496381', ownerAccountID: 17, - managerEmail: 'christoph+hightraffic@margelo.io', + managerID: 21, currency: 'USD', chatReportID: '4867098979334014', state: 'SUBMITTED', diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js deleted file mode 100644 index 20baf44b23f4..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js +++ /dev/null @@ -1,36 +0,0 @@ -import {createStackNavigator} from '@react-navigation/stack'; -import React from 'react'; -import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; -import getCurrentUrl from '@libs/Navigation/currentUrl'; -import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; -import styles from '@styles/styles'; -import SCREENS from '@src/SCREENS'; - -const Stack = createStackNavigator(); - -const url = getCurrentUrl(); -const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; - -function CentralPaneNavigator() { - return ( - - - - - - ); -} - -export default CentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js new file mode 100644 index 000000000000..a1646011e560 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js @@ -0,0 +1,33 @@ +import {createStackNavigator} from '@react-navigation/stack'; +import React from 'react'; +import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; +import styles from '@styles/styles'; +import SCREENS from '@src/SCREENS'; + +const Stack = createStackNavigator(); + +const url = getCurrentUrl(); +const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; + +function BaseCentralPaneNavigator() { + return ( + + + + ); +} + +export default BaseCentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js new file mode 100644 index 000000000000..711dd468c77d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import BaseCentralPaneNavigator from './BaseCentralPaneNavigator'; + +// We don't need to use freeze wraper on web because we don't render all report routes anyway. +// You can see this optimalization in the customStackNavigator. +function CentralPaneNavigator() { + return ; +} + +export default CentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js new file mode 100644 index 000000000000..45ab2f070717 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js @@ -0,0 +1,13 @@ +import React from 'react'; +import FreezeWrapper from '@libs/Navigation/FreezeWrapper'; +import BaseCentralPaneNavigator from './BaseCentralPaneNavigator'; + +function CentralPaneNavigator() { + return ( + + + + ); +} + +export default CentralPaneNavigator; diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js index ae36f4aff9ad..194b86259107 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js @@ -1,8 +1,9 @@ import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; import {StackView} from '@react-navigation/stack'; import PropTypes from 'prop-types'; -import React, {useRef} from 'react'; +import React, {useMemo, useRef} from 'react'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import NAVIGATORS from '@src/NAVIGATORS'; import CustomRouter from './CustomRouter'; const propTypes = { @@ -25,6 +26,24 @@ const defaultProps = { screenOptions: undefined, }; +function splitRoutes(routes) { + const reportRoutes = []; + const rhpRoutes = []; + const otherRoutes = []; + + routes.forEach((route) => { + if (route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR) { + reportRoutes.push(route); + } else if (route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + rhpRoutes.push(route); + } else { + otherRoutes.push(route); + } + }); + + return {reportRoutes, rhpRoutes, otherRoutes}; +} + function ResponsiveStackNavigator(props) { const {isSmallScreenWidth} = useWindowDimensions(); @@ -40,12 +59,25 @@ function ResponsiveStackNavigator(props) { getIsSmallScreenWidth: () => isSmallScreenWidthRef.current, }); + const stateToRender = useMemo(() => { + const {reportRoutes, rhpRoutes, otherRoutes} = splitRoutes(state.routes); + + // Remove all report routes except the last 3. This will improve performance. + const limitedReportRoutes = reportRoutes.slice(-3); + + return { + ...state, + index: otherRoutes.length + limitedReportRoutes.length + rhpRoutes.length - 1, + routes: [...otherRoutes, ...limitedReportRoutes, ...rhpRoutes], + }; + }, [state]); + return ( diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js new file mode 100644 index 000000000000..ae36f4aff9ad --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js @@ -0,0 +1,60 @@ +import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native'; +import {StackView} from '@react-navigation/stack'; +import PropTypes from 'prop-types'; +import React, {useRef} from 'react'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import CustomRouter from './CustomRouter'; + +const propTypes = { + /* Determines if the navigator should render the StackView (narrow) or ThreePaneView (wide) */ + isSmallScreenWidth: PropTypes.bool.isRequired, + + /* Children for the useNavigationBuilder hook */ + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, + + /* initialRouteName for this navigator */ + initialRouteName: PropTypes.oneOf([PropTypes.string, PropTypes.undefined]), + + /* Screen options defined for this navigator */ + // eslint-disable-next-line react/forbid-prop-types + screenOptions: PropTypes.object, +}; + +const defaultProps = { + initialRouteName: undefined, + screenOptions: undefined, +}; + +function ResponsiveStackNavigator(props) { + const {isSmallScreenWidth} = useWindowDimensions(); + + const isSmallScreenWidthRef = useRef(isSmallScreenWidth); + + isSmallScreenWidthRef.current = isSmallScreenWidth; + + const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder(CustomRouter, { + children: props.children, + screenOptions: props.screenOptions, + initialRouteName: props.initialRouteName, + // Options for useNavigationBuilder won't update on prop change, so we need to pass a getter for the router to have the current state of isSmallScreenWidth. + getIsSmallScreenWidth: () => isSmallScreenWidthRef.current, + }); + + return ( + + + + ); +} + +ResponsiveStackNavigator.defaultProps = defaultProps; +ResponsiveStackNavigator.propTypes = propTypes; +ResponsiveStackNavigator.displayName = 'ResponsiveStackNavigator'; + +export default createNavigatorFactory(ResponsiveStackNavigator); diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 74382c8b9065..54d09b75eff2 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -833,7 +833,7 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt categorySections.push({ // "Search" section title: '', - shouldShow: false, + shouldShow: true, indexOffset, data: getCategoryOptionTree(searchCategories, true), }); @@ -857,7 +857,7 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt categorySections.push({ // "Selected" section title: '', - shouldShow: false, + shouldShow: true, indexOffset, data: getCategoryOptionTree(selectedOptions, true), }); @@ -962,7 +962,7 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput tagSections.push({ // "Search" section title: '', - shouldShow: false, + shouldShow: true, indexOffset, data: getTagsOptions(searchTags), }); @@ -1004,7 +1004,7 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput tagSections.push({ // "Selected" section title: '', - shouldShow: false, + shouldShow: true, indexOffset, data: getTagsOptions(selectedTagOptions), }); diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 2eb1d3b02f25..5200e5803ee3 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -46,6 +46,10 @@ function canUseTags(betas: Beta[]): boolean { return betas?.includes(CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas); } +function canUseViolations(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.VIOLATIONS) || canUseAllBetas(betas); +} + /** * Link previews are temporarily disabled. */ @@ -64,4 +68,5 @@ export default { canUseCustomStatus, canUseTags, canUseLinkPreviews, + canUseViolations, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 21c29a07ebae..1e3fc5297193 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -475,6 +475,16 @@ function isChatThread(report) { return isThread(report) && report.type === CONST.REPORT.TYPE.CHAT; } +/** + * Returns true if report is a DM/Group DM chat. + * + * @param {Object} report + * @returns {Boolean} + */ +function isDM(report) { + return !getChatType(report); +} + /** * Only returns true if this is our main 1:1 DM report with Concierge * @@ -514,6 +524,24 @@ function isExpensifyOnlyParticipantInReport(report) { return reportParticipants.length === 1 && _.some(reportParticipants, (accountID) => _.contains(CONST.EXPENSIFY_ACCOUNT_IDS, accountID)); } +/** + * Returns whether a given report can have tasks created in it. + * We only prevent the task option if it's a DM/group-DM and the other users are all special Expensify accounts + * + * @param {Object} report + * @returns {Boolean} + */ +function canCreateTaskInReport(report) { + const otherReportParticipants = _.without(lodashGet(report, 'participantAccountIDs', []), currentUserAccountID); + const areExpensifyAccountsOnlyOtherParticipants = + otherReportParticipants.length >= 1 && _.every(otherReportParticipants, (accountID) => _.contains(CONST.EXPENSIFY_ACCOUNT_IDS, accountID)); + if (areExpensifyAccountsOnlyOtherParticipants && isDM(report)) { + return false; + } + + return true; +} + /** * Returns true if there are any Expensify accounts (i.e. with domain 'expensify.com') in the set of accountIDs * by cross-referencing the accountIDs with personalDetails. @@ -646,16 +674,6 @@ function isPolicyAdmin(policyID, policies) { return policyRole === CONST.POLICY.ROLE.ADMIN; } -/** - * Returns true if report is a DM/Group DM chat. - * - * @param {Object} report - * @returns {Boolean} - */ -function isDM(report) { - return !getChatType(report); -} - /** * Returns true if report has a single participant. * @@ -772,6 +790,27 @@ function isMoneyRequestReport(reportOrID) { return isIOUReport(report) || isExpenseReport(report); } +/** + * Should return true only for personal 1:1 report + * + * @param {Object} report (chatReport or iouReport) + * @returns {boolean} + */ +function isOneOnOneChat(report) { + const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); + return ( + !isThread(report) && + !isChatRoom(report) && + !isExpenseRequest(report) && + !isMoneyRequestReport(report) && + !isPolicyExpenseChat(report) && + !isTaskReport(report) && + isDM(report) && + !isIOUReport(report) && + participantAccountIDs.length === 1 + ); +} + /** * Get the report given a reportID * @@ -1191,6 +1230,7 @@ function getPersonalDetailsForAccountID(accountID) { return ( (allPersonalDetails && allPersonalDetails[accountID]) || { avatar: UserUtils.getDefaultAvatar(accountID), + isOptimisticPersonalDetail: true, } ); } @@ -1200,28 +1240,39 @@ function getPersonalDetailsForAccountID(accountID) { * * @param {Number} accountID * @param {Boolean} [shouldUseShortForm] + * @param {Boolean} shouldFallbackToHidden * @returns {String} */ -function getDisplayNameForParticipant(accountID, shouldUseShortForm = false) { +function getDisplayNameForParticipant(accountID, shouldUseShortForm = false, shouldFallbackToHidden = true) { if (!accountID) { return ''; } const personalDetails = getPersonalDetailsForAccountID(accountID); + // this is to check if account is an invite/optimistically created one + // and prevent from falling back to 'Hidden', so a correct value is shown + // when searching for a new user + if (lodashGet(personalDetails, 'isOptimisticPersonalDetail') === true) { + return personalDetails.login || ''; + } const longName = personalDetails.displayName; const shortName = personalDetails.firstName || longName; + if (!longName && !personalDetails.login && shouldFallbackToHidden) { + return Localize.translateLocal('common.hidden'); + } return shouldUseShortForm ? shortName : longName; } /** * @param {Object} personalDetailsList * @param {Boolean} isMultipleParticipantReport + * @param {Boolean} shouldFallbackToHidden * @returns {Array} */ -function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantReport) { +function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantReport, shouldFallbackToHidden = true) { return _.chain(personalDetailsList) .map((user) => { const accountID = Number(user.accountID); - const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport) || user.login || ''; + const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport, shouldFallbackToHidden) || user.login || ''; const avatar = UserUtils.getDefaultAvatar(accountID); let pronouns = user.pronouns; @@ -1324,61 +1375,70 @@ function getLastVisibleMessage(reportID, actionsToMerge = {}) { } /** - * Determines if a report has an IOU that is waiting for an action from the current user (either Pay or Add a credit bank account) + * Checks if a report is an open task report assigned to current user. * - * @param {Object} report (chatReport or iouReport) - * @returns {boolean} + * @param {Object} report + * @param {Object} [parentReportAction] - The parent report action of the report (Used to check if the task has been canceled) + * @returns {Boolean} */ -function isWaitingForIOUActionFromCurrentUser(report) { +function isWaitingForAssigneeToCompleteTask(report, parentReportAction = {}) { + return isTaskReport(report) && isReportManager(report) && isOpenTaskReport(report, parentReportAction); +} + +/** + * @param {Object} report + * @returns {Boolean} + */ +function isUnreadWithMention(report) { if (!report) { return false; } - if (isArchivedRoom(getReport(report.parentReportID))) { + // lastMentionedTime and lastReadTime are both datetime strings and can be compared directly + const lastMentionedTime = report.lastMentionedTime || ''; + const lastReadTime = report.lastReadTime || ''; + return lastReadTime < lastMentionedTime; +} + +/** + * Determines if the option requires action from the current user. This can happen when it: + - is unread and the user was mentioned in one of the unread comments + - is for an outstanding task waiting on the user + - has an outstanding child money request that is waiting for an action from the current user (e.g. pay, approve, add bank account) + * + * @param {Object} option (report or optionItem) + * @param {Object} parentReportAction (the report action the current report is a thread of) + * @returns {boolean} + */ +function requiresAttentionFromCurrentUser(option, parentReportAction = {}) { + if (!option) { return false; } - const policy = getPolicy(report.policyID); - if (policy.type === CONST.POLICY.TYPE.CORPORATE) { - // If the report is already settled, there's no action required from any user. - if (isSettled(report.reportID)) { - return false; - } + if (isArchivedRoom(option)) { + return false; + } - // Report is pending approval and the current user is the manager - if (isReportManager(report) && !isReportApproved(report)) { - return true; - } + if (isArchivedRoom(getReport(option.parentReportID))) { + return false; + } - // Current user is an admin and the report has been approved but not settled yet - return policy.role === CONST.POLICY.ROLE.ADMIN && isReportApproved(report); + if (option.isUnreadWithMention || isUnreadWithMention(option)) { + return true; } - // Money request waiting for current user to add their credit bank account - // hasOutstandingIOU will be false if the user paid, but isWaitingOnBankAccount will be true if user don't have a wallet or bank account setup - if (!report.hasOutstandingIOU && report.isWaitingOnBankAccount && report.ownerAccountID === currentUserAccountID) { + if (isWaitingForAssigneeToCompleteTask(option, parentReportAction)) { return true; } - // Money request waiting for current user to Pay (from expense or iou report) - if (report.hasOutstandingIOU && report.ownerAccountID && (report.ownerAccountID !== currentUserAccountID || currentUserAccountID === report.managerID)) { + // Has a child report that is awaiting action (e.g. approve, pay, add bank account) from current user + if (option.hasOutstandingChildRequest) { return true; } return false; } -/** - * Checks if a report is an open task report assigned to current user. - * - * @param {Object} report - * @param {Object} parentReportAction - The parent report action of the report (Used to check if the task has been canceled) - * @returns {Boolean} - */ -function isWaitingForTaskCompleteFromAssignee(report, parentReportAction = {}) { - return isTaskReport(report) && isReportManager(report) && isOpenTaskReport(report, parentReportAction); -} - /** * Returns number of transactions that are nonReimbursable * @@ -1432,15 +1492,18 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { } if (moneyRequestReport) { let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0); - let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0); + let totalSpend = lodashGet(moneyRequestReport, 'total', 0); - if (nonReimbursableSpend + reimbursableSpend !== 0) { + if (nonReimbursableSpend + totalSpend !== 0) { // There is a possibility that if the Expense report has a negative total. // This is because there are instances where you can get a credit back on your card, // or you enter a negative expense to “offset” future expenses nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend); - reimbursableSpend = isExpenseReport(moneyRequestReport) ? reimbursableSpend * -1 : Math.abs(reimbursableSpend); - const totalDisplaySpend = nonReimbursableSpend + reimbursableSpend; + totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend); + + const totalDisplaySpend = totalSpend; + const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend; + return { nonReimbursableSpend, reimbursableSpend, @@ -2135,7 +2198,7 @@ function getParentNavigationSubtitle(report) { function navigateToDetailsPage(report) { const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); - if (isDM(report) && participantAccountIDs.length === 1) { + if (isOneOnOneChat(report)) { Navigation.navigate(ROUTES.PROFILE.getRoute(participantAccountIDs[0])); return; } @@ -2297,13 +2360,12 @@ function getOptimisticDataForParentReportAction(reportID, lastVisibleActionCreat * Builds an optimistic reportAction for the parent report when a task is created * @param {String} taskReportID - Report ID of the task * @param {String} taskTitle - Title of the task - * @param {String} taskAssignee - Email of the person assigned to the task * @param {Number} taskAssigneeAccountID - AccountID of the person assigned to the task * @param {String} text - Text of the comment * @param {String} parentReportID - Report ID of the parent report * @returns {Object} */ -function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssignee, taskAssigneeAccountID, text, parentReportID) { +function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssigneeAccountID, text, parentReportID) { const reportAction = buildOptimisticAddCommentReportAction(text); reportAction.reportAction.message[0].taskReportID = taskReportID; @@ -3115,21 +3177,6 @@ function isUnread(report) { return lastReadTime < lastVisibleActionCreated; } -/** - * @param {Object} report - * @returns {Boolean} - */ -function isUnreadWithMention(report) { - if (!report) { - return false; - } - - // lastMentionedTime and lastReadTime are both datetime strings and can be compared directly - const lastMentionedTime = report.lastMentionedTime || ''; - const lastReadTime = report.lastReadTime || ''; - return lastReadTime < lastMentionedTime; -} - /** * @param {Object} report * @param {Object} allReportsDict @@ -3152,29 +3199,6 @@ function isIOUOwnedByCurrentUser(report, allReportsDict = null) { return reportToLook.ownerAccountID === currentUserAccountID; } -/** - * Should return true only for personal 1:1 report - * - * @param {Object} report (chatReport or iouReport) - * @returns {boolean} - */ -function isOneOnOneChat(report) { - const isChatRoomValue = lodashGet(report, 'isChatRoom', false); - const participantsListValue = lodashGet(report, 'participantsList', []); - return ( - !isThread(report) && - !isChatRoom(report) && - !isChatRoomValue && - !isExpenseRequest(report) && - !isMoneyRequestReport(report) && - !isPolicyExpenseChat(report) && - !isTaskReport(report) && - isDM(report) && - !isIOUReport(report) && - participantsListValue.length === 1 - ); -} - /** * Assuming the passed in report is a default room, lets us know whether we can see it or not, based on permissions and * the various subsets of users we've allowed to use default rooms. @@ -3296,8 +3320,8 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, return true; } - // Include reports that are relevant to the user in any view mode. Criteria include having a draft, having an outstanding IOU, or being assigned to an open task. - if (report.hasDraft || isWaitingForIOUActionFromCurrentUser(report) || isWaitingForTaskCompleteFromAssignee(report)) { + // Include reports that are relevant to the user in any view mode. Criteria include having a draft or having a GBR showing. + if (report.hasDraft || requiresAttentionFromCurrentUser(report)) { return true; } const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(report.reportID); @@ -3650,15 +3674,16 @@ function getMoneyRequestOptions(report, reportParticipants) { const participants = _.filter(reportParticipants, (accountID) => currentUserPersonalDetails.accountID !== accountID); - // Verify if there is any of the expensify accounts amongst the participants in which case user cannot take IOU actions on such report - const hasExcludedIOUAccountIDs = lodashIntersection(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS).length > 0; - const hasSingleParticipantInReport = participants.length === 1; - const hasMultipleParticipants = participants.length > 1; - - if (hasExcludedIOUAccountIDs) { + // We don't allow IOU actions if an Expensify account is a participant of the report, unless the policy that the report is on is owned by an Expensify account + const doParticipantsIncludeExpensifyAccounts = lodashIntersection(reportParticipants, CONST.EXPENSIFY_ACCOUNT_IDS).length > 0; + const isPolicyOwnedByExpensifyAccounts = report.policyID ? CONST.EXPENSIFY_ACCOUNT_IDS.includes(getPolicy(report.policyID).ownerAccountID || 0) : false; + if (doParticipantsIncludeExpensifyAccounts && !isPolicyOwnedByExpensifyAccounts) { return []; } + const hasSingleParticipantInReport = participants.length === 1; + const hasMultipleParticipants = participants.length > 1; + // User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option // unless there are no participants at all (e.g. #admins room for a policy with only 1 admin) // DM chats will have the Split Bill option only when there are at least 3 people in the chat. @@ -3905,7 +3930,6 @@ function shouldDisableRename(report, policy) { /** * Returns the onyx data needed for the task assignee chat * @param {Number} accountID - * @param {String} assigneeEmail * @param {Number} assigneeAccountID * @param {String} taskReportID * @param {String} assigneeChatReportID @@ -3914,7 +3938,7 @@ function shouldDisableRename(report, policy) { * @param {Object} assigneeChatReport * @returns {Object} */ -function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) { +function getTaskAssigneeChatOnyxData(accountID, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) { // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task let optimisticAssigneeAddComment; // Set if this is a new chat that needs to be created for the assignee @@ -3982,7 +4006,7 @@ function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID // If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned if (assigneeChatReportID !== parentReportID) { const displayname = lodashGet(allPersonalDetails, [assigneeAccountID, 'displayName']) || lodashGet(allPersonalDetails, [assigneeAccountID, 'login'], ''); - optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `assigned to ${displayname}`, parentReportID); + optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `assigned to ${displayname}`, parentReportID); const lastAssigneeCommentText = formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text); const optimisticAssigneeReport = { lastVisibleActionCreated: currentTime, @@ -4052,7 +4076,8 @@ function getIOUReportActionDisplayMessage(reportAction) { const originalMessage = _.get(reportAction, 'originalMessage', {}); let displayMessage; if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { - const {amount, currency, IOUReportID} = originalMessage; + const {IOUReportID} = originalMessage; + const {amount, currency} = originalMessage.IOUDetails || originalMessage; const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); const iouReport = getReport(IOUReportID); const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID, true); @@ -4074,10 +4099,17 @@ function getIOUReportActionDisplayMessage(reportAction) { const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID); const {amount, currency, comment} = getTransactionDetails(transaction); const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); - displayMessage = Localize.translateLocal('iou.requestedAmount', { - formattedAmount, - comment, - }); + const isRequestSettled = isSettled(originalMessage.IOUReportID); + if (isRequestSettled) { + displayMessage = Localize.translateLocal('iou.payerSettled', { + amount: formattedAmount, + }); + } else { + displayMessage = Localize.translateLocal('iou.requestedAmount', { + formattedAmount, + comment, + }); + } } return displayMessage; } @@ -4147,6 +4179,7 @@ export { getPolicyType, isArchivedRoom, isExpensifyOnlyParticipantInReport, + canCreateTaskInReport, isPolicyExpenseChatAdmin, isPolicyAdmin, isPublicRoom, @@ -4155,7 +4188,7 @@ export { isCurrentUserTheOnlyParticipant, hasAutomatedExpensifyAccountIDs, hasExpensifyGuidesEmails, - isWaitingForIOUActionFromCurrentUser, + requiresAttentionFromCurrentUser, isIOUOwnedByCurrentUser, getMoneyRequestReimbursableTotal, getMoneyRequestSpendBreakdown, @@ -4275,10 +4308,11 @@ export { hasNonReimbursableTransactions, hasMissingSmartscanFields, getIOUReportActionDisplayMessage, - isWaitingForTaskCompleteFromAssignee, + isWaitingForAssigneeToCompleteTask, isGroupChat, isReportDraft, shouldUseFullTitleToDisplay, parseReportRouteParams, getReimbursementQueuedActionMessage, + getPersonalDetailsForAccountID, }; diff --git a/src/libs/SelectionScraper/index.ts b/src/libs/SelectionScraper/index.ts index 1f62f83e1c91..01b9db4b4a43 100644 --- a/src/libs/SelectionScraper/index.ts +++ b/src/libs/SelectionScraper/index.ts @@ -5,7 +5,7 @@ import {parseDocument} from 'htmlparser2'; import CONST from '@src/CONST'; import GetCurrentSelection from './types'; -const elementsWillBeSkipped = ['html', 'body']; +const markdownElements = ['h1', 'strong', 'em', 'del', 'blockquote', 'q', 'code', 'pre', 'a', 'br', 'li', 'ul', 'ol', 'b', 'i', 's']; const tagAttribute = 'data-testid'; /** @@ -113,10 +113,9 @@ const replaceNodes = (dom: Node, isChildOfEditorElement: boolean): Node => { data = Str.htmlEncode(dom.data); } else if (dom instanceof Element) { domName = dom.name; - // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data - // has no meaning for us. if (dom.attribs?.[tagAttribute]) { - if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { + // If it's a markdown element, rename it according to the value of data-testid, so ExpensiMark can parse it + if (markdownElements.includes(dom.attribs[tagAttribute])) { domName = dom.attribs[tagAttribute]; } } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement) { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index a0b676bdffff..80ed96d25d65 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -38,6 +38,7 @@ Onyx.connect({ const reportActionsForDisplay = actionsArray.filter( (reportAction, actionKey) => ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) && + !ReportActionsUtils.isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); @@ -168,26 +169,23 @@ function getOrderedReportIDs( report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); }); - // The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: - // 1. Pinned - Always sorted by reportDisplayName - // 2. Outstanding IOUs - Always sorted by iouReportAmount with the largest amounts at the top of the group - // 3. Drafts - Always sorted by reportDisplayName - // 4. Non-archived reports and settled IOUs + // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: + // 1. Pinned/GBR - Always sorted by reportDisplayName + // 2. Drafts - Always sorted by reportDisplayName + // 3. Non-archived reports and settled IOUs // - Sorted by lastVisibleActionCreated in default (most recent) view mode // - Sorted by reportDisplayName in GSD (focus) view mode - // 5. Archived reports + // 4. Archived reports // - Sorted by lastVisibleActionCreated in default (most recent) view mode // - Sorted by reportDisplayName in GSD (focus) view mode - const pinnedReports: Report[] = []; - const outstandingIOUReports: Report[] = []; + const pinnedAndGBRReports: Report[] = []; const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; reportsToDisplay.forEach((report) => { - if (report.isPinned) { - pinnedReports.push(report); - } else if (ReportUtils.isWaitingForIOUActionFromCurrentUser(report)) { - outstandingIOUReports.push(report); + const isPinned = report.isPinned ?? false; + if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) { + pinnedAndGBRReports.push(report); } else if (report.hasDraft) { draftReports.push(report); } else if (ReportUtils.isArchivedRoom(report)) { @@ -198,12 +196,7 @@ function getOrderedReportIDs( }); // Sort each group of reports accordingly - pinnedReports.sort((a, b) => (a?.displayName && b?.displayName ? a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) : 0)); - outstandingIOUReports.sort((a, b) => { - const compareAmounts = a?.iouReportAmount && b?.iouReportAmount ? b.iouReportAmount - a.iouReportAmount : 0; - const compareDisplayNames = a?.displayName && b?.displayName ? a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) : 0; - return compareAmounts || compareDisplayNames; - }); + pinnedAndGBRReports.sort((a, b) => (a?.displayName && b?.displayName ? a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) : 0)); draftReports.sort((a, b) => (a?.displayName && b?.displayName ? a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()) : 0)); if (isInDefaultMode) { @@ -221,7 +214,7 @@ function getOrderedReportIDs( // Now that we have all the reports grouped and sorted, they must be flattened into an array and only return the reportID. // The order the arrays are concatenated in matters and will determine the order that the groups are displayed in the sidebar. - const LHNReports = [...pinnedReports, ...outstandingIOUReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); + const LHNReports = [...pinnedAndGBRReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); setWithLimit(reportIDsCache, cachedReportsKey, LHNReports); return LHNReports; } @@ -254,6 +247,7 @@ type OptionData = { searchText?: string | null; isPinned?: boolean | null; hasOutstandingIOU?: boolean | null; + hasOutstandingChildRequest?: boolean | null; iouReportID?: string | null; isIOUReportOwner?: boolean | null; iouReportAmount?: number | null; @@ -267,8 +261,8 @@ type OptionData = { isAllowedToComment?: boolean | null; isThread?: boolean | null; isTaskReport?: boolean | null; - isWaitingForTaskCompleteFromAssignee?: boolean | null; parentReportID?: string | null; + parentReportAction?: ReportAction; notificationPreference?: string | number | null; displayNamesWithTooltips?: DisplayNamesWithTooltip[] | null; chatType?: ValueOf | null; @@ -338,6 +332,7 @@ function getOptionData( searchText: null, isPinned: false, hasOutstandingIOU: false, + hasOutstandingChildRequest: false, iouReportID: null, isIOUReportOwner: null, iouReportAmount: 0, @@ -357,9 +352,7 @@ function getOptionData( result.isThread = ReportUtils.isChatThread(report); result.isChatRoom = ReportUtils.isChatRoom(report); result.isTaskReport = ReportUtils.isTaskReport(report); - if (result.isTaskReport) { - result.isWaitingForTaskCompleteFromAssignee = ReportUtils.isWaitingForTaskCompleteFromAssignee(report, parentReportAction); - } + result.parentReportAction = parentReportAction; result.isArchivedRoom = ReportUtils.isArchivedRoom(report); result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); result.isExpenseRequest = ReportUtils.isExpenseRequest(report); @@ -382,6 +375,7 @@ function getOptionData( result.keyForList = String(report.reportID); result.tooltipText = ReportUtils.getReportParticipantsTitle(report.participantAccountIDs ?? []); result.hasOutstandingIOU = report.hasOutstandingIOU; + result.hasOutstandingChildRequest = report.hasOutstandingChildRequest; result.parentReportID = report.parentReportID ?? null; result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.notificationPreference = report.notificationPreference ?? null; @@ -459,7 +453,7 @@ function getOptionData( } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { - result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } } else { if (!lastMessageText) { diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js index b3fde73aef6f..5e94a7ed2b65 100644 --- a/src/libs/actions/Card.js +++ b/src/libs/actions/Card.js @@ -160,7 +160,7 @@ function clearCardListErrors(cardID) { function revealVirtualCardDetails(cardID) { return new Promise((resolve, reject) => { // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects('RevealVirtualCardDetails', {cardID}) + API.makeRequestWithSideEffects('RevealExpensifyCardDetails', {cardID}) .then((response) => { if (response.jsonCode !== CONST.JSON_CODE.SUCCESS) { reject(); diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 2d471a0ca26c..19ac03228753 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -557,8 +557,9 @@ function getMoneyRequestInformation( iouReport.parentReportActionID = reportPreviewAction.reportActionID; } + const shouldCreateOptimisticPersonalDetails = isNewChatReport && !allPersonalDetails[payerAccountID]; // Add optimistic personal details for participant - const optimisticPersonalDetailListAction = isNewChatReport + const optimisticPersonalDetailListAction = shouldCreateOptimisticPersonalDetails ? { [payerAccountID]: { accountID: payerAccountID, @@ -1802,7 +1803,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC // Update the last message of the chat report const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport); const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { - payer: updatedMoneyRequestReport.managerEmail, + payer: ReportUtils.getPersonalDetailsForAccountID(updatedMoneyRequestReport.managerID).login || '', amount: CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedMoneyRequestReport.currency), }); updatedChatReport.lastMessageText = messageText; @@ -2047,7 +2048,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView updatedReportPreviewAction = {...reportPreviewAction}; const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport); const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', { - payer: updatedIOUReport.managerEmail, + payer: ReportUtils.getPersonalDetailsForAccountID(updatedIOUReport.managerID).login || '', amount: CurrencyUtils.convertToDisplayString(updatedIOUReport.total, updatedIOUReport.currency), }); updatedReportPreviewAction.message[0].text = messageText; @@ -2693,7 +2694,6 @@ function submitReport(expenseReport) { 'SubmitReport', { reportID: expenseReport.reportID, - managerEmail: expenseReport.managerEmail, managerAccountID: expenseReport.managerID, reportActionID: optimisticSubmittedReportAction.reportActionID, }, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index e716c17de8b2..9b33ff9b086e 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1,6 +1,7 @@ import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import {escapeRegExp} from 'lodash'; +import filter from 'lodash/filter'; import lodashGet from 'lodash/get'; import lodashUnion from 'lodash/union'; import Onyx from 'react-native-onyx'; @@ -74,6 +75,12 @@ Onyx.connect({ callback: (val) => (allPersonalDetails = val), }); +let reimbursementAccount; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => (reimbursementAccount = val), +}); + let allRecentlyUsedCategories = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, @@ -96,6 +103,36 @@ function updateLastAccessedWorkspace(policyID) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Check if the user has any active free policies (aka workspaces) + * + * @param {Array} policies + * @returns {Boolean} + */ +function hasActiveFreePolicy(policies) { + const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); + + if (adminFreePolicies.length === 0) { + return false; + } + + if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + return false; + } + + // If there are no add or delete pending actions the only option left is an update + // pendingAction, in which case we should return true. + return true; +} + /** * Delete the workspace * @@ -104,6 +141,7 @@ function updateLastAccessedWorkspace(policyID) { * @param {String} policyName */ function deleteWorkspace(policyID, reports, policyName) { + const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -146,6 +184,18 @@ function deleteWorkspace(policyID, reports, policyName) { value: optimisticReportActions, }; }), + + ...(!hasActiveFreePolicy(filteredPolicies) + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: null, + }, + }, + ] + : []), ]; // Restore the old report stateNum and statusNum @@ -160,6 +210,13 @@ function deleteWorkspace(policyID, reports, policyName) { oldPolicyName, }, })), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: lodashGet(reimbursementAccount, 'errors', null), + }, + }, ]; // We don't need success data since the push notification will update @@ -183,36 +240,6 @@ function isAdminOfFreePolicy(policies) { return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } -/** - * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} - */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); - - if (adminFreePolicies.length === 0) { - return false; - } - - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { - return false; - } - - // If there are no add or delete pending actions the only option left is an update - // pendingAction, in which case we should return true. - return true; -} - /** * Remove the passed members from the policy employeeList * diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 3f7dc76b174d..1de15c1184cb 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1542,6 +1542,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI // The participants include the current user (admin), and for restricted rooms, the policy members. Participants must not be empty. const members = visibility === CONST.REPORT.VISIBILITY.RESTRICTED ? policyMembersAccountIDs : []; const participants = _.unique([currentUserAccountID, ...members]); + const parsedWelcomeMessage = ReportUtils.getParsedComment(welcomeMessage); const policyReport = ReportUtils.buildOptimisticChatReport( participants, reportName, @@ -1557,7 +1558,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY, '', '', - welcomeMessage, + parsedWelcomeMessage, ); const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); @@ -1622,7 +1623,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI reportID: policyReport.reportID, createdReportActionID: createdReportAction.reportActionID, writeCapability, - welcomeMessage, + welcomeMessage: parsedWelcomeMessage, }, {optimisticData, successData, failureData}, ); @@ -1999,6 +2000,12 @@ function openReportFromDeepLink(url, isAuthenticated) { navigateToConciergeChat(true); return; } + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { + Navigation.isNavigationReady().then(() => { + Session.signOutAndRedirectToSignIn(); + }); + return; + } Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH); }); }); diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 74d2f609ab9b..ba6127801102 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -871,6 +871,33 @@ function waitForUserSignIn(): Promise { }); } +/** + * check if the route can be accessed by anonymous user + * + * @param {string} route + */ + +const canAccessRouteByAnonymousUser = (route: string) => { + const reportID = ReportUtils.getReportIDFromLink(route); + if (reportID) { + return true; + } + const parsedReportRouteParams = ReportUtils.parseReportRouteParams(route); + let routeRemovedReportId = route; + if ((parsedReportRouteParams as {reportID: string})?.reportID) { + routeRemovedReportId = route.replace((parsedReportRouteParams as {reportID: string})?.reportID, ':reportID'); + } + if (route.startsWith('/')) { + routeRemovedReportId = routeRemovedReportId.slice(1); + } + const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route]; + + if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) { + return true; + } + return false; +}; + export { beginSignIn, beginAppleSignIn, @@ -900,4 +927,5 @@ export { toggleTwoFactorAuth, validateTwoFactorAuth, waitForUserSignIn, + canAccessRouteByAnonymousUser, }; diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 76396b1f31b8..959710967881 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -71,7 +71,7 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail // Parent ReportAction indicating that a task has been created const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail); - const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `task for ${title}`, parentReportID); + const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; const currentTime = DateUtils.getDBTime(); @@ -148,7 +148,6 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail if (assigneeChatReport) { assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( currentUserAccountID, - assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, @@ -436,6 +435,13 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi let assigneeChatReportOnyxData; const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0; + const optimisticReport = { + reportName, + managerID: assigneeAccountID || report.managerID, + pendingFields: { + ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + }, + }; const optimisticData = [ { @@ -446,14 +452,7 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - reportName, - managerID: assigneeAccountID || report.managerID, - managerEmail: assigneeEmail || report.managerEmail, - pendingFields: { - ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), - }, - }, + value: optimisticReport, }, ]; const successData = [ @@ -472,16 +471,20 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: {assignee: report.managerEmail, assigneeAccountID: report.managerID}, + value: {managerID: report.managerID}, }, ]; // If we make a change to the assignee, we want to add a comment to the assignee's chat // Check if the assignee actually changed if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID && assigneeChatReport) { + const participants = lodashGet(report, 'participantAccountIDs', []); + if (!participants.includes(assigneeAccountID)) { + optimisticReport.participantAccountIDs = [...participants, assigneeAccountID]; + } + assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( currentUserAccountID, - assigneeEmail, assigneeAccountID, report.reportID, assigneeChatReportID, @@ -498,8 +501,7 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi 'EditTaskAssignee', { taskReportID: report.reportID, - assignee: assigneeEmail || report.managerEmail, - assigneeAccountID: assigneeAccountID || report.managerID, + assignee: assigneeEmail, editedTaskReportActionID: editTaskReportAction.reportActionID, assigneeChatReportID, assigneeChatReportActionID: @@ -635,7 +637,9 @@ function setParentReportID(parentReportID) { */ function clearOutTaskInfoAndNavigate(reportID) { clearOutTaskInfo(); - setParentReportID(reportID); + if (reportID && reportID !== '0') { + setParentReportID(reportID); + } Navigation.navigate(ROUTES.NEW_TASK_DETAILS); } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index a649267c0ae9..285fd5b251df 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -110,6 +110,10 @@ function removeWaypoint(transactionID: string, currentIndex: string) { const waypointValues = Object.values(existingWaypoints); const removed = waypointValues.splice(index, 1); + if (removed.length === 0) { + return; + } + const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {}); // When there are only two waypoints we are adding empty waypoint back diff --git a/src/libs/focusWithDelay.ts b/src/libs/focusComposerWithDelay.ts similarity index 69% rename from src/libs/focusWithDelay.ts rename to src/libs/focusComposerWithDelay.ts index 00d0e915879e..94eb168328aa 100644 --- a/src/libs/focusWithDelay.ts +++ b/src/libs/focusComposerWithDelay.ts @@ -1,11 +1,12 @@ import {InteractionManager, TextInput} from 'react-native'; +import * as EmojiPickerAction from './actions/EmojiPickerAction'; import ComposerFocusManager from './ComposerFocusManager'; -type FocusWithDelay = (shouldDelay?: boolean) => void; +type FocusComposerWithDelay = (shouldDelay?: boolean) => void; /** - * Create a function that focuses a text input. + * Create a function that focuses the composer. */ -function focusWithDelay(textInput: TextInput | null): FocusWithDelay { +function focusComposerWithDelay(textInput: TextInput | null): FocusComposerWithDelay { /** * Focus the text input * @param [shouldDelay] Impose delay before focusing the text input @@ -14,7 +15,7 @@ function focusWithDelay(textInput: TextInput | null): FocusWithDelay { // There could be other animations running while we trigger manual focus. // This prevents focus from making those animations janky. InteractionManager.runAfterInteractions(() => { - if (!textInput) { + if (!textInput || EmojiPickerAction.isEmojiPickerVisible()) { return; } @@ -32,4 +33,4 @@ function focusWithDelay(textInput: TextInput | null): FocusWithDelay { }; } -export default focusWithDelay; +export default focusComposerWithDelay; diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js index b986989a81dc..5fb26e961fad 100644 --- a/src/pages/EditRequestAmountPage.js +++ b/src/pages/EditRequestAmountPage.js @@ -4,6 +4,7 @@ import React, {useCallback, useRef} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; import MoneyRequestAmountForm from './iou/steps/MoneyRequestAmountForm'; @@ -43,6 +44,7 @@ function EditRequestAmountPage({defaultAmount, defaultCurrency, onNavigateToCurr diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js index c195e0237034..db5098777744 100644 --- a/src/pages/EnablePayments/OnfidoPrivacy.js +++ b/src/pages/EnablePayments/OnfidoPrivacy.js @@ -86,6 +86,7 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { OnfidoPrivacy.propTypes = propTypes; OnfidoPrivacy.defaultProps = defaultProps; +OnfidoPrivacy.displayName = 'OnfidoPrivacy'; export default compose( withLocalize, diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index b31e9b58cbe9..7c8aec8d12de 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -132,7 +132,7 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.repotID, route.params.accountID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} /> diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index ccc5cd14359a..f50c95cb23ed 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -188,7 +188,7 @@ function ProfilePage(props) { {Boolean(displayName) && ( {displayName} diff --git a/src/pages/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index 36fbc6e4c59d..b8675fd9cc0e 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -107,6 +107,7 @@ function AddressForm(props) { hint={props.translate('common.noPO')} renamedInputKeys={props.inputKeys} maxInputLength={CONST.FORM_CHARACTER_LIMIT} + isLimitedToUSA /> {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> diff --git a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js index 25997fa9b307..7b84c5bc94d1 100644 --- a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js +++ b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js @@ -5,7 +5,8 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -89,7 +90,7 @@ function IntroSchoolPrincipalPage(props) { title={translate('teachersUnitePage.introSchoolPrincipal')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> -
    {translate('teachersUnitePage.schoolPrincipalVerfiyExpense')} - - - -
    + ); } diff --git a/src/pages/home/report/ParticipantLocalTime.js b/src/pages/home/report/ParticipantLocalTime.js index 17f87f0391ea..2ce0054a3e59 100644 --- a/src/pages/home/report/ParticipantLocalTime.js +++ b/src/pages/home/report/ParticipantLocalTime.js @@ -45,6 +45,10 @@ function ParticipantLocalTime(props) { const reportRecipientDisplayName = lodashGet(props, 'participant.firstName') || lodashGet(props, 'participant.displayName'); + if (!reportRecipientDisplayName) { + return null; + } + return ( { - // 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(betas) || ReportUtils.isExpensifyOnlyParticipantInReport(report)) { + if (!Permissions.canUseTasks(betas) || !ReportUtils.canCreateTaskInReport(report)) { return []; } diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index b7013fe4ff2f..6b375fb5ffa5 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -17,7 +17,7 @@ import * as ComposerUtils from '@libs/ComposerUtils'; import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; import * as EmojiUtils from '@libs/EmojiUtils'; -import focusWithDelay from '@libs/focusWithDelay'; +import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -117,6 +117,7 @@ function ComposerWithSuggestions({ return draft; }); const commentRef = useRef(value); + const lastTextRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -196,6 +197,50 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); }, [textInputRef]); + /** + * Find the newly added characters between the previous text and the new text based on the selection. + * + * @param {string} prevText - The previous text. + * @param {string} newText - The new text. + * @returns {object} An object containing information about the newly added characters. + * @property {number} startIndex - The start index of the newly added characters in the new text. + * @property {number} endIndex - The end index of the newly added characters in the new text. + * @property {string} diff - The newly added characters. + */ + const findNewlyAddedChars = useCallback( + (prevText, newText) => { + let startIndex = -1; + let endIndex = -1; + let currentIndex = 0; + + // Find the first character mismatch with newText + while (currentIndex < newText.length && prevText.charAt(currentIndex) === newText.charAt(currentIndex) && selection.start > currentIndex) { + currentIndex++; + } + + if (currentIndex < newText.length) { + startIndex = currentIndex; + + // if text is getting pasted over find length of common suffix and subtract it from new text length + if (selection.end - selection.start > 0) { + const commonSuffixLength = ComposerUtils.getCommonSuffixLength(prevText, newText); + endIndex = newText.length - commonSuffixLength; + } else { + endIndex = currentIndex + (newText.length - prevText.length); + } + } + + return { + startIndex, + endIndex, + diff: newText.substring(startIndex, endIndex), + }; + }, + [selection.end, selection.start], + ); + + const insertWhiteSpace = (text, index) => `${text.slice(0, index)} ${text.slice(index)}`; + const debouncedSaveReportComment = useMemo( () => _.debounce((selectedReportID, newComment) => { @@ -213,7 +258,14 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { raiseIsScrollLikelyLayoutTriggered(); - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); + const isEmojiInserted = diff.length && endIndex > startIndex && EmojiUtils.containsOnlyEmojis(diff); + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis( + isEmojiInserted ? insertWhiteSpace(commentValue, endIndex) : commentValue, + preferredSkinTone, + preferredLocale, + ); + if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (!_.isEmpty(newEmojis)) { @@ -258,13 +310,14 @@ function ComposerWithSuggestions({ } }, [ - debouncedUpdateFrequentlyUsedEmojis, - preferredLocale, + raiseIsScrollLikelyLayoutTriggered, + findNewlyAddedChars, preferredSkinTone, - reportID, + preferredLocale, setIsCommentEmpty, + debouncedUpdateFrequentlyUsedEmojis, suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, + reportID, debouncedSaveReportComment, ], ); @@ -315,14 +368,8 @@ function ComposerWithSuggestions({ * @param {Boolean} shouldAddTrailSpace */ const replaceSelectionWithText = useCallback( - (text, shouldAddTrailSpace = true) => { - const updatedText = shouldAddTrailSpace ? `${text} ` : text; - const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; - updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); - setSelection((prevSelection) => ({ - start: prevSelection.start + text.length + selectionSpaceLength, - end: prevSelection.start + text.length + selectionSpaceLength, - })); + (text) => { + updateComment(ComposerUtils.insertText(commentRef.current, selection, text)); }, [selection, updateComment], ); @@ -394,7 +441,7 @@ function ComposerWithSuggestions({ * @memberof ReportActionCompose */ const focus = useCallback((shouldDelay = false) => { - focusWithDelay(textInputRef.current)(shouldDelay); + focusComposerWithDelay(textInputRef.current)(shouldDelay); }, []); const setUpComposeFocusManager = useCallback(() => { @@ -446,7 +493,12 @@ function ComposerWithSuggestions({ } focus(); - replaceSelectionWithText(e.key, false); + // Reset cursor to last known location + setSelection((prevSelection) => ({ + start: prevSelection.start + 1, + end: prevSelection.end + 1, + })); + replaceSelectionWithText(e.key); }, [checkComposerVisibility, focus, replaceSelectionWithText], ); @@ -510,6 +562,10 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + lastTextRef.current = value; + }, [value]); + useImperativeHandle( forwardedRef, () => ({ @@ -551,6 +607,7 @@ function ComposerWithSuggestions({ setIsFullComposerAvailable={setIsFullComposerAvailable} isComposerFullSize={isComposerFullSize} value={value} + testID="composer" numberOfLines={numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index f090a942e097..c0a1151f0202 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -464,6 +464,7 @@ function ReportActionCompose({ ReportActionCompose.propTypes = propTypes; ReportActionCompose.defaultProps = defaultProps; +ReportActionCompose.displayName = 'ReportActionCompose'; export default compose( withNetwork(), diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index baf93da6ccc4..2ea2dd334528 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -1,17 +1,15 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import * as Expensicons from '@components/Icon/Expensicons'; import MentionSuggestions from '@components/MentionSuggestions'; +import {usePersonalDetails} from '@components/OnyxProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import * as UserUtils from '@libs/UserUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import * as SuggestionProps from './suggestionProps'; /** @@ -29,9 +27,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** A ref to this component */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), @@ -39,7 +34,6 @@ const propTypes = { }; const defaultProps = { - personalDetails: {}, forwardedRef: null, }; @@ -49,7 +43,6 @@ function SuggestionMention({ selection, setSelection, isComposerFullSize, - personalDetails, updateComment, composerHeight, forwardedRef, @@ -57,6 +50,7 @@ function SuggestionMention({ measureParentContainer, isComposerFocused, }) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const {translate} = useLocalize(); const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -316,8 +310,4 @@ const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})(SuggestionMentionWithRef); +export default SuggestionMentionWithRef; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index f39a70a960cf..0050b56800cc 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; import * as SuggestionProps from './suggestionProps'; @@ -108,7 +109,7 @@ function Suggestions({ }; return ( - <> + - + ); } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index a7a3bc0739f3..4da88fd5d352 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -353,11 +353,16 @@ function ReportActionItem(props) { ); } else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) { children = ( - + + + ); } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail); @@ -773,7 +778,6 @@ export default compose( prevProps.report.description === nextProps.report.description && ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && prevProps.report.managerID === nextProps.report.managerID && - prevProps.report.managerEmail === nextProps.report.managerEmail && prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) && lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) && diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index 24e1c6bc1ef6..bbaa13484614 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -63,6 +63,12 @@ const propTypes = { /** Whether the comment is a thread parent message/the first message in a thread */ isThreadParentMessage: PropTypes.bool, + /** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */ + isApprovedOrSubmittedReportAction: PropTypes.bool, + + /** Used to format RTL display names in Old Dot system messages e.g. Arabic */ + isFragmentContainingDisplayName: PropTypes.bool, + ...windowDimensionsPropTypes, /** localization props */ @@ -86,6 +92,8 @@ const defaultProps = { delegateAccountID: 0, actorIcon: {}, isThreadParentMessage: false, + isApprovedOrSubmittedReportAction: false, + isFragmentContainingDisplayName: false, displayAsGroup: false, }; @@ -152,8 +160,15 @@ function ReportActionItemFragment(props) { ); } - case 'TEXT': - return ( + case 'TEXT': { + return props.isApprovedOrSubmittedReportAction ? ( + + {props.isFragmentContainingDisplayName ? convertToLTR(props.fragment.text) : props.fragment.text} + + ) : ( ); + } case 'LINK': return LINK; case 'INTEGRATION_COMMENT': diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 37aaa5adf287..4c6603c052a3 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -7,6 +7,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import styles from '@styles/styles'; +import CONST from '@src/CONST'; import ReportActionItemFragment from './ReportActionItemFragment'; import reportActionPropTypes from './reportActionPropTypes'; @@ -47,23 +48,45 @@ function ReportActionItemMessage(props) { } } + const isApprovedOrSubmittedReportAction = _.contains([CONST.REPORT.ACTIONS.TYPE.APPROVED, CONST.REPORT.ACTIONS.TYPE.SUBMITTED], props.action.actionName); + + /** + * Get the ReportActionItemFragments + * @param {Boolean} shouldWrapInText determines whether the fragments are wrapped in a Text component + * @returns {Object} report action item fragments + */ + const renderReportActionItemFragments = (shouldWrapInText) => { + const reportActionItemFragments = _.map(messages, (fragment, index) => ( + + )); + + // Approving or submitting reports in oldDot results in system messages made up of multiple fragments of `TEXT` type + // which we need to wrap in `` to prevent them rendering on separate lines. + + return shouldWrapInText ? {reportActionItemFragments} : reportActionItemFragments; + }; + return ( {!props.isHidden ? ( - _.map(messages, (fragment, index) => ( - - )) + renderReportActionItemFragments(isApprovedOrSubmittedReportAction) ) : ( {props.translate('moderation.flaggedContent')} )} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 589b4c367c25..67c3d5f5c5ec 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -20,7 +20,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import * as ComposerUtils from '@libs/ComposerUtils'; import * as EmojiUtils from '@libs/EmojiUtils'; -import focusWithDelay from '@libs/focusWithDelay'; +import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import onyxSubscribe from '@libs/onyxSubscribe'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; @@ -316,7 +316,7 @@ function ReportActionItemMessageEdit(props) { // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { textInputRef.current.blur(); - ReportActionContextMenu.showDeleteModal(props.reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); + ReportActionContextMenu.showDeleteModal(props.reportID, props.action, true, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); return; } Report.editReportComment(props.reportID, props.action, trimmedNewDraft); @@ -358,7 +358,7 @@ function ReportActionItemMessageEdit(props) { /** * Focus the composer text input */ - const focus = focusWithDelay(textInputRef.current); + const focus = focusComposerWithDelay(textInputRef.current); return ( <> @@ -432,10 +432,7 @@ function ReportActionItemMessageEdit(props) { { - setIsFocused(true); - focus(true); - }} + onModalHide={() => focus(true)} onEmojiSelected={addEmojiToTextBox} nativeID={emojiButtonID} emojiPickerID={props.action.reportActionID} diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 329952769a4f..e9e1ef39e417 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -85,7 +85,7 @@ const showWorkspaceDetails = (reportID) => { function ReportActionItemSingle(props) { const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const actorAccountID = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport ? props.iouReport.managerID : props.action.actorAccountID; - let {displayName} = personalDetails[actorAccountID] || {}; + let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID] || {}; let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]); @@ -113,7 +113,7 @@ function ReportActionItemSingle(props) { // The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID; const secondaryUserDetails = personalDetails[secondaryAccountId] || {}; - const secondaryDisplayName = lodashGet(secondaryUserDetails, 'displayName', ''); + const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId); displayName = `${primaryDisplayName} & ${secondaryDisplayName}`; secondaryAvatar = { source: UserUtils.getAvatar(secondaryUserDetails.avatar, secondaryAccountId), diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index e26aae7be7c5..4fd2bd21c99e 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -303,12 +303,9 @@ function ReportActionsList({ if (!currentUnreadMarker) { const nextMessage = sortedReportActions[index + 1]; const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime); - const isNextMessageRead = !isMessageUnread(nextMessage, report.lastReadTime); - const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true; - shouldDisplay = isCurrentMessageUnread && (!nextMessage || isNextMessageRead) && isWithinVisibleThreshold; - - if (shouldDisplay && !messageManuallyMarkedUnread) { - shouldDisplay = reportAction.actorAccountID !== Report.getCurrentUserAccountID(); + shouldDisplay = isCurrentMessageUnread && (!nextMessage || !isMessageUnread(nextMessage, report.lastReadTime)); + if (!messageManuallyMarkedUnread) { + shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID(); } } else { shouldDisplay = reportAction.reportActionID === currentUnreadMarker; @@ -415,6 +412,7 @@ function ReportActionsList({ diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index f9f029881eef..28ddcd94dfb2 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; import {withNetwork} from '@components/OnyxProvider'; @@ -9,15 +10,19 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; +import usePrevious from '@hooks/usePrevious'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {isUserCreatedPolicyRoom} from '@libs/ReportUtils'; +import {didUserLogInDuringSession} from '@libs/SessionUtils'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; import reportPropTypes from '@pages/reportPropTypes'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import reportActionPropTypes from './reportActionPropTypes'; import ReportActionsList from './ReportActionsList'; @@ -54,6 +59,12 @@ const propTypes = { avatar: PropTypes.string, }), + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; @@ -64,6 +75,9 @@ const defaultProps = { isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, isLoadingNewerReportActions: false, + session: { + authTokenType: '', + }, }; function ReportActionsView(props) { @@ -76,6 +90,8 @@ function ReportActionsView(props) { const mostRecentIOUReportActionID = useInitialValue(() => ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions)); const prevNetworkRef = useRef(props.network); + const prevAuthTokenType = usePrevious(props.session.authTokenType); + const prevIsSmallScreenWidthRef = useRef(props.isSmallScreenWidth); const isFocused = useIsFocused(); @@ -118,6 +134,18 @@ function ReportActionsView(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.network, props.report, isReportFullyVisible]); + useEffect(() => { + const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; + if (wasLoginChangedDetected && didUserLogInDuringSession() && isUserCreatedPolicyRoom(props.report)) { + if (isReportFullyVisible) { + openReportIfNecessary(); + } else { + Report.reconnect(reportID); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.session, props.report, isReportFullyVisible]); + useEffect(() => { const prevIsSmallScreenWidth = prevIsSmallScreenWidthRef.current; // If the view is expanded from mobile to desktop layout @@ -261,6 +289,10 @@ function arePropsEqual(oldProps, newProps) { return false; } + if (lodashGet(oldProps.session, 'authTokenType') !== lodashGet(newProps.session, 'authTokenType')) { + return false; + } + if (oldProps.isLoadingInitialReportActions !== newProps.isLoadingInitialReportActions) { return false; } @@ -313,10 +345,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps, 'report.managerEmail') !== lodashGet(oldProps, 'report.managerEmail')) { - return false; - } - if (lodashGet(newProps, 'report.total') !== lodashGet(oldProps, 'report.total')) { return false; } @@ -338,4 +366,14 @@ function arePropsEqual(oldProps, newProps) { const MemoizedReportActionsView = React.memo(ReportActionsView, arePropsEqual); -export default compose(Performance.withRenderTrace({id: ' rendering'}), withWindowDimensions, withLocalize, withNetwork())(MemoizedReportActionsView); +export default compose( + Performance.withRenderTrace({id: ' rendering'}), + withWindowDimensions, + withLocalize, + withNetwork(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), +)(MemoizedReportActionsView); diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index a5d58768a95d..293dc3f5cd9d 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -143,6 +143,7 @@ const chatReportSelector = (report) => total: report.total, nonReimbursableTotal: report.nonReimbursableTotal, hasOutstandingIOU: report.hasOutstandingIOU, + hasOutstandingChildRequest: report.hasOutstandingChildRequest, isWaitingOnBankAccount: report.isWaitingOnBankAccount, statusNum: report.statusNum, stateNum: report.stateNum, diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index c7b5885865df..20344a08a2c8 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -126,8 +126,12 @@ function IOUCurrencySelection(props) { }; }); - const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i'); - const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text) || searchRegex.test(currencyOption.currencyName)); + const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim().replace(CONST.REGEX.ANY_SPACE, ' ')), 'i'); + const filteredCurrencies = _.filter( + currencyOptions, + (currencyOption) => + searchRegex.test(currencyOption.text.replace(CONST.REGEX.ANY_SPACE, ' ')) || searchRegex.test(currencyOption.currencyName.replace(CONST.REGEX.ANY_SPACE, ' ')), + ); const isEmpty = searchValue.trim() && !filteredCurrencies.length; return { diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index 17893ce98f0b..125a83cd0fd3 100644 --- a/src/pages/iou/MoneyRequestSelectorPage.js +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -12,6 +12,7 @@ import TabSelector from '@components/TabSelector/TabSelector'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import compose from '@libs/compose'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; @@ -94,6 +95,7 @@ function MoneyRequestSelectorPage(props) { diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 79cf48ce634d..6b570ee872c3 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -71,10 +71,13 @@ function MoneyRequestConfirmPage(props) { const [receiptFile, setReceiptFile] = useState(); const participants = useMemo( () => - _.map(props.iou.participants, (participant) => { - const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); - return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, props.personalDetails); - }), + _.chain(props.iou.participants) + .map((participant) => { + const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); + return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, props.personalDetails); + }) + .filter((participant) => !!participant.login || !!participant.text) + .value(), [props.iou.participants, props.personalDetails], ); const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(props.report)), [props.report]); diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index ec5ab3a678bd..8302564cfcb7 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -1,15 +1,19 @@ import lodashGet from 'lodash/get'; +import lodashSize from 'lodash/size'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import transactionPropTypes from '@components/transactionPropTypes'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; +import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; import styles from '@styles/styles'; import * as IOU from '@userActions/IOU'; @@ -36,14 +40,18 @@ const propTypes = { /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]), + + /** Transaction that stores the distance request data */ + transaction: transactionPropTypes, }; const defaultProps = { iou: iouDefaultProps, + transaction: {}, selectedTab: undefined, }; -function MoneyRequestParticipantsPage({iou, selectedTab, route}) { +function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const {translate} = useLocalize(); const prevMoneyRequestId = useRef(iou.id); const optionsSelectorRef = useRef(); @@ -54,7 +62,9 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); - + const waypoints = lodashGet(transaction, 'comment.waypoints', {}); + const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); + const isInvalidWaypoint = lodashSize(validatedWaypoints) < 2; useEffect(() => { if (isDistanceRequest) { setHeaderTitle(translate('common.distance')); @@ -85,10 +95,12 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { }, []); useEffect(() => { + const isInvalidDistanceRequest = !isDistanceRequest || isInvalidWaypoint; + // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request if (prevMoneyRequestId.current !== iou.id) { // The ID is cleared on completing a request. In that case, we will do nothing - if (iou.id && !isDistanceRequest && !isSplitRequest) { + if (iou.id && isInvalidDistanceRequest && !isSplitRequest) { navigateBack(true); } return; @@ -100,14 +112,14 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { if (shouldReset) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (isInvalidDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { navigateBack(true); } return () => { prevMoneyRequestId.current = iou.id; }; - }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack]); + }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack, isInvalidWaypoint]); return ( `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, + }), +)(MoneyRequestParticipantsPage); diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 790793787e57..af6163729944 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -4,6 +4,8 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import Button from '@components/Button'; +import FormHelpMessage from '@components/FormHelpMessage'; import OptionsSelector from '@components/OptionsSelector'; import refPropTypes from '@components/refPropTypes'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; @@ -262,11 +264,38 @@ function MoneyRequestParticipantsSelector({ // Right now you can't split a request with a workspace and other additional participants // This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent - // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants + // the app from crashing on native when you try to do this, we'll going to show error message if you have a workspace and other participants const hasPolicyExpenseChatParticipant = _.some(participants, (participant) => participant.isPolicyExpenseChat); - const shouldShowConfirmButton = !(participants.length > 1 && hasPolicyExpenseChatParticipant); + const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = !isDistanceRequest && iouType !== CONST.IOU.TYPE.SEND; + const handleConfirmSelection = useCallback(() => { + if (shouldShowSplitBillErrorMessage) { + return; + } + + navigateToSplit(); + }, [shouldShowSplitBillErrorMessage, navigateToSplit]); + + const footerContent = ( + + {shouldShowSplitBillErrorMessage && ( + + )} +