diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 459a780ca8b4..f4f6a90ae6db 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -85,7 +85,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I verified that comments were added to code that is not self explanatory - [ ] I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing. - [ ] I verified any copy / text shown in the product is localized by adding it to `src/languages/*` files and using the [translation method](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60) - - [ ] If any non-english text was added/modified, I verified the translation was requested/reviewed in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message: + - [ ] If any non-english text was added/modified, I used [JaimeGPT](https://chatgpt.com/g/g-2dgOQl5VM-english-to-spanish-translator-aka-jaimegpt) to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message: - [ ] I verified all numbers, amounts, dates and phone numbers shown in the product are using the [localization methods](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60-L68) - [ ] I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue) - [ ] I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README. diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f7f2bd527db..38e05df288fb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009006403 - versionName "9.0.64-3" + versionCode 1009006501 + versionName "9.0.65-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/desktop/main.ts b/desktop/main.ts index 04aa3e1b478e..4f642d90da51 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -25,6 +25,14 @@ const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; // geolocation api (window.navigator.geolocation.getCurrentPosition) to work on desktop. // Source: https://github.com/electron/electron/blob/98cd16d336f512406eee3565be1cead86514db7b/docs/api/environment-variables.md#google_api_key process.env.GOOGLE_API_KEY = CONFIG.GCP_GEOLOCATION_API_KEY; +/** + * Suppresses Content Security Policy (CSP) console warnings related to 'unsafe-eval'. + * This is required because: + * 1. Webpack utilizes eval() for module bundling + * 2. The application requires 'unsafe-eval' in CSP to function properly + * Note: CSP warnings are expected and unavoidable in this context + */ +process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = CONFIG.ELECTRON_DISABLE_SECURITY_WARNINGS; app.setName('New Expensify'); diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md index aff11c059d81..b231984f61e2 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills.md @@ -11,7 +11,7 @@ You can receive bills in three ways: - Manual Upload: For physical bills, create a Bill in Expensify from the Reports page. # Bill Pay Workflow -1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group policy. +1. When a vendor or supplier sends a bill to Expensify, the document is automatically SmartScanned, and a Bill is created. This Bill is managed by the primary domain contact, who can view it on the Reports page within their default group workspace. 2. Once the Bill is ready for processing, it follows the established approval workflow. As each person approves it, the Bill appears in the next approver’s Inbox. The final approver will pay the Bill using one of the available payment methods. diff --git a/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md index 05149ebf868e..36717a421c67 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md +++ b/docs/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards.md @@ -8,7 +8,7 @@ Welcome to the world of effortless expense tracking! Connecting your personal cr ## How to connect your personal card to import expenses Importing your card or bank via Account Settings will: Automatically sync your bank/card transactions with your Expensify account. These will merge seamlessly with any SmartScanned expenses in your account. -Generate IRS-compliant eReceipts, provided your Policy Admin has enabled this feature. +Generate IRS-compliant eReceipts, provided your Workspace Admin has enabled this feature. Discover below the numerous ways to easily bring your personal card expenses into Expensify below. ### *Important terms to know:* @@ -45,7 +45,7 @@ _Please note: an OFX file type will require no editing but not all banks' OFX fi 6. Set the date format to match your CSV and adjust the currency to match your bank account currency. 7. If you've previously imported expenses for the same card, choose the default layout of a previously uploaded spreadsheet. 8. Scroll down and select which columns map to the merchant, date and amount (as a number without a currency symbol) – these are required presets which must be assigned. -9. If applicable, you can also map specific Categories and Tags as long as you don't have an integration connection to your default group policy. If you have an integration connected, you'll want to add the Categories and Tags to the expense after the expense is uploaded. +9. If applicable, you can also map specific Categories and Tags as long as you don't have an integration connection to your default group workspace. If you have an integration connected, you'll want to add the Categories and Tags to the expense after the expense is uploaded. 10. Check the preview of your selection under *Output Preview*. If everything looks good, you can then select *Add Expenses*. 11. For checking accounts, you may need to "Flip Amount Sign" as transactions are often exported as negative amounts. diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md index 0fde76c8fa92..553171d73dde 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings.md @@ -29,9 +29,9 @@ Personal Liability: Users are allowed to delete company card expenses. If you update the settings on an existing company card feed, the changes will apply to expenses imported after the date the setting is saved. The update will not affect previously imported expenses. -## Preferred policy +## Preferred workspace -Setting a preferred policy for a company card feed will ensure that the imported transactions are added to a report on the policy you set. This setting is useful when members are on multiple policies and need to ensure their company card expenses are reported to a particular policy. +Setting a preferred workspace for a company card feed will ensure that the imported transactions are added to a report on the workspace you set. This setting is useful when members are on multiple workspaces and need to ensure their company card expenses are reported to a particular workspace. # How to use Scheduled Submit with company cards All expenses must be placed on a report if they need to be approved; with Scheduled Submit, you no longer need to worry about the arduous task of employees creating their expenses, adding them to a report, and submitting them manually. All they need to do is SmartScan their receipts and Concierge will take care of the rest, on a variety of schedules that you can set according to your preferences! @@ -41,11 +41,11 @@ Concierge won't automatically submit expenses on reports that have Expense Viola An employee can add comments in the Expense Comment field or at the bottom of the report to clarify any details. ## Enable Scheduled Submit -Scheduled Submit is enabled in the Group Policy by navigating to Settings > Policies > Group > Policy Name > Reports > Scheduled Submit +Scheduled Submit is enabled in the Group Workspace by navigating to Settings > Workspaces > Group > Workspace Name > Reports > Scheduled Submit Use the toggle to enable Scheduled Submit Choose your desired frequency -If Scheduled Submit is disabled on the group policy level (or set to a manual frequency), and you have noticed expense reports are still automatically submitted to the group policy, it's likely Scheduled Submit is enabled on the user’s Individual Policy settings. +If Scheduled Submit is disabled on the group workspace level (or set to a manual frequency), and you have noticed expense reports are still automatically submitted to the group workspace, it's likely Scheduled Submit is enabled on the user’s Individual Workspace settings. # How to connect company cards to an accounting integration @@ -59,7 +59,7 @@ You're all done. After the account is set, exported expenses will be mapped to t ## Pooled GL account To export credit card expenses to a pooled GL account: -Go to Settings > Policies > Group > Policy Name > Connections > Accounting Integrations > Configure +Go to Settings > Workspaces > Group > Workspace Name > Connections > Accounting Integrations > Configure Select Credit Card / Charge Card / Bank Transaction as your Non-reimbursable export option. Please review the Export Settings page for exporting Expense Reports to NetSuite Select the Vendor/liability account you want to export all non-reimbursable expenses to. @@ -86,7 +86,7 @@ It's important to note that eReceipts are not generated for lodging expenses. Mo {% include faq-begin.md %} ## What plan/subscription is required in order to manage corporate cards? -Group Policy (Collect or Control plan only) +Group Workspace (Collect or Control plan only) ## When do my company card transactions import to Expensify? Credit card transactions are imported to Expensify once they’re posted to the bank account. This usually takes 1-3 business days between the point of purchase and when the transactions populate in your account. diff --git a/docs/articles/expensify-classic/connections/Deel.md b/docs/articles/expensify-classic/connections/Deel.md index 12e616d9657f..bdc4b89206ca 100644 --- a/docs/articles/expensify-classic/connections/Deel.md +++ b/docs/articles/expensify-classic/connections/Deel.md @@ -5,7 +5,7 @@ description: Automatically sync expenses from Expensify to Deel # Overview -This guide is for business clients who want to set up policies and synchronize expenses from Expensify to Deel. This one-way synchronization ensures that Expensify becomes the definitive source for all employee expenses. +This guide is for business clients who want to set up workspaces and synchronize expenses from Expensify to Deel. This one-way synchronization ensures that Expensify becomes the definitive source for all employee expenses. If you are a contractor or employee working for a company using Expensify, please refer to: @@ -16,7 +16,7 @@ If you are a contractor or employee working for a company using Expensify, pleas By integrating Expensify with Deel, you can utilize Expensify’s approval workflows to ensure timely payment through Deel for your team. -This process involves aligning user profiles and expense policies between Expensify and Deel. Once connected, Deel will scan for approved expenses from matched users included in selected workspaces for integration, allowing Deel to import these expenses for reimbursement. +This process involves aligning user profiles and expense workspaces between Expensify and Deel. Once connected, Deel will scan for approved expenses from matched users included in selected workspaces for integration, allowing Deel to import these expenses for reimbursement. This synchronization is one-way. Expenses and receipts logged and approved in Expensify will sync to Deel. Expenses logged in Deel will not sync to Expensify. @@ -27,7 +27,7 @@ This synchronization is one-way. Expenses and receipts logged and approved in Ex To establish a connection, make sure you have the following: - Deel Organization Manager permissions -- Expensify Admin permissions for policies you wish to integrate with Deel +- Expensify Admin permissions for workspaces you wish to integrate with Deel - A paid Expensify subscription to approve expenses and sync them to Deel Expensify Admin permissions can be intricate. Refer to [Expensify’s Introduction to Integration]([https://example.com](https://integrations.expensify.com/Integration-Server/doc/#introduction)) for more details. diff --git a/docs/articles/expensify-classic/connections/Greenhouse.md b/docs/articles/expensify-classic/connections/Greenhouse.md index b44e5a090d17..282ba33fd607 100644 --- a/docs/articles/expensify-classic/connections/Greenhouse.md +++ b/docs/articles/expensify-classic/connections/Greenhouse.md @@ -38,6 +38,6 @@ Expensify's direct integration with Greenhouse allows you to automatically send ## In Expensify: -1. Navigate to **Settings > Policies > Group > _[Workspace Name]_ > Members** +1. Navigate to **Settings > Workspaces > Group > _[Workspace Name]_ > Members** 2. The candidate you just sent to Expensify should be listed in the workspace members list 3. If the Recruiter (or Recruiting Coordinator) field was filled in in Greenhouse, the candidate will already be configured to submit reports to that recruiter for approval. If no Recruiter was selected, then the candidate will submit based on the Expensify workspace approval settings. diff --git a/docs/articles/expensify-classic/connections/QuickBooks-Time.md b/docs/articles/expensify-classic/connections/QuickBooks-Time.md index 5bbd2c4b583c..bcc06e171d4f 100644 --- a/docs/articles/expensify-classic/connections/QuickBooks-Time.md +++ b/docs/articles/expensify-classic/connections/QuickBooks-Time.md @@ -1,6 +1,6 @@ --- title: Expensify and TSheets/QuickBooks Time Integration Guide -description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify policy +description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify workspace --- # Overview diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md index aecf21acfc3f..068e4dd5bca9 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md @@ -40,18 +40,18 @@ The three options for the date your report will export with are: **Expense Reports:** Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite. -**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding policy. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills. +**Vendor Bills:** Expensify transactions export as vendor bills in NetSuite and will be mapped to the subsidiary associated with the corresponding workspace. Each report will be posted as payable to the vendor associated with the employee who submitted the report. You can also set an approval level in NetSuite for vendor bills. -**Journal Entries:** Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this policy. All the transactions will be posted to the payable account specified in the policy. You can also set an approval level in NetSuite for the journal entries. +**Journal Entries:** Expensify transactions that are set to export as journal entries in NetSuite will be mapped to the subsidiary associated with this workspace. All the transactions will be posted to the payable account specified in the workspace. You can also set an approval level in NetSuite for the journal entries. - Journal entry forms by default do not contain a customer column, so it is not possible to export customers or projects with this export option - The credit line and header level classifications are pulled from the employee record ## Export Settings for Non-Reimbursable Expenses -**Vendor Bills:** Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills. +**Vendor Bills:** Non-reimbursable expenses will be posted as a vendor bill payable to the default vendor specified in your workspace's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific vendor in NetSuite. You can also set an approval level in NetSuite for the bills. -**Journal Entries:** Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your policy's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite. +**Journal Entries:** Non-reimbursable expenses will be posted to the Journal Entries posting account selected in your workspace's connection settings. If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in NetSuite. - Expensify Card expenses will always export as Journal Entries, even if you have Expense Reports or Vendor Bills configured for non-reimbursable expenses on the Export tab - Journal entry forms do not contain a customer column, so it is not possible to export customers or projects with this export option diff --git a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md index c2dbb969d007..302277c3a45a 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md @@ -94,8 +94,8 @@ This can happen if the employee’s subsidiary in NetSuite doesn’t match what - Ensure the email on the employee record in NetSuite matches the email address of the report submitter in Expensify. - In NetSuite, make sure the employee's hire date is in the past and/or the termination date is in the future. 4. **Currency Match for Journal Entries:** - - If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify policy all match. - - In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/policy currency if necessary. + - If exporting as Journal Entries, ensure the currency for the NetSuite employee record, NetSuite subsidiary, and Expensify workspace all match. + - In NetSuite, go to the **Human Resources** tab > **Expense Report Currencies**, and add the subsidiary/workspace currency if necessary. # ExpensiError NS0024: Invalid Customer or Project Tag Employees must be listed as a resource on the customer/project in NetSuite to be able to apply it to an expense. If that isn’t set up in NetSuite, you can run into this error. diff --git a/docs/articles/expensify-classic/domains/SAML-SSO.md b/docs/articles/expensify-classic/domains/SAML-SSO.md index a6032afe8d24..da4bd5639120 100644 --- a/docs/articles/expensify-classic/domains/SAML-SSO.md +++ b/docs/articles/expensify-classic/domains/SAML-SSO.md @@ -88,7 +88,7 @@ Before getting started, you will need a verified domain and Control plan to set 6. The new trust is now created. Highlight the trust, then click *Edit claim rules* on the right. 7. Click *Add a Rule*. 8. The default option should be *Send LDAP Attributes as Claims*. Click Next. -9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a policy to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the email address attribute. +9. Depending upon how your Active Directory is set up, you may or may not have a useful email address associated with each user, or you may have a workspace to use the UPN as the user attribute for authentication. If so, using the UPN user attribute may be appropriate for you. If not, you can use the email address attribute. 10. Give the rule a name like *Get email address from AD*. Choose Active Directory as the attribute store from the dropdown list. Choose your source user attribute to pass to Expensify that has users’ email address info in it, usually either *E-Mail-Address* or *User-Principal-Name*. Select the outgoing claim type as “E-Mail Address”. Click OK. 11. Add another rule; this time, we want to *Transform an Incoming Claim*. Click Next. 12. Name the rule *Send email address*. The Incoming claim type should be *E-Mail Address*. The outgoing claim type should be *Name ID*, and the outgoing name ID format should be *Email*. Click OK. diff --git a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md index b245a26d10a0..0c0153522af3 100644 --- a/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md +++ b/docs/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription.md @@ -80,7 +80,7 @@ Once you’ve successfully downgraded to a free Expensify account, your Workspac ## Will I be charged for a monthly subscription even if I don't use SmartScans? Yes, the Monthly Subscription is prepaid and not based on activity, so you'll be charged regardless of usage. -## I'm on a group policy; do I need the monthly subscription too? -Probably not. Group policy members already have unlimited SmartScans, so there's usually no need to buy the subscription. However, you can use it for personal use if you leave your company's Workspace. +## I'm on a group workspace; do I need the monthly subscription too? +Probably not. Group workspace members already have unlimited SmartScans, so there's usually no need to buy the subscription. However, you can use it for personal use if you leave your company's Workspace. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md b/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md index 69dea87ad8ea..5d64a9de3df5 100644 --- a/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md +++ b/docs/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself.md @@ -39,7 +39,7 @@ Here’s how to determine whether a personal or group workspace might be best fo
  1. Hover over Settings, then click Workspaces.
  2. Click the Individual tab on the left.
  3. -
  4. Select the policy type that best fits your needs.
  5. +
  6. Select the workspace type that best fits your needs.
  7. Set up your workspace details including the workspace name, expense rules, categories, and more.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md index bded231d1daa..66466b57c854 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-Bootstrapped-Startups.md @@ -90,4 +90,4 @@ To view and pay bills: When you have bills to pay you can click *View all bills* under the *Manage your bills* box and we’ll keep a neatly organized list of all of the bills you can pay via ACH directly from your Expensify account. # You’re all set! -Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Concierge directly in *[new.expensify.com](https://new.expensify.com/concierge)*, or email concierge@expensify.com. Create a Collect or Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. +Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Concierge directly in *[new.expensify.com](https://new.expensify.com/concierge)*, or email concierge@expensify.com. Create a Collect or Control Workspace, and we’ll automatically assign a dedicated Setup Specialist to you. diff --git a/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md b/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md index f0d112b86e9f..61640ce69b77 100644 --- a/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md +++ b/docs/articles/expensify-classic/reports/Automatic-Receipt-Audit.md @@ -17,5 +17,5 @@ All Expensify Control plans automatically come with Concierge Receipt Audit. If **Can I disable Concierge Receipt Audit?** -All Control plan policies automatically include Concierge Receipt Audit. At this time, it cannot be disabled. +All Control plan workspaces automatically include Concierge Receipt Audit. At this time, it cannot be disabled. {% include faq-end.md %} diff --git a/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md index 7c3d8077c14d..1d5814138f6e 100644 --- a/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md +++ b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md @@ -29,7 +29,7 @@ If your workspace has automations set to automatically submit reports for approv - **Receipt required amount**: How much a single expense can cost before a receipt is required {% include info.html %} -Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. +Expensify includes certain system mandatory violations that can't be disabled, even if your workspace has violations turned off. {% include end-info.html %} # Set category rules diff --git a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md index c8be9a2728d5..04f2688eee90 100644 --- a/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md +++ b/docs/articles/expensify-classic/workspaces/Set-up-your-individual-workspace.md @@ -10,7 +10,7 @@ To set up your individual workspace, 1. Hover over Settings, then click **Workspaces**. 2. Click the **Individual** tab on the left. -3. Select the policy type that best fits your needs. +3. Select the workspace type that best fits your needs. 4. Set up your workspace details including the workspace name, expense rules, categories, and more. {% include info.html %} diff --git a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md b/docs/articles/expensify-classic/workspaces/Tax-Tracking.md index 7b859c5101b1..c47e5ed51f32 100644 --- a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md +++ b/docs/articles/expensify-classic/workspaces/Tax-Tracking.md @@ -11,9 +11,9 @@ Expensify’s tax tracking feature allows you to: # How to Enable Tax Tracking Tax tracking can be enabled in the Tax section of the Workspace settings of any Workspace, whether group or individual. ## If Connected to an Accounting Integration -If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Policies > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. +If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Workspaces > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. ## Not Connected to an Accounting Integration -If your Workspace is not connected to an accounting system, go to Settings > Policies > Group > [Workspace Name] > Tax to enable tax. +If your Workspace is not connected to an accounting system, go to Settings > Workspaces > Group > [Workspace Name] > Tax to enable tax. # Tracking Tax by Expense Category To set a different tax rate for a specific expense type in the Workspace currency, go to Settings > Workspaces > Group > [Workspace Name] > Categories page. Click "Edit Rules" next to the desired category and set the "Category default tax". This will be applied to new expenses, overriding the default Workspace currency tax rate. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dcf7c9f238a6..54084367040c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -244,12 +244,12 @@ platform :android do ENV["SUPPLY_UPLOAD_MAX_RETRIES"]="5" google_play_track_version_codes( package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', track: 'internal' ) upload_to_play_store( package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', version_code: ENV["VERSION"].to_i, track: 'internal', track_promote_to: 'production', @@ -268,11 +268,11 @@ platform :android do productionVersionCodes = google_play_track_version_codes( track: 'production', package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', ) upload_to_play_store( package_name: "org.me.mobiexpensifyg", - json_key: './android/app/android-fastlane-json-key.json', + json_key: './android-fastlane-json-key.json', version_code: productionVersionCodes.sort.last, # Get the latest version code track: 'production', rollout: '1', @@ -592,7 +592,7 @@ platform :ios do desc "Submit HybridApp to 100% rollout on App Store" lane :complete_hybrid_rollout do - api_token = Spaceship::ConnectAPI::Token.from_json_file("./ios/ios-fastlane-json-key.json") + api_token = Spaceship::ConnectAPI::Token.from_json_file("./ios-fastlane-json-key.json") Spaceship::ConnectAPI.token = api_token app = Spaceship::ConnectAPI::App.find("com.expensify.expensifylite") @@ -604,7 +604,7 @@ platform :ios do lane :submit_hybrid_for_rollout do deliver( app_identifier: "com.expensify.expensifylite", - api_key_path: "./ios/ios-fastlane-json-key.json", + api_key_path: "./ios-fastlane-json-key.json", # Skip HTML report verification force: true, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5fb52516800a..5f361a3cc53b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.64 + 9.0.65 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.64.3 + 9.0.65.1 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 09403adcc493..3f0481c530d0 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.64 + 9.0.65 CFBundleSignature ???? CFBundleVersion - 9.0.64.3 + 9.0.65.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d4ecfca41e38..29c7703f56d8 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.64 + 9.0.65 CFBundleVersion - 9.0.64.3 + 9.0.65.1 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 18d24d2d377b..0975df3d5a7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.64-3", + "version": "9.0.65-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.64-3", + "version": "9.0.65-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -91,11 +91,11 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.14.1", + "react-native-keyboard-controller": "1.14.4", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.79", + "react-native-onyx": "2.0.81", "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -30774,6 +30774,18 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, + "node_modules/lodash.bindall": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.bindall/-/lodash.bindall-4.4.0.tgz", + "integrity": "sha512-NQ+QvFohS2gPbWpyLfyuiF0ZQA3TTaJ+n0XDID5jwtMZBKE32gN5vSyy7xBVsqvJkvT/UY9dvHXIk9tZmBVF3g==", + "license": "MIT" + }, + "node_modules/lodash.clone": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", + "integrity": "sha512-GhrVeweiTD6uTmmn5hV/lzgCQhccwReIVRLHp7LT4SopOjqEZ5BbX8b5WWEtAKasjmy8hR7ZPwsYlxRCku5odg==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "dev": true, @@ -30822,10 +30834,22 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", + "license": "MIT" + }, "node_modules/lodash.throttle": { "version": "4.1.1", "license": "MIT" }, + "node_modules/lodash.transform": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", + "integrity": "sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==", + "license": "MIT" + }, "node_modules/lodash.union": { "version": "4.6.0", "dev": true, @@ -34919,6 +34943,16 @@ "resolved": "git+ssh://git@github.com/Expensify/react-native-image-size.git#cb392140db4953a283590d7cf93b4d0461baa2a9", "integrity": "sha512-kF/8fGsKoOnjPZceipRUaM9Xg9a/aKXU2Vm5eHYEKHrRt8FP39oCbaELPTb/vUKRTu1HmEGffDFzRT02BcdzYQ==" }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.6.tgz", + "integrity": "sha512-1pHnFTlBahins6UAajXUqeCOHew9l9C2C8tErnpGC3IyLJzvxD+TpYAixnCbrVS52f7+NvMttbiSI290XfwN0w==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.2.0", + "react-native": ">=0.73.0" + } + }, "node_modules/react-native-key-command": { "version": "1.0.8", "license": "MIT", @@ -34938,9 +34972,13 @@ "license": "MIT" }, "node_modules/react-native-keyboard-controller": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.1.tgz", - "integrity": "sha512-HUrZTaaDPxm94EVXlguwJB2gm6mc+VRTTzR66luFGZJZnL2SJoxN+dwsNW3twkwUVDrCPPA3U21q9YWUKVmwvg==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.4.tgz", + "integrity": "sha512-hVt9KhK2dxBNtk4xHTnKLeO9Jv7v5h2TZlIeCQkbBLMd5NIJa4ll0GxIpbuutjP1ctPdhXUVpCfQzgXXJOYlzw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.1.6" + }, "peerDependencies": { "react": "*", "react-native": "*", @@ -35727,13 +35765,17 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.79", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.79.tgz", - "integrity": "sha512-1rbhDdufp2vXmw3ttCtEXPK3p6F94nqKgqqvcRIqo6xLzgTI74rdm3Kqiyx4r6tYCTjN/TfmI/KLV+2EUShJZQ==", + "version": "2.0.81", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.81.tgz", + "integrity": "sha512-EwBqruX4lLnlk3KyZp4bst/voekLJFus7UhtvKmDuqR2Iz/FremwE04JW6YxGyc7C6KpbQrCFdWg/oF9ptRAtg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", + "lodash.bindall": "^4.4.0", + "lodash.clone": "^4.5.0", + "lodash.pick": "^4.4.0", + "lodash.transform": "^4.6.0", "underscore": "^1.13.6" }, "engines": { diff --git a/package.json b/package.json index ce12f384ea45..66e729ed3875 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.64-3", + "version": "9.0.65-1", "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.", @@ -148,11 +148,11 @@ "react-native-image-picker": "^7.0.3", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9", "react-native-key-command": "^1.0.8", - "react-native-keyboard-controller": "1.14.1", + "react-native-keyboard-controller": "1.14.4", "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.79", + "react-native-onyx": "2.0.81", "react-native-pager-view": "6.5.0", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", diff --git a/patches/react-native+0.75.2+021+ReactEditText.patch b/patches/react-native+0.75.2+021+ReactEditText.patch new file mode 100644 index 000000000000..2f9e3e3284a4 --- /dev/null +++ b/patches/react-native+0.75.2+021+ReactEditText.patch @@ -0,0 +1,42 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java +@@ -418,6 +418,10 @@ public class ReactEditText extends AppCompatEditText { + return; + } + ++ maybeSetSelection(start, end); ++ } ++ ++ private void maybeSetSelection(int start, int end) { + if (start != ReactConstants.UNSET && end != ReactConstants.UNSET) { + // clamp selection values for safety + start = clampToTextLength(start); +@@ -544,7 +548,8 @@ public class ReactEditText extends AppCompatEditText { + int selectionStart = getSelectionStart(); + int selectionEnd = getSelectionEnd(); + setInputType(mStagedInputType); +- setSelection(selectionStart, selectionEnd); ++ // Restore the selection ++ maybeSetSelection(selectionStart, selectionEnd); + } + } + +@@ -1063,11 +1068,17 @@ public class ReactEditText extends AppCompatEditText { + public void onAttachedToWindow() { + super.onAttachedToWindow(); + ++ int selectionStart = getSelectionStart(); ++ int selectionEnd = getSelectionEnd(); ++ + // Used to ensure that text is selectable inside of removeClippedSubviews + // See https://github.com/facebook/react-native/issues/6805 for original + // fix that was ported to here. + + super.setTextIsSelectable(true); ++ ++ // Restore the selection since `setTextIsSelectable` changed it. ++ maybeSetSelection(selectionStart, selectionEnd); + + if (mContainsImages) { + Spanned text = getText(); \ No newline at end of file diff --git a/patches/react-native+0.75.2+021+allow-recursive-commits-with-synchronous-mount-on-android.patch b/patches/react-native+0.75.2+021+allow-recursive-commits-with-synchronous-mount-on-android.patch new file mode 100644 index 000000000000..95c3b08dc433 --- /dev/null +++ b/patches/react-native+0.75.2+021+allow-recursive-commits-with-synchronous-mount-on-android.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp b/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp +index 280ae46..572fb3d 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp ++++ b/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp +@@ -483,8 +483,10 @@ void Binding::schedulerShouldRenderTransactions( + return; + } + +- if (ReactNativeFeatureFlags:: +- allowRecursiveCommitsWithSynchronousMountOnAndroid()) { ++ // Avoid freeze and crash caused by deadlock. ++ // Remove this patch after the feature flag is enabled by default. ++ // See: https://github.com/Expensify/App/issues/52496 ++ if (true) { + std::vector pendingTransactions; + + { diff --git a/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch b/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch similarity index 96% rename from patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch rename to patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch index 6bb62155a98c..8d2d81aab40a 100644 --- a/patches/react-native-keyboard-controller+1.14.1+001+disable-android.patch +++ b/patches/react-native-keyboard-controller+1.14.4+001+disable-android.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt -index 7ef8b36..f4d44ff 100644 +index 93c20d3..df1e846 100644 --- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt @@ -74,7 +74,7 @@ class EdgeToEdgeReactViewGroup( @@ -51,7 +51,7 @@ index 7ef8b36..f4d44ff 100644 } // endregion -@@ -219,7 +219,7 @@ class EdgeToEdgeReactViewGroup( +@@ -223,7 +223,7 @@ class EdgeToEdgeReactViewGroup( fun forceStatusBarTranslucent(isStatusBarTranslucent: Boolean) { if (active && this.isStatusBarTranslucent != isStatusBarTranslucent) { this.isStatusBarTranslucent = isStatusBarTranslucent diff --git a/src/CONFIG.ts b/src/CONFIG.ts index 8a30c8bf57c2..e5e9a9d1540a 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -103,4 +103,5 @@ export default { }, // to read more about StrictMode see: contributingGuides/STRICT_MODE.md USE_REACT_STRICT_MODE_IN_DEV: false, + ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', } as const; diff --git a/src/CONST.ts b/src/CONST.ts index ef5eaf4e403b..06feb863a80a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5034,7 +5034,7 @@ const CONST = { '\n' + `Here’s how to connect to ${integrationName}:\n` + '\n' + - '1. Click your profile photo.\n' + + '1. Click the settings tab.\n' + '2. Go to Workspaces.\n' + '3. Select your workspace.\n' + '4. Click Accounting.\n' + diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index 23e0227788f2..c443b1ab8093 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -4,6 +4,7 @@ import type {ValueOf} from 'type-fest'; import type {Attachment} from '@components/Attachments/types'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; @@ -19,10 +20,13 @@ function extractAttachments( accountID, parentReportAction, reportActions, - }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry}, + reportID, + }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry; reportID: string}, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; const attachments: Attachment[] = []; + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate // and navigating back (<) shows the image preceding the first instance, not the selected duplicate's position. @@ -111,7 +115,7 @@ function extractAttachments( const actions = [...(parentReportAction ? [parentReportAction] : []), ...ReportActionsUtils.getSortedReportActions(Object.values(reportActions ?? {}))]; actions.forEach((action, key) => { - if (!ReportActionsUtils.shouldReportActionBeVisible(action, key) || ReportActionsUtils.isMoneyRequestAction(action)) { + if (!ReportActionsUtils.shouldReportActionBeVisible(action, key, canUserPerformWriteAction) || ReportActionsUtils.isMoneyRequestAction(action)) { return; } diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index a8eb614202a7..9aa619eb1cda 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -34,9 +34,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, reportID: report.reportID}); } let newIndex = newAttachments.findIndex(compareImage); diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 335f1811b3b5..3a7540f65055 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -3,7 +3,7 @@ import type {MutableRefObject} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {Keyboard, PixelRatio, View} from 'react-native'; -import type {GestureType} from 'react-native-gesture-handler'; +import type {ComposedGesture, GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import {useOnyx} from 'react-native-onyx'; import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; @@ -38,6 +38,19 @@ const viewabilityConfig = { const MIN_FLING_VELOCITY = 500; +type DeviceAwareGestureDetectorProps = { + canUseTouchScreen: boolean; + gesture: ComposedGesture | GestureType; + children: React.ReactNode; +}; + +function DeviceAwareGestureDetector({canUseTouchScreen, gesture, children}: DeviceAwareGestureDetectorProps) { + // Don't render GestureDetector on non-touchable devices to prevent unexpected pointer event capture. + // This issue is left out on touchable devices since finger touch works fine. + // See: https://github.com/Expensify/App/issues/51246 + return canUseTouchScreen ? {children} : children; +} + function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose, attachmentLink}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); @@ -76,9 +89,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined, reportID: report.reportID}); } if (isEqual(attachments, newAttachments)) { @@ -117,7 +130,19 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi onNavigate(attachment); } } - }, [report.privateNotes, reportActions, parentReportActions, compareImage, report.parentReportActionID, attachments, setDownloadButtonVisibility, onNavigate, accountID, type]); + }, [ + report.privateNotes, + reportActions, + parentReportActions, + compareImage, + report.parentReportActionID, + attachments, + setDownloadButtonVisibility, + onNavigate, + accountID, + type, + report.reportID, + ]); // Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { @@ -290,7 +315,10 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi cancelAutoHideArrow={cancelAutoHideArrows} /> - + - + diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 56461d5d9b39..493ddec5a5d0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -82,7 +82,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { if (props.childTnode.tagName === 'br') { return {'\n'}; } - if (props.childTnode.type === 'text') { + if (props.childTnode.type === 'text' && props.childTnode.tagName !== 'code') { return ( ) { return ( ); @@ -13,4 +17,4 @@ function OptionRowRendererComponent(props: CellContainerProps) { OptionRowRendererComponent.displayName = 'OptionRowRendererComponent'; -export default OptionRowRendererComponent; +export default forwardRef(OptionRowRendererComponent); diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 997106f3e649..6abf72e9e520 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -1,12 +1,15 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type {ParentNavigationSummaryParams} from '@src/languages/params'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; @@ -29,6 +32,8 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct const {workspaceName, reportName} = parentNavigationSubtitleData; const {isOffline} = useNetwork(); const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // We should not display the parent navigation subtitle if the user does not have access to the parent chat (the reportName is empty in this case) if (!reportName) { @@ -39,7 +44,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct { const parentAction = ReportActionsUtils.getReportAction(parentReportID, parentReportActionID ?? '-1'); - const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(parentAction, parentAction?.reportActionID ?? '-1'); + const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(parentAction, parentAction?.reportActionID ?? '-1', canUserPerformWriteAction); // Pop the thread report screen before navigating to the chat report. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); if (isVisibleAction && !isOffline) { @@ -52,7 +57,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct style={pressableStyles} > {!!reportName && ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 65d253fad0b5..b05d34b2351b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,5 +1,6 @@ import {useNavigationState} from '@react-navigation/native'; import {Str} from 'expensify-common'; +import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {TextInputProps} from 'react-native'; @@ -323,6 +324,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) ], ); + const prevUserQueryRef = useRef(null); useEffect(() => { Report.searchInServer(debouncedInputValue.trim()); }, [debouncedInputValue]); @@ -340,11 +342,14 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); - if (newUserQuery) { + if (newUserQuery || !isEmpty(prevUserQueryRef.current)) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } + + // Store the previous newUserQuery + prevUserQueryRef.current = newUserQuery; }, [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index b7bef18896d1..66d648f1b472 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -645,10 +645,13 @@ function BaseSelectionList( ) { return; } - // Remove the focus if the search input is empty or selected options length is changed (and allOptions length remains the same) + // Remove the focus if the search input is empty and prev search input not empty or selected options length is changed (and allOptions length remains the same) // else focus on the first non disabled item const newSelectedIndex = - textInputValue === '' || (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length) ? -1 : 0; + (isEmpty(prevTextInputValue) && textInputValue === '') || + (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && prevAllOptionsLength === flattenedSections.allOptions.length) + ? -1 + : 0; // reseting the currrent page to 1 when the user types something setCurrentPage(1); diff --git a/src/hooks/usePaginatedReportActions.ts b/src/hooks/usePaginatedReportActions.ts index a32d4f7d3dd0..791b6c47d1a0 100644 --- a/src/hooks/usePaginatedReportActions.ts +++ b/src/hooks/usePaginatedReportActions.ts @@ -2,6 +2,7 @@ import {useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; import PaginationUtils from '@libs/PaginationUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; /** @@ -11,10 +12,12 @@ function usePaginatedReportActions(reportID?: string, reportActionID?: string) { // Use `||` instead of `??` to handle empty string. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const reportIDWithDefault = reportID || '-1'; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDWithDefault}`, { canEvict: false, - selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), + selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, canUserPerformWriteAction, true), }); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportIDWithDefault}`); diff --git a/src/languages/en.ts b/src/languages/en.ts index e90626871a6d..25a77bd81e81 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1467,7 +1467,7 @@ const translations = { workflowTitle: 'Spend', workflowDescription: 'Configure a workflow from the moment spend occurs, including approval and payment.', delaySubmissionTitle: 'Delay submissions', - delaySubmissionDescription: 'Delay expense submissions based on a custom schedule, or keep this option disabled to maintain realtime spend visibility.', + delaySubmissionDescription: 'Choose a custom schedule for submitting expenses, or leave this off for realtime updates on spending.', submissionFrequency: 'Submission frequency', submissionFrequencyDateOfMonth: 'Date of month', addApprovalsTitle: 'Add approvals', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2f7d8fbc8fbc..d56c860792ef 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -666,7 +666,7 @@ const translations = { beginningOfChatHistoryDomainRoomPartTwo: ' Úsalo para chatear con colegas, compartir consejos y hacer preguntas.', beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `Este chat es con los administradores del espacio de trabajo ${workspaceName}.`, - beginningOfChatHistoryAdminRoomPartTwo: ' Use it to chat about workspace setup and more.', + beginningOfChatHistoryAdminRoomPartTwo: ' Úsalo para hablar sobre la configuración del espacio de trabajo y más.', beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => `Este chat es con todos en ${workspaceName}.`, beginningOfChatHistoryAnnounceRoomPartTwo: ` Úsalo para hablar sobre la configuración del espacio de trabajo y más.`, beginningOfChatHistoryUserRoomPartOne: 'ste chat es para todo lo relacionado con ', @@ -1171,15 +1171,15 @@ const translations = { }, updateRequiredView: { updateRequired: 'Actualización requerida', - pleaseInstall: 'Por favor, actualiza a la última versión de Nuevo Expensify', - pleaseInstallExpensifyClassic: 'Por favor, instala la última versión de Expensify', + pleaseInstall: 'Por favor, actualiza a la última versión de New Expensify', + pleaseInstallExpensifyClassic: 'Por favor, instala la última versión de Expensify', toGetLatestChanges: 'Para móvil o escritorio, descarga e instala la última versión. Para la web, actualiza tu navegador.', newAppNotAvailable: 'La App New Expensify ya no está disponible.', }, initialSettingsPage: { about: 'Acerca de', aboutPage: { - description: 'La Nueva Expensify está creada por una comunidad de desarrolladores de código abierto de todo el mundo. Ayúdanos a construir el futuro de Expensify.', + description: 'New Expensify está creada por una comunidad de desarrolladores de código abierto de todo el mundo. Ayúdanos a construir el futuro de Expensify.', appDownloadLinks: 'Enlaces para descargar la App', viewKeyboardShortcuts: 'Ver atajos de teclado', viewTheCode: 'Ver código', @@ -1268,7 +1268,7 @@ const translations = { }, passwordPage: { changePassword: 'Cambiar contraseña', - changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com como la de Nuevo Expensify.', + changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com como la de New Expensify.', currentPassword: 'Contraseña actual', newPassword: 'Nueva contraseña', newPasswordPrompt: 'La nueva contraseña debe ser diferente de la antigua y contener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', @@ -1468,7 +1468,7 @@ const translations = { workflowTitle: 'Gasto', workflowDescription: 'Configure un flujo de trabajo desde el momento en que se produce el gasto, incluida la aprobación y el pago', delaySubmissionTitle: 'Retrasar envíos', - delaySubmissionDescription: 'Retrasa la presentación de gastos en base a un calendario personalizado, o mantén esta opción desactivada para seguir viendo los gastos en tiempo real.', + delaySubmissionDescription: 'Elige una frecuencia para enviar los gastos, o dejalo desactivado para recibir actualizaciones en tiempo real sobre los gastos.', submissionFrequency: 'Frecuencia de envíos', submissionFrequencyDateOfMonth: 'Fecha del mes', addApprovalsTitle: 'Aprobaciones', @@ -1863,7 +1863,7 @@ const translations = { enterPassword: 'Escribe una contraseña', setPassword: 'Configura tu contraseña', newPasswordPrompt: 'La contraseña debe tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', - passwordFormTitle: '¡Bienvenido de vuelta al Nuevo Expensify! Por favor, elige una contraseña.', + passwordFormTitle: '¡Bienvenido de vuelta a New Expensify! Por favor, elige una contraseña.', passwordNotSet: 'No se pudo cambiar tu clave. Te hemos enviado un nuevo enlace para que intentes cambiar la clave nuevamente.', setPasswordLinkInvalid: 'El enlace para configurar tu contraseña ha expirado. Te hemos enviado un nuevo enlace a tu correo.', validateAccount: 'Verificar cuenta', @@ -2017,10 +2017,9 @@ const translations = { butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.', genericError: 'Se ha producido un error al procesar este paso. Inténtalo de nuevo.', cameraPermissionsNotGranted: 'Permiso para acceder a la cámara', - cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', + cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > New Expensify.', microphonePermissionsNotGranted: 'Permiso para acceder al micrófono', - microphoneRequestMessage: - 'Necesitamos acceso a tu micrófono para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', + microphoneRequestMessage: 'Necesitamos acceso a tu micrófono para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > New Expensify.', originalDocumentNeeded: 'Por favor, sube una imagen original de tu identificación en lugar de una captura de pantalla o imagen escaneada.', documentNeedsBetterQuality: 'Parece que tu identificación esta dañado o le faltan características de seguridad. Por favor, sube una imagen de tu documento sin daños y que se vea completamente.', @@ -4612,17 +4611,17 @@ const translations = { }, }, desktopApplicationMenu: { - mainMenu: 'Nuevo Expensify', - about: 'Sobre Nuevo Expensify', - update: 'Actualizar Nuevo Expensify', + mainMenu: 'New Expensify', + about: 'Sobre New Expensify', + update: 'Actualizar New Expensify', checkForUpdates: 'Buscar actualizaciones', toggleDevTools: 'Ver herramientas de desarrollo', viewShortcuts: 'Ver atajos de teclado', services: 'Servicios', - hide: 'Ocultar Nuevo Expensify', + hide: 'Ocultar New Expensify', hideOthers: 'Ocultar otros', showAll: 'Mostrar todos', - quit: 'Salir de Nuevo Expensify', + quit: 'Salir de New Expensify', fileMenu: 'Archivo', closeWindow: 'Cerrar ventana', editMenu: 'Editar', diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts index d999f96fb505..78eb0adecc5e 100644 --- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts +++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts @@ -20,8 +20,6 @@ type CategorizeTrackedExpenseParams = { taxCode: string; taxAmount: number; billable?: boolean; - waypoints?: string; - customUnitRateID?: string; }; export default CategorizeTrackedExpenseParams; diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index c89c0d400e72..cee4bc40d9ac 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -20,8 +20,6 @@ type ShareTrackedExpenseParams = { taxCode: string; taxAmount: number; billable?: boolean; - customUnitRateID?: string; - waypoints?: string; }; export default ShareTrackedExpenseParams; diff --git a/src/libs/Middleware/Pagination.ts b/src/libs/Middleware/Pagination.ts index bfa8183ac03b..251609d1254c 100644 --- a/src/libs/Middleware/Pagination.ts +++ b/src/libs/Middleware/Pagination.ts @@ -15,7 +15,7 @@ type PagedResource = OnyxValues[TResourc type PaginationCommonConfig = { resourceCollectionKey: TResourceKey; pageCollectionKey: TPageKey; - sortItems: (items: OnyxValues[TResourceKey]) => Array>; + sortItems: (items: OnyxValues[TResourceKey], reportID: string) => Array>; getItemID: (item: PagedResource) => string; }; @@ -96,7 +96,7 @@ const Pagination: Middleware = (requestResponse, request) => { // Create a new page based on the response const pageItems = (response.onyxData.find((data) => data.key === resourceKey)?.value ?? {}) as OnyxValues[typeof resourceCollectionKey]; - const sortedPageItems = sortItems(pageItems); + const sortedPageItems = sortItems(pageItems, resourceID); if (sortedPageItems.length === 0) { // Must have at least 1 action to create a page. Log.hmmm(`[Pagination] Did not receive any items in the response to ${request.command}`); @@ -115,7 +115,7 @@ const Pagination: Middleware = (requestResponse, request) => { const resourceCollections = resources.get(resourceCollectionKey) ?? {}; const existingItems = resourceCollections[resourceKey] ?? {}; const allItems = fastMerge(existingItems, pageItems, true); - const sortedAllItems = sortItems(allItems); + const sortedAllItems = sortItems(allItems, resourceID); const pagesCollections = pages.get(pageCollectionKey) ?? {}; const existingPages = pagesCollections[pageKey] ?? []; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 3308658fa735..bb2c6a6e92fb 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -283,11 +283,14 @@ Onyx.connect({ lastReportActions[reportID] = firstReportAction; } + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); + // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = sortedReportActions.filter( (reportAction, actionKey) => - ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey) && + ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey, canUserPerformWriteAction) && !ReportActionUtils.isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && @@ -545,7 +548,7 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails const iouReport = ReportUtils.getReportOrDraftReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); const lastIOUMoneyReportAction = allSortedReportActions[iouReport?.reportID ?? '-1']?.find( (reportAction, key): reportAction is ReportAction => - ReportActionUtils.shouldReportActionBeVisible(reportAction, key) && + ReportActionUtils.shouldReportActionBeVisible(reportAction, key, ReportUtils.canUserPerformWriteAction(report)) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && ReportActionUtils.isMoneyRequestAction(reportAction), ); diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index a354ea3d5444..8d828f457ece 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -635,7 +635,7 @@ const supportedActionTypes: ReportActionName[] = [...Object.values(otherActionTy * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid * and supported type, it's not deleted and also not closed. */ -function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string | number): boolean { +function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string | number, canUserPerformWriteAction?: boolean): boolean { if (!reportAction) { return false; } @@ -668,6 +668,13 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } + if ( + (isActionableReportMentionWhisper(reportAction) || isActionableJoinRequestPendingReportAction(reportAction) || isActionableMentionWhisper(reportAction)) && + !canUserPerformWriteAction + ) { + return false; + } + if (isTripPreview(reportAction)) { return true; } @@ -711,7 +718,7 @@ function isResolvedActionTrackExpense(reportAction: OnyxEntry): bo * Checks if a reportAction is fit for display as report last action, meaning that * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted. */ -function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry): boolean { +function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry, canUserPerformWriteAction?: boolean): boolean { if (!reportAction) { return false; } @@ -723,7 +730,7 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxInputOrEntry< // If a whisper action is the REPORT_PREVIEW action, we are displaying it. // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable. return ( - shouldReportActionBeVisible(reportAction, reportAction.reportActionID) && + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canUserPerformWriteAction) && !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) && !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction)) && !isResolvedActionTrackExpense(reportAction) @@ -756,7 +763,7 @@ function replaceBaseURLInPolicyChangeLogAction(reportAction: ReportAction): Repo return updatedReportAction; } -function getLastVisibleAction(reportID: string, actionsToMerge: Record | null> = {}): OnyxEntry { +function getLastVisibleAction(reportID: string, canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}): OnyxEntry { let reportActions: Array = []; if (!isEmpty(actionsToMerge)) { reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge ?? {}, true)) as Array< @@ -765,7 +772,7 @@ function getLastVisibleAction(reportID: string, actionsToMerge: Record shouldReportActionBeVisibleAsLastAction(action)); + const visibleReportActions = reportActions.filter((action): action is ReportAction => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); const sortedReportActions = getSortedReportActions(visibleReportActions, true); if (sortedReportActions.length === 0) { return undefined; @@ -787,10 +794,11 @@ function formatLastMessageText(lastMessageText: string) { function getLastVisibleMessage( reportID: string, + canUserPerformWriteAction?: boolean, actionsToMerge: Record | null> = {}, reportAction: OnyxInputOrEntry | undefined = undefined, ): LastVisibleMessage { - const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, actionsToMerge); + const lastVisibleAction = reportAction ?? getLastVisibleAction(reportID, canUserPerformWriteAction, actionsToMerge); const message = getReportActionMessage(lastVisibleAction); if (message && isReportMessageAttachment(message)) { @@ -831,7 +839,11 @@ function filterOutDeprecatedReportActions(reportActions: OnyxEntry | ReportAction[], shouldIncludeInvisibleActions = false): ReportAction[] { +function getSortedReportActionsForDisplay( + reportActions: OnyxEntry | ReportAction[], + canUserPerformWriteAction?: boolean, + shouldIncludeInvisibleActions = false, +): ReportAction[] { let filteredReportActions: ReportAction[] = []; if (!reportActions) { return []; @@ -841,7 +853,7 @@ function getSortedReportActionsForDisplay(reportActions: OnyxEntry shouldReportActionBeVisible(reportAction, key)) + .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction)) .map(([, reportAction]) => reportAction); } @@ -1090,9 +1102,9 @@ function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEn * When we delete certain reports, we want to check whether there are any visible actions left to display. * If there are no visible actions left (including system messages), we can hide the report from view entirely */ -function doesReportHaveVisibleActions(reportID: string, actionsToMerge: ReportActions = {}): boolean { +function doesReportHaveVisibleActions(reportID: string, canUserPerformWriteAction?: boolean, actionsToMerge: ReportActions = {}): boolean { const reportActions = Object.values(fastMerge(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? {}, actionsToMerge, true)); - const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action)); + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction)); // Exclude the task system message and the created message const visibleReportActionsWithoutTaskSystemMessage = visibleReportActions.filter((action) => !isTaskAction(action) && !isCreatedAction(action)); @@ -1485,11 +1497,12 @@ function isActionableJoinRequest(reportAction: OnyxEntry): reportA return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST); } -function getActionableJoinRequestPendingReportAction(reportID: string): OnyxEntry { - const findPendingRequest = Object.values(getAllReportActions(reportID)).find( - (reportActionItem) => isActionableJoinRequest(reportActionItem) && getOriginalMessage(reportActionItem)?.choice === ('' as JoinWorkspaceResolution), - ); +function isActionableJoinRequestPendingReportAction(reportAction: OnyxEntry): boolean { + return isActionableJoinRequest(reportAction) && getOriginalMessage(reportAction)?.choice === ('' as JoinWorkspaceResolution); +} +function getActionableJoinRequestPendingReportAction(reportID: string): OnyxEntry { + const findPendingRequest = Object.values(getAllReportActions(reportID)).find((reportActionItem) => isActionableJoinRequestPendingReportAction(reportActionItem)); return findPendingRequest; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 373a861f7c2e..164869e7127a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -441,7 +441,6 @@ type TransactionDetails = { type OptimisticIOUReport = Pick< Report, - | 'cachedTotal' | 'type' | 'chatReportID' | 'currency' @@ -2699,7 +2698,7 @@ function buildOptimisticCancelPaymentReportAction(expenseReportID: string, amoun */ function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: ReportActions = {}): LastVisibleMessage { const report = getReportOrDraftReport(reportID); - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID ?? '-1', actionsToMerge); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID ?? '-1', canUserPerformWriteAction(report), actionsToMerge); // For Chat Report with deleted parent actions, let us fetch the correct message if (ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && !isEmptyObject(report) && isChatReport(report)) { @@ -2710,7 +2709,7 @@ function getLastVisibleMessage(reportID: string | undefined, actionsToMerge: Rep } // Fetch the last visible message for report represented by reportID and based on actions to merge. - return ReportActionsUtils.getLastVisibleMessage(reportID ?? '-1', actionsToMerge); + return ReportActionsUtils.getLastVisibleMessage(reportID ?? '-1', canUserPerformWriteAction(report), actionsToMerge); } /** @@ -4553,7 +4552,6 @@ function buildOptimisticIOUReport( return { type: CONST.REPORT.TYPE.IOU, - cachedTotal: formattedTotal, chatReportID, currency, managerID: payerAccountID, @@ -8327,6 +8325,7 @@ function findPolicyExpenseChatByPolicyID(policyID: string): OnyxEntry { * A function to get the report last message. This is usually used to restore the report message preview in LHN after report actions change. * @param reportID * @param actionsToMerge + * @param canUserPerformWriteActionInReport * @returns containing the calculated message preview data of the report */ function getReportLastMessage(reportID: string, actionsToMerge?: ReportActions) { @@ -8339,7 +8338,8 @@ function getReportLastMessage(reportID: string, actionsToMerge?: ReportActions) const {lastMessageText = '', lastMessageTranslationKey = ''} = getLastVisibleMessage(reportID, actionsToMerge); if (lastMessageText || lastMessageTranslationKey) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, actionsToMerge); + const report = getReport(reportID); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, canUserPerformWriteAction(report), actionsToMerge); const lastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; result = { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 806cebd9bf7f..f4979f942363 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -41,13 +41,15 @@ Onyx.connect({ return; } const reportID = CollectionUtils.extractCollectionItemID(key); - + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); const actionsArray: ReportAction[] = ReportActionsUtils.getSortedReportActions(Object.values(actions)); // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = actionsArray.filter( - (reportAction) => ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, + (reportAction) => + ReportActionsUtils.shouldReportActionBeVisibleAsLastAction(reportAction, canUserPerformWriteAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, ); const reportAction = reportActionsForDisplay.at(-1); @@ -481,7 +483,7 @@ function getOptionData({ result.alternateText = lastMessageTextFromReport.length > 0 ? ReportUtils.formatReportLastMessageText(Parser.htmlToText(lastMessageText)) - : ReportActionsUtils.getLastVisibleMessage(report.reportID, {}, lastAction)?.lastMessageText; + : ReportActionsUtils.getLastVisibleMessage(report.reportID, result.isAllowedToComment, {}, lastAction)?.lastMessageText; if (!result.alternateText) { result.alternateText = ReportUtils.formatReportLastMessageText(getWelcomeMessage(report, policy).messageText ?? Localize.translateLocal('report.noActivityYet')); } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index e9eba8d57ef4..5853e8854b3d 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1717,8 +1717,12 @@ function getDeleteTrackExpenseInformation( }, ...(actionableWhisperReportActionID && {[actionableWhisperReportActionID]: {originalMessage: {resolution}}}), } as OnyxTypes.ReportActions; - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '-1', updatedReportAction); - const {lastMessageText = '', lastMessageHtml = ''} = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '-1', updatedReportAction); + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction); + const {lastMessageText = '', lastMessageHtml = ''} = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction); // STEP 4: Build Onyx data const optimisticData: OnyxUpdate[] = []; @@ -2613,10 +2617,6 @@ function getUpdateMoneyRequestParams( updatedMoneyRequestReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true); } - if (updatedMoneyRequestReport) { - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); - } - optimisticData.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -3389,8 +3389,6 @@ function categorizeTrackedExpense( billable?: boolean, receipt?: Receipt, createdWorkspaceParams?: CreateWorkspaceParams, - waypoints?: string, - customUnitRateID?: string, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3437,8 +3435,6 @@ function categorizeTrackedExpense( policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, - waypoints, - customUnitRateID, }; API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); @@ -3474,8 +3470,6 @@ function shareTrackedExpense( billable?: boolean, receipt?: Receipt, createdWorkspaceParams?: CreateWorkspaceParams, - waypoints?: string, - customUnitRateID?: string, ) { const {optimisticData, successData, failureData} = onyxData ?? {}; @@ -3522,8 +3516,6 @@ function shareTrackedExpense( policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, - waypoints, - customUnitRateID, }; API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); @@ -3827,8 +3819,6 @@ function trackExpense( value: recentServerValidatedWaypoints, }); - const waypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined; - switch (action) { case CONST.IOU.ACTION.CATEGORIZE: { if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { @@ -3859,8 +3849,6 @@ function trackExpense( billable, trackedReceipt, createdWorkspaceParams, - waypoints, - customUnitRateID, ); break; } @@ -3892,8 +3880,6 @@ function trackExpense( billable, trackedReceipt, createdWorkspaceParams, - waypoints, - customUnitRateID, ); break; } @@ -3922,7 +3908,7 @@ function trackExpense( receiptGpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, transactionThreadReportID: transactionThreadReportID ?? '-1', createdReportActionIDForThread: createdReportActionIDForThread ?? '-1', - waypoints, + waypoints: validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined, customUnitRateID, }; if (actionableWhisperReportActionIDParam) { @@ -5417,8 +5403,12 @@ function prepareToCleanUpMoneyRequest(transactionID: string, reportAction: OnyxT }, } as Record>; - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(iouReport?.reportID ?? '-1', updatedReportAction); - const iouReportLastMessageText = ReportActionsUtils.getLastVisibleMessage(iouReport?.reportID ?? '-1', updatedReportAction).lastMessageText; + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(iouReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction); + const iouReportLastMessageText = ReportActionsUtils.getLastVisibleMessage(iouReport?.reportID ?? '-1', canUserPerformWriteAction, updatedReportAction).lastMessageText; const shouldDeleteIOUReport = iouReportLastMessageText.length === 0 && !ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && (!transactionThreadID || shouldDeleteTransactionThread); @@ -5611,6 +5601,10 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo } if (shouldDeleteIOUReport) { + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } onyxUpdates.push( { onyxMethod: Onyx.METHOD.MERGE, @@ -5618,8 +5612,12 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo value: { hasOutstandingChildRequest: false, iouReportID: null, - lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.lastMessageText, - lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.created, + lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, { + [reportPreviewAction?.reportActionID ?? '-1']: null, + })?.lastMessageText, + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, { + [reportPreviewAction?.reportActionID ?? '-1']: null, + })?.created, }, }, { @@ -5727,14 +5725,21 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor } if (shouldDeleteIOUReport) { + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!ReportUtils.canUserPerformWriteAction(chatReport); + } optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, value: { hasOutstandingChildRequest: false, iouReportID: null, - lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.lastMessageText, - lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', {[reportPreviewAction?.reportActionID ?? '-1']: null})?.created, + lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, {[reportPreviewAction?.reportActionID ?? '-1']: null}) + ?.lastMessageText, + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport?.chatReportID ?? '-1', canUserPerformWriteAction, { + [reportPreviewAction?.reportActionID ?? '-1']: null, + })?.created, }, }); optimisticData.push({ diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 81f7116b3da7..e685af5d0faa 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -318,7 +318,11 @@ registerPaginationConfig({ nextCommand: READ_COMMANDS.GET_NEWER_ACTIONS, resourceCollectionKey: ONYXKEYS.COLLECTION.REPORT_ACTIONS, pageCollectionKey: ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES, - sortItems: (reportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true), + sortItems: (reportActions, reportID) => { + const report = ReportUtils.getReport(reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); + return ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction, true); + }, getItemID: (reportAction) => reportAction.reportActionID, }); @@ -1525,8 +1529,10 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { lastVisibleActionCreated: '', }; const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions as ReportActions); + const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); if (lastMessageText || lastMessageTranslationKey) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions as ReportActions); + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, canUserPerformWriteAction, optimisticReportActions as ReportActions); const lastVisibleActionCreated = lastVisibleAction?.created; const lastActorAccountID = lastVisibleAction?.actorAccountID; optimisticReport = { @@ -1536,7 +1542,6 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { lastActorAccountID, }; } - const report = ReportConnection.getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const didCommentMentionCurrentUser = ReportActionsUtils.didMessageMentionCurrentUser(reportAction); if (didCommentMentionCurrentUser && reportAction.created === report?.lastMentionedTime) { const reportActionsForReport = allReportActions?.[reportID]; @@ -1667,6 +1672,8 @@ function handleUserDeletedLinksInHtml(newCommentText: string, originalCommentMar /** Saves a new message for a comment. Marks the comment as edited, which will be reflected in the UI. */ function editReportComment(reportID: string, originalReportAction: OnyxEntry, textForNewComment: string, videoAttributeCache?: Record) { const originalReportID = ReportUtils.getOriginalReportID(reportID, originalReportAction); + const report = ReportUtils.getReport(originalReportID ?? '-1'); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); if (!originalReportID || !originalReportAction) { return; @@ -1732,7 +1739,7 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry) { const optimisticReportActionID = optimisticCancelReportAction.reportActionID; const parentReportAction = getParentReportAction(report); const parentReport = getParentReport(report); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // If the task report is the last visible action in the parent report, we should navigate back to the parent report - const shouldDeleteTaskReport = !ReportActionsUtils.doesReportHaveVisibleActions(report.reportID ?? '-1'); + const shouldDeleteTaskReport = !ReportActionsUtils.doesReportHaveVisibleActions(report.reportID ?? '-1', canUserPerformWriteAction); const optimisticReportAction: Partial = { pendingAction: shouldDeleteTaskReport ? CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousMessage: parentReportAction?.message, @@ -1010,8 +1011,14 @@ function deleteTask(report: OnyxEntry) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${parentReport?.reportID}`, value: { - lastMessageText: ReportActionsUtils.getLastVisibleMessage(parentReport?.reportID ?? '-1', optimisticReportActions as OnyxTypes.ReportActions).lastMessageText ?? '', - lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(parentReport?.reportID ?? '-1', optimisticReportActions as OnyxTypes.ReportActions)?.created, + lastMessageText: + ReportActionsUtils.getLastVisibleMessage(parentReport?.reportID ?? '-1', canUserPerformWriteAction, optimisticReportActions as OnyxTypes.ReportActions).lastMessageText ?? + '', + lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction( + parentReport?.reportID ?? '-1', + canUserPerformWriteAction, + optimisticReportActions as OnyxTypes.ReportActions, + )?.created, hasOutstandingChildTask, }, }, diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 46f17e76c083..40e2a6094ac7 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -1,53 +1,44 @@ import {useFocusEffect} from '@react-navigation/native'; -import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import type {AuthScreensParamList} from '@libs/Navigation/types'; import * as App from '@userActions/App'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type {Session} from '@src/types/onyx'; - -type ConciergePageOnyxProps = { - /** Session info for the currently logged in user. */ - session: OnyxEntry; -}; - -type ConciergePageProps = ConciergePageOnyxProps & StackScreenProps; /* * This is a "utility page", that does this: * - If the user is authenticated, find their concierge chat and re-route to it * - Else re-route to the login page */ -function ConciergePage({session}: ConciergePageProps) { +function ConciergePage() { const styles = useThemeStyles(); const isUnmounted = useRef(false); const {shouldUseNarrowLayout} = useResponsiveLayout(); - - useFocusEffect(() => { - if (session && 'authToken' in session) { - App.confirmReadyToOpenApp(); - // Pop the concierge loading page before opening the concierge report. - Navigation.isNavigationReady().then(() => { - if (isUnmounted.current) { - return; - } - Report.navigateToConciergeChat(true, () => !isUnmounted.current); - }); - } else { - Navigation.navigate(); - } - }); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + + useFocusEffect( + useCallback(() => { + if (session && 'authToken' in session) { + App.confirmReadyToOpenApp(); + Navigation.isNavigationReady().then(() => { + if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData) { + return; + } + Report.navigateToConciergeChat(true, () => !isUnmounted.current); + }); + } else { + Navigation.navigate(); + } + }, [session, isLoadingReportData]), + ); useEffect(() => { isUnmounted.current = false; @@ -68,8 +59,4 @@ function ConciergePage({session}: ConciergePageProps) { ConciergePage.displayName = 'ConciergePage'; -export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(ConciergePage); +export default ConciergePage; diff --git a/src/pages/Debug/Report/DebugReportActions.tsx b/src/pages/Debug/Report/DebugReportActions.tsx index 3c08eb5bfdb1..9368ca5116bd 100644 --- a/src/pages/Debug/Report/DebugReportActions.tsx +++ b/src/pages/Debug/Report/DebugReportActions.tsx @@ -10,6 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {ReportAction} from '@src/types/onyx'; @@ -21,9 +22,11 @@ type DebugReportActionsProps = { function DebugReportActions({reportID}: DebugReportActionsProps) { const {translate, datetimeToCalendarTime} = useLocalize(); const styles = useThemeStyles(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); const [sortedAllReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { canEvict: false, - selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, true), + selector: (allReportActions) => ReportActionsUtils.getSortedReportActionsForDisplay(allReportActions, canUserPerformWriteAction, true), }); const renderItem = ({item}: ListRenderItemInfo) => ( ; -}; - -type InviteReportParticipantsPageProps = InviteReportParticipantsPageOnyxProps & WithReportOrNotFoundProps & WithNavigationTransitionEndProps; +type InviteReportParticipantsPageProps = WithReportOrNotFoundProps & WithNavigationTransitionEndProps; type Sections = Array>>; -function InviteReportParticipantsPage({betas, personalDetails, report, didScreenTransitionEnd}: InviteReportParticipantsPageProps) { +function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: InviteReportParticipantsPageProps) { const route = useRoute>(); const {options, areOptionsInitialized} = useOptionsList({ shouldInitialize: didScreenTransitionEnd, @@ -51,12 +45,14 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen const styles = useThemeStyles(); const {translate} = useLocalize(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); const [searchValue, debouncedSearchTerm, setSearchValue] = useDebouncedState(userSearchPhrase ?? ''); const [selectedOptions, setSelectedOptions] = useState([]); useEffect(() => { UserSearchPhraseActions.updateUserSearchPhrase(debouncedSearchTerm); + Report.searchInServer(debouncedSearchTerm); }, [debouncedSearchTerm]); // Any existing participants and Expensify emails should not be eligible for invitation @@ -259,12 +255,4 @@ function InviteReportParticipantsPage({betas, personalDetails, report, didScreen InviteReportParticipantsPage.displayName = 'InviteReportParticipantsPage'; -export default withNavigationTransitionEnd( - withReportOrNotFound()( - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - })(InviteReportParticipantsPage), - ), -); +export default withNavigationTransitionEnd(withReportOrNotFound()(InviteReportParticipantsPage)); diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 796e127dbbc0..60c01e6f75f0 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -278,7 +278,15 @@ function AdvancedSearchFilters() { const personalDetails = usePersonalDetails(); const [policies = {}] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [allPolicyCategories = {}] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [allPolicyCategories = {}] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES, { + selector: (policyCategories) => + Object.fromEntries( + Object.entries(policyCategories ?? {}).filter(([, categories]) => { + const availableCategories = Object.values(categories ?? {}).filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return availableCategories.length > 0; + }), + ), + }); const singlePolicyCategories = allPolicyCategories[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; const [allPolicyTagLists = {}] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const singlePolicyTagLists = allPolicyTagLists[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index c26e8aeeb155..507ab593eafb 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -18,6 +18,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import {hasSeenTourSelector} from '@libs/onboardingSelectors'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {getNavatticURL} from '@libs/TourUtils'; @@ -25,6 +26,8 @@ import * as TripsResevationUtils from '@libs/TripReservationUtils'; import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import * as Link from '@userActions/Link'; +import * as Task from '@userActions/Task'; +import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -103,9 +106,15 @@ function EmptySearchView({type}: EmptySearchViewProps) { ); }, [styles, translate, ctaErrorMessage]); - const [onboardingPurpose] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: (introSelected) => introSelected?.choice}); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const onboardingPurpose = introSelected?.choice; const {environment} = useEnvironment(); const navatticURL = getNavatticURL(environment, onboardingPurpose); + const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasSeenTourSelector, + }); + const viewTourTaskReportID = introSelected?.viewTour; + const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`); const content = useMemo(() => { switch (type) { @@ -132,7 +141,18 @@ function EmptySearchView({type}: EmptySearchViewProps) { title: translate('search.searchResults.emptyExpenseResults.title'), subtitle: translate('search.searchResults.emptyExpenseResults.subtitle'), buttons: [ - {buttonText: translate('emptySearchView.takeATour'), buttonAction: () => Link.openExternalLink(navatticURL)}, + ...(!hasSeenTour + ? [ + { + buttonText: translate('emptySearchView.takeATour'), + buttonAction: () => { + Link.openExternalLink(navatticURL); + Welcome.setSelfTourViewed(); + Task.completeTask(viewTourTaskReport); + }, + }, + ] + : []), { buttonText: translate('iou.createExpense'), buttonAction: () => @@ -171,6 +191,8 @@ function EmptySearchView({type}: EmptySearchViewProps) { ctaErrorMessage, navatticURL, shouldRedirectToExpensifyClassic, + hasSeenTour, + viewTourTaskReport, ]); return ( diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 97057770baca..7a99d2144f82 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -332,7 +332,10 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro ? reportActions.length > 0 : reportActions.length >= CONST.REPORT.MIN_INITIAL_REPORT_ACTION_COUNT || isPendingActionExist || (doesCreatedActionExists() && reportActions.length > 0); - const isLinkedActionDeleted = useMemo(() => !!linkedAction && !ReportActionsUtils.shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID), [linkedAction]); + const isLinkedActionDeleted = useMemo( + () => !!linkedAction && !ReportActionsUtils.shouldReportActionBeVisible(linkedAction, linkedAction.reportActionID, ReportUtils.canUserPerformWriteAction(report)), + [linkedAction, report], + ); const prevIsLinkedActionDeleted = usePrevious(linkedAction ? isLinkedActionDeleted : undefined); const isLinkedActionInaccessibleWhisper = useMemo( () => !!linkedAction && ReportActionsUtils.isWhisperAction(linkedAction) && !(linkedAction?.whisperedToAccountIDs ?? []).includes(currentUserAccountID), diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index 4612a2f84f66..d66b91436b79 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -106,57 +106,65 @@ function ReportActionItemParentAction({ {/* eslint-disable-next-line react-compiler/react-compiler */} - {allAncestors.map((ancestor) => ( - Report.navigateToConciergeChatAndDeleteReport(ancestor.report.reportID)} - > - - {ReportActionsUtils.isTripPreview(ancestor?.reportAction) ? ( - - - - ) : ( - { - const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(ancestor.reportAction, ancestor.reportAction.reportActionID ?? '-1'); - // Pop the thread report screen before navigating to the chat report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID ?? '-1')); - if (isVisibleAction && !isOffline) { - // Pop the chat report screen before navigating to the linked report action. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID ?? '-1', ancestor.reportAction.reportActionID)); - } - } - : undefined - } - parentReportAction={parentReportAction} - report={ancestor.report} - reportActions={reportActions} - transactionThreadReport={transactionThreadReport} - action={ancestor.reportAction} - displayAsGroup={false} - isMostRecentIOUReportAction={false} - shouldDisplayNewMarker={ancestor.shouldDisplayNewMarker} - index={index} - isFirstVisibleReportAction={isFirstVisibleReportAction} - shouldUseThreadDividerLine={shouldUseThreadDividerLine} - hideThreadReplies + {allAncestors.map((ancestor) => { + const ancestorReport = ReportUtils.getReport(ancestor.report.reportID); + const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(ancestorReport); + return ( + Report.navigateToConciergeChatAndDeleteReport(ancestor.report.reportID)} + > + - )} - - ))} + {ReportActionsUtils.isTripPreview(ancestor?.reportAction) ? ( + + + + ) : ( + { + const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible( + ancestor.reportAction, + ancestor.reportAction.reportActionID ?? '-1', + canUserPerformWriteAction, + ); + // Pop the thread report screen before navigating to the chat report. + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID ?? '-1')); + if (isVisibleAction && !isOffline) { + // Pop the chat report screen before navigating to the linked report action. + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID ?? '-1', ancestor.reportAction.reportActionID)); + } + } + : undefined + } + parentReportAction={parentReportAction} + report={ancestor.report} + reportActions={reportActions} + transactionThreadReport={transactionThreadReport} + action={ancestor.reportAction} + displayAsGroup={false} + isMostRecentIOUReportAction={false} + shouldDisplayNewMarker={ancestor.shouldDisplayNewMarker} + index={index} + isFirstVisibleReportAction={isFirstVisibleReportAction} + shouldUseThreadDividerLine={shouldUseThreadDividerLine} + hideThreadReplies + /> + )} + + ); + })} {shouldDisplayReplyDivider && } ); diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 8ddea8e7e940..f8b58cffd225 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -190,9 +190,9 @@ function ReportActionsList({ ReportActionsUtils.isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && - ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID), + ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, ReportUtils.canUserPerformWriteAction(report)), ), - [sortedReportActions, isOffline], + [sortedReportActions, isOffline, report], ); const lastAction = sortedVisibleReportActions.at(0); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 31c0bc0fa752..e8512f90c01b 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -89,7 +89,8 @@ function ReportActionsView({ const route = useRoute>(); const [session] = useOnyx(ONYXKEYS.SESSION); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID ?? -1}`, { - selector: (reportActions: OnyxEntry) => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true), + selector: (reportActions: OnyxEntry) => + ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, ReportUtils.canUserPerformWriteAction(report), true), }); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID ?? -1}`); const prevTransactionThreadReport = usePrevious(transactionThreadReport); diff --git a/src/pages/home/report/ThreadDivider.tsx b/src/pages/home/report/ThreadDivider.tsx index d2ffa97f58b2..0cf34d6f40e6 100644 --- a/src/pages/home/report/ThreadDivider.tsx +++ b/src/pages/home/report/ThreadDivider.tsx @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import type {Ancestor} from '@libs/ReportUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -47,7 +48,11 @@ function ThreadDivider({ancestor, isLinkDisabled = false}: ThreadDividerProps) { ) : ( { - const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible(ancestor.reportAction, ancestor.reportAction.reportActionID ?? '-1'); + const isVisibleAction = ReportActionsUtils.shouldReportActionBeVisible( + ancestor.reportAction, + ancestor.reportAction.reportActionID ?? '-1', + ReportUtils.canUserPerformWriteAction(ancestor.report), + ); // Pop the thread report screen before navigating to the chat report. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID ?? '-1')); if (isVisibleAction && !isOffline) { diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 93d582c391c5..b88b9331c061 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -501,6 +501,9 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl isValidReport, ]); + const viewTourTaskReportID = introSelected?.viewTour; + const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`); + return ( { Welcome.setSelfTourViewed(); Link.openExternalLink(navatticURL); + Task.completeTask(viewTourTaskReport); }, }, ] diff --git a/src/types/onyx/IntroSelected.ts b/src/types/onyx/IntroSelected.ts index edeb79d34113..d8077a4a8a4a 100644 --- a/src/types/onyx/IntroSelected.ts +++ b/src/types/onyx/IntroSelected.ts @@ -11,6 +11,9 @@ type IntroSelected = { /** Whether the onboarding is complete */ isInviteOnboardingComplete?: boolean; + + /** Task reportID for 'viewTour' type */ + viewTour?: string; }; export default IntroSelected; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 7eae6affd254..4df489930e23 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -151,9 +151,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback< /** The report visibility */ visibility?: RoomVisibility; - /** Report cached total */ - cachedTotal?: string; - /** Invoice room receiver data */ invoiceReceiver?: InvoiceReceiver; diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts index 71510e498854..b3126f07827b 100644 --- a/src/types/utils/whitelistedReportKeys.ts +++ b/src/types/utils/whitelistedReportKeys.ts @@ -34,7 +34,6 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback< writeCapability: unknown; type: unknown; visibility: unknown; - cachedTotal: unknown; invoiceReceiver: unknown; lastMessageTranslationKey: unknown; parentReportID: unknown; diff --git a/tests/perf-test/ReportActionsUtils.perf-test.ts b/tests/perf-test/ReportActionsUtils.perf-test.ts index a33a448cfee7..b0ebd7c0c10a 100644 --- a/tests/perf-test/ReportActionsUtils.perf-test.ts +++ b/tests/perf-test/ReportActionsUtils.perf-test.ts @@ -89,11 +89,11 @@ describe('ReportActionsUtils', () => { } as unknown as ReportActions; await waitForBatchedUpdates(); - await measureFunction(() => ReportActionsUtils.getLastVisibleAction(reportId, actionsToMerge)); + await measureFunction(() => ReportActionsUtils.getLastVisibleAction(reportId, true, actionsToMerge)); }); test('[ReportActionsUtils] getMostRecentIOURequestActionID on 10k ReportActions', async () => { - const reportActionsArray = ReportActionsUtils.getSortedReportActionsForDisplay(reportActions); + const reportActionsArray = ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true); await waitForBatchedUpdates(); await measureFunction(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActionsArray)); @@ -127,12 +127,12 @@ describe('ReportActionsUtils', () => { } as unknown as ReportActions; await waitForBatchedUpdates(); - await measureFunction(() => ReportActionsUtils.getLastVisibleMessage(reportId, actionsToMerge)); + await measureFunction(() => ReportActionsUtils.getLastVisibleMessage(reportId, true, actionsToMerge)); }); test('[ReportActionsUtils] getSortedReportActionsForDisplay on 10k ReportActions', async () => { await waitForBatchedUpdates(); - await measureFunction(() => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions)); + await measureFunction(() => ReportActionsUtils.getSortedReportActionsForDisplay(reportActions, true)); }); test('[ReportActionsUtils] getLastClosedReportAction on 10k ReportActions', async () => { diff --git a/tests/ui/GroupChatNameTests.tsx b/tests/ui/GroupChatNameTests.tsx new file mode 100644 index 000000000000..fc383efe4e28 --- /dev/null +++ b/tests/ui/GroupChatNameTests.tsx @@ -0,0 +1,342 @@ +/* eslint-disable testing-library/no-node-access */ + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import {act, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import * as Localize from '@libs/Localize'; +import * as AppActions from '@userActions/App'; +import * as User from '@userActions/User'; +import App from '@src/App'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Participant} from '@src/types/onyx/Report'; +import PusherHelper from '../utils/PusherHelper'; +import * as TestHelper from '../utils/TestHelper'; +import {navigateToSidebarOption} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// We need a large timeout here as we are lazy loading React Navigation screens and this test is running against the entire mounted App +jest.setTimeout(50000); + +jest.mock('../../src/components/ConfirmedRoute.tsx'); + +// Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest +jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + __esModule: true, + default: { + ignoreLogs: jest.fn(), + ignoreAllLogs: jest.fn(), + }, +})); + +/** + * We need to keep track of the transitionEnd callback so we can trigger it in our tests + */ +let transitionEndCB: () => void; + +jest.mock('@react-navigation/native'); + +TestHelper.setupApp(); + +const REPORT_ID = '1'; +const USER_A_ACCOUNT_ID = 1; +const USER_A_EMAIL = 'user_a@test.com'; +const USER_B_ACCOUNT_ID = 2; +const USER_B_EMAIL = 'user_b@test.com'; +const USER_C_ACCOUNT_ID = 3; +const USER_C_EMAIL = 'user_c@test.com'; +const USER_D_ACCOUNT_ID = 4; +const USER_D_EMAIL = 'user_d@test.com'; +const USER_E_ACCOUNT_ID = 5; +const USER_E_EMAIL = 'user_e@test.com'; +const USER_F_ACCOUNT_ID = 6; +const USER_F_EMAIL = 'user_f@test.com'; +const USER_G_ACCOUNT_ID = 7; +const USER_G_EMAIL = 'user_g@test.com'; +const USER_H_ACCOUNT_ID = 8; +const USER_H_EMAIL = 'user_h@test.com'; + +/** + * Sets up a test with a logged in user. Returns the test instance. + */ +function signInAndGetApp(reportName = '', participantAccountIDs?: number[]): Promise { + // Render the App and sign in as a test user. + render(); + + const participants: Record = {}; + participantAccountIDs?.forEach((id) => { + participants[id] = { + notificationPreference: 'always', + hidden: false, + role: id === 1 ? CONST.REPORT.ROLE.ADMIN : CONST.REPORT.ROLE.MEMBER, + } as Participant; + }); + + return waitForBatchedUpdatesWithAct() + .then(async () => { + await waitForBatchedUpdatesWithAct(); + const hintText = Localize.translateLocal('loginForm.loginForm'); + const loginForm = screen.queryAllByLabelText(hintText); + expect(loginForm).toHaveLength(1); + + await act(async () => { + await TestHelper.signInWithTestUser(USER_A_ACCOUNT_ID, USER_A_EMAIL, undefined, undefined, 'A'); + }); + return waitForBatchedUpdatesWithAct(); + }) + .then(() => { + User.subscribeToUserEvents(); + return waitForBatchedUpdates(); + }) + .then(async () => { + // Simulate setting an unread report and personal details + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, { + reportID: REPORT_ID, + reportName, + lastMessageText: 'Test', + participants, + lastActorAccountID: USER_B_ACCOUNT_ID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + }); + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [USER_A_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_A_EMAIL, USER_A_ACCOUNT_ID, 'A'), + [USER_B_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_B_EMAIL, USER_B_ACCOUNT_ID, 'B'), + [USER_C_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_C_EMAIL, USER_C_ACCOUNT_ID, 'C'), + [USER_D_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_D_EMAIL, USER_D_ACCOUNT_ID, 'D'), + [USER_E_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_E_EMAIL, USER_E_ACCOUNT_ID, 'E'), + [USER_F_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_F_EMAIL, USER_F_ACCOUNT_ID, 'F'), + [USER_G_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_G_EMAIL, USER_G_ACCOUNT_ID, 'G'), + [USER_H_ACCOUNT_ID]: TestHelper.buildPersonalDetails(USER_H_EMAIL, USER_H_ACCOUNT_ID, 'H'), + }); + + // We manually setting the sidebar as loaded since the onLayout event does not fire in tests + AppActions.setSidebarLoaded(); + return waitForBatchedUpdatesWithAct(); + }); +} + +/** + * Tests for checking the group chat names at places like LHN, chat header, details page etc. + * Note that limit of 5 names is only for the header. + */ +describe('Tests for group chat name', () => { + beforeEach(() => { + jest.clearAllMocks(); + Onyx.clear(); + + // Unsubscribe to pusher channels + PusherHelper.teardown(); + }); + + const participantAccountIDs4 = [USER_A_ACCOUNT_ID, USER_B_ACCOUNT_ID, USER_C_ACCOUNT_ID, USER_D_ACCOUNT_ID]; + const participantAccountIDs8 = [...participantAccountIDs4, USER_E_ACCOUNT_ID, USER_F_ACCOUNT_ID, USER_G_ACCOUNT_ID, USER_H_ACCOUNT_ID]; + + it('Should show correctly in LHN', () => + signInAndGetApp('A, B, C, D', participantAccountIDs4).then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + return waitFor(() => expect(displayNameText?.props?.children?.[0]).toBe('A, B, C, D')); + })); + + it('Should show correctly in LHN when report name is not present', () => + signInAndGetApp('', participantAccountIDs4).then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + return waitFor(() => expect(displayNameText?.props?.children?.[0]).toBe('A, B, C, D')); + })); + + it('Should show limited names in LHN when 8 participants are present', () => + signInAndGetApp('', participantAccountIDs8).then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + return waitFor(() => expect(displayNameText?.props?.children?.[0]).toBe('A, B, C, D, E')); + })); + + it('Check if group name shows fine for report header', () => + signInAndGetApp('', participantAccountIDs4) + .then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + expect(displayNameText?.props?.children?.[0]).toBe('A, B, C, D'); + + return navigateToSidebarOption(0); + }) + .then(waitForBatchedUpdates) + .then(async () => { + await act(() => transitionEndCB?.()); + const name = 'A, B, C, D'; + const displayNameTexts = screen.queryAllByLabelText(name); + return waitFor(() => expect(displayNameTexts).toHaveLength(1)); + })); + + it('Should show only 5 names when there are 8 participants in the report header', () => + signInAndGetApp('', participantAccountIDs8) + .then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + expect(displayNameText?.props?.children?.[0]).toBe('A, B, C, D, E'); + + return navigateToSidebarOption(0); + }) + .then(waitForBatchedUpdates) + .then(async () => { + await act(() => transitionEndCB?.()); + const name = 'A, B, C, D, E'; + const displayNameTexts = screen.queryAllByLabelText(name); + return waitFor(() => expect(displayNameTexts).toHaveLength(1)); + })); + + it('Should show exact name in header when report name is available with 4 participants', () => + signInAndGetApp('Test chat', participantAccountIDs4) + .then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + expect(displayNameText?.props?.children?.[0]).toBe('Test chat'); + + return navigateToSidebarOption(0); + }) + .then(waitForBatchedUpdates) + .then(async () => { + await act(() => transitionEndCB?.()); + const name = 'Test chat'; + const displayNameTexts = screen.queryAllByLabelText(name); + return waitFor(() => expect(displayNameTexts).toHaveLength(1)); + })); + + it('Should show exact name in header when report name is available with 8 participants', () => + signInAndGetApp("Let's talk", participantAccountIDs8) + .then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + expect(displayNameText?.props?.children?.[0]).toBe("Let's talk"); + + return navigateToSidebarOption(0); + }) + .then(waitForBatchedUpdates) + .then(async () => { + await act(() => transitionEndCB?.()); + const name = "Let's talk"; + const displayNameTexts = screen.queryAllByLabelText(name); + return waitFor(() => expect(displayNameTexts).toHaveLength(1)); + })); + + it('Should show last message preview in LHN', () => + signInAndGetApp('A, B, C, D', participantAccountIDs4).then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const lastChatHintText = Localize.translateLocal('accessibilityHints.lastChatMessagePreview'); + const lastChatText = screen.queryByLabelText(lastChatHintText); + + return waitFor(() => expect(lastChatText?.props?.children).toBe('B: Test')); + })); + + it('Should sort the names before displaying', () => + signInAndGetApp('', [USER_E_ACCOUNT_ID, ...participantAccountIDs4]).then(() => { + // Verify the sidebar links are rendered + const sidebarLinksHintText = Localize.translateLocal('sidebarScreen.listOfChats'); + const sidebarLinks = screen.queryAllByLabelText(sidebarLinksHintText); + expect(sidebarLinks).toHaveLength(1); + + // Verify there is only one option in the sidebar + const optionRowsHintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRows = screen.queryAllByAccessibilityHint(optionRowsHintText); + expect(optionRows).toHaveLength(1); + + const displayNameHintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); + const displayNameText = screen.queryByLabelText(displayNameHintText); + + return waitFor(() => expect(displayNameText?.props?.children?.[0]).toBe('A, B, C, D, E')); + })); +}); diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 576696ef1965..e6acd3e9a19d 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -25,6 +25,7 @@ import type {ReportAction, ReportActions} from '@src/types/onyx'; import type {NativeNavigationMock} from '../../__mocks__/@react-navigation/native'; import PusherHelper from '../utils/PusherHelper'; import * as TestHelper from '../utils/TestHelper'; +import {navigateToSidebarOption} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -82,16 +83,6 @@ function navigateToSidebar(): Promise { return waitForBatchedUpdates(); } -async function navigateToSidebarOption(index: number): Promise { - const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); - const optionRow = screen.queryAllByAccessibilityHint(hintText).at(index); - if (!optionRow) { - return; - } - fireEvent(optionRow, 'press'); - await waitForBatchedUpdatesWithAct(); -} - function areYouOnChatListScreen(): boolean { const hintText = Localize.translateLocal('sidebarScreen.listOfChats'); const sidebarLinks = screen.queryAllByLabelText(hintText); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 6437e0275863..b94740375261 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -306,7 +306,7 @@ describe('ReportActionsUtils', () => { // eslint-disable-next-line rulesdir/prefer-at const expectedOutput: ReportAction[] = [...input.slice(0, 1), ...input.slice(2), input[1]]; - const result = ReportActionsUtils.getSortedReportActionsForDisplay(input); + const result = ReportActionsUtils.getSortedReportActionsForDisplay(input, true); expect(result).toStrictEqual(expectedOutput); }); @@ -401,7 +401,7 @@ describe('ReportActionsUtils', () => { // eslint-disable-next-line rulesdir/prefer-at const expectedOutput: ReportAction[] = [...input.slice(0, 1), ...input.slice(2, -1), input[1]]; - const result = ReportActionsUtils.getSortedReportActionsForDisplay(input); + const result = ReportActionsUtils.getSortedReportActionsForDisplay(input, true); expect(result).toStrictEqual(expectedOutput); }); @@ -445,10 +445,103 @@ describe('ReportActionsUtils', () => { message: [{html: '', type: 'Action type', text: 'Action text'}], }, ]; - const result = ReportActionsUtils.getSortedReportActionsForDisplay(input); + const result = ReportActionsUtils.getSortedReportActionsForDisplay(input, true); input.pop(); expect(result).toStrictEqual(input); }); + + it('should filter actionable whisper actions e.g. "join", "create room" when room is archived', () => { + // Given several different action types, including actionable whispers for creating, inviting and joining rooms, as well as non-actionable whispers + // - ADD_COMMENT + // - ACTIONABLE_REPORT_MENTION_WHISPER + // - ACTIONABLE_MENTION_WHISPER + const input: ReportAction[] = [ + { + created: '2024-11-19 08:04:13.728', + reportActionID: '1607371725956675966', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + originalMessage: { + html: '', + whisperedTo: [], + lastModified: '2024-11-19 08:04:13.728', + mentionedAccountIDs: [18301266], + }, + message: [ + { + html: '', + text: '@as', + type: 'COMMENT', + whisperedTo: [], + }, + ], + }, + { + created: '2024-11-19 08:00:14.352', + reportActionID: '4655978522337302598', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + originalMessage: { + html: '#join', + whisperedTo: [], + lastModified: '2024-11-19 08:00:14.352', + }, + message: [ + { + html: '#join', + text: '#join', + type: 'COMMENT', + whisperedTo: [], + }, + ], + }, + { + created: '2022-11-09 22:27:01.825', + reportActionID: '8049485084562457', + actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_REPORT_MENTION_WHISPER, + originalMessage: { + lastModified: '2024-11-19 08:00:14.353', + mentionedAccountIDs: [], + whisperedTo: [18301266], + }, + message: { + html: "Heads up, #join doesn't exist yet. Do you want to create it?", + text: "Heads up, #join doesn't exist yet. Do you want to create it?", + type: 'COMMENT', + whisperedTo: [18301266], + }, + }, + + { + created: '2022-11-12 22:27:01.825', + reportActionID: '6401435781022176', + actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER, + originalMessage: { + inviteeAccountIDs: [18414674], + lastModified: '2024-11-19 08:04:25.813', + whisperedTo: [18301266], + }, + message: [ + { + html: "Heads up, isn't a member of this room.", + text: "Heads up, isn't a member of this room.", + type: 'COMMENT', + }, + ], + }, + ]; + + // When the report actions are sorted for display with the second parameter (canUserPerformWriteAction) set to false (to simulate a report that has been archived) + const result = ReportActionsUtils.getSortedReportActionsForDisplay(input, false); + // The output should correctly filter out the actionable whisper types for "join," "invite," and "create room" because the report is archived. + // Taking these actions not only doesn't make sense from a UX standpoint, but also leads to server errors since such actions are not possible. + const expectedOutput: ReportAction[] = input.filter( + (action) => + action.actionName !== CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_REPORT_MENTION_WHISPER && + action.actionName !== CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_JOIN_REQUEST && + action.actionName !== CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER, + ); + + expect(result).toStrictEqual(expectedOutput); + }); }); describe('getLastVisibleAction', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2af8a0777c74..4144a6334150 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -10,6 +10,7 @@ import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import * as NumberUtils from '../../src/libs/NumberUtils'; import * as LHNTestUtils from '../utils/LHNTestUtils'; +import {fakePersonalDetails} from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; // Be sure to include the mocked permissions library or else the beta tests won't work @@ -1068,4 +1069,115 @@ describe('ReportUtils', () => { expect(report).toEqual(undefined); }); }); + + describe('getGroupChatName tests', () => { + afterEach(() => Onyx.clear()); + + const fourParticipants = [ + {accountID: 1, login: 'email1@test.com'}, + {accountID: 2, login: 'email2@test.com'}, + {accountID: 3, login: 'email3@test.com'}, + {accountID: 4, login: 'email4@test.com'}, + ]; + + const eightParticipants = [ + {accountID: 1, login: 'email1@test.com'}, + {accountID: 2, login: 'email2@test.com'}, + {accountID: 3, login: 'email3@test.com'}, + {accountID: 4, login: 'email4@test.com'}, + {accountID: 5, login: 'email5@test.com'}, + {accountID: 6, login: 'email6@test.com'}, + {accountID: 7, login: 'email7@test.com'}, + {accountID: 8, login: 'email8@test.com'}, + ]; + + describe('When participantAccountIDs is passed to getGroupChatName', () => { + it('Should show all participants name if count <= 5 and shouldApplyLimit is false', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(fourParticipants)).toEqual('Four, One, Three, Two'); + }); + + it('Should show all participants name if count <= 5 and shouldApplyLimit is true', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(fourParticipants)).toEqual('Four, One, Three, Two'); + }); + + it('Should show 5 participants name if count > 5 and shouldApplyLimit is true', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(eightParticipants, true)).toEqual('Five, Four, One, Three, Two'); + }); + + it('Should show all participants name if count > 5 and shouldApplyLimit is false', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(eightParticipants, false)).toEqual('Eight, Five, Four, One, Seven, Six, Three, Two'); + }); + + it('Should use correct display name for participants', async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantsPersonalDetails); + expect(ReportUtils.getGroupChatName(fourParticipants, true)).toEqual('(833) 240-3627, floki@vikings.net, Lagertha, Ragnar'); + }); + }); + + describe('When participantAccountIDs is not passed to getGroupChatName and report ID is passed', () => { + it('Should show report name if count <= 5 and shouldApplyLimit is false', async () => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2, 3, 4], 0, false, [1]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportID: `1`, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(undefined, false, report)).toEqual("Let's talk"); + }); + + it('Should show report name if count <= 5 and shouldApplyLimit is true', async () => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2, 3, 4], 0, false, [1]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportID: `1`, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(undefined, true, report)).toEqual("Let's talk"); + }); + + it('Should show report name if count > 5 and shouldApplyLimit is true', async () => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2, 3, 4, 5, 6, 7, 8], 0, false, [1, 2]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportID: `1`, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(undefined, true, report)).toEqual("Let's talk"); + }); + + it('Should show report name if count > 5 and shouldApplyLimit is false', async () => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2, 3, 4, 5, 6, 7, 8], 0, false, [1, 2]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportID: `1`, + reportName: "Let's talk", + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(undefined, false, report)).toEqual("Let's talk"); + }); + + it('Should show participant names if report name is not available', async () => { + const report = { + ...LHNTestUtils.getFakeReport([1, 2, 3, 4, 5, 6, 7, 8], 0, false, [1, 2]), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + reportID: `1`, + reportName: '', + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, report); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, fakePersonalDetails); + expect(ReportUtils.getGroupChatName(undefined, false, report)).toEqual('Eight, Five, Four, One, Seven, Six, Three, Two'); + }); + }); + }); }); diff --git a/tests/utils/LHNTestUtils.tsx b/tests/utils/LHNTestUtils.tsx index cdb9f51bb3b7..73d10365c272 100644 --- a/tests/utils/LHNTestUtils.tsx +++ b/tests/utils/LHNTestUtils.tsx @@ -113,6 +113,13 @@ const fakePersonalDetails: PersonalDetailsList = { avatar: 'none', firstName: 'Nine', }, + 10: { + accountID: 10, + login: 'email10@test.com', + displayName: 'Email Ten', + avatar: 'none', + firstName: 'Ten', + }, }; let lastFakeReportID = 0; @@ -121,16 +128,25 @@ let lastFakeReportActionID = 0; /** * @param millisecondsInThePast the number of milliseconds in the past for the last message timestamp (to order reports by most recent messages) */ -function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0, isUnread = false): Report { +function getFakeReport(participantAccountIDs = [1, 2], millisecondsInThePast = 0, isUnread = false, adminIDs: number[] = []): Report { const lastVisibleActionCreated = DateUtils.getDBTime(Date.now() - millisecondsInThePast); + const participants = ReportUtils.buildParticipantsFromAccountIDs(participantAccountIDs); + + adminIDs.forEach((id) => { + participants[id] = { + notificationPreference: 'always', + role: CONST.REPORT.ROLE.ADMIN, + }; + }); + return { type: CONST.REPORT.TYPE.CHAT, reportID: `${++lastFakeReportID}`, reportName: 'Report', lastVisibleActionCreated, lastReadTime: isUnread ? DateUtils.subtractMillisecondsFromDateTime(lastVisibleActionCreated, 1) : lastVisibleActionCreated, - participants: ReportUtils.buildParticipantsFromAccountIDs(participantAccountIDs), + participants, }; } diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index c44710f5f466..394c05fe8b7c 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -1,7 +1,9 @@ +import {fireEvent, screen} from '@testing-library/react-native'; import {Str} from 'expensify-common'; import {Linking} from 'react-native'; import Onyx from 'react-native-onyx'; import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types'; +import * as Localize from '@libs/Localize'; import * as Pusher from '@libs/Pusher/pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import CONFIG from '@src/CONFIG'; @@ -13,6 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import appSetup from '@src/setup'; import type {Response as OnyxResponse, PersonalDetails, Report} from '@src/types/onyx'; import waitForBatchedUpdates from './waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from './waitForBatchedUpdatesWithAct'; type MockFetch = jest.MockedFn & { pause: () => void; @@ -308,6 +311,16 @@ function assertFormDataMatchesObject(obj: Report, formData?: FormData) { } } +async function navigateToSidebarOption(index: number): Promise { + const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const optionRow = screen.queryAllByAccessibilityHint(hintText).at(index); + if (!optionRow) { + return; + } + fireEvent(optionRow, 'press'); + await waitForBatchedUpdatesWithAct(); +} + export type {MockFetch, FormData}; export { assertFormDataMatchesObject, @@ -321,4 +334,5 @@ export { expectAPICommandToHaveBeenCalled, expectAPICommandToHaveBeenCalledWith, setupGlobalFetchMock, + navigateToSidebarOption, };