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/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/android/app/build.gradle b/android/app/build.gradle index 36725e2c5044..e43909433367 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 1001039400 - versionName "1.3.94-0" + versionCode 1001039504 + versionName "1.3.95-4" } flavorDimensions "default" 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/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/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 f085379357c4..46434787d6df 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -35,31 +35,8 @@ html { } table { - margin-bottom: 20px; border-spacing: 0; border-collapse: collapse; - 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; } caption, @@ -68,13 +45,6 @@ td { text-align: left; font-weight: 400; vertical-align: middle; - padding: 6px 13px; - border: 1px solid $color-green-borders; -} - -thead tr th { - font-weight: bold; - background-color: $color-green-highlightBG; } q, @@ -395,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/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/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/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/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/travel-integrations/TripCatcher.md b/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.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/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cdd6e463d12b..4d019ccacaa1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.94 + 1.3.95 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.94.0 + 1.3.95.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 34d617586b7b..64aaf1899c16 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.94 + 1.3.95 CFBundleSignature ???? CFBundleVersion - 1.3.94.0 + 1.3.95.4 diff --git a/package-lock.json b/package-lock.json index 490c63a5da28..7c4ba8f2aad7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "new.expensify", - "version": "1.3.94-0", + "version": "1.3.95-4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.94-0", + "version": "1.3.95-4", "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", @@ -57,7 +59,7 @@ "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", @@ -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", @@ -5635,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", @@ -38114,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 } @@ -44774,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", @@ -45102,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", @@ -55203,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", @@ -57115,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", @@ -80510,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": { @@ -85386,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", @@ -85599,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 3221a20654a5..c18fa7d9da00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.94-0", + "version": "1.3.95-4", "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", @@ -105,7 +107,7 @@ "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,6 +185,7 @@ "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.21.5", "@babel/runtime": "^7.20.0", + "@dword-design/eslint-plugin-import-alias": "^4.0.8", "@electron/notarize": "^2.1.0", "@jest/globals": "^29.5.0", "@octokit/core": "4.0.4", @@ -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 155e50a35cde..29bb0b83aaee 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', diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 7010ab514617..566b6c709423 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -240,7 +240,7 @@ function AddPlaidBankAccount({ /> {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/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/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index f18ec346dfa2..4bffadecb733 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -7,7 +7,6 @@ import variables from '@styles/variables'; import Checkbox from './Checkbox'; import FormHelpMessage from './FormHelpMessage'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; -import refPropTypes from './refPropTypes'; import Text from './Text'; /** @@ -55,7 +54,7 @@ const propTypes = { defaultValue: PropTypes.bool, /** React ref being forwarded to the Checkbox input */ - forwardedRef: refPropTypes, + forwardedRef: PropTypes.func, /** The ID used to uniquely identify the input in a Form */ /* eslint-disable-next-line react/no-unused-prop-types */ diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 02042d86a6e0..f8045eb87f9f 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -8,7 +8,7 @@ import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withNavigation from '@components/withNavigation'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; @@ -88,8 +88,6 @@ const propTypes = { isComposerFullSize: PropTypes.bool, ...withLocalizePropTypes, - - ...windowDimensionsPropTypes, }; const defaultProps = { @@ -171,6 +169,7 @@ function Composer({ isComposerFullSize, ...props }) { + const {windowWidth} = useWindowDimensions(); const textRef = useRef(null); const textInput = useRef(null); const initialValue = defaultValue ? `${defaultValue}` : `${value || ''}`; @@ -368,7 +367,7 @@ function Composer({ setNumberOfLines(generalNumberOfLines); textInput.current.style.height = 'auto'; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, maxLines, numberOfLinesProp, onNumberOfLinesChange, isFullComposerAvailable, setIsFullComposerAvailable]); + }, [value, maxLines, numberOfLinesProp, onNumberOfLinesChange, isFullComposerAvailable, setIsFullComposerAvailable, windowWidth]); useEffect(() => { updateNumberOfLines(); @@ -502,4 +501,4 @@ 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..d92869162d49 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} 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(new Date(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(new Date(dateValue), CONST.DATE.FNS_FORMAT_STRING) : ''; return ( <> diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a5b282b22c73..3bed9ca55321 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,4 +1,4 @@ -import moment from 'moment'; +import {format, isValid} 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 = new Date(text); + if (isValid(date)) { + onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING)); } }; 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/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index ebba2ffe0587..cb64a135b264 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -168,14 +168,17 @@ export default React.memo( }, fullReport: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + initialValue: {}, }, reportActions: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, canEvict: false, + initialValue: {}, }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, selector: personalDetailsSelector, + initialValue: {}, }, preferredLocale: { key: ONYXKEYS.NVP_PREFERRED_LOCALE, @@ -186,15 +189,17 @@ export default React.memo( parentReportActions: { key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`, canEvict: false, + initialValue: {}, }, policy: { key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`, + initialValue: {}, }, // Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions. // In some scenarios, a transaction might be created after reportActions have been modified. // This can lead to situations where `lastTransaction` doesn't update and retains the previous value. // However, performance overhead of this is minimized by using memos inside the component. - receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION}, + receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION, initialValue: {}}, }), // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ 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/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/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/QRCode/index.tsx b/src/components/QRCode.tsx similarity index 100% rename from src/components/QRCode/index.tsx rename to src/components/QRCode.tsx diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index 46aff91c93ea..590cfc5c7b11 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -39,6 +39,7 @@ function ReimbursementAccountLoadingIndicator(props) { autoPlay loop style={styles.loadingVBAAnimation} + webStyle={styles.loadingVBAAnimationWeb} /> {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/UserDetailsTooltip/BaseUserDetailsTooltip.web.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js index 871173d01b56..9d89ccaa8889 100644 --- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js +++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js @@ -2,23 +2,24 @@ import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import React, {useCallback} from 'react'; import {Text, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Avatar from '@components/Avatar'; +import {usePersonalDetails} from '@components/OnyxProvider'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; +import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import styles from '@styles/styles'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, propTypes} from './userDetailsTooltipPropTypes'; function BaseUserDetailsTooltip(props) { const {translate} = useLocalize(); + const personalDetails = usePersonalDetails(); - const userDetails = lodashGet(props.personalDetailsList, props.accountID, props.fallbackUserDetails); - let userDisplayName = userDetails.displayName ? userDetails.displayName.trim() : ''; + const userDetails = lodashGet(personalDetails, props.accountID, props.fallbackUserDetails); + let userDisplayName = ReportUtils.getDisplayNameForParticipant(props.accountID); let userLogin = (userDetails.login || '').trim() && !_.isEqual(userDetails.login, userDetails.displayName) ? Str.removeSMSDomain(userDetails.login) : ''; let userAvatar = userDetails.avatar; let userAccountID = props.accountID; @@ -26,8 +27,8 @@ function BaseUserDetailsTooltip(props) { // We replace the actor's email, name, and avatar with the Copilot manually for now. This will be improved upon when // the Copilot feature is implemented. if (props.delegateAccountID) { - const delegateUserDetails = lodashGet(props.personalDetailsList, props.delegateAccountID, {}); - const delegateUserDisplayName = delegateUserDetails.displayName ? delegateUserDetails.displayName.trim() : ''; + const delegateUserDetails = lodashGet(personalDetails, props.delegateAccountID, {}); + const delegateUserDisplayName = ReportUtils.getDisplayNameForParticipant(props.delegateAccountID); userDisplayName = `${delegateUserDisplayName} (${translate('reportAction.asCopilot')} ${userDisplayName})`; userLogin = delegateUserDetails.login; userAvatar = delegateUserDetails.avatar; @@ -78,8 +79,4 @@ BaseUserDetailsTooltip.propTypes = propTypes; BaseUserDetailsTooltip.defaultProps = defaultProps; BaseUserDetailsTooltip.displayName = 'BaseUserDetailsTooltip'; -export default withOnyx({ - personalDetailsList: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})(BaseUserDetailsTooltip); +export default BaseUserDetailsTooltip; diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx index ed580b4dbe4a..20e536d9d733 100644 --- a/src/components/withCurrentUserPersonalDetails.tsx +++ b/src/components/withCurrentUserPersonalDetails.tsx @@ -2,15 +2,14 @@ import React, {ComponentType, ForwardedRef, RefAttributes, useMemo} from 'react' import {OnyxEntry, withOnyx} from 'react-native-onyx'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import personalDetailsPropType from '@pages/personalDetailsPropType'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Session} from '@src/types/onyx'; +import {usePersonalDetails} from './OnyxProvider'; type CurrentUserPersonalDetails = PersonalDetails | Record; 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/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 cf4f7e66101f..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', @@ -1843,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 f1e24a7a6777..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', @@ -2326,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/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 54d09b75eff2..99853975f86a 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -87,6 +87,7 @@ Onyx.connect({ const policyExpenseReports = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, callback: (report, key) => { if (!ReportUtils.isPolicyExpenseChat(report)) { return; 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/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 11e11f549682..45bdfb18b451 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -406,9 +406,12 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = }; } - const messageText = message?.text ?? ''; + let messageText = message?.text ?? ''; + if (messageText) { + messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + } return { - lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(), + lastMessageText: messageText, }; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 851dc08defaf..5bb8fd4ad4fc 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. * @@ -800,8 +818,15 @@ function isOneOnOneChat(report) { * @returns {Object} */ function getReport(reportID) { - // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check - return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {}; + /** + * Using typical string concatenation here due to performance issues + * with template literals. + */ + if (!allReports) { + return {}; + } + + return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; } /** @@ -1212,6 +1237,7 @@ function getPersonalDetailsForAccountID(accountID) { return ( (allPersonalDetails && allPersonalDetails[accountID]) || { avatar: UserUtils.getDefaultAvatar(accountID), + isOptimisticPersonalDetail: true, } ); } @@ -1221,28 +1247,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; @@ -1385,6 +1422,10 @@ function requiresAttentionFromCurrentUser(option, parentReportAction = {}) { return false; } + if (isArchivedRoom(option)) { + return false; + } + if (isArchivedRoom(getReport(option.parentReportID))) { return false; } @@ -1489,14 +1530,25 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { * @returns {String} */ function getPolicyExpenseChatName(report, policy = undefined) { - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName; + const ownerAccountID = report.ownerAccountID; + const personalDetails = allPersonalDetails[ownerAccountID]; + const login = personalDetails ? personalDetails.login : null; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { return getPolicyName(report, false, policy); } - const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user'; + let policyExpenseChatRole = 'user'; + /** + * Using typical string concatenation here due to performance issues + * with template literals. + */ + const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; + if (policyItem) { + policyExpenseChatRole = policyItem.role || 'user'; + } // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. @@ -2323,13 +2375,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; @@ -3638,15 +3689,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. @@ -3893,7 +3945,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 @@ -3902,7 +3953,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 @@ -3970,7 +4021,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, @@ -4040,7 +4091,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); @@ -4062,10 +4114,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; } @@ -4135,6 +4194,7 @@ export { getPolicyType, isArchivedRoom, isExpensifyOnlyParticipantInReport, + canCreateTaskInReport, isPolicyExpenseChatAdmin, isPolicyAdmin, isPublicRoom, @@ -4269,4 +4329,5 @@ export { shouldUseFullTitleToDisplay, parseReportRouteParams, getReimbursementQueuedActionMessage, + getPersonalDetailsForAccountID, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4aa708d5882d..4951432bcd03 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -156,18 +156,6 @@ function getOrderedReportIDs( } } - // There are a few properties that need to be calculated for the report which are used when sorting reports. - reportsToDisplay.forEach((report) => { - // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. - // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add - // the reportDisplayName property to the report object directly. - // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); - - // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); - }); - // 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 @@ -181,7 +169,17 @@ function getOrderedReportIDs( const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; + reportsToDisplay.forEach((report) => { + // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. + // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add + // the reportDisplayName property to the report object directly. + // eslint-disable-next-line no-param-reassign + report.displayName = ReportUtils.getReportName(report); + + // eslint-disable-next-line no-param-reassign + report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); + const isPinned = report.isPinned ?? false; if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) { pinnedAndGBRReports.push(report); diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index 9af74f8313c3..bfa0cd911177 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -1,3 +1,4 @@ +import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ReportUtils from '@libs/ReportUtils'; @@ -5,11 +6,33 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import updateUnread from './updateUnread/index'; +let previousUnreadCount = 0; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (reportsFromOnyx) => { - const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - updateUnread(_.size(unreadReports)); + if (!reportsFromOnyx) { + return; + } + + /** + * We need to wait until after interactions have finished to update the unread count because otherwise + * the unread count will be updated while the interactions/animations are in progress and we don't want + * to put more work on the main thread. + * + * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions + * have finished. + * + * More info: https://reactnative.dev/docs/interactionmanager + */ + InteractionManager.runAfterInteractions(() => { + const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + const unreadReportsCount = _.size(unreadReports); + if (previousUnreadCount !== unreadReportsCount) { + previousUnreadCount = unreadReportsCount; + updateUnread(unreadReportsCount); + } + }); }, }); 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/Report.js b/src/libs/actions/Report.js index 3f7dc76b174d..4646e0e33da1 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -65,6 +65,7 @@ Onyx.connect({ const currentReportData = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, callback: (data, key) => { if (!key || !data) { return; @@ -1542,6 +1543,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 +1559,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 +1624,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI reportID: policyReport.reportID, createdReportActionID: createdReportAction.reportActionID, writeCapability, - welcomeMessage, + welcomeMessage: parsedWelcomeMessage, }, {optimisticData, successData, failureData}, ); @@ -1999,6 +2001,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/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/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/SearchPage.js b/src/pages/SearchPage.js index f312ef2aa307..3e7731efc7b2 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -65,15 +65,13 @@ class SearchPage extends Component { this.searchRendered = this.searchRendered.bind(this); this.selectReport = this.selectReport.bind(this); this.onChangeText = this.onChangeText.bind(this); + this.updateOptions = this.updateOptions.bind(this); this.debouncedUpdateOptions = _.debounce(this.updateOptions.bind(this), 75); - - const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getSearchOptions(props.reports, props.personalDetails, '', props.betas); - this.state = { searchValue: '', - recentReports, - personalDetails, - userToInvite, + recentReports: {}, + personalDetails: {}, + userToInvite: {}, }; } @@ -186,6 +184,7 @@ class SearchPage extends Component { {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> 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 a254ca8b0d83..3bbc2b03ff6f 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -226,8 +226,14 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); + const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); + const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); + + /** Only update isCommentEmpty state if it's different from previous one */ + if (isNewCommentEmpty !== isPrevCommentEmpty) { + setIsCommentEmpty(isNewCommentEmpty); + } emojisPresentBefore.current = emojis; - setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/)); setValue(newCommentConverted); if (commentValue !== newComment) { const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment); @@ -504,9 +510,7 @@ function ComposerWithSuggestions({ if (value.length === 0) { return; } - Report.setReportWithDraft(reportID, true); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -551,6 +555,7 @@ function ComposerWithSuggestions({ setIsFullComposerAvailable={setIsFullComposerAvailable} isComposerFullSize={isComposerFullSize} value={value} + testID="composer" numberOfLines={numberOfLines} onNumberOfLinesChange={updateNumberOfLines} shouldCalculateCaretPosition 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/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 329952769a4f..2b4526af98d1 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]); @@ -108,12 +108,12 @@ function ReportActionItemSingle(props) { // If this is a report preview, display names and avatars of both people involved let secondaryAvatar = {}; - const primaryDisplayName = displayName; + const primaryDisplayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); if (displayAllActors) { // 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..2608aaf51c9b 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -313,10 +313,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; } diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 293dc3f5cd9d..1e5e11fd9fcb 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -198,23 +198,28 @@ export default compose( chatReports: { key: ONYXKEYS.COLLECTION.REPORT, selector: chatReportSelector, + initialValue: {}, }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, + initialValue: CONST.PRIORITY_MODE.DEFAULT, }, betas: { key: ONYXKEYS.BETAS, + initialValue: [], }, allReportActions: { key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, selector: reportActionsSelector, + initialValue: {}, }, policies: { key: ONYXKEYS.COLLECTION.POLICY, selector: policySelector, + initialValue: {}, }, }), )(SidebarLinksData); 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/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 && ( + + )} +