diff --git a/android/app/build.gradle b/android/app/build.gradle index a0d1a770170e..b07c66308609 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001039402 - versionName "1.3.94-2" + versionCode 1001039505 + versionName "1.3.95-5" } flavorDimensions "default" diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 7834a0ebb861..078cec114c31 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -54,6 +54,11 @@ src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'), url('/fonts/ExpensifyNewKansas-MediumItalic.woff') format('woff'); } +@font-face { + font-family: Windows Segoe UI Emoji; + src: url('/fonts/seguiemj.ttf'); +} + * { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/assets/fonts/web/seguiemj.ttf b/assets/fonts/web/seguiemj.ttf new file mode 100644 index 000000000000..3a455801aa0c Binary files /dev/null and b/assets/fonts/web/seguiemj.ttf differ diff --git a/config/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/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/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index 6debce6240ff..fc1e83701caf 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -1,5 +1,101 @@ --- -title: CSV Import -description: CSV Import +title: Import and assign company cards from CSV file +description: uploading a CSV file containing your company card transactions --- -## Resource Coming Soon! + +# Overview +Expensify offers a convenient CSV import feature for managing company card expenses when direct connections or commercial card feeds aren't available. This feature allows you to upload a CSV file containing your company card transactions and assign them to cardholders within your Expensify domain. +This feature is available on Group Workspaces and requires Domain Admin access. + +# How to import company cards via CSV +1. Download a CSV of transactions from your bank by logging into their website and finding the relevant statement. +2. Format the CSV for upload using [this template](https://s3-us-west-1.amazonaws.com/concierge-responses-expensify-com/uploads%2F1594908368712-Best+Example+CSV+for+Domains.csv) as a guide. +- At a minimum, your file must include the following columns: + - **Card Number** - each number in this column should display at least the last four digits, and you can obscure up to 12 characters +(e.g., 543212XXXXXX12334). + - **Posted Date** - use the YYYY-MM-DD format in this column (and any other date column in your spreadsheet). + - **Merchant** - the name of the individual or business that provided goods or services for the transaction. This is a free-text field. + - **Posted Amount** - use the number format in this column, and indicate negative amounts with parentheses (e.g., (335.98) for -$335.98). + - **Posted Currency** - use currency codes (e.g., USD, GBP, EUR) to indicate the currency of the posted transactions. +- You can also add mapping for Categories and Tags, but those parameters are optional. +3. Log into Expensify on your web browser. +4. Head to Settings > Domains > Domain Name > Company Cards +5. Click Manage/Import CSV +6. Create a Company Card Layout Name for your spreadsheet +7. Click Upload CSV +8. Review the mapping of your spreadsheet to ensure that the Card Number, Date, Merchant, Amount, and Currency match your data. +9. Double-check the Output Preview for any errors and, if needed, refer to the common error solutions listed in the FAQ below. +10. Once the mapping is correct, click Submit Spreadsheet to complete the import. +11. After submitting the spreadsheet, click I'll wait a minute. Then, wait about 1-2 minutes for the import to process. The domain page will refresh once the upload is complete. + +# How to assign new cards +If you're assigning cards via CSV upload for the first time: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Find the new CSV feed in the drop-down list underneath **Imported Cards** +3. Click **Assign New Cards** +4. Under **Assign a Card**, enter the relevant info +5. Click **Assign** +From there, transactions will be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval. + +# How to upload new expenses for existing assigned cards +There's no need to create a new upload layout for subsequent CSV uploads. Instead, add new expenses to the existing CSV: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Click **Manage/Import CSV** +3. Select the saved layout from the drop-down list +4. Click **Upload CSV** +5. After uploading the more recent CSV, click **Update All Cards** to retrieve the new expenses for the assigned cards. + +# Deep dive +If the CSV upload isn't formatted correctly, it will cause issues when you try to import or assign cards. Let's go over some common issues and how to fix them. + +## Error: "Attribute value mapping is missing" +If you encounter an error that says "Attribute-value mapping is missing," the spreadsheet likely lacks critical details like Card Number, Date, Merchant, Amount, or Currency. To resolve: +1. Click the **X** at the top of the page to close the mapping window +2. Confirm what's missing from the spreadsheet +3. Add a new column to your spreadsheet and add the missing detail +4. Upload the revised spreadsheet by clicking **Manage Spreadsheet** +5. Enter a **Company Card Layout Name** for the contents of your spreadsheet +6. Click **Upload CSV** + +## Error: "We've detected an error while processing your spreadsheet feed" +This error usually occurs when there's an upload issue. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + +## Error: "An unexpected error occurred, and we could not retrieve the list of cards" +This error occurs when there's an issue uploading the spreadsheet or the upload fails. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + + +## I added a new parameter to an existing spreadsheet, but the data isn't showing in Expensify after the upload completes. What's going on? +If you added a new card to an existing spreadsheet and imported it via a saved layout, but it isn't showing up for assignment, this suggests that the modification may have caused an issue. +The next step in troubleshooting this issue is to compare the number of rows on the revised spreadsheet to the Output Preview to ensure the row count matches the revised spreadsheet. +To check this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. Select your saved layout in the dropdown list +3. Click **Upload CSV** and select the revised spreadsheet +4. Compare the Output Preview row count to your revised spreadsheet to ensure they match + + +If they don't match, you'll need to revise the spreadsheet by following the CSV formatting guidelines in step 2 of "How to import company cards via CSV" above. +Once you do that, save the revised spreadsheet with a new layout name. +Then, try to upload the revised spreadsheet again: + +1. Click **Upload CSV** +2. Upload the revised file +3. Check the row count again on the Output Preview to confirm it matches the spreadsheet +4. Click **Submit Spreadsheet** + +# FAQ +## Why can't I see my CSV transactions immediately after uploading them? +Don't worry! You'll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!** + +## I'm trying to import a credit. Why isn't it uploading? +Negative expenses shouldn't include a minus sign. Instead, they should just be wrapped in parentheses. For example, to indicate "-335.98," you'll want to make sure it's formatted as "(335.98)." diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md similarity index 99% rename from docs/articles/expensify-classic/billing-and-subscriptions/Overview.md rename to docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md index b835db54cbf2..30a507a1f9df 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md @@ -1,5 +1,5 @@ --- -title: Billing in Expensify +title: Billing Overview description: An overview of how billing works in Expensify. --- # Overview diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md index ae367d25891e..7f3d83af1e6e 100644 --- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md +++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md @@ -11,12 +11,14 @@ Every expense has an Attendees field and will list the expense creator’s name ## How to Add Additional Attendees to an Expense * Go to the attendees field * Search for the names of the attendees - * The default list will be of internal attendees belonging to your workspace and domain. + * The default list will be of internal attendees belonging to your workspace and domain * External attendees are not part of your workspace or domain, so you will need to enter their name or email * Select the attendees you would like to add * Save the expense -* Once added, the list of attendees for each expense will be visible on the expense line. -* An amount per employee expense will also be displayed on the report for easy viewing +* Once added, the list of attendees for each expense will be visible on the expense line +* An amount per employee expense will also be displayed on the report for easy viewing + +![image of an expense with attendee tracking]({{site.url}}/assets/images/attendee-tracking.png){:width="100%"} # FAQ diff --git a/docs/articles/expensify-classic/expensify-card/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md similarity index 98% rename from docs/articles/expensify-classic/expensify-card/Card-Settings.md rename to docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md index a8d56f267757..3e2eb2deec46 100644 --- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md +++ b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md @@ -1,6 +1,6 @@ --- -title: Expensify Card Settings -description: Admin Card Settings and Features +title: Admin Card Settings and Features +description: An in-depth look into the Expensify Card program's admin controls and settings. --- # Overview diff --git a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md b/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md deleted file mode 100644 index 9888edd139ac..000000000000 --- a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Connect to Indirect Integration -description: Connect to Indirect Integration ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md b/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md similarity index 100% rename from docs/articles/expensify-classic/expensify-card/File-A-Dispute.md rename to docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md diff --git a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md b/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md deleted file mode 100644 index a8cddcdfdd42..000000000000 --- a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Third Party Payments -description: Third Party Payments ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/getting-started/Best-Practices.md b/docs/articles/expensify-classic/getting-started/Best-Practices.md deleted file mode 100644 index b02ea9d68fe6..000000000000 --- a/docs/articles/expensify-classic/getting-started/Best-Practices.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Best Practices -description: Best Practices ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md new file mode 100644 index 000000000000..267c938a3edf --- /dev/null +++ b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md @@ -0,0 +1,43 @@ +--- +title: Fringe Benefits +description: How to track your Fringe Benefits +--- +# Overview +If you’re looking to track and report expense data to calculate Fringe Benefits Tax (FBT), you can use Expensify’s special workflow that allows you to capture extra information and use a template to export to a spreadsheet. + +# How to set up Fringe Benefit Tax + +## Add Attendee Count Tags +First, you’ll need to add these two tags to your Workspace: +1) Number of Internal Attendees +2) Number of External Attendees + +These tags must be named exactly as written above, ensuring there are no extra spaces at the beginning or at the end. You’ll need to set the tags to be numbers 00 - 10 or whatever number you wish to go up to (up to the maximum number of attendees you would expect at any one time), one tag per number i.e. “01”, “02”, “03” etc. These tags can be added in addition to those that are pulled in from your accounting solution. Follow these [instructions](https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags#gsc.tab=0) to add tags. + +## Add Payroll Code +Go to **Settings > Workspaces > Group > _Workspace Name_ > Categories** and within the categories you wish to track FBT against, select **Edit Category** and add the code “TAG”: + +## Enable Workflow +Once you’ve added both tags (Internal Attendees and External Attendees) and added the payroll code “TAG” to FBT categories, you can send a request to Expensify at concierge@expensify.com to enable the FBT workflow. Please send the following request: +>“Can you please add the custom workflow/DEW named FRINGE_BENEFIT_TAX to my company workspace named ?” +Once the FBT workflow is enabled, it will require anything with the code “TAG” to include the two attendee count tags in order to be submitted. + + +# For Users +Once these steps are completed, users who create expenses coded with any category that has the payroll code “TAG” (e.g. Entertainment Expenses) but don’t add the internal and external attendee counts, will not be able to submit their expenses. +# For Admins +You are now able to create and run a report, which shows all expenses under these categories and also shows the number of internal and external attendees. Because we don’t presume to know all of the data points you wish to capture, you’ll need to create a Custom CSV export. +Here are a couple of examples of Excel formulas to use to report on attendees: +- `{expense:tag:ntag-1}` outputs the first tag the user chooses. +- `{expense:tag:ntag-3}` outputs the third tag the user chooses. + +Your expenses may have multiple levels of coding, i.e.: +- GL Code (Category) +- Department (Tag 1) +- Location (Tag 2) +- Number of Internal Attendees (Tag 3) +- Number of External Attendees (Tag 4) + +In the above case, you’ll want to use `{expense:tag:ntag-3}` and `{expense:tag:ntag-4}` as formulas to report on the number of internal and external attendees. + +Our article on [Custom Templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates#gsc.tab=0) shows how to create a custom CSV. diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index e9077fc40a50..ecdea4699ee0 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -64,6 +64,8 @@ Before completing the steps below, you will need Workday Report Writer access to - Note: _if there is field data you want to import that is not listed above, or you have any special requests, let your Expensify Account Manager know and we will work with you to accommodate the request._ 4. Rename the columns so they match Expensify's API key names (The full list of names are found here): - employeeID + - customField1 + - customField2 - firstName - lastName - employeeEmail diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TripCatcher.md b/docs/articles/expensify-classic/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/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md similarity index 61% rename from docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md index 3ee1c8656b4b..f2978434959b 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md @@ -1,5 +1,5 @@ --- -title: Coming Soon +title: Add Members to your Workspace description: Coming Soon --- ## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/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/assets/images/attendee-tracking.png b/docs/assets/images/attendee-tracking.png new file mode 100644 index 000000000000..1888851b2a13 Binary files /dev/null and b/docs/assets/images/attendee-tracking.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3503f7218339..1966f3862d59 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.2 + 1.3.95.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 29f6a6ea8c82..387687a2beaa 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.2 + 1.3.95.5 diff --git a/package-lock.json b/package-lock.json index cf3354f37b18..a80022853a24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "new.expensify", - "version": "1.3.94-2", + "version": "1.3.95-5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.94-2", + "version": "1.3.95-5", "hasInstallScript": true, "license": "MIT", "dependencies": { + "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-web": "0.18.15", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -21,6 +22,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -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 8a52d19d2a11..f3462a2b63bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.94-2", + "version": "1.3.95-5", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -58,6 +58,7 @@ "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { + "@dotlottie/react-player": "^1.6.3", "@expensify/react-native-web": "0.18.15", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -69,6 +70,7 @@ "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.0.1", "@kie/mock-github": "^1.0.0", + "@lottiefiles/react-lottie-player": "^3.5.3", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "^1.17.10", @@ -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 db8a9cc49dc0..9e7c1f007335 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -259,6 +259,7 @@ const CONST = { CUSTOM_STATUS: 'customStatus', NEW_DOT_TAGS: 'newDotTags', NEW_DOT_SAML: 'newDotSAML', + VIOLATIONS: 'violations', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1311,6 +1312,7 @@ const CONST = { TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, + ANY_SPACE: /\s/g, // Extract attachment's source from the data's html string ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, 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} - + { @@ -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/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/index.android.js b/src/components/DatePicker/index.android.js index d92869162d49..561fc700b6a5 100644 --- a/src/components/DatePicker/index.android.js +++ b/src/components/DatePicker/index.android.js @@ -1,5 +1,5 @@ import RNDatePicker from '@react-native-community/datetimepicker'; -import {format} from 'date-fns'; +import {format, parseISO} from 'date-fns'; import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import TextInput from '@components/TextInput'; @@ -39,7 +39,7 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain ); const date = value || defaultValue; - const dateAsText = date ? format(new Date(date), CONST.DATE.FNS_FORMAT_STRING) : ''; + const dateAsText = date ? format(parseISO(date), CONST.DATE.FNS_FORMAT_STRING) : ''; return ( <> diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js index 0f741e8db1ea..60307f70e954 100644 --- a/src/components/DatePicker/index.ios.js +++ b/src/components/DatePicker/index.ios.js @@ -1,5 +1,5 @@ import RNDatePicker from '@react-native-community/datetimepicker'; -import {format} from 'date-fns'; +import {format, parseISO} from 'date-fns'; import isFunction from 'lodash/isFunction'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Button, Keyboard, View} from 'react-native'; @@ -77,7 +77,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca setSelectedDate(date); }; - const dateAsText = dateValue ? format(new Date(dateValue), CONST.DATE.FNS_FORMAT_STRING) : ''; + const dateAsText = dateValue ? format(parseISO(dateValue), CONST.DATE.FNS_FORMAT_STRING) : ''; return ( <> diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index 3bed9ca55321..33266242c5db 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,4 +1,4 @@ -import {format, isValid} from 'date-fns'; +import {format, isValid, parseISO} from 'date-fns'; import React, {useEffect, useRef} from 'react'; import _ from 'underscore'; import TextInput from '@components/TextInput'; @@ -29,7 +29,7 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl return; } - const date = new Date(text); + const date = parseISO(text); if (isValid(date)) { onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING)); } diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js new file mode 100644 index 000000000000..69c6b6767dae --- /dev/null +++ b/src/components/FlatList/MVCPFlatList.js @@ -0,0 +1,205 @@ +/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */ +import PropTypes from 'prop-types'; +import React from 'react'; +import {FlatList} from 'react-native'; + +function mergeRefs(...args) { + return function forwardRef(node) { + args.forEach((ref) => { + if (ref == null) { + return; + } + if (typeof ref === 'function') { + ref(node); + return; + } + if (typeof ref === 'object') { + // eslint-disable-next-line no-param-reassign + ref.current = node; + return; + } + console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`); + }); + }; +} + +function useMergeRefs(...args) { + return React.useMemo( + () => mergeRefs(...args), + // eslint-disable-next-line + [...args], + ); +} + +const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, inverted, onScroll, ...props}, forwardedRef) => { + const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {}; + const scrollRef = React.useRef(null); + const prevFirstVisibleOffsetRef = React.useRef(null); + const firstVisibleViewRef = React.useRef(null); + const mutationObserverRef = React.useRef(null); + const lastScrollOffsetRef = React.useRef(0); + + const getScrollOffset = React.useCallback(() => { + if (scrollRef.current == null) { + return 0; + } + return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop; + }, [horizontal]); + + const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []); + + const scrollToOffset = React.useCallback( + (offset, animated) => { + const behavior = animated ? 'smooth' : 'instant'; + scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + }, + [horizontal], + ); + + const prepareForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const contentView = getContentView(); + if (contentView == null) { + return; + } + + const scrollOffset = getScrollOffset(); + + const contentViewLength = contentView.childNodes.length; + for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { + const subview = contentView.childNodes[inverted ? contentViewLength - i - 1 : i]; + const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; + if (subviewOffset > scrollOffset || i === contentViewLength - 1) { + prevFirstVisibleOffsetRef.current = subviewOffset; + firstVisibleViewRef.current = subview; + break; + } + } + }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal, inverted]); + + const adjustForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const firstVisibleView = firstVisibleViewRef.current; + const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current; + if (firstVisibleView == null || prevFirstVisibleOffset == null) { + return; + } + + const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop; + const delta = firstVisibleViewOffset - prevFirstVisibleOffset; + if (Math.abs(delta) > 0.5) { + const scrollOffset = getScrollOffset(); + prevFirstVisibleOffsetRef.current = firstVisibleViewOffset; + scrollToOffset(scrollOffset + delta, false); + if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) { + scrollToOffset(0, true); + } + } + }, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]); + + const setupMutationObserver = React.useCallback(() => { + const contentView = getContentView(); + if (contentView == null) { + return; + } + + mutationObserverRef.current?.disconnect(); + + const mutationObserver = new MutationObserver(() => { + // Chrome adjusts scroll position when elements are added at the top of the + // view. We want to have the same behavior as react-native / Safari so we + // reset the scroll position to the last value we got from an event. + const lastScrollOffset = lastScrollOffsetRef.current; + const scrollOffset = getScrollOffset(); + if (lastScrollOffset !== scrollOffset) { + scrollToOffset(lastScrollOffset, false); + } + + // This needs to execute after scroll events are dispatched, but + // in the same tick to avoid flickering. rAF provides the right timing. + requestAnimationFrame(() => { + adjustForMaintainVisibleContentPosition(); + }); + }); + mutationObserver.observe(contentView, { + attributes: true, + childList: true, + subtree: true, + }); + + mutationObserverRef.current = mutationObserver; + }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); + + React.useEffect(() => { + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); + + const setMergedRef = useMergeRefs(scrollRef, forwardedRef); + + const onRef = React.useCallback( + (newRef) => { + // Make sure to only call refs and re-attach listeners if the node changed. + if (newRef == null || newRef === scrollRef.current) { + return; + } + + setMergedRef(newRef); + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, + [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver], + ); + + React.useEffect(() => { + const mutationObserver = mutationObserverRef.current; + return () => { + mutationObserver?.disconnect(); + }; + }, []); + + const onScrollInternal = React.useCallback( + (ev) => { + lastScrollOffsetRef.current = getScrollOffset(); + + prepareForMaintainVisibleContentPosition(); + + onScroll?.(ev); + }, + [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll], + ); + + return ( + + ); +}); + +MVCPFlatList.displayName = 'MVCPFlatList'; +MVCPFlatList.propTypes = { + maintainVisibleContentPosition: PropTypes.shape({ + minIndexForVisible: PropTypes.number.isRequired, + autoscrollToTopThreshold: PropTypes.number, + }), + horizontal: PropTypes.bool, +}; + +MVCPFlatList.defaultProps = { + maintainVisibleContentPosition: null, + horizontal: false, +}; + +export default MVCPFlatList; diff --git a/src/components/FlatList/index.web.js b/src/components/FlatList/index.web.js new file mode 100644 index 000000000000..7299776db9bc --- /dev/null +++ b/src/components/FlatList/index.web.js @@ -0,0 +1,3 @@ +import MVCPFlatList from './MVCPFlatList'; + +export default MVCPFlatList; diff --git a/src/components/FullscreenLoadingIndicator.js b/src/components/FullscreenLoadingIndicator.js deleted file mode 100644 index 42be33ef3843..000000000000 --- a/src/components/FullscreenLoadingIndicator.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import {ActivityIndicator, StyleSheet, View} from 'react-native'; -import _ from 'underscore'; -import stylePropTypes from '@styles/stylePropTypes'; -import styles from '@styles/styles'; -import themeColors from '@styles/themes/default'; - -const propTypes = { - /** Additional style props */ - style: stylePropTypes, -}; - -const defaultProps = { - style: [], -}; - -function FullScreenLoadingIndicator(props) { - const additionalStyles = _.isArray(props.style) ? props.style : [props.style]; - return ( - - - - ); -} - -FullScreenLoadingIndicator.propTypes = propTypes; -FullScreenLoadingIndicator.defaultProps = defaultProps; -FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; - -export default FullScreenLoadingIndicator; diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx new file mode 100644 index 000000000000..b4483d2e0113 --- /dev/null +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {ActivityIndicator, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; + +type FullScreenLoadingIndicatorProps = { + style?: StyleProp; +}; + +function FullScreenLoadingIndicator({style}: FullScreenLoadingIndicatorProps) { + return ( + + + + ); +} + +FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; + +export default FullScreenLoadingIndicator; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index be70af0adb4f..9079a7f3c091 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -11,6 +11,7 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import * as Url from '@libs/Url'; import styles from '@styles/styles'; import * as Link from '@userActions/Link'; +import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -52,6 +53,10 @@ function AnchorRenderer(props) { // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) if (internalNewExpensifyPath && hasSameOrigin) { + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(internalNewExpensifyPath)) { + Session.signOutAndRedirectToSignIn(); + return; + } Navigation.navigate(internalNewExpensifyPath); return; } diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js index 54a3b0e7b07c..bece92e8fdfc 100644 --- a/src/components/IllustratedHeaderPageLayout.js +++ b/src/components/IllustratedHeaderPageLayout.js @@ -41,6 +41,7 @@ function IllustratedHeaderPageLayout({backgroundColor, children, illustration, f diff --git a/src/components/InlineErrorText.js b/src/components/InlineErrorText.js deleted file mode 100644 index 80438eea8b5f..000000000000 --- a/src/components/InlineErrorText.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import _ from 'underscore'; -import styles from '@styles/styles'; -import Text from './Text'; - -const propTypes = { - /** Text to display */ - children: PropTypes.string.isRequired, - - /** Styling for inline error text */ - // eslint-disable-next-line react/forbid-prop-types - styles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - styles: [], -}; - -function InlineErrorText(props) { - if (_.isEmpty(props.children)) { - return null; - } - - return {props.children}; -} - -InlineErrorText.propTypes = propTypes; -InlineErrorText.defaultProps = defaultProps; -InlineErrorText.displayName = 'InlineErrorText'; -export default InlineErrorText; diff --git a/src/components/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 1a1ea9a726e8..4b17766feb17 100644 --- a/src/components/NewDatePicker/CalendarPicker/index.js +++ b/src/components/NewDatePicker/CalendarPicker/index.js @@ -1,4 +1,4 @@ -import {addMonths, endOfMonth, format, getYear, isSameDay, setDate, setYear, startOfDay, subMonths} from 'date-fns'; +import {addMonths, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, subMonths} from 'date-fns'; import Str from 'expensify-common/lib/str'; import PropTypes from 'prop-types'; import React from 'react'; @@ -205,8 +205,7 @@ class CalendarPicker extends React.PureComponent { 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 = isSameDay(new Date(this.props.value), new Date(currentYearView, currentMonthView, 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/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/UserDetailsTooltip/BaseUserDetailsTooltip.web.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js index 794ab130ed96..9d89ccaa8889 100644 --- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js +++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js @@ -2,9 +2,9 @@ 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'; @@ -12,13 +12,13 @@ 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); + 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; @@ -27,7 +27,7 @@ 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 delegateUserDetails = lodashGet(personalDetails, props.delegateAccountID, {}); const delegateUserDisplayName = ReportUtils.getDisplayNameForParticipant(props.delegateAccountID); userDisplayName = `${delegateUserDisplayName} (${translate('reportAction.asCopilot')} ${userDisplayName})`; userLogin = delegateUserDetails.login; @@ -79,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/components/withNavigationFallback.js b/src/components/withNavigationFallback.js deleted file mode 100644 index a03c1155fa46..000000000000 --- a/src/components/withNavigationFallback.js +++ /dev/null @@ -1,47 +0,0 @@ -import {NavigationContext} from '@react-navigation/core'; -import React, {forwardRef, useContext, useMemo} from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -export default function (WrappedComponent) { - function WithNavigationFallback(props) { - const context = useContext(NavigationContext); - - const navigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []); - - return context ? ( - - ) : ( - - - - ); - } - WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; - WithNavigationFallback.propTypes = { - forwardedRef: refPropTypes, - }; - WithNavigationFallback.defaultProps = { - forwardedRef: undefined, - }; - - const WithNavigationFallbackWithRef = forwardRef((props, ref) => ( - - )); - - WithNavigationFallbackWithRef.displayName = `WithNavigationFallbackWithRef`; - - return WithNavigationFallbackWithRef; -} diff --git a/src/components/withNavigationFallback.tsx b/src/components/withNavigationFallback.tsx new file mode 100644 index 000000000000..aa58b12d4b01 --- /dev/null +++ b/src/components/withNavigationFallback.tsx @@ -0,0 +1,49 @@ +import {NavigationContext} from '@react-navigation/core'; +import {NavigationProp} from '@react-navigation/native'; +import {ParamListBase} from '@react-navigation/routers'; +import React, {ComponentType, ForwardedRef, forwardRef, ReactElement, RefAttributes, useContext, useMemo} from 'react'; + +type AddListenerCallback = () => void; + +type RemoveListenerCallback = () => void; + +type NavigationContextValue = { + isFocused: () => boolean; + addListener: () => AddListenerCallback; + removeListener: () => RemoveListenerCallback; +}; + +export default function (WrappedComponent: ComponentType>): (props: TProps & RefAttributes) => ReactElement | null { + function WithNavigationFallback(props: TProps, ref: ForwardedRef) { + const context = useContext(NavigationContext); + + const navigationContextValue: NavigationContextValue = useMemo( + () => ({ + isFocused: () => true, + addListener: () => () => {}, + removeListener: () => () => {}, + }), + [], + ); + + return context ? ( + + ) : ( + }> + + + ); + } + + WithNavigationFallback.displayName = 'WithNavigationFocusWithFallback'; + + return forwardRef(WithNavigationFallback); +} diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js deleted file mode 100644 index 04c6ab8e8481..000000000000 --- a/src/components/withToggleVisibilityView.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import styles from '@styles/styles'; -import refPropTypes from './refPropTypes'; - -const toggleVisibilityViewPropTypes = { - /** Whether the content is visible. */ - isVisible: PropTypes.bool, -}; - -export default function (WrappedComponent) { - function WithToggleVisibilityView(props) { - return ( - - - - ); - } - - WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; - WithToggleVisibilityView.propTypes = { - forwardedRef: refPropTypes, - - /** Whether the content is visible. */ - isVisible: PropTypes.bool, - }; - WithToggleVisibilityView.defaultProps = { - forwardedRef: undefined, - isVisible: false, - }; - - const WithToggleVisibilityViewWithRef = React.forwardRef((props, ref) => ( - - )); - - WithToggleVisibilityViewWithRef.displayName = `WithToggleVisibilityViewWithRef`; - - return WithToggleVisibilityViewWithRef; -} - -export {toggleVisibilityViewPropTypes}; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx new file mode 100644 index 000000000000..5e0204f6e06f --- /dev/null +++ b/src/components/withToggleVisibilityView.tsx @@ -0,0 +1,30 @@ +import React, {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react'; +import {View} from 'react-native'; +import {SetOptional} from 'type-fest'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import styles from '@styles/styles'; + +type ToggleVisibilityViewProps = { + /** Whether the content is visible. */ + isVisible: boolean; +}; + +export default function withToggleVisibilityView( + WrappedComponent: ComponentType>, +): (props: TProps & RefAttributes) => ReactElement | null { + function WithToggleVisibilityView({isVisible = false, ...rest}: SetOptional, ref: ForwardedRef) { + return ( + + + + ); + } + + WithToggleVisibilityView.displayName = `WithToggleVisibilityViewWithRef(${getComponentDisplayName(WrappedComponent)})`; + return React.forwardRef(WithToggleVisibilityView); +} diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js index 874f9d72b276..62a919925a53 100644 --- a/src/hooks/useDebounce.js +++ b/src/hooks/useDebounce.js @@ -1,5 +1,5 @@ import lodashDebounce from 'lodash/debounce'; -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; /** * Create and return a debounced function. @@ -27,11 +27,13 @@ export default function useDebounce(func, wait, options) { return debouncedFn.cancel; }, [func, wait, leading, maxWait, trailing]); - return (...args) => { + const debounceCallback = useCallback((...args) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }; + }, []); + + return debounceCallback; } diff --git a/src/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/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js deleted file mode 100644 index fe79e38585c4..000000000000 --- a/src/libs/Clipboard/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -import Clipboard from '@react-native-clipboard/clipboard'; - -/** - * Sets a string on the Clipboard object via @react-native-clipboard/clipboard - * - * @param {String} text - */ -const setString = (text) => { - Clipboard.setString(text); -}; - -export default { - setString, - - // We don't want to set HTML on native platforms so noop them. - canSetHtml: () => false, - setHtml: () => {}, -}; diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts new file mode 100644 index 000000000000..f78c5e4ab230 --- /dev/null +++ b/src/libs/Clipboard/index.native.ts @@ -0,0 +1,19 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import {CanSetHtml, SetHtml, SetString} from './types'; + +/** + * Sets a string on the Clipboard object via @react-native-clipboard/clipboard + */ +const setString: SetString = (text) => { + Clipboard.setString(text); +}; + +// We don't want to set HTML on native platforms so noop them. +const canSetHtml: CanSetHtml = () => false; +const setHtml: SetHtml = () => {}; + +export default { + setString, + canSetHtml, + setHtml, +}; diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.ts similarity index 68% rename from src/libs/Clipboard/index.js rename to src/libs/Clipboard/index.ts index 3fb2091c5cb1..b703b0b4d7f5 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.ts @@ -1,16 +1,34 @@ import Clipboard from '@react-native-clipboard/clipboard'; -import lodashGet from 'lodash/get'; import * as Browser from '@libs/Browser'; import CONST from '@src/CONST'; +import {CanSetHtml, SetHtml, SetString} from './types'; -const canSetHtml = () => lodashGet(navigator, 'clipboard.write'); +type ComposerSelection = { + start: number; + end: number; + direction: 'forward' | 'backward' | 'none'; +}; + +type AnchorSelection = { + anchorOffset: number; + focusOffset: number; + anchorNode: Node; + focusNode: Node; +}; + +type NullableObject = {[K in keyof T]: T[K] | null}; + +type OriginalSelection = ComposerSelection | NullableObject; + +const canSetHtml: CanSetHtml = + () => + (...args: ClipboardItems) => + navigator?.clipboard?.write([...args]); /** * Deprecated method to write the content as HTML to clipboard. - * @param {String} html HTML representation - * @param {String} text Plain text representation */ -function setHTMLSync(html, text) { +function setHTMLSync(html: string, text: string) { const node = document.createElement('span'); node.textContent = html; node.style.all = 'unset'; @@ -21,16 +39,21 @@ function setHTMLSync(html, text) { node.addEventListener('copy', (e) => { e.stopPropagation(); e.preventDefault(); - e.clipboardData.clearData(); - e.clipboardData.setData('text/html', html); - e.clipboardData.setData('text/plain', text); + e.clipboardData?.clearData(); + e.clipboardData?.setData('text/html', html); + e.clipboardData?.setData('text/plain', text); }); document.body.appendChild(node); - const selection = window.getSelection(); - const firstAnchorChild = selection.anchorNode && selection.anchorNode.firstChild; + const selection = window?.getSelection(); + + if (selection === null) { + return; + } + + const firstAnchorChild = selection.anchorNode?.firstChild; const isComposer = firstAnchorChild instanceof HTMLTextAreaElement; - let originalSelection = null; + let originalSelection: OriginalSelection | null = null; if (isComposer) { originalSelection = { start: firstAnchorChild.selectionStart, @@ -60,12 +83,14 @@ function setHTMLSync(html, text) { selection.removeAllRanges(); - if (isComposer) { + const anchorSelection = originalSelection as AnchorSelection; + + if (isComposer && 'start' in originalSelection) { firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction); - } else if (originalSelection.anchorNode && originalSelection.focusNode) { + } else if (anchorSelection.anchorNode && anchorSelection.focusNode) { // When copying to the clipboard here, the original values of anchorNode and focusNode will be null since there will be no user selection. // We are adding a check to prevent null values from being passed to setBaseAndExtent, in accordance with the standards of the Selection API as outlined here: https://w3c.github.io/selection-api/#dom-selection-setbaseandextent. - selection.setBaseAndExtent(originalSelection.anchorNode, originalSelection.anchorOffset, originalSelection.focusNode, originalSelection.focusOffset); + selection.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset); } document.body.removeChild(node); @@ -73,10 +98,8 @@ function setHTMLSync(html, text) { /** * Writes the content as HTML if the web client supports it. - * @param {String} html HTML representation - * @param {String} text Plain text representation */ -const setHtml = (html, text) => { +const setHtml: SetHtml = (html: string, text: string) => { if (!html || !text) { return; } @@ -93,8 +116,8 @@ const setHtml = (html, text) => { setHTMLSync(html, text); } else { navigator.clipboard.write([ - // eslint-disable-next-line no-undef new ClipboardItem({ + /* eslint-disable @typescript-eslint/naming-convention */ 'text/html': new Blob([html], {type: 'text/html'}), 'text/plain': new Blob([text], {type: 'text/plain'}), }), @@ -104,10 +127,8 @@ const setHtml = (html, text) => { /** * Sets a string on the Clipboard object via react-native-web - * - * @param {String} text */ -const setString = (text) => { +const setString: SetString = (text) => { Clipboard.setString(text); }; diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts new file mode 100644 index 000000000000..1d899144a2ba --- /dev/null +++ b/src/libs/Clipboard/types.ts @@ -0,0 +1,5 @@ +type SetString = (text: string) => void; +type SetHtml = (html: string, text: string) => void; +type CanSetHtml = (() => (...args: ClipboardItems) => Promise) | (() => boolean); + +export type {SetString, CanSetHtml, SetHtml}; diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 5a7da7ca08cf..58e1efa7aa65 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -32,7 +32,11 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo */ function getCommonSuffixLength(str1: string, str2: string): number { let i = 0; - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { + if (str1.length === 0 || str2.length === 0) { + return 0; + } + const minLen = Math.min(str1.length, str2.length); + while (i < minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { i++; } return i; diff --git a/src/libs/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/Permissions.ts b/src/libs/Permissions.ts index 2eb1d3b02f25..5200e5803ee3 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -46,6 +46,10 @@ function canUseTags(betas: Beta[]): boolean { return betas?.includes(CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas); } +function canUseViolations(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.VIOLATIONS) || canUseAllBetas(betas); +} + /** * Link previews are temporarily disabled. */ @@ -64,4 +68,5 @@ export default { canUseCustomStatus, canUseTags, canUseLinkPreviews, + canUseViolations, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 10630d7fb122..1e3fc5297193 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -533,7 +533,8 @@ function isExpensifyOnlyParticipantInReport(report) { */ function canCreateTaskInReport(report) { const otherReportParticipants = _.without(lodashGet(report, 'participantAccountIDs', []), currentUserAccountID); - const areExpensifyAccountsOnlyOtherParticipants = _.every(otherReportParticipants, (accountID) => _.contains(CONST.EXPENSIFY_ACCOUNT_IDS, accountID)); + const areExpensifyAccountsOnlyOtherParticipants = + otherReportParticipants.length >= 1 && _.every(otherReportParticipants, (accountID) => _.contains(CONST.EXPENSIFY_ACCOUNT_IDS, accountID)); if (areExpensifyAccountsOnlyOtherParticipants && isDM(report)) { return false; } @@ -1414,6 +1415,10 @@ function requiresAttentionFromCurrentUser(option, parentReportAction = {}) { return false; } + if (isArchivedRoom(option)) { + return false; + } + if (isArchivedRoom(getReport(option.parentReportID))) { return false; } @@ -1487,15 +1492,18 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { } if (moneyRequestReport) { let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0); - let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0); + let totalSpend = lodashGet(moneyRequestReport, 'total', 0); - if (nonReimbursableSpend + reimbursableSpend !== 0) { + if (nonReimbursableSpend + totalSpend !== 0) { // There is a possibility that if the Expense report has a negative total. // This is because there are instances where you can get a credit back on your card, // or you enter a negative expense to “offset” future expenses nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend); - reimbursableSpend = isExpenseReport(moneyRequestReport) ? reimbursableSpend * -1 : Math.abs(reimbursableSpend); - const totalDisplaySpend = nonReimbursableSpend + reimbursableSpend; + totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend); + + const totalDisplaySpend = totalSpend; + const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend; + return { nonReimbursableSpend, reimbursableSpend, @@ -2352,13 +2360,12 @@ function getOptimisticDataForParentReportAction(reportID, lastVisibleActionCreat * Builds an optimistic reportAction for the parent report when a task is created * @param {String} taskReportID - Report ID of the task * @param {String} taskTitle - Title of the task - * @param {String} taskAssignee - Email of the person assigned to the task * @param {Number} taskAssigneeAccountID - AccountID of the person assigned to the task * @param {String} text - Text of the comment * @param {String} parentReportID - Report ID of the parent report * @returns {Object} */ -function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssignee, taskAssigneeAccountID, text, parentReportID) { +function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssigneeAccountID, text, parentReportID) { const reportAction = buildOptimisticAddCommentReportAction(text); reportAction.reportAction.message[0].taskReportID = taskReportID; @@ -3923,7 +3930,6 @@ function shouldDisableRename(report, policy) { /** * Returns the onyx data needed for the task assignee chat * @param {Number} accountID - * @param {String} assigneeEmail * @param {Number} assigneeAccountID * @param {String} taskReportID * @param {String} assigneeChatReportID @@ -3932,7 +3938,7 @@ function shouldDisableRename(report, policy) { * @param {Object} assigneeChatReport * @returns {Object} */ -function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) { +function getTaskAssigneeChatOnyxData(accountID, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) { // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task let optimisticAssigneeAddComment; // Set if this is a new chat that needs to be created for the assignee @@ -4000,7 +4006,7 @@ function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID // If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned if (assigneeChatReportID !== parentReportID) { const displayname = lodashGet(allPersonalDetails, [assigneeAccountID, 'displayName']) || lodashGet(allPersonalDetails, [assigneeAccountID, 'login'], ''); - optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `assigned to ${displayname}`, parentReportID); + optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `assigned to ${displayname}`, parentReportID); const lastAssigneeCommentText = formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text); const optimisticAssigneeReport = { lastVisibleActionCreated: currentTime, @@ -4071,7 +4077,7 @@ function getIOUReportActionDisplayMessage(reportAction) { let displayMessage; if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { const {IOUReportID} = originalMessage; - const {amount, currency} = originalMessage.IOUDetails; + 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); @@ -4308,4 +4314,5 @@ export { shouldUseFullTitleToDisplay, parseReportRouteParams, getReimbursementQueuedActionMessage, + getPersonalDetailsForAccountID, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4aa708d5882d..80ed96d25d65 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -38,6 +38,7 @@ Onyx.connect({ const reportActionsForDisplay = actionsArray.filter( (reportAction, actionKey) => ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) && + !ReportActionsUtils.isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); @@ -452,7 +453,7 @@ function getOptionData( } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { - result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } } else { if (!lastMessageText) { diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 9be87312775a..19ac03228753 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1803,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; @@ -2048,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; @@ -2694,7 +2694,6 @@ function submitReport(expenseReport) { 'SubmitReport', { reportID: expenseReport.reportID, - managerEmail: expenseReport.managerEmail, managerAccountID: expenseReport.managerID, reportActionID: optimisticSubmittedReportAction.reportActionID, }, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index e716c17de8b2..9b33ff9b086e 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1,6 +1,7 @@ import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import {escapeRegExp} from 'lodash'; +import filter from 'lodash/filter'; import lodashGet from 'lodash/get'; import lodashUnion from 'lodash/union'; import Onyx from 'react-native-onyx'; @@ -74,6 +75,12 @@ Onyx.connect({ callback: (val) => (allPersonalDetails = val), }); +let reimbursementAccount; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => (reimbursementAccount = val), +}); + let allRecentlyUsedCategories = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, @@ -96,6 +103,36 @@ function updateLastAccessedWorkspace(policyID) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Check if the user has any active free policies (aka workspaces) + * + * @param {Array} policies + * @returns {Boolean} + */ +function hasActiveFreePolicy(policies) { + const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); + + if (adminFreePolicies.length === 0) { + return false; + } + + if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + return false; + } + + // If there are no add or delete pending actions the only option left is an update + // pendingAction, in which case we should return true. + return true; +} + /** * Delete the workspace * @@ -104,6 +141,7 @@ function updateLastAccessedWorkspace(policyID) { * @param {String} policyName */ function deleteWorkspace(policyID, reports, policyName) { + const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -146,6 +184,18 @@ function deleteWorkspace(policyID, reports, policyName) { value: optimisticReportActions, }; }), + + ...(!hasActiveFreePolicy(filteredPolicies) + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: null, + }, + }, + ] + : []), ]; // Restore the old report stateNum and statusNum @@ -160,6 +210,13 @@ function deleteWorkspace(policyID, reports, policyName) { oldPolicyName, }, })), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: lodashGet(reimbursementAccount, 'errors', null), + }, + }, ]; // We don't need success data since the push notification will update @@ -183,36 +240,6 @@ function isAdminOfFreePolicy(policies) { return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } -/** - * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} - */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); - - if (adminFreePolicies.length === 0) { - return false; - } - - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { - return false; - } - - // If there are no add or delete pending actions the only option left is an update - // pendingAction, in which case we should return true. - return true; -} - /** * Remove the passed members from the policy employeeList * diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index c56eab004437..1de15c1184cb 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -2000,6 +2000,12 @@ function openReportFromDeepLink(url, isAuthenticated) { navigateToConciergeChat(true); return; } + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { + Navigation.isNavigationReady().then(() => { + Session.signOutAndRedirectToSignIn(); + }); + return; + } Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH); }); }); diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 74d2f609ab9b..ba6127801102 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -871,6 +871,33 @@ function waitForUserSignIn(): Promise { }); } +/** + * check if the route can be accessed by anonymous user + * + * @param {string} route + */ + +const canAccessRouteByAnonymousUser = (route: string) => { + const reportID = ReportUtils.getReportIDFromLink(route); + if (reportID) { + return true; + } + const parsedReportRouteParams = ReportUtils.parseReportRouteParams(route); + let routeRemovedReportId = route; + if ((parsedReportRouteParams as {reportID: string})?.reportID) { + routeRemovedReportId = route.replace((parsedReportRouteParams as {reportID: string})?.reportID, ':reportID'); + } + if (route.startsWith('/')) { + routeRemovedReportId = routeRemovedReportId.slice(1); + } + const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route]; + + if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) { + return true; + } + return false; +}; + export { beginSignIn, beginAppleSignIn, @@ -900,4 +927,5 @@ export { toggleTwoFactorAuth, validateTwoFactorAuth, waitForUserSignIn, + canAccessRouteByAnonymousUser, }; diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 551b04a3a85b..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, @@ -439,7 +438,6 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi const optimisticReport = { reportName, managerID: assigneeAccountID || report.managerID, - managerEmail: assigneeEmail || report.managerEmail, pendingFields: { ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, @@ -473,7 +471,7 @@ 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}, }, ]; @@ -487,7 +485,6 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData( currentUserAccountID, - assigneeEmail, assigneeAccountID, report.reportID, assigneeChatReportID, @@ -504,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: @@ -641,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/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/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index 36fbc6e4c59d..b8675fd9cc0e 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -107,6 +107,7 @@ function AddressForm(props) { hint={props.translate('common.noPO')} renamedInputKeys={props.inputKeys} maxInputLength={CONST.FORM_CHARACTER_LIMIT} + isLimitedToUSA /> { + let startIndex = -1; + let endIndex = -1; + let currentIndex = 0; + + // Find the first character mismatch with newText + while (currentIndex < newText.length && prevText.charAt(currentIndex) === newText.charAt(currentIndex) && selection.start > currentIndex) { + currentIndex++; + } + + if (currentIndex < newText.length) { + startIndex = currentIndex; + + // if text is getting pasted over find length of common suffix and subtract it from new text length + if (selection.end - selection.start > 0) { + const commonSuffixLength = ComposerUtils.getCommonSuffixLength(prevText, newText); + endIndex = newText.length - commonSuffixLength; + } else { + endIndex = currentIndex + (newText.length - prevText.length); + } + } + + return { + startIndex, + endIndex, + diff: newText.substring(startIndex, endIndex), + }; + }, + [selection.end, selection.start], + ); + + const insertWhiteSpace = (text, index) => `${text.slice(0, index)} ${text.slice(index)}`; + const debouncedSaveReportComment = useMemo( () => _.debounce((selectedReportID, newComment) => { @@ -213,7 +258,14 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { raiseIsScrollLikelyLayoutTriggered(); - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); + const isEmojiInserted = diff.length && endIndex > startIndex && EmojiUtils.containsOnlyEmojis(diff); + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis( + isEmojiInserted ? insertWhiteSpace(commentValue, endIndex) : commentValue, + preferredSkinTone, + preferredLocale, + ); + if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (!_.isEmpty(newEmojis)) { @@ -258,13 +310,14 @@ function ComposerWithSuggestions({ } }, [ - debouncedUpdateFrequentlyUsedEmojis, - preferredLocale, + raiseIsScrollLikelyLayoutTriggered, + findNewlyAddedChars, preferredSkinTone, - reportID, + preferredLocale, setIsCommentEmpty, + debouncedUpdateFrequentlyUsedEmojis, suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, + reportID, debouncedSaveReportComment, ], ); @@ -315,14 +368,8 @@ function ComposerWithSuggestions({ * @param {Boolean} shouldAddTrailSpace */ const replaceSelectionWithText = useCallback( - (text, shouldAddTrailSpace = true) => { - const updatedText = shouldAddTrailSpace ? `${text} ` : text; - const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; - updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); - setSelection((prevSelection) => ({ - start: prevSelection.start + text.length + selectionSpaceLength, - end: prevSelection.start + text.length + selectionSpaceLength, - })); + (text) => { + updateComment(ComposerUtils.insertText(commentRef.current, selection, text)); }, [selection, updateComment], ); @@ -446,7 +493,12 @@ function ComposerWithSuggestions({ } focus(); - replaceSelectionWithText(e.key, false); + // Reset cursor to last known location + setSelection((prevSelection) => ({ + start: prevSelection.start + 1, + end: prevSelection.end + 1, + })); + replaceSelectionWithText(e.key); }, [checkComposerVisibility, focus, replaceSelectionWithText], ); @@ -510,6 +562,10 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + lastTextRef.current = value; + }, [value]); + useImperativeHandle( forwardedRef, () => ({ diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index baf93da6ccc4..2ea2dd334528 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -1,17 +1,15 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import * as Expensicons from '@components/Icon/Expensicons'; import MentionSuggestions from '@components/MentionSuggestions'; +import {usePersonalDetails} from '@components/OnyxProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import * as UserUtils from '@libs/UserUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import * as SuggestionProps from './suggestionProps'; /** @@ -29,9 +27,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** A ref to this component */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), @@ -39,7 +34,6 @@ const propTypes = { }; const defaultProps = { - personalDetails: {}, forwardedRef: null, }; @@ -49,7 +43,6 @@ function SuggestionMention({ selection, setSelection, isComposerFullSize, - personalDetails, updateComment, composerHeight, forwardedRef, @@ -57,6 +50,7 @@ function SuggestionMention({ measureParentContainer, isComposerFocused, }) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const {translate} = useLocalize(); const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -316,8 +310,4 @@ const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})(SuggestionMentionWithRef); +export default SuggestionMentionWithRef; diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index f39a70a960cf..0050b56800cc 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useImperativeHandle, useRef} from 'react'; +import {View} from 'react-native'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; import * as SuggestionProps from './suggestionProps'; @@ -108,7 +109,7 @@ function Suggestions({ }; return ( - <> + - + ); } diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 0f9d566a43e1..1e0520ae6739 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -354,11 +354,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); @@ -782,7 +787,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/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index b3efb0388364..67c3d5f5c5ec 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -316,7 +316,7 @@ function ReportActionItemMessageEdit(props) { // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { textInputRef.current.blur(); - ReportActionContextMenu.showDeleteModal(props.reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); + ReportActionContextMenu.showDeleteModal(props.reportID, props.action, true, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); return; } Report.editReportComment(props.reportID, props.action, trimmedNewDraft); diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 2b4526af98d1..e9e1ef39e417 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -108,7 +108,7 @@ function ReportActionItemSingle(props) { // If this is a report preview, display names and avatars of both people involved let secondaryAvatar = {}; - const primaryDisplayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); + const primaryDisplayName = displayName; 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; diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index f9f029881eef..28ddcd94dfb2 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; import {withNetwork} from '@components/OnyxProvider'; @@ -9,15 +10,19 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; +import usePrevious from '@hooks/usePrevious'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {isUserCreatedPolicyRoom} from '@libs/ReportUtils'; +import {didUserLogInDuringSession} from '@libs/SessionUtils'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; import reportPropTypes from '@pages/reportPropTypes'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import reportActionPropTypes from './reportActionPropTypes'; import ReportActionsList from './ReportActionsList'; @@ -54,6 +59,12 @@ const propTypes = { avatar: PropTypes.string, }), + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; @@ -64,6 +75,9 @@ const defaultProps = { isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, isLoadingNewerReportActions: false, + session: { + authTokenType: '', + }, }; function ReportActionsView(props) { @@ -76,6 +90,8 @@ function ReportActionsView(props) { const mostRecentIOUReportActionID = useInitialValue(() => ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions)); const prevNetworkRef = useRef(props.network); + const prevAuthTokenType = usePrevious(props.session.authTokenType); + const prevIsSmallScreenWidthRef = useRef(props.isSmallScreenWidth); const isFocused = useIsFocused(); @@ -118,6 +134,18 @@ function ReportActionsView(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.network, props.report, isReportFullyVisible]); + useEffect(() => { + const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; + if (wasLoginChangedDetected && didUserLogInDuringSession() && isUserCreatedPolicyRoom(props.report)) { + if (isReportFullyVisible) { + openReportIfNecessary(); + } else { + Report.reconnect(reportID); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.session, props.report, isReportFullyVisible]); + useEffect(() => { const prevIsSmallScreenWidth = prevIsSmallScreenWidthRef.current; // If the view is expanded from mobile to desktop layout @@ -261,6 +289,10 @@ function arePropsEqual(oldProps, newProps) { return false; } + if (lodashGet(oldProps.session, 'authTokenType') !== lodashGet(newProps.session, 'authTokenType')) { + return false; + } + if (oldProps.isLoadingInitialReportActions !== newProps.isLoadingInitialReportActions) { return false; } @@ -313,10 +345,6 @@ function arePropsEqual(oldProps, newProps) { return false; } - if (lodashGet(newProps, 'report.managerEmail') !== lodashGet(oldProps, 'report.managerEmail')) { - return false; - } - if (lodashGet(newProps, 'report.total') !== lodashGet(oldProps, 'report.total')) { return false; } @@ -338,4 +366,14 @@ function arePropsEqual(oldProps, newProps) { const MemoizedReportActionsView = React.memo(ReportActionsView, arePropsEqual); -export default compose(Performance.withRenderTrace({id: ' rendering'}), withWindowDimensions, withLocalize, withNetwork())(MemoizedReportActionsView); +export default compose( + Performance.withRenderTrace({id: ' rendering'}), + withWindowDimensions, + withLocalize, + withNetwork(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), +)(MemoizedReportActionsView); diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index c7b5885865df..20344a08a2c8 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -126,8 +126,12 @@ function IOUCurrencySelection(props) { }; }); - const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i'); - const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text) || searchRegex.test(currencyOption.currencyName)); + const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim().replace(CONST.REGEX.ANY_SPACE, ' ')), 'i'); + const filteredCurrencies = _.filter( + currencyOptions, + (currencyOption) => + searchRegex.test(currencyOption.text.replace(CONST.REGEX.ANY_SPACE, ' ')) || searchRegex.test(currencyOption.currencyName.replace(CONST.REGEX.ANY_SPACE, ' ')), + ); const isEmpty = searchValue.trim() && !filteredCurrencies.length; return { diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index ec5ab3a678bd..8302564cfcb7 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -1,15 +1,19 @@ import lodashGet from 'lodash/get'; +import lodashSize from 'lodash/size'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import transactionPropTypes from '@components/transactionPropTypes'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; +import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; import styles from '@styles/styles'; import * as IOU from '@userActions/IOU'; @@ -36,14 +40,18 @@ const propTypes = { /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]), + + /** Transaction that stores the distance request data */ + transaction: transactionPropTypes, }; const defaultProps = { iou: iouDefaultProps, + transaction: {}, selectedTab: undefined, }; -function MoneyRequestParticipantsPage({iou, selectedTab, route}) { +function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const {translate} = useLocalize(); const prevMoneyRequestId = useRef(iou.id); const optionsSelectorRef = useRef(); @@ -54,7 +62,9 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); - + const waypoints = lodashGet(transaction, 'comment.waypoints', {}); + const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); + const isInvalidWaypoint = lodashSize(validatedWaypoints) < 2; useEffect(() => { if (isDistanceRequest) { setHeaderTitle(translate('common.distance')); @@ -85,10 +95,12 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { }, []); useEffect(() => { + const isInvalidDistanceRequest = !isDistanceRequest || isInvalidWaypoint; + // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request if (prevMoneyRequestId.current !== iou.id) { // The ID is cleared on completing a request. In that case, we will do nothing - if (iou.id && !isDistanceRequest && !isSplitRequest) { + if (iou.id && isInvalidDistanceRequest && !isSplitRequest) { navigateBack(true); } return; @@ -100,14 +112,14 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { if (shouldReset) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (isInvalidDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { navigateBack(true); } return () => { prevMoneyRequestId.current = iou.id; }; - }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack]); + }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack, isInvalidWaypoint]); return ( `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, + }), +)(MoneyRequestParticipantsPage); diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 790793787e57..af6163729944 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -4,6 +4,8 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import Button from '@components/Button'; +import FormHelpMessage from '@components/FormHelpMessage'; import OptionsSelector from '@components/OptionsSelector'; import refPropTypes from '@components/refPropTypes'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; @@ -262,11 +264,38 @@ function MoneyRequestParticipantsSelector({ // Right now you can't split a request with a workspace and other additional participants // This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent - // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants + // the app from crashing on native when you try to do this, we'll going to show error message if you have a workspace and other participants const hasPolicyExpenseChatParticipant = _.some(participants, (participant) => participant.isPolicyExpenseChat); - const shouldShowConfirmButton = !(participants.length > 1 && hasPolicyExpenseChatParticipant); + const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = !isDistanceRequest && iouType !== CONST.IOU.TYPE.SEND; + const handleConfirmSelection = useCallback(() => { + if (shouldShowSplitBillErrorMessage) { + return; + } + + navigateToSplit(); + }, [shouldShowSplitBillErrorMessage, navigateToSplit]); + + const footerContent = ( + + {shouldShowSplitBillErrorMessage && ( + + )} +