diff --git a/.env.example b/.env.example
index c4adc4f98b65..2bdda890b2ef 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,7 @@ ONYX_METRICS=false
USE_THIRD_PARTY_SCRIPTS=false
EXPENSIFY_ACCOUNT_ID_ACCOUNTING=-1
+EXPENSIFY_ACCOUNT_ID_ACCOUNTS_PAYABLE=-1
EXPENSIFY_ACCOUNT_ID_ADMIN=-1
EXPENSIFY_ACCOUNT_ID_BILLS=-1
EXPENSIFY_ACCOUNT_ID_CHRONOS=-1
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 069b3fafba09..46d9b09fe89f 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 1009006400
- versionName "9.0.64-0"
+ versionCode 1009006503
+ versionName "9.0.65-3"
// 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/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md
index ecebbaae4e0e..9e942f21d918 100644
--- a/contributingGuides/PERFORMANCE_METRICS.md
+++ b/contributingGuides/PERFORMANCE_METRICS.md
@@ -14,7 +14,7 @@ Project is using Firebase for tracking these metrics. However, not all of them a
| `js_loaded` | ✅ | The time it takes for the JavaScript bundle to load.
**Platforms:** Android, iOS | **Android:** Starts in the `onCreate` method.
**iOS:** Starts in the AppDelegate's `didFinishLaunchingWithOptions` method. | Stops at the first render of the app via native module on the JS side. |
| `_app_in_foreground` | ✅ | The time when the app is running in the foreground and available to the user.
**Platforms:** Android, iOS | **Android:** Starts when the first activity to reach the foreground has its `onResume()` method called.
**iOS:** Starts when the application receives the `UIApplicationDidBecomeActiveNotification` notification. | **Android:** Stops when the last activity to leave the foreground has its `onStop()` method called.
**iOS:** Stops when it receives the `UIApplicationWillResignActiveNotification` notification. |
| `_app_in_background` | ✅ | Time when the app is running in the background.
**Platforms:** Android, iOS | **Android:** Starts when the last activity to leave the foreground has its `onStop()` method called.
**iOS:** Starts when the application receives the `UIApplicationWillResignActiveNotification` notification. | **Android:** Stops when the first activity to reach the foreground has its `onResume()` method called.
**iOS:** Stops when it receives the `UIApplicationDidBecomeActiveNotification` notification. |
-| `sidebar_loaded` | ❌ | Time taken for the Sidebar to load.
**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the LHN finishes laying out. |
+| `sidebar_loaded` | ✅ | Time taken for the Sidebar to load.
**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the LHN finishes laying out. |
| `calc_most_recent_last_modified_action` | ✅ | Time taken to find the most recently modified report action or report.
**Platforms:** All | Starts when the app reconnects to the network | Ends when the app reconnects to the network and the most recent report action or report is found. |
| `open_search` | ✅ | Time taken to open up the Search Router.
**Platforms:** All | Starts when the Search Router icon in LHN is pressed. | Stops when the list of available options finishes laying out. |
| `load_search_options` | ✅ | Time taken to generate the list of options used in the Search Router.
**Platforms:** All | Starts when the `getSearchOptions` function is called. | Stops when the list of available options is generated. |
diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md
index e6660d848129..c7f05e661bd2 100644
--- a/contributingGuides/STYLE.md
+++ b/contributingGuides/STYLE.md
@@ -477,20 +477,68 @@ if (ref.current && 'getBoundingClientRect' in ref.current) {
### Default value for inexistent IDs
- Use `'-1'` or `-1` when there is a possibility that the ID property of an Onyx value could be `null` or `undefined`.
+Use `CONST.DEFAULT_NUMBER_ID` when there is a possibility that the number ID property of an Onyx value could be `null` or `undefined`. **Do not default string IDs to any value!**
+
+> Why? The default number ID (currently set to `0`, which matches the backend’s default) is a falsy value. This makes it compatible with conditions that check if an ID is set, such as `if (!ownerAccountID) {`. Since it’s stored as a constant, it can easily be changed across the codebase if needed.
+>
+> However, defaulting string IDs to `'0'` breaks such conditions because `'0'` is a truthy value in JavaScript. Defaulting to `''` avoids this issue, but it can cause crashes or bugs if the ID is passed to Onyx. This is because `''` could accidentally subscribe to an entire Onyx collection instead of a single record.
+>
+> To address both problems, string IDs **should not have a default value**. This approach allows conditions like `if (!policyID) {` to work correctly, as `undefined` is a falsy value. At the same time, it prevents Onyx bugs: if `policyID` is used to subscribe to a specific Onyx record, a `policy_undefined` key will be used, and Onyx won’t return any records.
+>
+> In case you are confused or find a situation where you can't apply the rules mentioned above, please raise your question in the [`#expensify-open-source`](https://expensify.slack.com/archives/C01GTK53T8Q) Slack channel.
``` ts
// BAD
-const foo = report?.reportID ?? '';
-const bar = report?.reportID ?? '0';
-
-report ? report.reportID : '0';
-report ? report.reportID : '';
+const accountID = report?.ownerAccountID ?? -1;
+const policyID = report?.policyID ?? '-1';
+const managerID = report ? report.managerID : 0;
// GOOD
-const foo = report?.reportID ?? '-1';
+const accountID = report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID;
+const policyID = report?.policyID;
+const managerID = report ? report.managerID : CONST.DEFAULT_NUMBER_ID;
+```
+
+Here are some common cases you may face when fixing your code to remove the old/bad default values.
+
+#### **Case 1**: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
+
+```diff
+-Report.getNewerActions(newestActionCurrentReport?.reportID ?? '-1', newestActionCurrentReport?.reportActionID ?? '-1');
++Report.getNewerActions(newestActionCurrentReport?.reportID, newestActionCurrentReport?.reportActionID);
+```
+
+> error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'. Type 'undefined' is not assignable to type 'string'.
+
+We need to change `Report.getNewerActions()` arguments to allow `undefined`. By doing that we could add a condition that return early if one of the parameters are falsy, preventing the code (which is expecting defined IDs) from executing.
+
+```diff
+-function getNewerActions(reportID: string, reportActionID: string) {
++function getNewerActions(reportID: string | undefined, reportActionID: string | undefined) {
++ if (!reportID || !reportActionID) {
++ return;
++ }
+```
+
+#### **Case 2**: Type 'undefined' cannot be used as an index type.
+
+```diff
+function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) {
+ const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, {
+ canEvict: false,
+ });
+- const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1'];
++ const parentReportAction = parentReportActions?.[report?.parentReportActionID];
+```
+
+> error TS2538: Type 'undefined' cannot be used as an index type.
+
+This error is inside a component, so we can't simply use early return conditions here. Instead, we can check if `report?.parentReportActionID` is defined, if it is we can safely use it to find the record inside `parentReportActions`. If it's not defined, we just return `undefined`.
-report ? report.reportID : '-1';
+```diff
+function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) {
+- const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1'];
++ const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined;
```
### Extract complex types
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 01aa21a28b80..302277c3a45a 100644
--- a/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md
+++ b/docs/articles/expensify-classic/connections/netsuite/Netsuite-Troubleshooting.md
@@ -2,8 +2,6 @@
title: Netsuite Troubleshooting
description: Troubleshoot common NetSuite sync and export errors.
---
-
-# Overview of NetSuite Troubleshooting
Synchronizing and exporting data between Expensify and NetSuite can streamline your financial processes, but occasionally, users may encounter errors that prevent a smooth integration. These errors often arise from discrepancies in settings, missing data, or configuration issues within NetSuite or Expensify.
This troubleshooting guide aims to help you identify and resolve common sync and export errors, ensuring a seamless connection between your financial management systems. By following the step-by-step solutions provided for each specific error, you can quickly address issues and maintain accurate and efficient expense reporting and data management.
@@ -30,7 +28,7 @@ When exporting as a Vendor Bill, we pull from the vendor record, not the employe
**Journal Entries and Expense Reports:**
If you see this error when exporting a Journal Entry or Expense Report, it might be because the report submitter doesn’t have default settings for Departments, Classes, or Locations.
-**To fix this:**
+**To resolve:**
1. Go to **Lists > Employees** in NetSuite.
2. Click **"Edit"** next to the employee's name who submitted the report.
3. Scroll down to the **Classification** section.
@@ -41,28 +39,29 @@ If you see this error when exporting a Journal Entry or Expense Report, it might
# ExpensiError NS0012: Currency Does Not Exist In NetSuite
-**Scenario One:** When dealing with foreign transactions, Expensify sends the conversion rate and currency of the original expense to NetSuite. If the currency isn't listed in your NetSuite subsidiary, you'll see an error message saying the currency does not exist in NetSuite.
+## Scenario One
+When dealing with foreign transactions, Expensify sends the conversion rate and currency of the original expense to NetSuite. If the currency isn't listed in your NetSuite subsidiary, you'll see an error message saying the currency does not exist in NetSuite.
-**To fix this:**
+**To resolve:**
1. Ensure the currency in Expensify matches what's in your NetSuite subsidiary.
2. If you see an error saying 'The currency X does not exist in NetSuite', re-sync your connection to NetSuite through the workspace admin section in Expensify.
3. Try exporting again.
-**Scenario Two:** This error can happen if you’re using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD.
+## Scenario Two
+This error can happen if you’re using a non-OneWorld NetSuite instance and exporting a currency other than EUR, GBP, USD, or CAD.
-**To fix this:**
+**To resolve:**
1. Head to NetSuite.
2. Go to **Setup > Enable Features**.
3. Check the **Multiple Currencies** box.
Once you've done this, you can add the offending currency by searching **New Currencies** in the NetSuite global search.
-
# ExpensiError NS0021: Invalid tax code reference key
This error usually indicates an issue with the Tax Group settings in NetSuite, which can arise from several sources.
-#### Tax Group to Tax Code Mapping
+## Tax Group to Tax Code Mapping
If a Tax Code on Sales Transactions is mapped to a Tax Group, an error will occur. To fix this, the Tax Code must be mapped to a Tax Code on Purchase Transactions instead.
To verify if a Tax Code is for Sales or Purchase transactions, view the relevant Tax Code(s).
@@ -78,9 +77,7 @@ Tax Groups can represent different types of taxes. For compatibility with Expens
#### Enable Tax Groups
Some subsidiaries require you to enable Tax Groups. Go to **Set Up Taxes** for the subsidiary's country and ensure the Tax Code lists include both Tax Codes and Tax Groups.
-
# ExpensiError NS0023: Employee Does Not Exist in NetSuite (Invalid Employee)
-
This can happen if the employee’s subsidiary in NetSuite doesn’t match what’s listed in Expensify.
## How to Fix ExpensiError NS0023
@@ -97,16 +94,13 @@ 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.
## How to Fix ExpensiError NS0024
-
1. **Ensure Employee Access:**
- In NetSuite, go to **Lists > Relationships > Customer/Projects**.
- Click **Edit** next to the desired Customer/Project.
@@ -124,9 +118,7 @@ Employees must be listed as a resource on the customer/project in NetSuite to be
- Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > NetSuite > Configure > Advanced**.
- Enable **Cross-Subsidiary Customers/Projects** to remove the requirement for the employee's subsidiary and the customer's subsidiary to match.
-
# ExpensiError NS0034: This record already exists
-
This error occurs when the report in question was already exported to NetSuite.
## How to fix ExpensiError NS0034
@@ -141,9 +133,7 @@ This error occurs when the report in question was already exported to NetSuite.
5. **Re-export the Report from Expensify to NetSuite:**
- After deleting the report in NetSuite, re-export it from Expensify to NetSuite.
-
# ExpensiError NS0046: Billable Expenses Not Coded with a NetSuite Customer or Billable Project
-
NetSuite requires billable expenses to be assigned to a Customer or a Project that is configured as billable to a Customer. If this is not set up correctly in NetSuite, this error can occur.
## How to Fix ExpensiError NS0046
@@ -160,10 +150,8 @@ NetSuite requires billable expenses to be assigned to a Customer or a Project th
- Verify that there are no violations and that a value has been applied to the field.
5. Make any necessary adjustments to the billable expenses and try the export again.
-
# ExpensiError NS0059: A credit card account has not been selected for corporate card expenses.
-
-**To resolve this error:**
+**To resolve:**
1. Log into NetSuite as an admin.
2. Type "Page: Subsidiaries" in the global search box and select the subsidiary you will export to.
3. Under the Preferences tab of the subsidiary, locate the field: Default Account for Corporate Card Expenses.
@@ -179,9 +167,7 @@ NetSuite requires billable expenses to be assigned to a Customer or a Project th
For accounts without subsidiaries (non-OneWorld accounts), the default field is in your accounting preferences.
-
# ExpensiError NS0085: Expense Does Not Have Appropriate Permissions for Settings an Exchange Rate in NetSuite
-
This error occurs when the exchange rate settings in NetSuite aren't updated correctly.
## How to Fix ExpensiError NS0085
@@ -203,7 +189,6 @@ This error occurs when the exchange rate settings in NetSuite aren't updated cor
# ExpensiError NS0079: The Transaction Date is Not Within the Date Range of Your Accounting Period
-
The transaction date you specified is not within the date range of your accounting period. When the posting period settings in NetSuite are not configured to allow a transaction date outside the posting period, you can't export a report to the next open period, which is why you’ll run into this error.
## How to Fix ExpensiError NS0079
@@ -211,7 +196,7 @@ The transaction date you specified is not within the date range of your accounti
2. Under the General Ledger section, ensure the field Allow Transaction Date Outside of the Posting Period is set to Warn.
3. Then, choose whether to export your reports to the First Open Period or the Current Period.
-Additionally, ensure the Export to Next Open Period feature is enabled within Expensify:
+**Additionally, ensure the Export to Next Open Period feature is enabled within Expensify:**
1. Navigate to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure**.
2. Open the **Advanced tab**.
3. Confirm that the setting for **Export to Next Open Period** is enabled.
@@ -220,7 +205,6 @@ If any configuration settings are updated on the NetSuite connection, be sure to
# ExpensiError NS0055: The Vendor You are Trying to Export to Does Not Have Access to the Currency X
-
This error occurs when a vendor tied to a report in Expensify does not have access to a currency on the report in NetSuite. The vendor used in NetSuite depends on the type of expenses on the report you're exporting.
- For **reimbursable** (out-of-pocket) expenses, this is the report's submitter (the employee who submitted the report).
- For **non-reimbursable** (e.g., company card) expenses, this is the default vendor set via Settings > Workspaces > Group > [Workspace Name] > Connections > NetSuite > Configure.
@@ -246,13 +230,13 @@ To fix this, the vendor needs to be given access to the applicable currency:
5. Sync the NetSuite connection under **Settings > Workspaces > Group > [Workspace Name] > Connections > Sync Now**.
6. Export the report(s) again.
-#### For reports with Expensify Card expenses
+## ExpensiError NS0068: Reports with Expensify Card expenses
Expensify Card expenses export as Journal Entries. If you encounter this error when exporting a report with Expensify Card non-reimbursable expenses, ensure the field Created From has the Show checkbox checked for Journal Entries in NetSuite.
# ExpensiError NS0037: You do not have permission to set a value for element - “Receipt URL”
-**To resolve this error:**
+**To resolve:**
1. In NetSuite, go to Customization > Forms > Transaction Forms.
2. Search for the form type that the report is being exported as in NetSuite (Expense Report, Journal Entry, or Vendor Bill).
3. Click Edit next to the form that has the Preferred checkbox checked.
@@ -270,14 +254,12 @@ Expensify Card expenses export as Journal Entries. If you encounter this error w
# ExpensiError NS0042: Error creating vendor - this entity already exists
-
This error occurs when a vendor record already exists in NetSuite, but Expensify is still attempting to create a new one. This typically means that Expensify cannot find the existing vendor during export.
- The vendor record already exists in NetSuite, but there may be discrepancies preventing Expensify from recognizing it.
- The email on the NetSuite vendor record does not match the email of the report submitter in Expensify.
- The vendor record might not be associated with the correct subsidiary in NetSuite.
## How to Fix ExpensiError NS0042
-
Follow these steps to resolve the issue:
1. **Check Email Matching:**
- Ensure the email on the NetSuite vendor record matches the email of the report submitter in Expensify.
@@ -299,7 +281,6 @@ Follow these steps to resolve the issue:
# ExpensiError NS0109: Failed to login to NetSuite, please verify your credentials
-
This error indicates a problem with the tokens created for the connection between Expensify and NetSuite. The error message will say, "Login Error. Please check your credentials."
## How to Fix ExpensiError NS0109
@@ -308,7 +289,6 @@ This error indicates a problem with the tokens created for the connection betwee
# ExpensiError NS0123 Login Error: Please make sure that the Expensify integration is enabled
-
This error indicates that the Expensify integration is not enabled in NetSuite.
## How to Fix ExpensiError NS0123
@@ -321,10 +301,9 @@ This error indicates that the Expensify integration is not enabled in NetSuite.
Once the Expensify integration is enabled, try syncing the NetSuite connection again.
-
# ExpensiError NS0045: Expenses Not Categorized with a NetSuite Account
-**To resolve this error:**
+**To resolve:**
1. Log into NetSuite
2. Do a global search for the missing record.
- Ensure the expense category is active and correctly named.
@@ -335,7 +314,6 @@ Once the Expensify integration is enabled, try syncing the NetSuite connection a
# ExpensiError NS0061: Please Enter Value(s) for: Tax Code
-
This error typically occurs when attempting to export expense reports to a Canadian subsidiary in NetSuite for the first time and/or if your subsidiary in NetSuite has Tax enabled.
## How to Fix ExpensiError NS0061
@@ -348,12 +326,10 @@ To fix this, you need to enable Tax in the NetSuite configuration settings.
**Note:** Expenses created before Tax was enabled might need to have the newly imported taxes applied to them retroactively to be exported.
-
# Error creating employee: Your current role does not have permission to access this record.
-
This error indicates that the credentials or role used to connect NetSuite to Expensify do not have the necessary permissions within NetSuite. You can find setup instructions for configuring permissions in NetSuite [here](https://help.expensify.com/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite#step-3-add-expensify-integration-role-to-a-user).
-**To resolve this error:**
+**To resolve:**
1. If permissions are configured correctly, confirm the report submitter exists in the subsidiary set on the workspace and that their Expensify email address matches the email on the NetSuite Employee Record.
2. If the above is true, try toggling off "Automatically create vendors/employees" under the Advanced tab of the NetSuite configuration window.
- Head to **Settings > Workspaces > Group > Workspace Name > Connections > NetSuite > Configure**
@@ -363,10 +339,9 @@ This error indicates that the credentials or role used to connect NetSuite to Ex
4. Export the report again.
# Elimination Settings for X Do Not Match
-
This error occurs when an Intercompany Payable account is set as the default in the Default Payable Account field in the NetSuite subsidiary preferences, and the Accounting Approval option is enabled for Expense Reports.
-**To resolve this error:**
+**To resolve:**
Set the Default Payable Account for Expense Reports on each subsidiary in NetSuite to ensure the correct payable account is active.
1. Navigate to Subsidiaries:
- Go to Setup > Company > Subsidiaries.
@@ -378,23 +353,21 @@ Set the Default Payable Account for Expense Reports on each subsidiary in NetSui
Repeat these steps for each subsidiary to ensure the settings are correct, and then sync Expensify to NetSuite to update the connection.
-# Why are reports exporting as `Accounting Approved` instead of `Paid in Full`?
+
+{% include faq-begin.md %}
+## Why are reports exporting as `Accounting Approved` instead of `Paid in Full`?
**This can occur for two reasons:**
- Missing Locations, Classes, or Departments in the Bill Payment Form
- Incorrect Settings in Expensify Workspace Configuration
-## Missing Locations, Classes, or Departments in Bill Payment Form
-
-If locations, classes, or departments are required in your accounting classifications but are not marked as 'Show' on the preferred bill payment form, this error can occur, and you will need to update the bill payment form in NetSuite:
+**Missing Locations, Classes, or Departments in Bill Payment Form:** If locations, classes, or departments are required in your accounting classifications but are not marked as 'Show' on the preferred bill payment form, this error can occur, and you will need to update the bill payment form in NetSuite:
1. Go to Customization > Forms > Transaction Forms.
2. Find your preferred (checkmarked) Bill Payment form.
3. Click Edit or Customize.
4. Under the Screen Fields > Main tab, check 'Show' near the department, class, and location options.
-## Incorrect Settings in Expensify Workspace Configuration
-
-To fix this, you'll want to confirm the NetSuite connection settings are set up correctly in Expensify:
+**Incorrect Settings in Expensify Workspace Configuration:** To fix this, you'll want to confirm the NetSuite connection settings are set up correctly in Expensify:
1. Head to **Settings > Workspaces > Group > Workspace Name > Connections > NetSuite > Configure > Advanced**
2. **Ensure the following settings are correct:**
- Sync Reimbursed Reports: Enabled and payment account chosen.
@@ -410,9 +383,7 @@ To fix this, you'll want to confirm the NetSuite connection settings are set up
Following these steps will help ensure that reports are exported as "Paid in Full" instead of "Accounting Approved."
-
-# Why are reports exporting as `Pending Approval`?
-
+## Why are reports exporting as `Pending Approval`?
If reports are exporting as "Pending Approval" instead of "Approved," you'll need to adjust the approval preferences in NetSuite.
**Exporting as Journal Entries/Vendor Bills:**
@@ -426,8 +397,7 @@ If reports are exporting as "Pending Approval" instead of "Approved," you'll nee
1. In NetSuite, navigate to Setup > Company > Enable Features.
2. On the "Employee" tab, uncheck "Approval Routing" to remove the approval requirement for Expense Reports created in NetSuite. Please note that this setting also applies to purchase orders.
-
-# How do I Change the Default Payable Account for Reimbursable Expenses in NetSuite?
+## How do I Change the Default Payable Account for Reimbursable Expenses in NetSuite?
NetSuite is set up with a default payable account that is credited each time reimbursable expenses are exported as Expense Reports to NetSuite (once approved by the supervisor and accounting). If you need to change this to credit a different account, follow the below steps:
@@ -445,7 +415,7 @@ NetSuite is set up with a default payable account that is credited each time rei
4. Click Save.
-# Why are my Company Card Expenses Exporting to the Wrong Account in NetSuite?
+## Why are my Company Card Expenses Exporting to the Wrong Account in NetSuite?
If your company card transactions are exporting to the wrong account in your accounting system, there are a couple of factors to check:
1. **Verify Card Mapping:**
@@ -462,3 +432,4 @@ Even if an expense was paid with the company card, it is considered a 'cash' exp
Less commonly, the issue may occur if the company card has been added to the user's personal settings. Expenses imported from a card linked at the individual account level will have a plain card icon.
+{% include faq-end.md %}
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
Hover over Settings, then click Workspaces.
Click the Individual tab on the left.
-
Select the policy type that best fits your needs.
+
Select the workspace type that best fits your needs.
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/spending-insights/Custom-Templates.md b/docs/articles/expensify-classic/spending-insights/Custom-Templates.md
deleted file mode 100644
index 75d436471dbf..000000000000
--- a/docs/articles/expensify-classic/spending-insights/Custom-Templates.md
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: Custom Templates
-description: Custom Templates
----
-# Overview
-
-If you don't have a direct connection to your accounting system, as long as the system accepts a CSV file, you can easily export your expense data for upload to the system. Custom templates are great if you want to analyze the data in your favorite spreadsheet program.
-
-# How to use custom templates
-If you are a Group workspace admin you can create a custom template that will be available to all Workspace Admins on the workspace from **Settings > Workspaces > Group > _Workspace Name_ > Export Formats**.
-
-If you are using a free account you can create a custom template from **Settings > Account > Preferences > CSV Export Formats**.
-
-You can use your custom templates from the **Reports** page.
-1. Select the checkbox next to the report you’d like to export
-3. Click **Export to** at the top of the page
-4. Select your template from the dropdown
-
-# Formulas
-## Report level
-
-| Formula | Description |
-| -- | -- |
-| **Report title** | **the title of the report the expense is part of** |
-| {report:title} | would output "Expense Expenses to 2019-11-05" assuming that is the report's title.|
-| **Report ID** | **number is a unique number per report and can be used to identify specific reports**|
-| {report:id} | would output R00I7J3xs5fn assuming that is the report's ID.|
-| **Old Report ID** | **a unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62.** |
-| {report:oldID} | would output R3513250790654885 assuming that is the report's old ID.|
-| **Reimbursement ID** | **the unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.**|
-| {report:reimbursementid} | would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.|
-| **Report Total** | **the total amount of the expense report.**|
-| {report:total} | would output $325.34 assuming that is the report's total.|
-| **Type** | **is the type of report (either Expense Report, Invoice or Bill)**|
-| {report:type} | would output "Expense Report" assuming that is the report's type|
-| **Reimbursable Total** | **is the total amount that is reimbursable on the report.**|
-| {report:reimbursable} | would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.|
-| **Currency** | **is the currency to which all expenses on the report are being converted.**|
-| {report:currency} | would output USD assuming that the report total was calculated in US Dollars|
-|| Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.|
-| **Report Field** | **formula will output the value for a given Report Field which is created in the workspace settings.**|
-| {field:Employee ID} | would output 12456 , assuming "Employee ID" is the name of the Report Field and "123456" is the value of that field on the report.|
-| **Created date** | **the expense report was originally created by the user.**|
-| {report:created} | would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.|
-| {report:created:yyyy-MM-dd} | would output 2010-09-15 assuming the expense report was created on September 15, 2010.|
-| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).|
-| **StartDate** | **is the date of the earliest expense on the report.**|
-| {report:startdate} | would output 2010-09-15 assuming that is the date of the earliest expense on the report.|
-| **EndDate**| **is the date of the last expense on the report.**|
-| {report:enddate} | would output 2010-09-26 assuming that is the date of the last expense on the report.|
-| **Scheduled Submit Dates** | **the start and end dates of the Scheduled Submit reporting cycle.**|
-| {report:autoReporting:start} | would output 2010-09-15 assuming that is the start date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
-| {report:autoReporting:end} | would output 2010-09-26 assuming that is the end date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
-| **Submission Date** | **is the date that the report was submitted.**|
-| {report:submit:date} | would output 1986-09-15 12:00:00 assuming that the report was submitted on September 15, 1986, at noon.|
-| {report:submit:date:yyyy-MM-dd} | would output 1986-09-15 assuming that the report was submitted on September 15, 1986.|
-| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting |
-| **Approval Date** | **the date the report was approved. This formula can be used for export templates, but not for report titles.**|
-| {report:approve:date} | would output 2011-09-25 12:00:00 assuming that the report was approved on September 25, 2011, at noon.|
-| {report:approve:date:yyyy-MM-dd} | would output 2011-09-25 assuming that the report was approved on September 25, 2011.|
-| **Reimbursement Date** | **the date an expense report was reimbursed. This formula can be used for export templates, but not for report titles.**|
-| {report:achreimburse} | would output 2011-09-25 assuming that is the date the report was reimbursed via ACH Direct Deposit.|
-| {report:manualreimburse} | would output 2011-09-25 assuming that is the date the report was marked as reimbursed. |
-| **Export Date** | **is the date when the report is exported. This formula can be used for export templates, but not for report titles.**|
-| {report:dateexported} | would output 2013-09-15 12:00 assuming that the report was exported on September 15, 2013, at noon.|
-| {report:dateexported:yyyy-MM-dd} | would output 2013-09-15 assuming that the report was exported on September 15, 2013.|
-| **Expenses Count** | **is the number of total expenses on the report of this specific expense.**|
-| {report:expensescount} | would output 10 assuming that there were 10 expenses on the given report for this expense.|
-| **Workspace Name** | **is the name of the workspace applied to the report.**|
-| {report:policyname} | would output Sales assuming that the given report was under a workspace named Sales.|
-| **Status** | **is the current state of the report when it was exported**.|
-| {report:status} | would output Approved assuming that the report has been approved and not yet reimbursed.|
-| **Custom Fields** | |
-| {report:submit:from:customfield1} | would output the custom field 1 entry associated with the user who submitted the report. If John Smith’s Custom Field 1 contains 100, then this formula would output 100.|
-| {report:submit:from:customfield2} | would output the custom field 2 entry associated with the user who submitted the report. If John Smith’s Custom Field 2 contains 1234, then this formula would output 1234. |
-| **To** | **is the email address of the last person who the report was submitted to.**|
-| {report:submit:to} | would output alice@email.com if they are the current approver|
-| {report:submit:to:email\|frontPart} | would output alice.|
-| **Current user** | **To export the email of the currently logged in Expensify user**|
-| {user:email} | would output bob@example.com assuming that is the currently logged in Expensify user's email.|
-| **Submitter** | **"Sally Ride" with email "sride@email.com" is the submitter for the following examples**|
-| {report:submit:from:email}| sride@email.com|
-| {report:submit:from}| Sally Ride|
-| {report:submit:from:firstname}| Sally|
-| {report:submit:from:lastname}| Ride|
-| {report:submit:from:fullname}| Sally Ride |
-| | Note - If user's name is blank, then {report:submit:from} and {report:submit:from:email\|frontPart} will print the user's whole email.|
-
-`{report:submit:from:email|frontPart}` sride
-
-`{report:submit:from:email|domain}` email.com
-
-`{user:email|frontPart}` would output bob assuming that is the currently logged in Expensify user's email.
-
-## Expense level
-
-| Formula | Description |
-| -- | -- |
-| **Merchant** | **Merchant of the expense** |
-| {expense:merchant} | would output Sharons Coffee Shop and Grill assuming the expense is from Sharons Coffee Shop |
-| {expense:distance:count} | would output the total miles/kilometers of the expense.|
-| {expense:distance:rate} | would output the monetary rate allowed per mile/kilometer. |
-| {expense:distance:unit} | would output either mi or km depending on which unit is applied in the workspace settings. |
-| **Date** | **Related to the date listed on the expense** |
-| {expense:created:yyyy-MM-dd} | would output 2019-11-05 assuming the expense was created on November 5th, 2019 |
-| {expense:posted:yyyy-MM-dd} | would output 2023-07-24 assuming the expense was posted on July 24th, 2023 |
-| **Tax** | **The tax type and amount applied to the expense line item** |
-| {expense:tax:field} | would output VAT assuming this is the name of the tax field.|
-| {expense:tax:ratename} | would output the name of the tax rate that was used (ex: Standard). This will show custom if the chosen tax amount is manually entered and not chosen from the list of given options.|
-| {expense:tax:amount} | would output $2.00 assuming that is the amount of the tax on the expense.|
-| {expense:tax:percentage} | would output 20% assuming this is the amount of tax that was applied to the subtotal.|
-| {expense:tax:net} | would output $18.66 assuming this is the amount of the expense before tax was applied.|
-| {expense:tax:code} | would output the tax code that was set in the workspace settings.|
-| **Expense Amount** | **Related to the currency type and amount of the expense** |
-| {expense:amount} | would output $3.95 assuming the expense was for three dollars and ninety-five cents|
-| {expense:amount:isk} | would output Íkr3.95 assuming the expense was for 3.95 Icelandic króna.|
-| {expense:amount:nosymbol} | would output 3.95. Notice that there is no currency symbol in front of the expense amount because we designated none.|
-| {expense:exchrate} | would output the currency conversion rate used to convert the expense amount|
-| | Add an optional extra input that is either a three-letter currency code or nosymbol to denote the output's currency. The default if one isn't provided is USD.|
-| {expense:amount:originalcurrency} | This gives the amount of the expense in the currency in which it occurred before currency conversion |
-| {expense:amount:originalcurrency:nosymbol} | will export the expense in its original currency without the currency symbol. |
-| {expense:amount:negsign} | displays negative expenses with a minus sign in front rather wrapped in parenthesis. It would output -$3.95 assuming the expense was already a negative expense for three dollars and ninety-five cents. This formula does not convert a positive expense to a negative value.|
-| {expense:amount:unformatted} | displays expense amounts without commas. This removes commas from expenses that have an amount of more than 1000. It would output $10000 assuming the expense was for ten thousand dollars.|
-| {expense:debitamount} | displays the amount of the expense if the expense is positive. Nothing will be displayed in this column if the expense is negative. It would output $3.95 assuming the expense was for three dollars and ninety-five cents.|
-| {expense:creditamount} | displays the amount of the expense if the expense is negative. Nothing will be displayed in this column if the expense is positive. It would output -$3.95 assuming the expense was for negative three dollars and ninety-five cents.|
-| **For expenses imported via CDF/VCF feed only** ||
-| {expense:purchaseamount} | is the amount of the original purchase in the currency it was purchased in. Control plan users only.|
-| {expense:purchaseamount} | would output Irk 3.95 assuming the expense was for 3.95 Icelandic krónur, no matter what currency your bank has translated it to.|
-| {expense:purchasecurrency} | would output Irk assuming the expense was incurred in Icelandic krónur (before your bank converted it back to your home currency)|
-| **Original Amount** | **when import with a connected bank**|
-| {expense:originalamount} | is the amount of the expense imported from your bank or credit card feed. It would output $3.95 assuming the expense equated to $3.95 and you use US-based bank. You may add an optional extra input that is either a three-letter currency code or NONE to denote the output's currency.|
-| **Category** | **The category of the expense** |
-| {expense:category} | would output Employee Moral assuming that is the expenses' category.|
-| {expense:category:glcode} | would output the category gl code of the category selected.|
-| {expense:category:payrollcode} | outputs the payroll code information entered for the category that is applied to the expense. If the payroll code for the Mileage category was 39847, this would output simply 39847.|
-| **Attendees** | **Persons listed as attendees on the expense**|
-| {expense:attendees} | would output the name or email address entered in the Attendee field within the expense (ex. guest@domain.com). |
-| {expense:attendees:count} | would output the number of attendees that were added to the expense (ex. 2).8. Attendees - persons listed as attendees on the expense.|
-| **Tags** | Tags of the expense - in this example the name of the tag is "Department" |
-| {expense:tag} | would output Henry at Example Co. assuming that is the expenses' tag. |
-| **Multiple Tags** | Tags for companies that have multiple tags setup. |
-| {expense:tag:ntag-1} | outputs the first tag on the expense, if one is selected |
-| {expense:tag:ntag-3} | outputs the third tag on the expense, if one is selected |
-| **Description** | The description on the expense |
-| {expense:comment} |would output "office lunch" assuming that is the expenses' description.|
-| **Receipt** | |
-| {expense:receipt:type} | would output eReceipt if the receipt is an Expensify Guaranteed eReceipt.|
-| {expense:receipt:url} | would output a link to the receipt image page that anyone with access to the receipt in Expensify could view.|
-| {expense:receipt:url:direct} | would show the direct receipt image url for download. |
-| {expense:mcc} | would output 3351 assuming that is the expenses' MCC (Merchant Category Code of the expense).|
-| | Note, we only have the MCC for expenses that are automatically imported or imported from an OFX/QFX file. For those we don't have an MCC for the output would be (an empty string).|
-| **Card name/number expense type** | |
-| {expense:card} | Manual/Cash Expenses — would output Cash assuming the expense was manually entered using either the website or the mobile app.|
-| {expense:card} | Bank Card Expenses — would output user@company.com – 1234 assuming the expense was imported from a credit card feed.|
-| | Note - If you do not have access to the card that the expense was created on 'Unknown' will be displayed. If cards are assigned to users under Domain, then you'll need to be a Domain Admin to export the card number.|
-| **Expense ID** | |
-| {expense:id} | would output the unique number associated with each individual expense "4294967579".|
-| **Reimbursable state** | |
-| {expense:reimbursable} | would output "yes" or "no" depending on whether the expense is reimbursable or not.|
-| **Billable state** | |
-| {expense:billable} | would output "yes" or "no" depending on whether the expense is billable or not.
-| **Expense Number** | **is the ordinal number of the expense on its expense report.**|
-| {report:expense:number} | would output 2 assuming that the given expense was the second expense on its report.|
-| **GL codes** | |
-| {expense:category:glcode} | would output the GL code associated with the category of the expense. If the GL code for Meals is 45256 this would output simply 45256.|
-| {expense:tag:glcode} | would output the GL code associated with the tag of the expense. If the GL code for Client X is 08294 this would output simply 08294.|
-| {expense:tag:ntag-3:glcode} | would output the GL code associated with the third tag the user chooses. This is only for companies that have multiple tags setup.|
-
-## Date formats
-
-| Formula | Description |
-| -- | -- |
-| M/dd/yyyy | 5/23/2019|
-|MMMM dd, yyyy| May 23, 2019|
-|dd MMM yyyy| 23 May 2019|
-|yyyy/MM/dd| 2019/05/23|
-|dd MMM yyyy| 23 May 2019|
-|yyyy/MM/dd| 2019/05/23|
-|MMMM, yyyy| May, 2019|
-|yy/MM/dd| 19/05/23|
-|dd/MM/yy| 23/05/19|
-|yyyy| 2019|
-
-## Math formulas
-
-| Formula | Description |
-| -- | -- |
-| * | Multiplication {math: 3 * 4} output 12|
-| / | Division {math: 3 / 4 }output 0.75|
-| + | Addition {math: 3 + 4 }output |
-| - | Subtraction {math: 3 - 4 }output -1|
-| ^ | Exponent {math: 3 ^ 4 } output 81|
-| sqrt | The square root of a number. {sqrt:64} output 8|
-|| Note - You can also combine the value of any two numeric fields. For example, you can use {math: {expense:tag:glcode} + {expense:category:glcode}} to add the value of the Tag GL code with the Category GL code.|
-
-## Substring formulas
-This formula will output a subset of the string in question. It is important to remember that the count starts at 0 not 1.
-
-`{expense:merchant|substr:0:4}` would output "Star" for a merchant named Starbucks. This is because we are telling it to start at position 0 and be of 4 character length.
-
-`{expense:merchant|substr:4:5}` would output "bucks" for a merchant named Starbucks. This is because we are telling it to start at position 4 and be of 5 character length.
-
-# FAQs
-
-**Can I export one line per report?**
-
-No, the custom template always exports one line per expense. At the moment it is not possible to create a template that will export one line per report.
diff --git a/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md b/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md
deleted file mode 100644
index b89dca85df04..000000000000
--- a/docs/articles/expensify-classic/spending-insights/Default-Export-Templates.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-title: Default Export Templates
-description: Default Export Templates
----
-# Overview
-Use default export templates for exporting report data to a CSV format, for data analysis, or uploading to an accounting software.
-Below is a breakdown of the available default templates.
-# How to use default export templates
-- **All Data - Expense Level Export** - This export prints a line for each expense with all of the data associated with the expenses. This is useful if you want to see all of the data stored in Expensify for each expense.
-- **All Data - Report Level Export** - This export prints a line per report, giving a summary of the report data.
-- **Basic Export** - A simpler expense level export without as much detail. This exports the data visible on the PDF of the report. Basics such as date, amount, merchant, category, tag, reimbursable state, description, receipt URL, and original expense currency and amount.
-- **Canadian Multiple Tax Export** - Exports a line per expense with all available info on the taxes applied to the expenses on your report(s). This is useful if you need to see the tax spend.
-- **Category Export** - Exports category names with the total amount attributed to each category on the report. While you can also access this information on the Insights page, it can be convenient to export to a CSV to run further analysis in your favorite spreadsheet program.
-- **Per Diem Export** - This exports basic expense details only for the per diem expenses on the report. Useful for reviewing employee Per Diem spend.
-- **Tag Export** - Exports tag names into columns with the total amount attributed to each tag on the report.
-
-# How to export using a default template
-1. Navigate to your Reports page
-2. Select the reports you want to export (you can use the filters to help you find the reports you’re after)
-3. Click the **Export to** in the top right corner
-4. Select the export template you’d like to use
-
-{% include faq-begin.md %}
-## Why are my numbers exporting in a weird format?
-Do your numbers look something like this: 1.7976931348623157e+308? This means that your spreadsheet program is formatting long numbers in an exponential or scientific format. If that happens, you can correct it by changing the data to Plain Text or a Number in your spreadsheet program.
-## Why are my leading zeros missing?
-Is the export showing “1” instead of “01”? This means that your spreadsheet program is cutting off the leading zero. This is a common issue with viewing exported data in Excel. Unfortunately, we don’t have a good solution for this. We recommend checking your spreadsheet program’s help documents for suggestions for formatting.
-## I want a report that is not in the default list, how can I build that?
-For a guide on building your own custom template check out Exports > Custom Exports in the Help pages!
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
new file mode 100644
index 000000000000..eac2723e5c9c
--- /dev/null
+++ b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
@@ -0,0 +1,294 @@
+---
+title: Export Expenses and Reports
+description: How to export expenses and reports using custom reports, PDF files, CSVs, and more
+---
+
+There are several methods you can use to export your expenses and reports, including:
+- Export as a PDF
+- Export as a CSV or to an accounting integration
+- Export using a default or custom export template
+
+# Export PDF
+
+1. Click the **Reports** tab.
+2. Open a report.
+3. Click **Details** in the top right of the report.
+4. Click the download icon.
+
+The PDF will be downloaded with all expenses, any attached receipts, and all report notes.
+
+# Export CSV or apply a template
+
+1. Click either the **Expenses** or **Reports** tab.
+2. On the left hand side, select the expenses/reports you’d like to export.
+3. Click **Export to** at the top right of the page.
+4. Choose the desired export option. You can use one of the default templates below, or you can create your own template. *Note: The default templates and the option to export to a connected accounting package are only available on the Reports page.*
+ - **All Data - Expense Level Export**: Prints a line for each expense with all of the data associated with the expenses. This is useful if you want to see all of the data stored in Expensify for each expense.
+ - **All Data - Report Level Export**: Prints a line per report, giving a summary of the report data.
+ - **Basic Export**: A simpler expense-level export of the data visible on the PDF report. Includes basics such as date, amount, merchant, category, tag, reimbursable state, description, receipt URL, and original expense currency and amount.
+ - **Canadian Multiple Tax Export**: Exports a line per expense with all available information on the taxes applied to the expenses on your report(s). This is useful if you need to see the tax spend.
+ - **Category Export**: Exports category names with the total amount attributed to each category on the report. While you can also access this information on the Insights page, it can be convenient to export to a CSV to run further analysis in your favorite spreadsheet program.
+ - **Per Diem Export**: Exports basic expense details for only the per diem expenses on the report. Useful for reviewing employee Per Diem spend.
+ - **Tag Export**: Exports tag names into columns with the total amount attributed to each tag on the report.
+
+# Create custom export templates
+
+If you don't have a direct connection to your accounting system, you can export your expense data to the system for upload as long as the system accepts a CSV file. You can then analyze the data in your favorite spreadsheet program.
+
+Custom export templates can be created and made available to all Workspace Admins for your workspace, or you can create a template that is just for your own use.
+
+## For a workspace
+
+{% include info.html %}
+Must be a Group Workspace Admin to complete this process.
+{% include end-info.html %}
+
+1. Hover over **Settings** and click **Workspaces**.
+2. Select the desired workspace.
+3. Click the **Export Formats** tab on the left.
+4. Click **New Export Format**.
+5. Enter a name for the export format.
+6. Select the format type (e.g., CSV, XLS for Excel, or CSV without BOM for MS Access)
+7. Enter a name and formula for each column (formulas provided below).
+8. Scroll below all of the columns and, if needed:
+ - Click **Add Column** to add a new column.
+ - Drag and drop the columns into a different order.
+ - Hover over a column and click the red X in the right corner to delete it.
+9. Check the Example Output at the bottom and click **Save Export Format** when all the columns are complete.
+
+## For personal use
+
+1. Hover over **Settings** and click **Account**.
+2. Click **Preferences**.
+3. Under CSV Export Formats, click **New Export Format**.
+4. Enter a name for the export format.
+5. Select the format type (e.g., CSV, XLS for Excel, or CSV without BOM for MS Access)
+6. Enter a name and formula for each column (formulas provided below).
+7. Scroll below all of the columns and, if needed:
+ - Click **Add Column** to add a new column.
+ - Drag and drop the columns into a different order.
+ - Hover over a column and click the red X in the right corner to delete it.
+8. Check the Example Output at the bottom and click **Save Export Format** when all the columns are complete.
+
+## Formulas
+
+Enter any of the following formulas into the Formula field for each column. Be sure to also include both brackets around the formula as shown in the table below.
+
+### Report level
+
+| Formula | Description |
+| -- | -- |
+| Report title | The title of the report the expense is part of. |
+| {report:title} | Would output "Expense Expenses to 2019-11-05" assuming that is the report's title.|
+| Report ID | Number is a unique number per report and can be used to identify specific reports.|
+| {report:id} | Would output R00I7J3xs5fn assuming that is the report's ID.|
+| Old Report ID | A unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62. |
+| {report:oldID} | Would output R3513250790654885 assuming that is the report's old ID.|
+| Reimbursement ID | The unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.|
+| {report:reimbursementid} | Would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.|
+| Report Total | The total amount of the expense report.|
+| {report:total} | Would output $325.34 assuming that is the report's total.|
+| Type | Is the type of report (either Expense Report, Invoice or Bill)|
+| {report:type} | Would output "Expense Report" assuming that is the report's type.|
+| Reimbursable Total | Is the total amount that is reimbursable on the report.|
+| {report:reimbursable} | Would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.|
+| Currency | Is the currency to which all expenses on the report are being converted.|
+| {report:currency} | Would output USD assuming that the report total was calculated in US dollars.|
+|| Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.|
+| Report Field | Formula will output the value for a given Report Field which is created in the workspace settings.|
+| {field:Employee ID} | Would output 12456 , assuming "Employee ID" is the name of the Report Field and "123456" is the value of that field on the report.|
+| Created date | The expense report was originally created by the user.|
+| {report:created} | Would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.|
+| {report:created:yyyy-MM-dd} | Would output 2010-09-15 assuming the expense report was created on September 15, 2010.|
+| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).|
+| StartDate | Is the date of the earliest expense on the report.|
+| {report:startdate} | Would output 2010-09-15 assuming that is the date of the earliest expense on the report.|
+| EndDate| Is the date of the last expense on the report.|
+| {report:enddate} | Would output 2010-09-26 assuming that is the date of the last expense on the report.|
+| Scheduled Submit Dates | The start and end dates of the Scheduled Submit reporting cycle.|
+| {report:autoReporting:start} | Would output 2010-09-15 assuming that is the start date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
+| {report:autoReporting:end} | Would output 2010-09-26 assuming that is the end date of the automatic reporting cycle, when the automatic reporting frequency is not set to daily.|
+| Submission Date | Is the date that the report was submitted.|
+| {report:submit:date} | Would output 1986-09-15 12:00:00 assuming that the report was submitted on September 15, 1986, at noon.|
+| {report:submit:date:yyyy-MM-dd} | Would output 1986-09-15 assuming that the report was submitted on September 15, 1986.|
+| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting.|
+| Approval Date | The date the report was approved. This formula can be used for export templates, but not for report titles.|
+| {report:approve:date} | Would output 2011-09-25 12:00:00 assuming that the report was approved on September 25, 2011, at noon.|
+| {report:approve:date:yyyy-MM-dd} | Would output 2011-09-25 assuming that the report was approved on September 25, 2011.|
+| Reimbursement Date | The date an expense report was reimbursed. This formula can be used for export templates, but not for report titles.|
+| {report:achreimburse} | Would output 2011-09-25 assuming that is the date the report was reimbursed via ACH Direct Deposit.|
+| {report:manualreimburse} | Would output 2011-09-25 assuming that is the date the report was marked as reimbursed. |
+| Export Date | Is the date when the report is exported. This formula can be used for export templates, but not for report titles.|
+| {report:dateexported} | Would output 2013-09-15 12:00 assuming that the report was exported on September 15, 2013, at noon.|
+| {report:dateexported:yyyy-MM-dd} | Would output 2013-09-15 assuming that the report was exported on September 15, 2013.|
+| Expenses Count | Is the number of total expenses on the report of this specific expense.|
+| {report:expensescount} | Would output 10 assuming that there were 10 expenses on the given report for this expense.|
+| Workspace Name | Is the name of the workspace applied to the report.|
+| {report:policyname} | Would output Sales assuming that the given report was under a workspace named Sales.|
+| Status | Is the current state of the report when it was exported.|
+| {report:status} | Would output Approved assuming that the report has been approved and not yet reimbursed.|
+| Custom Fields | |
+| {report:submit:from:customfield1} | Would output the custom field 1 entry associated with the user who submitted the report. If John Smith’s Custom Field 1 contains 100, then this formula would output 100.|
+| {report:submit:from:customfield2} | Would output the custom field 2 entry associated with the user who submitted the report. If John Smith’s Custom Field 2 contains 1234, then this formula would output 1234. |
+| To | Is the email address of the last person who the report was submitted to.|
+| {report:submit:to} | Would output alice@email.com if they are the current approver.|
+| {report:submit:to:email\|frontPart} | Would output alice.|
+| Current user | To export the email of the currently logged in Expensify user.|
+| {user:email} | Would output bob@example.com assuming that is the currently logged in Expensify user's email.|
+| Submitter | "Sally Ride" with email "sride@email.com" is the submitter for the following examples.|
+| {report:submit:from:email}| sride@email.com|
+| {report:submit:from}| Sally Ride|
+| {report:submit:from:firstname}| Sally|
+| {report:submit:from:lastname}| Ride|
+| {report:submit:from:fullname}| Sally Ride |
+| | Note - If user's name is blank, then {report:submit:from} and {report:submit:from:email\|frontPart} will print the user's whole email.|
+
+`{report:submit:from:email|frontPart}` sride
+
+`{report:submit:from:email|domain}` email.com
+
+`{user:email|frontPart}` would output bob assuming that is the currently logged in Expensify user's email.
+
+### Expense level
+
+| Formula | Description |
+| -- | -- |
+| Merchant | Merchant of the expense |
+| {expense:merchant} | Would output Sharons Coffee Shop and Grill assuming the expense is from Sharons Coffee Shop. |
+| {expense:distance:count} | Would output the total miles/kilometers of the expense.|
+| {expense:distance:rate} | Would output the monetary rate allowed per mile/kilometer. |
+| {expense:distance:unit} | Would output either mi or km depending on which unit is applied in the workspace settings. |
+| Date | Related to the date listed on the expense |
+| {expense:created:yyyy-MM-dd} | Would output 2019-11-05 assuming the expense was created on November 5th, 2019. |
+| {expense:posted:yyyy-MM-dd} | Would output 2023-07-24 assuming the expense was posted on July 24th, 2023. |
+| Tax | The tax type and amount applied to the expense line item. |
+| {expense:tax:field} | Would output VAT assuming this is the name of the tax field.|
+| {expense:tax:ratename} | Would output the name of the tax rate that was used (ex: Standard). This will show custom if the chosen tax amount is manually entered and not chosen from the list of given options.|
+| {expense:tax:amount} | Would output $2.00 assuming that is the amount of the tax on the expense.|
+| {expense:tax:percentage} | Would output 20% assuming this is the amount of tax that was applied to the subtotal.|
+| {expense:tax:net} | would output $18.66 assuming this is the amount of the expense before tax was applied.|
+| {expense:tax:code} | would output the tax code that was set in the workspace settings.|
+| Expense Amount | Related to the currency type and amount of the expense. |
+| {expense:amount} | Would output $3.95 assuming the expense was for three dollars and ninety-five cents.|
+| {expense:amount:isk} | Would output Íkr3.95 assuming the expense was for 3.95 Icelandic króna.|
+| {expense:amount:nosymbol} | Would output 3.95. Notice that there is no currency symbol in front of the expense amount because we designated none.|
+| {expense:exchrate} | Would output the currency conversion rate used to convert the expense amount|
+| | Add an optional extra input that is either a three-letter currency code or nosymbol to denote the output's currency. The default if one isn't provided is USD.|
+| {expense:amount:originalcurrency} | This gives the amount of the expense in the currency in which it occurred before currency conversion |
+| {expense:amount:originalcurrency:nosymbol} | Will export the expense in its original currency without the currency symbol. |
+| {expense:amount:negsign} | displays negative expenses with a minus sign in front rather wrapped in parenthesis. It would output -$3.95 assuming the expense was already a negative expense for three dollars and ninety-five cents. This formula does not convert a positive expense to a negative value.|
+| {expense:amount:unformatted} | Displays expense amounts without commas. This removes commas from expenses that have an amount of more than 1000. It would output $10000 assuming the expense was for ten thousand dollars.|
+| {expense:debitamount} | Displays the amount of the expense if the expense is positive. Nothing will be displayed in this column if the expense is negative. It would output $3.95 assuming the expense was for three dollars and ninety-five cents.|
+| {expense:creditamount} | Displays the amount of the expense if the expense is negative. Nothing will be displayed in this column if the expense is positive. It would output -$3.95 assuming the expense was for negative three dollars and ninety-five cents.|
+| For expenses imported via CDF/VCF feed only ||
+| {expense:purchaseamount} | Is the amount of the original purchase in the currency it was purchased in. Control plan users only.|
+| {expense:purchaseamount} | Would output Irk 3.95 assuming the expense was for 3.95 Icelandic krónur, no matter what currency your bank has translated it to.|
+| {expense:purchasecurrency} | Would output Irk assuming the expense was incurred in Icelandic krónur (before your bank converted it back to your home currency).|
+| Original Amount | When import with a connected bank.|
+| {expense:originalamount} | Is the amount of the expense imported from your bank or credit card feed. It would output $3.95 assuming the expense equated to $3.95 and you use US-based bank. You may add an optional extra input that is either a three-letter currency code or NONE to denote the output's currency.|
+| Category | The category of the expense. |
+| {expense:category} | Would output Employee Moral assuming that is the expenses' category.|
+| {expense:category:glcode} | Would output the category gl code of the category selected.|
+| {expense:category:payrollcode} | Outputs the payroll code information entered for the category that is applied to the expense. If the payroll code for the Mileage category was 39847, this would output simply 39847.|
+| Attendees | Persons listed as attendees on the expense.|
+| {expense:attendees} | Would output the name or email address entered in the Attendee field within the expense (ex. guest@domain.com). |
+| {expense:attendees:count} | Would output the number of attendees that were added to the expense (ex. 2).8. Attendees - persons listed as attendees on the expense.|
+| Tags | Tags of the expense - in this example the name of the tag is "Department." |
+| {expense:tag} | Would output Henry at Example Co. assuming that is the expenses' tag. |
+| Multiple Tags | Tags for companies that have multiple tags setup. |
+| {expense:tag:ntag-1} | Outputs the first tag on the expense, if one is selected. |
+| {expense:tag:ntag-3} | Outputs the third tag on the expense, if one is selected. |
+| Description | The description on the expense. |
+| {expense:comment} | Would output "office lunch" assuming that is the expenses' description.|
+| Receipt | |
+| {expense:receipt:type} | Would output eReceipt if the receipt is an Expensify Guaranteed eReceipt.|
+| {expense:receipt:url} | Would output a link to the receipt image page that anyone with access to the receipt in Expensify could view.|
+| {expense:receipt:url:direct} | Would show the direct receipt image url for download. |
+| {expense:mcc} | Would output 3351 assuming that is the expenses' MCC (Merchant Category Code of the expense).|
+| | Note, we only have the MCC for expenses that are automatically imported or imported from an OFX/QFX file. For those we don't have an MCC for the output would be (an empty string).|
+| Card name/number expense type | |
+| {expense:card} | Manual/Cash Expenses — would output Cash assuming the expense was manually entered using either the website or the mobile app.|
+| {expense:card} | Bank Card Expenses — would output user@company.com – 1234 assuming the expense was imported from a credit card feed.|
+| | Note - If you do not have access to the card that the expense was created on 'Unknown' will be displayed. If cards are assigned to users under Domain, then you'll need to be a Domain Admin to export the card number.|
+| Expense ID | |
+| {expense:id} | Would output the unique number associated with each individual expense "4294967579".|
+| Reimbursable state | |
+| {expense:reimbursable} | Would output "yes" or "no" depending on whether the expense is reimbursable or not.|
+| Billable state | |
+| {expense:billable} | Would output "yes" or "no" depending on whether the expense is billable or not.
+| Expense Number | Is the ordinal number of the expense on its expense report.|
+| {report:expense:number} | Would output 2 assuming that the given expense was the second expense on its report.|
+| GL codes | |
+| {expense:category:glcode} | Would output the GL code associated with the category of the expense. If the GL code for Meals is 45256 this would output simply 45256.|
+| {expense:tag:glcode} | Would output the GL code associated with the tag of the expense. If the GL code for Client X is 08294 this would output simply 08294.|
+| {expense:tag:ntag-3:glcode} | Would output the GL code associated with the third tag the user chooses. This is only for companies that have multiple tags setup.|
+
+### Date formats
+
+| Formula | Description |
+| -- | -- |
+| M/dd/yyyy | 5/23/2019|
+|MMMM dd, yyyy| May 23, 2019|
+|dd MMM yyyy| 23 May 2019|
+|yyyy/MM/dd| 2019/05/23|
+|dd MMM yyyy| 23 May 2019|
+|yyyy/MM/dd| 2019/05/23|
+|MMMM, yyyy| May, 2019|
+|yy/MM/dd| 19/05/23|
+|dd/MM/yy| 23/05/19|
+|yyyy| 2019|
+
+### Math formulas
+
+| Formula | Description |
+| -- | -- |
+| * | Multiplication {math: 3 * 4} output 12|
+| / | Division {math: 3 / 4 }output 0.75|
+| + | Addition {math: 3 + 4 }output |
+| - | Subtraction {math: 3 - 4 }output -1|
+| ^ | Exponent {math: 3 ^ 4 } output 81|
+| sqrt | The square root of a number. {sqrt:64} output 8|
+|| Note - You can also combine the value of any two numeric fields. For example, you can use {math: {expense:tag:glcode} + {expense:category:glcode}} to add the value of the Tag GL code with the Category GL code.|
+
+### Substring formulas
+
+This formula will output a subset of the string in question. It is important to remember that the count starts at 0 not 1.
+
+`{expense:merchant|substr:0:4}` would output "Star" for a merchant named Starbucks. This is because we are telling it to start at position 0 and be of 4 character length.
+
+`{expense:merchant|substr:4:5}` would output "bucks" for a merchant named Starbucks. This is because we are telling it to start at position 4 and be of 5 character length.
+
+{% include faq-begin.md %}
+
+**Can I export one line per report?**
+
+No, the custom template always exports one line per *expense*. At the moment, it is not possible to create a template that will export one line per report.
+
+**How do I print a report?**
+
+1. Click the **Reports** tab.
+2. Open a report.
+3. Click **Details** in the top right of the report.
+4. Click the Print icon.
+
+**Why isn’t my report exporting?**
+
+Big reports with a lot of expenses may cause the PDF download to fail due to images with large resolutions. In that case, try breaking the report into multiple smaller reports. A report must have at least one expense to be exported or saved as a PDF.
+
+**Can I download multiple PDFs at once?**
+
+No, you can’t download multiple reports as PDFs at the same time. If you’d like to export multiple reports, an alternative to consider is the CSV export option.
+
+**The data exported to Excel is showing incorrectly. How can I fix this?**
+
+When opening a CSV file export from Expensify in Excel, it’ll automatically register report IDs and transaction IDs as numbers and assign the number format to the report ID column. If a number is greater than a certain length, Excel will contract the number and display it in exponential form. To prevent this, the number needs to be imported as text, which can be done by opening Excel and clicking File > Import > select your CSV file. Follow the prompts, then on step 3, set the report ID/transactionID column to import as Text.
+
+**Why are my numbers exporting in a weird format?**
+
+Do your numbers look something like this: 1.7976931348623157e+308? This means that your spreadsheet program is formatting long numbers in an exponential or scientific format. If that happens, you can correct it by changing the data to Plain Text or a Number in your spreadsheet program.
+
+**Why are my leading zeros missing?**
+
+Is the export showing “1” instead of “01”? This means that your spreadsheet program is cutting off the leading zero. This is a common issue with viewing exported data in Excel. Unfortunately, we don’t have a good solution for this. We recommend checking your spreadsheet program’s help documents for formatting suggestions.
+
+{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md b/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md
deleted file mode 100644
index 9d752dec3eb9..000000000000
--- a/docs/articles/expensify-classic/spending-insights/Other-Export-Options.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: Other Export Options
-description: Other Export Options
----
-
-# Overview
-Here’s a quick look at how to export your expense and report data into a spreadsheet, accounting package, or PDF. We’ll also show you how to print out your reports in a few easy steps.
-
-# How to export expenses and reports to a CSV or accounting package
-From the **Expenses** page, you can export individual expenses into a CSV. From the Reports page, you can export entire reports into a CSV or connected accounting package. Here’s how to do both:
-
-1. Go to either the Expenses or Reports page
-2. On the left hand side, select the expenses/reports you’d like to export
-3. Click **Export to** at the top right of the page
-4. Choose the desired export option
-
-You can use one of the [default templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Default-Export-Templates) or [create your own template](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates). The default templates and the option to export to a connected accounting package are only available on the **Reports** page. Visit the specific help page for your accounting package to learn more about how to get this set up.
-
-# How to export a report as a PDF
-1. Go to the **Reports** page
-2. Click into a report
-3. Click on **Details** in the top right of the report
-4. Click the **download icon** to generate a PDF
-
-The PDF will include all expenses, any attached receipts, and all report notes.
-
-# How to print a report
-1. Go to the Reports page
-2. Click into a report
-3. Click on **Details** in the top right of the report
-4. Click the **print icon**
-
-{% include faq-begin.md %}
-## Why isn’t my report exporting?
-Big reports with lots of expenses may cause the PDF download to fail due to images with large resolutions. In that case, try breaking the report into multiple smaller reports. Also, please note that a report must have at least one expense to be exported or saved as a PDF.
-## Can I download multiple PDFs at once?
-No, you can’t download multiple reports as PDFs at the same time. If you’d like to export multiple reports, an alternative to consider is the CSV export option.
-## The data exported to Excel is showing incorrectly. How can I fix this?
-When opening a CSV file export from Expensify in Excel, it’ll automatically register report IDs and transaction IDs as numbers and assign the number format to the report ID column. If a number is greater than a certain length, Excel will contract the number and display it in exponential form. To prevent this, the number needs to be imported as text, which can be done by opening Excel and clicking File > Import > select your CSV file > follow the prompts and on step 3 set the report ID/transactionID column to import as Text.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
index 5d25670ac5ab..f48d069e21dc 100644
--- a/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
+++ b/docs/articles/expensify-classic/travel/Book-with-Expensify-Travel.md
@@ -1,8 +1,7 @@
---
title: Book with Expensify Travel
-description: Book flights, hotels, cars, trains, and more with Expensify Travel
+description: How to book flights, hotels, cars, trains, and more with Expensify Travel
---
-
Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available.
@@ -38,52 +37,6 @@ The traveler is emailed an itinerary of the booking. Additionally,
The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable.
{% include end-info.html %}
-
+# Edit or cancel travel arrangements
-
-Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available.
-
-With Expensify Travel, you can:
-- Search and book travel arrangements all in one place
-- Book travel for yourself or for someone else
-- Get real-time support by chat or phone
-- Manage all your T&E expenses in Expensify
-- Create specific rules for booking travel
-- Enable approvals for out-of-policy trips
-- Book with any credit card on the market
-- Book with the Expensify Card to get cash back and automatically reconcile transactions
-
-There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental.
-
-# Book travel
-
-{% include selector.html values="desktop, mobile" %}
-
-{% include option.html value="desktop" %}
-1. Click the + icon in the bottom left menu and select **Book travel**.
-2. Click **Book or manage travel**.
-3. Agree to the terms and conditions and click **Continue**.
-4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
-5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
-6. Select all the details for the arrangement you want to book.
-7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
-{% include end-option.html %}
-
-{% include option.html value="mobile" %}
-1. Tap the + icon in the bottom menu and select **Book travel**.
-2. Tap **Book or manage travel**.
-3. Agree to the terms and conditions and tap **Continue**.
-4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains.
-5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.).
-6. Select all the details for the arrangement you want to book.
-7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking.
-{% include end-option.html %}
-
-{% include end-selector.html %}
-
-The traveler is emailed an itinerary of the booking. Additionally,
-- Their travel details are added to a Trip chat room under their primary workspace.
-- An expense report for the trip is created.
-- If booked with an Expensify Card, the trip is automatically reconciled.
-
-
+Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
diff --git a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md b/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md
deleted file mode 100644
index 7dc71c3220ca..000000000000
--- a/docs/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-title: Edit or cancel travel arrangements
-description: Modify travel arrangements booked with Expensify Travel
----
-
-
-Click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
-
-
-
-
-
-You can review your travel arrangements any time by opening the Trip chat in your inbox. For example, if you booked a flight to San Francisco, a “Trip to San Francisco” chat will be automatically added to your chat inbox.
-
-To edit or cancel a travel arrangement,
-1. Click your profile image or icon in the bottom left menu.
-2. Scroll down and click **Workspaces** in the left menu.
-3. Select the workspace the travel is booked under.
-4. Tap into the booking to see more details.
-5. Click **Trip Support**.
-
-If there is an unexpected change to the itinerary (for example, a flight cancellation), Expensify’s travel partner **Spotnana** will reach out to the traveler to provide updates on those changes.
-
-{% include info.html %}
-You can click **Get Support** on your emailed travel itinerary for real-time help with the booking. Any modifications, exchanges, or voidings made to a trip via support will incur a $25 booking change fee.
-{% include end-info.html %}
-
-
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/docs/articles/new-expensify/expenses-&-payments/Pay-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Pay-an-expense.md
new file mode 100644
index 000000000000..5d2b634e8032
--- /dev/null
+++ b/docs/articles/new-expensify/expenses-&-payments/Pay-an-expense.md
@@ -0,0 +1,65 @@
+---
+title: Pay Expenses
+description: Pay workspace expenses or expenses submitted by friends and family
+---
+
+
+# Pay expenses submitted to a workspace
+
+To pay expenses within Expensify, you’ll need to set up your [business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account).
+The submitter must also connect a [personal bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Personal-Bank-Account) to receive the payment.
+
+To pay an expense,
+{% include selector.html values="desktop, mobile" %}
+{% include option.html value="desktop" %}
+1. You will receive an email and in-app notification prompting you to review and **Pay** the expense. If your default contact method is a phone number, you'll receive a text.
+2. Click the **Pay** button on the notification to be directed to New Expensify.
+3. Select a payment option.
+- **Pay with Expensify** to pay the total expense within Expensify. Follow the prompt to pay with a [business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account).
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. When an employee sends you an expense, you will receive an email and in-app notification prompting you to review and **Pay** the expense. If your default contact method is a phone number, you'll receive a text.
+2. Tap the **Pay** button on the notification to be directed to New Expensify.
+3. Select a payment option.
+- **Pay with Expensify** to pay the total expense within Expensify. Follow the prompt to pay with a [business bank account](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Connect-a-Business-Bank-Account).
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+{% include end-selector.html %}
+
+# Pay back friends and family
+
+You'll need to [set up your wallet](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Set-up-your-wallet) to send and receive personal payments within Expensify. The wallet is currently available to customers in the US-only.
+
+To pay an expense,
+
+{% include selector.html values="desktop, mobile" %}
+{% include option.html value="desktop" %}
+1. You will receive an email or in-app notification when an individual sends you an expense. If your default contact method is a phone number, you'll receive a text.
+2. Click the **Pay** button to be directed to New Expensify.
+3. Review the expense details and click **Pay**.
+4. Select a payment option.
+- **Pay with Expensify** to pay the expense with your connected Wallet.
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+
+{% include option.html value="mobile" %}
+1. You will receive an email or in-app notification when an individual sends you an expense. If your default contact method is a phone number, you'll receive a text.
+2. Tap the **Pay** button to be directed to New Expensify.
+3. Review the expense details and tap **Pay**.
+4. Select a payment option.
+- **Pay with Expensify** to pay the expense with your connected Wallet.
+- **Pay Elsewhere** to pay outside Expensify.
+{% include end-option.html %}
+{% include end-selector.html %}
+
+{% include faq-begin.md %}
+
+**Can I pay someone in another currency?**
+
+While you can record your expenses in different currencies, Expensify is configured to pay a U.S. personal or business bank account.
+
+{% include faq-end.md %}
+
+
diff --git a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md
index 615fac731c41..4be5f9d739b5 100644
--- a/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md
+++ b/docs/articles/new-expensify/expenses-&-payments/Pay-an-invoice.md
@@ -10,7 +10,7 @@ Anyone who receives an Expensify invoice can pay it using Expensify—even if th
You'll receive an automated email or text notification when an invoice is sent to you for payment.
-To pay an invoice,
+# Pay an invoice
{% include selector.html values="desktop, mobile" %}
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 4a08a683d08e..5c83d510ccb8 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -598,4 +598,8 @@ https://help.expensify.com/articles/expensify-classic/expenses/Track-mileage-exp
https://help.expensify.com/articles/expensify-classic/expenses/Track-per-diem-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense
https://community.expensify.com/discussion/5116/faq-where-can-i-use-the-expensify-card,https://help.expensify.com/articles/new-expensify/expensify-card/Use-your-Expensify-Card#where-can-i-use-my-expensify-card
https://help.expensify.com/articles/other/Expensify-Lounge,https://help.expensify.com/Hidden/Expensify-Lounge
-https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses
\ No newline at end of file
+https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-and-pay-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Approve-expenses
+https://help.expensify.com/articles/expensify-classic/spending-insights/Custom-Templates,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
+https://help.expensify.com/articles/expensify-classic/spending-insights/Default-Export-Templates,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
+https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/
+https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements,https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel
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 20c407dd5930..7f65a14b4d6e 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.0.64
+ 9.0.65CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.64.0
+ 9.0.65.3FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index e8bfec4a0629..38d7dea5a9ce 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.64
+ 9.0.65CFBundleSignature????CFBundleVersion
- 9.0.64.0
+ 9.0.65.3
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 86ad689e275f..b53c5eb457a1 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.64
+ 9.0.65CFBundleVersion
- 9.0.64.0
+ 9.0.65.3NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 01aefab63378..5ea5b19896e4 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2661,7 +2661,7 @@ PODS:
- RNSound/Core (= 0.11.2)
- RNSound/Core (0.11.2):
- React-Core
- - RNSVG (15.6.0):
+ - RNSVG (15.9.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2681,9 +2681,9 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- - RNSVG/common (= 15.6.0)
+ - RNSVG/common (= 15.9.0)
- Yoga
- - RNSVG/common (15.6.0):
+ - RNSVG/common (15.9.0):
- DoubleConversion
- glog
- hermes-engine
@@ -3295,7 +3295,7 @@ SPEC CHECKSUMS:
RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2
RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0
RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852
- RNSVG: 1079f96b39a35753d481a20e30603fd6fc4f6fa9
+ RNSVG: b2fbe96b2bb3887752f8abc1f495953847e90384
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
SDWebImageAVIFCoder: 00310d246aab3232ce77f1d8f0076f8c4b021d90
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
diff --git a/package-lock.json b/package-lock.json
index 22a03bc5e464..c5e6acd5f6f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.64-0",
+ "version": "9.0.65-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.64-0",
+ "version": "9.0.65-3",
"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",
@@ -111,7 +111,7 @@
"react-native-screens": "3.34.0",
"react-native-share": "11.0.2",
"react-native-sound": "^0.11.2",
- "react-native-svg": "15.6.0",
+ "react-native-svg": "15.9.0",
"react-native-tab-view": "^3.5.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.8.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": {
@@ -35989,9 +36031,10 @@
}
},
"node_modules/react-native-svg": {
- "version": "15.6.0",
- "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.6.0.tgz",
- "integrity": "sha512-TUtR+h+yi1ODsd8FHdom1TpjfWOmnaK5pri5rnSBXnMqpzq8o2zZfonHTjPX+nS3wb/Pu2XsoARgYaHNjVWXhQ==",
+ "version": "15.9.0",
+ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.9.0.tgz",
+ "integrity": "sha512-pwo7hteAM0P8jNpPGQtiSd0SnbBhE8tNd94LT8AcZcbnH5AJdXBIcXU4+tWYYeGUjiNAH2E5d0T5XIfnvaz1gA==",
+ "license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
diff --git a/package.json b/package.json
index d984af3f9431..0addac362a27 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.64-0",
+ "version": "9.0.65-3",
"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",
@@ -168,7 +168,7 @@
"react-native-screens": "3.34.0",
"react-native-share": "11.0.2",
"react-native-sound": "^0.11.2",
- "react-native-svg": "15.6.0",
+ "react-native-svg": "15.9.0",
"react-native-tab-view": "^3.5.2",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "3.8.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/patches/recyclerlistview+4.2.1.patch b/patches/recyclerlistview+4.2.1.patch
new file mode 100644
index 000000000000..bc68489246cd
--- /dev/null
+++ b/patches/recyclerlistview+4.2.1.patch
@@ -0,0 +1,12 @@
+diff --git a/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js b/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js
+index 3ca4550..753c2f7 100644
+--- a/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js
++++ b/node_modules/recyclerlistview/dist/reactnative/core/RecyclerListView.js
+@@ -251,6 +251,7 @@ var RecyclerListView = /** @class */ (function (_super) {
+ this._virtualRenderer.setOptimizeForAnimations(false);
+ };
+ RecyclerListView.prototype.componentDidMount = function () {
++ this._isMounted = true;
+ if (this._initComplete) {
+ this._processInitialOffset();
+ this._processOnEndReached();
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 d493bb0b9f43..ed5f1837fe3b 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -620,6 +620,31 @@ const CONST = {
AGREEMENTS: 'AgreementsStep',
FINISH: 'FinishStep',
},
+ BENEFICIAL_OWNER_INFO_STEP: {
+ SUBSTEP: {
+ IS_USER_BENEFICIAL_OWNER: 1,
+ IS_ANYONE_ELSE_BENEFICIAL_OWNER: 2,
+ BENEFICIAL_OWNER_DETAILS_FORM: 3,
+ ARE_THERE_MORE_BENEFICIAL_OWNERS: 4,
+ OWNERSHIP_CHART: 5,
+ BENEFICIAL_OWNERS_LIST: 6,
+ },
+ BENEFICIAL_OWNER_DATA: {
+ BENEFICIAL_OWNER_KEYS: 'beneficialOwnerKeys',
+ PREFIX: 'beneficialOwner',
+ FIRST_NAME: 'firstName',
+ LAST_NAME: 'lastName',
+ OWNERSHIP_PERCENTAGE: 'ownershipPercentage',
+ DOB: 'dob',
+ SSN_LAST_4: 'ssnLast4',
+ STREET: 'street',
+ CITY: 'city',
+ STATE: 'state',
+ ZIP_CODE: 'zipCode',
+ COUNTRY: 'country',
+ },
+ CURRENT_USER_KEY: 'currentUser',
+ },
STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
STEP_HEADER_HEIGHT: 40,
SIGNER_INFO_STEP: {
@@ -850,6 +875,7 @@ const CONST = {
CLOUDFRONT_URL,
EMPTY_ARRAY,
EMPTY_OBJECT,
+ DEFAULT_NUMBER_ID: 0,
USE_EXPENSIFY_URL,
EXPENSIFY_URL,
GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com',
@@ -1620,6 +1646,7 @@ const CONST = {
EXPENSIFY_MERCHANT: 'Expensify, Inc.',
EMAIL: {
ACCOUNTING: 'accounting@expensify.com',
+ ACCOUNTS_PAYABLE: 'accountspayable@expensify.com',
ADMIN: 'admin@expensify.com',
BILLS: 'bills@expensify.com',
CHRONOS: 'chronos@expensify.com',
@@ -2082,6 +2109,7 @@ const CONST = {
ACCOUNT_ID: {
ACCOUNTING: Number(Config?.EXPENSIFY_ACCOUNT_ID_ACCOUNTING ?? 9645353),
+ ACCOUNTS_PAYABLE: Number(Config?.EXPENSIFY_ACCOUNT_ID_ACCOUNTS_PAYABLE ?? 10903701),
ADMIN: Number(Config?.EXPENSIFY_ACCOUNT_ID_ADMIN ?? -1),
BILLS: Number(Config?.EXPENSIFY_ACCOUNT_ID_BILLS ?? 1371),
CHRONOS: Number(Config?.EXPENSIFY_ACCOUNT_ID_CHRONOS ?? 10027416),
@@ -2815,6 +2843,7 @@ const CONST = {
AMEX_CUSTOM_FEED: {
CORPORATE: 'American Express Corporate Cards',
BUSINESS: 'American Express Business Cards',
+ PERSONAL: 'American Express Personal Cards',
},
DELETE_TRANSACTIONS: {
RESTRICT: 'corporate',
@@ -2941,8 +2970,8 @@ const CONST = {
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
- // eslint-disable-next-line max-len, no-misleading-character-class
- EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu,
+ // eslint-disable-next-line max-len, no-misleading-character-class, no-empty-character-class
+ EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/du,
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJI_SKIN_TONES: /[\u{1f3fb}-\u{1f3ff}]/gu,
@@ -2979,6 +3008,10 @@ const CONST = {
return new RegExp(`[\\n\\s]|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
},
+ get ALL_EMOJIS() {
+ return new RegExp(this.EMOJIS, this.EMOJIS.flags.concat('g'));
+ },
+
MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/,
ROUTES: {
VALIDATE_LOGIN: /\/v($|(\/\/*))/,
@@ -3026,6 +3059,7 @@ const CONST = {
get EXPENSIFY_EMAILS() {
return [
this.EMAIL.ACCOUNTING,
+ this.EMAIL.ACCOUNTS_PAYABLE,
this.EMAIL.ADMIN,
this.EMAIL.BILLS,
this.EMAIL.CHRONOS,
@@ -3046,6 +3080,7 @@ const CONST = {
get EXPENSIFY_ACCOUNT_IDS() {
return [
this.ACCOUNT_ID.ACCOUNTING,
+ this.ACCOUNT_ID.ACCOUNTS_PAYABLE,
this.ACCOUNT_ID.ADMIN,
this.ACCOUNT_ID.BILLS,
this.ACCOUNT_ID.CHRONOS,
@@ -4821,7 +4856,6 @@ const CONST = {
WELCOME_VIDEO_URL: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`,
- ONBOARDING_INTRODUCTION: 'Let’s get you set up 🔧',
ONBOARDING_CHOICES: {...onboardingChoices},
SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices},
COMBINED_TRACK_SUBMIT_ONBOARDING_CHOICES: {...combinedTrackSubmitOnboardingChoices},
@@ -5005,7 +5039,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/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx
index 9a90de17595d..ed2eae7a0a4c 100644
--- a/src/components/AccountSwitcher.tsx
+++ b/src/components/AccountSwitcher.tsx
@@ -10,6 +10,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate';
+import * as EmojiUtils from '@libs/EmojiUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import variables from '@styles/variables';
@@ -46,6 +47,7 @@ function AccountSwitcher() {
const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false;
const canSwitchAccounts = delegators.length > 0 || isActingAsDelegate;
+ const processedTextArray = EmojiUtils.splitTextWithEmojis(currentUserPersonalDetails?.displayName);
const createBaseMenuItem = (
personalDetails: PersonalDetails | undefined,
@@ -149,7 +151,9 @@ function AccountSwitcher() {
numberOfLines={1}
style={[styles.textBold, styles.textLarge, styles.flexShrink1]}
>
- {currentUserPersonalDetails?.displayName}
+ {processedTextArray.length !== 0
+ ? EmojiUtils.getProcessedText(processedTextArray, styles.initialSettingsUsernameEmoji)
+ : currentUserPersonalDetails?.displayName}
{!!canSwitchAccounts && (
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 ce160edefd89..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();
@@ -70,15 +83,15 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi
setShouldShowArrows(true);
}, [canUseTouchScreen, page, setShouldShowArrows]);
- const compareImage = useCallback((attachment: Attachment) => attachment.source === source && attachment.attachmentLink === attachmentLink, [attachmentLink, source]);
+ const compareImage = useCallback((attachment: Attachment) => attachment.source === source && (!attachmentLink || attachment.attachmentLink === attachmentLink), [attachmentLink, source]);
useEffect(() => {
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/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx
index be875790d75e..e71ade65e66d 100755
--- a/src/components/Composer/implementation/index.tsx
+++ b/src/components/Composer/implementation/index.tsx
@@ -19,6 +19,7 @@ import * as Browser from '@libs/Browser';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
const excludeNoStyles: Array = [];
@@ -70,6 +71,7 @@ function Composer(
start: selectionProp.start,
end: selectionProp.end,
});
+ const [hasMultipleLines, setHasMultipleLines] = useState(false);
const [isRendered, setIsRendered] = useState(false);
const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? '');
const [prevScroll, setPrevScroll] = useState();
@@ -328,10 +330,10 @@ function Composer(
scrollStyleMemo,
StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize),
isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined,
- textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {},
+ textContainsOnlyEmojis && hasMultipleLines ? styles.onlyEmojisTextLineHeight : {},
],
- [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
+ [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, hasMultipleLines, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis],
);
return (
@@ -350,6 +352,9 @@ function Composer(
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
+ onContentSizeChange={(e) => {
+ setHasMultipleLines(e.nativeEvent.contentSize.height > variables.componentSizeLarge);
+ }}
disabled={isDisabled}
onKeyPress={handleKeyPress}
addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL}
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 (
) {
const styles = useThemeStyles();
- const style = {...styleProp, ...('islarge' in tnode.attributes ? styles.onlyEmojisText : {})};
+ const style = useMemo(() => {
+ if ('islarge' in tnode.attributes) {
+ return [styleProp as TextStyle, styles.onlyEmojisText];
+ }
+
+ if ('ismedium' in tnode.attributes) {
+ return [styleProp as TextStyle, styles.emojisWithTextFontSize, styles.verticalAlignTopText];
+ }
+
+ return null;
+ }, [tnode.attributes, styles, styleProp]);
return (
();
const {setSplashScreenState} = useSplashScreenStateContext();
- const [initialLastUpdateIDAppliedToClient, metadata] = useOnyx(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT);
+
useEffect(() => {
- if (metadata.status !== 'loaded') {
- return;
- }
if (url) {
- signInAfterTransitionFromOldDot(url, initialLastUpdateIDAppliedToClient).then((route) => {
+ signInAfterTransitionFromOldDot(url).then((route) => {
setInitialURL(route);
setSplashScreenState(CONST.BOOT_SPLASH_STATE.READY_TO_BE_HIDDEN);
});
@@ -45,7 +40,7 @@ function InitialURLContextProvider({children, url}: InitialURLContextProviderPro
Linking.getInitialURL().then((initURL) => {
setInitialURL(initURL as Route);
});
- }, [initialLastUpdateIDAppliedToClient, metadata.status, setSplashScreenState, url]);
+ }, [setSplashScreenState, url]);
const initialUrlContext = useMemo(
() => ({
@@ -61,5 +56,4 @@ function InitialURLContextProvider({children, url}: InitialURLContextProviderPro
InitialURLContextProvider.displayName = 'InitialURLContextProvider';
export default InitialURLContextProvider;
-
export {InitialURLContext};
diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx
index 9e7c7291a9a6..237530223246 100644
--- a/src/components/InlineCodeBlock/WrappedText.tsx
+++ b/src/components/InlineCodeBlock/WrappedText.tsx
@@ -39,7 +39,7 @@ function getTextMatrix(text: string): string[][] {
* Validates if the text contains any emoji
*/
function containsEmoji(text: string): boolean {
- return CONST.REGEX.EMOJIS.test(text);
+ return CONST.REGEX.ALL_EMOJIS.test(text);
}
/**
diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx
index 08240a211804..9594e6ede24a 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.tsx
+++ b/src/components/LHNOptionsList/LHNOptionsList.tsx
@@ -19,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import * as DraftCommentUtils from '@libs/DraftCommentUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import * as ReportUtils from '@libs/ReportUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -139,7 +140,9 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
: '-1';
const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
const hasDraftComment = DraftCommentUtils.isValidDraftComment(draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`]);
- const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions);
+
+ const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(itemFullReport);
+ const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(itemReportActions, canUserPerformWriteAction);
const lastReportAction = sortedReportActions.at(0);
// Get the transaction for the last report action
diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx
index ff050f673951..a0179145b77c 100644
--- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx
+++ b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx
@@ -1,11 +1,15 @@
import {CellContainer} from '@shopify/flash-list';
import type {CellContainerProps} from '@shopify/flash-list/dist/native/cell-container/CellContainer';
+import type {ForwardedRef} from 'react';
+import {forwardRef} from 'react';
+import type {View} from 'react-native';
-function OptionRowRendererComponent(props: CellContainerProps) {
+function OptionRowRendererComponent(props: CellContainerProps, ref: ForwardedRef) {
return (
);
@@ -13,4 +17,4 @@ function OptionRowRendererComponent(props: CellContainerProps) {
OptionRowRendererComponent.displayName = 'OptionRowRendererComponent';
-export default OptionRowRendererComponent;
+export default forwardRef(OptionRowRendererComponent);
diff --git a/src/components/LocationPermissionModal/index.android.tsx b/src/components/LocationPermissionModal/index.android.tsx
index 30896cf37084..6e4e6877c540 100644
--- a/src/components/LocationPermissionModal/index.android.tsx
+++ b/src/components/LocationPermissionModal/index.android.tsx
@@ -50,7 +50,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
setHasError(true);
return;
} else {
- onDeny(status);
+ onDeny();
}
setShowModal(false);
setHasError(false);
@@ -58,7 +58,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
});
const skipLocationPermission = () => {
- onDeny(RESULTS.DENIED);
+ onDeny();
setShowModal(false);
setHasError(false);
};
diff --git a/src/components/LocationPermissionModal/index.tsx b/src/components/LocationPermissionModal/index.tsx
index 0e500a9b7cc4..45e3f5b22d1b 100644
--- a/src/components/LocationPermissionModal/index.tsx
+++ b/src/components/LocationPermissionModal/index.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useState} from 'react';
+import React, {useEffect, useMemo, useState} from 'react';
import {Linking} from 'react-native';
import {RESULTS} from 'react-native-permissions';
import ConfirmModal from '@components/ConfirmModal';
@@ -39,10 +39,10 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
if (hasError) {
if (Linking.openSettings) {
Linking.openSettings();
+ } else {
+ onDeny?.();
}
setShowModal(false);
- setHasError(false);
- resetPermissionFlow();
return;
}
cb();
@@ -54,7 +54,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) {
onGrant();
} else {
- onDeny(status);
+ onDeny();
}
})
.finally(() => {
@@ -64,7 +64,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
});
const skipLocationPermission = () => {
- onDeny(RESULTS.DENIED);
+ onDeny();
setShowModal(false);
setHasError(false);
};
@@ -81,15 +81,22 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
setShowModal(false);
resetPermissionFlow();
};
+
+ const locationErrorMessage = useMemo(() => (isWeb ? 'receipt.allowLocationFromSetting' : 'receipt.locationErrorMessage'), [isWeb]);
+
return (
{
+ setHasError(false);
+ resetPermissionFlow();
+ }}
isVisible={showModal}
onConfirm={grantLocationPermission}
onCancel={skipLocationPermission}
onBackdropPress={closeModal}
confirmText={getConfirmText()}
cancelText={translate('common.notNow')}
- prompt={translate(hasError ? 'receipt.locationErrorMessage' : 'receipt.locationAccessMessage')}
promptStyles={[styles.textLabelSupportingEmptyValue, styles.mb4]}
title={translate(hasError ? 'receipt.locationErrorTitle' : 'receipt.locationAccessTitle')}
titleContainerStyles={[styles.mt2, styles.mb0]}
@@ -100,6 +107,7 @@ function LocationPermissionModal({startPermissionFlow, resetPermissionFlow, onDe
iconHeight={120}
shouldCenterIcon
shouldReverseStackedButtons
+ prompt={translate(hasError ? locationErrorMessage : 'receipt.locationAccessMessage')}
/>
);
}
diff --git a/src/components/LocationPermissionModal/types.ts b/src/components/LocationPermissionModal/types.ts
index ec603bfdb8c1..eb18e1d71c13 100644
--- a/src/components/LocationPermissionModal/types.ts
+++ b/src/components/LocationPermissionModal/types.ts
@@ -1,11 +1,9 @@
-import type {PermissionStatus} from 'react-native-permissions';
-
type LocationPermissionModalProps = {
/** A callback to call when the permission has been granted */
onGrant: () => void;
/** A callback to call when the permission has been denied */
- onDeny: (permission: PermissionStatus) => void;
+ onDeny: () => void;
/** Should start the permission flow? */
startPermissionFlow: boolean;
diff --git a/src/components/OptionsListSkeletonView.tsx b/src/components/OptionsListSkeletonView.tsx
index b6333c16e23c..032bf01f5a10 100644
--- a/src/components/OptionsListSkeletonView.tsx
+++ b/src/components/OptionsListSkeletonView.tsx
@@ -28,7 +28,7 @@ function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = fal
return (
{
const lineWidth = getLinedWidth(itemIndex);
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 4c57c0d1f63f..b05d34b2351b 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,7 +1,9 @@
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';
import {useOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -46,9 +48,10 @@ import type {AutocompleteItemData} from './SearchRouterList';
type SearchRouterProps = {
onRouterClose: () => void;
+ shouldHideInputCaret?: TextInputProps['caretHidden'];
};
-function SearchRouter({onRouterClose}: SearchRouterProps) {
+function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [betas] = useOnyx(ONYXKEYS.BETAS);
@@ -321,6 +324,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
],
);
+ const prevUserQueryRef = useRef(null);
useEffect(() => {
Report.searchInServer(debouncedInputValue.trim());
}, [debouncedInputValue]);
@@ -338,11 +342,14 @@ function SearchRouter({onRouterClose}: 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],
);
@@ -396,6 +403,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
onSubmit={() => {
onSearchSubmit(textInputValue);
}}
+ caretHidden={shouldHideInputCaret}
routerListRef={listRef}
shouldShowOfflineMessage
wrapperStyle={[styles.border, styles.alignItemsCenter]}
diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx
index c66437f63bdd..6b99588a21df 100644
--- a/src/components/Search/SearchRouter/SearchRouterInput.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx
@@ -1,6 +1,6 @@
import type {ReactNode, RefObject} from 'react';
import React, {useState} from 'react';
-import type {StyleProp, ViewStyle} from 'react-native';
+import type {StyleProp, TextInputProps, ViewStyle} from 'react-native';
import {View} from 'react-native';
import FormHelpMessage from '@components/FormHelpMessage';
import type {SelectionListHandle} from '@components/SelectionList/types';
@@ -34,9 +34,6 @@ type SearchRouterInputProps = {
/** Whether the offline message should be shown */
shouldShowOfflineMessage?: boolean;
- /** Whether the input should be focused */
- autoFocus?: boolean;
-
/** Any additional styles to apply */
wrapperStyle?: StyleProp;
@@ -51,7 +48,7 @@ type SearchRouterInputProps = {
/** Whether the search reports API call is running */
isSearchingForReports?: boolean;
-};
+} & Pick;
function SearchRouterInput({
value,
@@ -62,6 +59,7 @@ function SearchRouterInput({
disabled = false,
shouldShowOfflineMessage = false,
autoFocus = true,
+ caretHidden = false,
wrapperStyle,
wrapperFocusedStyle,
outerWrapperStyle,
@@ -86,6 +84,7 @@ function SearchRouterInput({
onChangeText={updateSearch}
autoFocus={autoFocus}
shouldDelayFocus={shouldDelayFocus}
+ caretHidden={caretHidden}
loadingSpinnerStyle={[styles.mt0, styles.mr2]}
role={CONST.ROLE.PRESENTATION}
placeholder={translate('search.searchPlaceholder')}
diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx
index 356dcb70f199..340b46cbf173 100644
--- a/src/components/Search/SearchRouter/SearchRouterModal.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useState} from 'react';
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
import Modal from '@components/Modal';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
@@ -7,10 +7,15 @@ import CONST from '@src/CONST';
import SearchRouter from './SearchRouter';
import {useSearchRouterContext} from './SearchRouterContext';
+const isMobileSafari = Browser.isMobileSafari();
+
function SearchRouterModal() {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
+ // On mWeb Safari, the input caret stuck for a moment while the modal is animating. So, we hide the caret until the animation is done.
+ const [shouldHideInputCaret, setShouldHideInputCaret] = useState(isMobileSafari);
+
const modalType = shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.CENTERED_SWIPABLE_TO_RIGHT : CONST.MODAL.MODAL_TYPE.POPOVER;
return (
@@ -22,10 +27,15 @@ function SearchRouterModal() {
propagateSwipe
shouldHandleNavigationBack={Browser.isMobileChrome()}
onClose={closeSearchRouter}
+ onModalHide={() => setShouldHideInputCaret(isMobileSafari)}
+ onModalShow={() => setShouldHideInputCaret(false)}
>
{isSearchRouterDisplayed && (
-
+
)}
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/components/SelectionList/Search/UserInfoCell.tsx b/src/components/SelectionList/Search/UserInfoCell.tsx
index 6a653471683a..4e71b97028bb 100644
--- a/src/components/SelectionList/Search/UserInfoCell.tsx
+++ b/src/components/SelectionList/Search/UserInfoCell.tsx
@@ -35,7 +35,7 @@ function UserInfoCell({participant, displayName}: UserInfoCellProps) {
/>
{displayName}
diff --git a/src/components/SkeletonViewContentLoader/index.native.tsx b/src/components/SkeletonViewContentLoader/index.native.tsx
index 6d275e065bb0..afd58361947a 100644
--- a/src/components/SkeletonViewContentLoader/index.native.tsx
+++ b/src/components/SkeletonViewContentLoader/index.native.tsx
@@ -1,10 +1,17 @@
import React from 'react';
import SkeletonViewContentLoader from 'react-content-loader/native';
+import {StyleSheet} from 'react-native';
import type SkeletonViewContentLoaderProps from './types';
-function ContentLoader(props: SkeletonViewContentLoaderProps) {
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+function ContentLoader({style, ...props}: SkeletonViewContentLoaderProps) {
+ return (
+
+ );
}
export default ContentLoader;
diff --git a/src/components/SkeletonViewContentLoader/index.tsx b/src/components/SkeletonViewContentLoader/index.tsx
index ad3858a2d8d4..cab7710d02ee 100644
--- a/src/components/SkeletonViewContentLoader/index.tsx
+++ b/src/components/SkeletonViewContentLoader/index.tsx
@@ -1,10 +1,19 @@
import React from 'react';
+// eslint-disable-next-line no-restricted-imports
+import type {CSSProperties} from 'react';
import SkeletonViewContentLoader from 'react-content-loader';
+import {StyleSheet} from 'react-native';
import type SkeletonViewContentLoaderProps from './types';
-function ContentLoader(props: SkeletonViewContentLoaderProps) {
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+function ContentLoader({style, ...props}: SkeletonViewContentLoaderProps) {
+ return (
+
+ );
}
export default ContentLoader;
diff --git a/src/components/SkeletonViewContentLoader/types.ts b/src/components/SkeletonViewContentLoader/types.ts
index 5f4089f316dd..de1bdef558ef 100644
--- a/src/components/SkeletonViewContentLoader/types.ts
+++ b/src/components/SkeletonViewContentLoader/types.ts
@@ -1,6 +1,6 @@
import type {IContentLoaderProps} from 'react-content-loader';
import type {IContentLoaderProps as NativeIContentLoaderProps} from 'react-content-loader/native';
-type SkeletonViewContentLoaderProps = IContentLoaderProps & NativeIContentLoaderProps;
+type SkeletonViewContentLoaderProps = Omit & NativeIContentLoaderProps;
export default SkeletonViewContentLoaderProps;
diff --git a/src/components/Skeletons/CardRowSkeleton.tsx b/src/components/Skeletons/CardRowSkeleton.tsx
index d0e14b2bbb9a..24a2f8826908 100644
--- a/src/components/Skeletons/CardRowSkeleton.tsx
+++ b/src/components/Skeletons/CardRowSkeleton.tsx
@@ -31,7 +31,7 @@ function CardRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEn
shouldAnimate={shouldAnimate}
fixedNumItems={fixedNumItems}
gradientOpacityEnabled={gradientOpacityEnabled}
- itemViewStyle={[styles.highlightBG, styles.mb3, styles.br3, styles.mh5]}
+ itemViewStyle={[styles.highlightBG, styles.mb3, styles.br3, styles.ml5]}
renderSkeletonItem={() => (
<>
-
- {renderSkeletonItem({itemIndex: i})}
-
- ,
+ {renderSkeletonItem({itemIndex: i})}
+ ,
);
}
return items;
@@ -83,7 +80,7 @@ function ItemListSkeletonView({
return (
{skeletonViewItems}
diff --git a/src/components/Skeletons/SearchRowSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx
index 3535ba329a90..53f5aaa6065b 100644
--- a/src/components/Skeletons/SearchRowSkeleton.tsx
+++ b/src/components/Skeletons/SearchRowSkeleton.tsx
@@ -42,7 +42,7 @@ function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacity
(
<>
(
<>
(autoGrowHeight ? layout.width : prevWidth));
- setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight));
+ setHeight((prevHeight: number) => (!multiline ? layout.height + heightToFitEmojis : prevHeight));
},
[autoGrowHeight, multiline],
);
diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.native.tsx
index b857ded2588b..9f5f246ff9d3 100644
--- a/src/components/TextWithTooltip/index.native.tsx
+++ b/src/components/TextWithTooltip/index.native.tsx
@@ -1,14 +1,19 @@
import React from 'react';
import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as EmojiUtils from '@libs/EmojiUtils';
import type TextWithTooltipProps from './types';
function TextWithTooltip({text, style, numberOfLines = 1}: TextWithTooltipProps) {
+ const styles = useThemeStyles();
+ const processedTextArray = EmojiUtils.splitTextWithEmojis(text);
+
return (
- {text}
+ {processedTextArray.length !== 0 ? EmojiUtils.getProcessedText(processedTextArray, [style, styles.emojisFontFamily]) : text}
);
}
diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
index cd80330b08ef..930d4139d388 100644
--- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
+++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -179,7 +179,7 @@ function BaseValidateCodeForm({
setValidateCode(text);
setFormError({});
- if (validateError) {
+ if (!isEmptyObject(validateError)) {
clearError();
User.clearValidateCodeActionError('actionVerified');
}
diff --git a/src/components/ValidateCodeActionModal/index.tsx b/src/components/ValidateCodeActionModal/index.tsx
index f715fd8ef136..470d846ccc76 100644
--- a/src/components/ValidateCodeActionModal/index.tsx
+++ b/src/components/ValidateCodeActionModal/index.tsx
@@ -7,6 +7,7 @@ import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import useSafePaddingBottomStyle from '@hooks/useSafePaddingBottomStyle';
import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ValidateCodeActionModalProps} from './type';
@@ -56,6 +57,7 @@ function ValidateCodeActionModal({
isVisible={isVisible}
onClose={hide}
onModalHide={onModalHide ?? hide}
+ onBackdropPress={() => Navigation.dismissModal()}
hideModalContentWhileAnimating
useNativeDriver
shouldUseModalPaddingStyle={false}
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx
index 012537b75108..b1a829b4cc4b 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.tsx
+++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx
@@ -70,6 +70,7 @@ function BaseVideoPlayer({
const [position, setPosition] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
+ const [isEnded, setIsEnded] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
// we add "#t=0.001" at the end of the URL to skip first milisecond of the video and always be able to show proper video preview when video is paused at the beginning
const [sourceURL] = useState(VideoUtils.addSkipTimeTagToURL(url.includes('blob:') || url.includes('file:///') ? url : addEncryptedAuthTokenToURL(url), 0.001));
@@ -95,6 +96,7 @@ function BaseVideoPlayer({
const shouldUseNewRate = typeof source === 'number' || !source || source.uri !== sourceURL;
const togglePlayCurrentVideo = useCallback(() => {
+ setIsEnded(false);
videoResumeTryNumberRef.current = 0;
if (!isCurrentlyURLSet) {
updateCurrentlyPlayingURL(url);
@@ -106,9 +108,12 @@ function BaseVideoPlayer({
}, [isCurrentlyURLSet, isPlaying, pauseVideo, playVideo, updateCurrentlyPlayingURL, url, videoResumeTryNumberRef]);
const hideControl = useCallback(() => {
+ if (isEnded) {
+ return;
+ }
// eslint-disable-next-line react-compiler/react-compiler
controlsOpacity.value = withTiming(0, {duration: 500}, () => runOnJS(setControlStatusState)(CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE));
- }, [controlsOpacity]);
+ }, [controlsOpacity, isEnded]);
const debouncedHideControl = useMemo(() => debounce(hideControl, 1500), [hideControl]);
useEffect(() => {
@@ -198,6 +203,13 @@ function BaseVideoPlayer({
onPlaybackStatusUpdate?.(status);
return;
}
+ if (status.didJustFinish) {
+ setIsEnded(status.didJustFinish && !status.isLooping);
+ setControlStatusState(CONST.VIDEO_PLAYER.CONTROLS_STATUS.SHOW);
+ controlsOpacity.value = 1;
+ } else if (status.isPlaying && isEnded) {
+ setIsEnded(false);
+ }
if (prevIsMutedRef.current && prevVolumeRef.current === 0 && !status.isMuted) {
updateVolume(0.25);
@@ -213,7 +225,7 @@ function BaseVideoPlayer({
const currentDuration = status.durationMillis || videoDuration * 1000;
const currentPositon = status.positionMillis || 0;
- if (shouldReplayVideo(status, isVideoPlaying, currentDuration, currentPositon)) {
+ if (shouldReplayVideo(status, isVideoPlaying, currentDuration, currentPositon) && !isEnded) {
videoPlayerRef.current?.setStatusAsync({positionMillis: 0, shouldPlay: true});
}
@@ -228,7 +240,7 @@ function BaseVideoPlayer({
onPlaybackStatusUpdate?.(status);
},
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this when isPlaying changes because isPlaying is only used inside shouldReplayVideo
- [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration],
+ [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration, isEnded],
);
const handleFullscreenUpdate = useCallback(
@@ -456,7 +468,7 @@ function BaseVideoPlayer({
{((isLoading && !isOffline) || (isBuffering && !isPlaying)) && }
{isLoading && (isOffline || !isBuffering) && }
- {controlStatusState !== CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE && !isLoading && (isPopoverVisible || isHovered || canUseTouchScreen) && (
+ {controlStatusState !== CONST.VIDEO_PLAYER.CONTROLS_STATUS.HIDE && !isLoading && (isPopoverVisible || isHovered || canUseTouchScreen || isEnded) && (
+ {processedOwnerName.length !== 0
+ ? EmojiUtils.getProcessedText(processedOwnerName, [styles.labelStrong, isDeleted ? styles.offlineFeedback.deleted : {}, styles.emojisWithTextFontFamily])
+ : ownerName}
+
+ );
+}
+
+WorkspacesListRowDisplayName.displayName = 'WorkspacesListRowDisplayName';
+
+export default WorkspacesListRowDisplayName;
diff --git a/src/components/WorkspacesListRowDisplayName/index.tsx b/src/components/WorkspacesListRowDisplayName/index.tsx
new file mode 100644
index 000000000000..0d3acb736d2f
--- /dev/null
+++ b/src/components/WorkspacesListRowDisplayName/index.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type WorkspacesListRowDisplayNameProps from './types';
+
+function WorkspacesListRowDisplayName({isDeleted, ownerName}: WorkspacesListRowDisplayNameProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {ownerName}
+
+ );
+}
+
+WorkspacesListRowDisplayName.displayName = 'WorkspacesListRowDisplayName';
+
+export default WorkspacesListRowDisplayName;
diff --git a/src/components/WorkspacesListRowDisplayName/types.tsx b/src/components/WorkspacesListRowDisplayName/types.tsx
new file mode 100644
index 000000000000..0744ebc18fc1
--- /dev/null
+++ b/src/components/WorkspacesListRowDisplayName/types.tsx
@@ -0,0 +1,9 @@
+type WorkspacesListRowDisplayNameProps = {
+ /** Should the deleted style be applied */
+ isDeleted: boolean;
+
+ /** Workspace owner name */
+ ownerName: string;
+};
+
+export default WorkspacesListRowDisplayNameProps;
diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts
index 2006ca85dd13..7b38cc12347f 100644
--- a/src/hooks/useMarkdownStyle.ts
+++ b/src/hooks/useMarkdownStyle.ts
@@ -10,7 +10,7 @@ const defaultEmptyArray: Array = [];
function useMarkdownStyle(message: string | null = null, excludeStyles: Array = defaultEmptyArray): MarkdownStyle {
const theme = useTheme();
const hasMessageOnlyEmojis = message != null && message.length > 0 && containsOnlyEmojis(message);
- const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal;
+ const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeEmojisWithinText;
// this map is used to reset the styles that are not needed - passing undefined value can break the native side
const nonStylingDefaultValues: Record = useMemo(
@@ -38,6 +38,7 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array 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 a8acfd99f185..9bea1261ddbd 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -795,6 +795,7 @@ const translations = {
locationAccessMessage: 'Location access helps us keep your timezone and currency accurate wherever you go.',
locationErrorTitle: 'Allow location access',
locationErrorMessage: 'Location access helps us keep your timezone and currency accurate wherever you go.',
+ allowLocationFromSetting: `Location access helps us keep your timezone and currency accurate wherever you go. Please allow location access from your device's permission settings.`,
dropTitle: 'Let it go',
dropMessage: 'Drop your file here',
flash: 'flash',
@@ -1172,7 +1173,9 @@ const translations = {
updateRequiredView: {
updateRequired: 'Update required',
pleaseInstall: 'Please update to the latest version of New Expensify',
+ pleaseInstallExpensifyClassic: 'Please install the latest version of Expensify',
toGetLatestChanges: 'For mobile or desktop, download and install the latest version. For web, refresh your browser.',
+ newAppNotAvailable: 'The New Expensify app is no longer available.',
},
initialSettingsPage: {
about: 'About',
@@ -1464,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',
@@ -1953,6 +1956,7 @@ const translations = {
noDefaultDepositAccountOrDebitCardAvailable: 'Please add a default deposit account or debit card.',
validationAmounts: 'The validation amounts you entered are incorrect. Please double check your bank statement and try again.',
fullName: 'Please enter a valid full name.',
+ ownershipPercentage: 'Please enter a valid percentage number.',
},
},
addPersonalBankAccountPage: {
@@ -2229,6 +2233,43 @@ const translations = {
byAddingThisBankAccount: "By adding this bank account, you confirm that you've read, understand, and accept",
owners: 'Owners',
},
+ ownershipInfoStep: {
+ ownerInfo: 'Owner info',
+ businessOwner: 'Business owner',
+ signerInfo: 'Signer info',
+ doYouOwn: ({companyName}: CompanyNameParams) => `Do you own 25% or more of ${companyName}`,
+ doesAnyoneOwn: ({companyName}: CompanyNameParams) => `Does any individuals own 25% or more of ${companyName}`,
+ regulationsRequire: 'Regulations require us to verify the identity of any individual who owns more than 25% of the business.',
+ legalFirstName: 'Legal first name',
+ legalLastName: 'Legal last name',
+ whatsTheOwnersName: "What's the owner's legal name?",
+ whatsYourName: "What's your legal name?",
+ whatPercentage: 'What percentage of the business belongs to the owner?',
+ whatsYoursPercentage: 'What percentage of the business do you own?',
+ ownership: 'Ownership',
+ whatsTheOwnersDOB: "What's the owner's date of birth?",
+ whatsYourDOB: "What's your date of birth?",
+ whatsTheOwnersAddress: "What's the owner's address?",
+ whatsYourAddress: "What's your address?",
+ whatAreTheLast: "What are the last 4 digits of the owner's Social Security Number?",
+ whatsYourLast: 'What are the last 4 digits of your Social Security Number?',
+ dontWorry: "Don't worry, we don't do any personal credit checks!",
+ last4: 'Last 4 of SSN',
+ whyDoWeAsk: 'Why do we ask for this?',
+ letsDoubleCheck: 'Let’s double check that everything looks right.',
+ legalName: 'Legal name',
+ ownershipPercentage: 'Ownership percentage',
+ areThereOther: ({companyName}: CompanyNameParams) => `Are there other individuals who own 25% or more of ${companyName}`,
+ owners: 'Owners',
+ addCertified: 'Add a certified org chart that shows the beneficial owners',
+ regulationRequiresChart: 'Regulation requires us to collect a certified copy of the ownership chart that shows every individual or entity who owns 25% or more of the business.',
+ uploadEntity: 'Upload entity ownership chart',
+ noteEntity: 'Note: Entity ownership chart must be signed by your accountant, legal counsel, or notarized.',
+ certified: 'Certified entity ownership chart',
+ selectCountry: 'Select country',
+ findCountry: 'Find country',
+ address: 'Address',
+ },
validationStep: {
headerTitle: 'Validate bank account',
buttonText: 'Finish setup',
@@ -2330,10 +2371,25 @@ const translations = {
agreementsStep: {
agreements: 'Agreements',
pleaseConfirm: 'Please confirm the agreements below',
+ regulationRequiresUs: 'Regulation requires us to verify the identity of any individual who owns more than 25% of the business.',
+ iAmAuthorized: 'I am authorized to use the business bank account for business spend.',
+ iCertify: 'I certify that the information provided is true and accurate.',
+ termsAndConditions: 'terms and conditions.',
accept: 'Accept and add bank account',
+ error: {
+ authorized: 'You must be a controlling officer with authorization to operate the business bank account',
+ certify: 'Please certify that the information is true and accurate',
+ },
},
finishStep: {
connect: 'Connect bank account',
+ letsFinish: "Let's finish in chat!",
+ thanksFor:
+ "Thanks for those details. A dedicated support agent will now review your information. We'll circle back if we need anything else from you, but in the meantime, feel free to reach out to us with any questions.",
+ iHaveA: 'I have a question',
+ enable2FA: 'Enable two-factor authentication (2FA) to prevent fraud',
+ weTake: 'We take your security seriously. Please set up 2FA now to add an extra layer of protection to your account.',
+ secure: 'Secure your account',
},
reimbursementAccountLoadingAnimation: {
oneMoment: 'One moment',
@@ -3198,7 +3254,9 @@ const translations = {
enableFeed: {
title: ({provider}: GoBackMessageParams) => `Enable your ${provider} feed`,
heading: 'We have a direct integration with your card issuer and can import your transaction data into Expensify quickly and accurately.\n\nTo get started, simply:',
- vcf: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them toenable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`,
+ visa: 'We have global integrations with Visa, though eligibility varies by bank and card program.\n\nTo get started, simply:',
+ mastercard: 'We have global integrations with Mastercard, though eligibility varies by bank and card program.\n\nTo get started, simply:',
+ vcf: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Visa Commercial Cards.\n\n2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them to enable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`,
gl1025: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) to find out if American Express can enable a custom feed for your program.\n\n2. Once the feed is enabled, Amex will send you a production letter.\n\n3. *Once you have the feed information, continue to the next screen.*`,
cdf: `1. Visit [this help article](${CONST.COMPANY_CARDS_HELP}) for detailed instructions on how to set up your Mastercard Commercial Cards.\n\n 2. [Contact your bank](${CONST.COMPANY_CARDS_HELP}) to verify they support a custom feed for your program, and ask them to enable it.\n\n3. *Once the feed is enabled and you have its details, continue to the next screen.*`,
stripe: `1. Visit Stripe’s Dashboard, and go to [Settings](${CONST.COMPANY_CARDS_STRIPE_HELP}).\n\n2. Under Product Integrations, click Enable next to Expensify.\n\n3. Once the feed is enabled, click Submit below and we’ll work on adding it.`,
@@ -3226,6 +3284,7 @@ const translations = {
},
amexCorporate: 'Select this if the front of your cards say “Corporate”',
amexBusiness: 'Select this if the front of your cards say “Business”',
+ amexPersonal: 'Select this if your cards are personal',
error: {
pleaseSelectProvider: 'Please select a card provider before continuing.',
pleaseSelectBankAccount: 'Please select a bank account before continuing.',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index c97a4aa6d798..7e6f8efc897a 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 ',
@@ -789,6 +789,7 @@ const translations = {
locationAccessMessage: 'El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que vayas.',
locationErrorTitle: 'Permitir acceso a la ubicación',
locationErrorMessage: 'El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que vayas.',
+ allowLocationFromSetting: `El acceso a la ubicación nos ayuda a mantener tu zona horaria y moneda precisas dondequiera que estés. Por favor, permite el acceso a la ubicación en la configuración de permisos de tu dispositivo.`,
cameraErrorMessage: 'Se ha producido un error al hacer una foto. Por favor, inténtalo de nuevo.',
dropTitle: 'Suéltalo',
dropMessage: 'Suelta tu archivo aquí',
@@ -1170,13 +1171,15 @@ const translations = {
},
updateRequiredView: {
updateRequired: 'Actualización requerida',
- pleaseInstall: 'Por favor, actualiza a la última versión de Nuevo 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',
@@ -1265,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.',
@@ -1465,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',
@@ -1860,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',
@@ -1975,6 +1978,7 @@ const translations = {
noDefaultDepositAccountOrDebitCardAvailable: 'Por favor, añade una cuenta bancaria para depósitos o una tarjeta de débito.',
validationAmounts: 'Los importes de validación que introduciste son incorrectos. Por favor, comprueba tu cuenta bancaria e inténtalo de nuevo.',
fullName: 'Please enter a valid full name.',
+ ownershipPercentage: 'Por favor, ingrese un número de porcentaje válido.',
},
},
addPersonalBankAccountPage: {
@@ -2013,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.',
@@ -2254,6 +2257,43 @@ const translations = {
byAddingThisBankAccount: 'Al añadir esta cuenta bancaria, confirmas que has leído, comprendido y aceptado',
owners: 'Dueños',
},
+ ownershipInfoStep: {
+ ownerInfo: 'Información del propietario',
+ businessOwner: 'Propietario del negocio',
+ signerInfo: 'Información del firmante',
+ doYouOwn: ({companyName}: CompanyNameParams) => `¿Posee el 25% o más de ${companyName}?`,
+ doesAnyoneOwn: ({companyName}: CompanyNameParams) => `¿Alguien posee el 25% o más de ${companyName}?`,
+ regulationsRequire: 'Las regulaciones requieren que verifiquemos la identidad de cualquier persona que posea más del 25% del negocio.',
+ legalFirstName: 'Nombre legal',
+ legalLastName: 'Apellido legal',
+ whatsTheOwnersName: '¿Cuál es el nombre legal del propietario?',
+ whatsYourName: '¿Cuál es su nombre legal?',
+ whatPercentage: '¿Qué porcentaje del negocio pertenece al propietario?',
+ whatsYoursPercentage: '¿Qué porcentaje del negocio posee?',
+ ownership: 'Propiedad',
+ whatsTheOwnersDOB: '¿Cuál es la fecha de nacimiento del propietario?',
+ whatsYourDOB: '¿Cuál es su fecha de nacimiento?',
+ whatsTheOwnersAddress: '¿Cuál es la dirección del propietario?',
+ whatsYourAddress: '¿Cuál es su dirección?',
+ whatAreTheLast: '¿Cuáles son los últimos 4 dígitos del número de seguro social del propietario?',
+ whatsYourLast: '¿Cuáles son los últimos 4 dígitos de su número de seguro social?',
+ dontWorry: 'No se preocupe, ¡no realizamos ninguna verificación de crédito personal!',
+ last4: 'Últimos 4 del SSN',
+ whyDoWeAsk: '¿Por qué solicitamos esto?',
+ letsDoubleCheck: 'Verifiquemos que todo esté correcto.',
+ legalName: 'Nombre legal',
+ ownershipPercentage: 'Porcentaje de propiedad',
+ areThereOther: ({companyName}: CompanyNameParams) => `¿Hay otras personas que posean el 25% o más de ${companyName}?`,
+ owners: 'Propietarios',
+ addCertified: 'Agregue un organigrama certificado que muestre los propietarios beneficiarios',
+ regulationRequiresChart: 'La regulación nos exige recopilar una copia certificada del organigrama que muestre a cada persona o entidad que posea el 25% o más del negocio.',
+ uploadEntity: 'Subir organigrama de propiedad de la entidad',
+ noteEntity: 'Nota: El organigrama de propiedad de la entidad debe estar firmado por su contador, asesor legal o notariado.',
+ certified: 'Organigrama certificado de propiedad de la entidad',
+ selectCountry: 'Seleccionar país',
+ findCountry: 'Buscar país',
+ address: 'Dirección',
+ },
validationStep: {
headerTitle: 'Validar cuenta bancaria',
buttonText: 'Finalizar configuración',
@@ -2341,7 +2381,7 @@ const translations = {
uploadID: 'Subir documento de identidad y prueba de domicilio',
id: 'Identificación (licencia de conducir o pasaporte)',
personalAddress: 'Prueba de domicilio personal (por ejemplo, factura de servicios públicos)',
- letsDoubleCheck: 'Vamos a comprobar que todo está bien.',
+ letsDoubleCheck: 'Vamos a verificar que todo esté correcto.',
legalName: 'Nombre legal',
proofOf: 'Comprobante de domicilio personal',
enterOneEmail: 'Introduce el correo electrónico del director o alto funcionario en',
@@ -2355,10 +2395,25 @@ const translations = {
agreementsStep: {
agreements: 'Acuerdos',
pleaseConfirm: 'Por favor confirme los acuerdos a continuación',
- accept: 'Aceptar y añadir cuenta bancaria',
+ regulationRequiresUs: 'La normativa requiere que verifiquemos la identidad de cualquier individuo que posea más del 25% del negocio.',
+ iAmAuthorized: 'Estoy autorizado para usar la cuenta bancaria para gastos del negocio.',
+ iCertify: 'Certifico que la información proporcionada es verdadera y correcta.',
+ termsAndConditions: 'términos y condiciones.',
+ accept: 'Agregar y aceptar cuenta bancaria',
+ error: {
+ authorized: 'Debe ser un funcionario controlador con autorización para operar la cuenta bancaria comercial',
+ certify: 'Por favor certifique que la información es verdadera y exacta',
+ },
},
finishStep: {
connect: 'Conectar cuenta bancaria',
+ letsFinish: '¡Terminemos en el chat!',
+ thanksFor:
+ 'Gracias por esos detalles. Un agente de soporte dedicado revisará ahora tu información. Nos pondremos en contacto si necesitamos algo más de tu parte, pero mientras tanto, no dudes en comunicarte con nosotros si tienes alguna pregunta.',
+ iHaveA: 'Tengo una pregunta',
+ enable2FA: 'Habilite la autenticación de dos factores (2FA) para prevenir fraudes',
+ weTake: 'Nos tomamos su seguridad en serio. Por favor, configure 2FA ahora para agregar una capa adicional de protección a su cuenta.',
+ secure: 'Asegure su cuenta',
},
reimbursementAccountLoadingAnimation: {
oneMoment: 'Un momento',
@@ -3239,6 +3294,8 @@ const translations = {
title: ({provider}: GoBackMessageParams) => `Habilita tu feed ${provider}`,
heading:
'Tenemos una integración directa con el emisor de su tarjeta y podemos importar los datos de sus transacciones a Expensify de forma rápida y precisa.\n\nPara empezar, simplemente:',
+ visa: 'Contamos con integraciones globales con Visa, aunque la elegibilidad varía según el banco y el programa de la tarjeta.\n\nTPara empezar, simplemente:',
+ mastercard: 'Contamos con integraciones globales con Mastercard, aunque la elegibilidad varía según el banco y el programa de la tarjeta.\n\nPara empezar, simplemente:',
vcf: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Visa.\n\n2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para comprobar que admiten un feed personalizado para su programa, y pídales que lo activen.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`,
gl1025: `1. Visite [este artículo de ayuda](${CONST.COMPANY_CARDS_HELP}) para saber si American Express puede habilitar un feed personalizado para su programa.\n\n2. Una vez activada la alimentación, Amex le enviará una carta de producción.\n\n3. *Una vez que tenga la información de alimentación, continúe con la siguiente pantalla.*`,
cdf: `1. Visite [este artículo de ayuda](${CONST.NETSUITE_IMPORT.HELP_LINKS.CUSTOM_SEGMENTS}) para obtener instrucciones detalladas sobre cómo configurar sus tarjetas comerciales Mastercard.\n\n 2. [Póngase en contacto con su banco](${CONST.COMPANY_CARDS_HELP}) para verificar que admiten un feed personalizado para su programa, y pídales que lo habiliten.\n\n3. *Una vez que el feed esté habilitado y tengas sus datos, pasa a la siguiente pantalla.*`,
@@ -3267,6 +3324,7 @@ const translations = {
},
amexCorporate: 'Seleccione esto si el frente de sus tarjetas dice “Corporativa”',
amexBusiness: 'Seleccione esta opción si el frente de sus tarjetas dice “Negocios”',
+ amexPersonal: 'Selecciona esta opción si tus tarjetas son personales',
error: {
pleaseSelectProvider: 'Seleccione un proveedor de tarjetas antes de continuar.',
pleaseSelectBankAccount: 'Seleccione una cuenta bancaria antes de continuar.',
@@ -4571,17 +4629,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/CardUtils.ts b/src/libs/CardUtils.ts
index d56477c3f148..77aeb8e0ecc3 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -135,15 +135,26 @@ function maskCard(lastFour = ''): string {
* Converts given 'X' to '•' for the entire card string.
*
* @param cardName - card name with XXXX in the middle.
+ * @param feed - card feed.
* @returns - The masked card string.
*/
-function maskCardNumber(cardName: string): string {
+function maskCardNumber(cardName: string, feed: string | undefined): string {
if (!cardName || cardName === '') {
return '';
}
const hasSpace = /\s/.test(cardName);
const maskedString = cardName.replace(/X/g, '•');
- return hasSpace ? cardName : maskedString.replace(/(.{4})/g, '$1 ').trim();
+ const isAmexBank = [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT].some((value) => value === feed);
+
+ if (hasSpace) {
+ return cardName;
+ }
+
+ if (isAmexBank && maskedString.length === 15) {
+ return maskedString.replace(/(.{4})(.{6})(.{5})/, '$1 $2 $3');
+ }
+
+ return maskedString.replace(/(.{4})/g, '$1 ').trim();
}
/**
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index 3bdcfd598b7e..671fb03f268b 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -446,7 +446,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
throw SyntaxError('debug.missingValue');
}
switch (key) {
- case 'avatarFileName':
case 'avatarUrl':
case 'lastMessageText':
case 'lastVisibleActionCreated':
@@ -462,7 +461,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
case 'reportActionID':
case 'chatReportID':
case 'type':
- case 'cachedTotal':
case 'lastMessageTranslationKey':
case 'parentReportID':
case 'parentReportActionID':
@@ -471,7 +469,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
case 'currency':
case 'iouReportID':
case 'preexistingReportID':
- case 'transactionThreadReportID':
case 'private_isArchived':
return validateString(value);
case 'hasOutstandingChildRequest':
@@ -484,7 +481,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
case 'isWaitingOnBankAccount':
case 'isCancelledIOU':
case 'isHidden':
- case 'isLoadingPrivateNotes':
return validateBoolean(value);
case 'lastReadSequenceNumber':
case 'managerID':
@@ -584,7 +580,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
policyID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
avatarUrl: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- avatarFileName: CONST.RED_BRICK_ROAD_PENDING_ACTION,
chatType: CONST.RED_BRICK_ROAD_PENDING_ACTION,
hasOutstandingChildRequest: CONST.RED_BRICK_ROAD_PENDING_ACTION,
hasOutstandingChildTask: CONST.RED_BRICK_ROAD_PENDING_ACTION,
@@ -607,7 +602,6 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
statusNum: CONST.RED_BRICK_ROAD_PENDING_ACTION,
writeCapability: CONST.RED_BRICK_ROAD_PENDING_ACTION,
visibility: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- cachedTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION,
invoiceReceiver: CONST.RED_BRICK_ROAD_PENDING_ACTION,
lastMessageTranslationKey: CONST.RED_BRICK_ROAD_PENDING_ACTION,
parentReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
@@ -628,9 +622,7 @@ function validateReportDraftProperty(key: keyof Report, value: string) {
preexistingReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
nonReimbursableTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION,
isHidden: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- isLoadingPrivateNotes: CONST.RED_BRICK_ROAD_PENDING_ACTION,
pendingChatMembers: CONST.RED_BRICK_ROAD_PENDING_ACTION,
- transactionThreadReportID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
fieldList: CONST.RED_BRICK_ROAD_PENDING_ACTION,
permissions: CONST.RED_BRICK_ROAD_PENDING_ACTION,
tripData: CONST.RED_BRICK_ROAD_PENDING_ACTION,
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.tsx
similarity index 88%
rename from src/libs/EmojiUtils.ts
rename to src/libs/EmojiUtils.tsx
index 7c042bbefe67..a8fb6f7a92b3 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.tsx
@@ -1,8 +1,12 @@
import {Str} from 'expensify-common';
+import lodashSortBy from 'lodash/sortBy';
+import React from 'react';
+import type {StyleProp, TextStyle} from 'react-native';
import Onyx from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import * as Emojis from '@assets/emojis';
import type {Emoji, HeaderEmoji, PickerEmojis} from '@assets/emojis/types';
+import Text from '@components/Text';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx';
@@ -18,11 +22,17 @@ type EmojiPickerListItem = EmojiSpacer | Emoji | HeaderEmoji;
type EmojiPickerList = EmojiPickerListItem[];
type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number};
type EmojiTrieModule = {default: typeof EmojiTrie};
+type TextWithEmoji = {
+ text: string;
+ isEmoji: boolean;
+};
const findEmojiByName = (name: string): Emoji => Emojis.emojiNameTable[name];
const findEmojiByCode = (code: string): Emoji => Emojis.emojiCodeTableWithSkinTones[code];
+const sortByName = (emoji: Emoji, emojiData: RegExpMatchArray) => !emoji.name.includes(emojiData[0].toLowerCase().slice(1));
+
let frequentlyUsedEmojis: FrequentlyUsedEmoji[] = [];
Onyx.connect({
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
@@ -148,7 +158,7 @@ function trimEmojiUnicode(emojiCode: string): string {
*/
function isFirstLetterEmoji(message: string): boolean {
const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
- const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+ const match = trimmedMessage.match(CONST.REGEX.ALL_EMOJIS);
if (!match) {
return false;
@@ -162,7 +172,7 @@ function isFirstLetterEmoji(message: string): boolean {
*/
function containsOnlyEmojis(message: string): boolean {
const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
- const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+ const match = trimmedMessage.match(CONST.REGEX.ALL_EMOJIS);
if (!match) {
return false;
@@ -285,7 +295,7 @@ function extractEmojis(text: string): Emoji[] {
}
// Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩']
- const parsedEmojis = text.match(CONST.REGEX.EMOJIS);
+ const parsedEmojis = text.match(CONST.REGEX.ALL_EMOJIS);
if (!parsedEmojis) {
return [];
@@ -424,7 +434,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO
for (const node of nodes) {
if (node.metaData?.code && !matching.find((obj) => obj.name === node.name)) {
if (matching.length === limit) {
- return matching;
+ return lodashSortBy(matching, (emoji) => sortByName(emoji, emojiData));
}
matching.push({code: node.metaData.code, name: node.name, types: node.metaData.types});
}
@@ -434,7 +444,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO
}
for (const suggestion of suggestions) {
if (matching.length === limit) {
- return matching;
+ return lodashSortBy(matching, (emoji) => sortByName(emoji, emojiData));
}
if (!matching.find((obj) => obj.name === suggestion.name)) {
@@ -442,7 +452,7 @@ function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_CO
}
}
}
- return matching;
+ return lodashSortBy(matching, (emoji) => sortByName(emoji, emojiData));
}
/**
@@ -595,6 +605,75 @@ function getSpacersIndexes(allEmojis: EmojiPickerList): number[] {
return spacersIndexes;
}
+/** Splits the text with emojis into array if emojis exist in the text */
+function splitTextWithEmojis(text = ''): TextWithEmoji[] {
+ if (!text) {
+ return [];
+ }
+
+ const doesTextContainEmojis = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')).test(text);
+
+ if (!doesTextContainEmojis) {
+ return [];
+ }
+
+ // The regex needs to be cloned because `exec()` is a stateful operation and maintains the state inside
+ // the regex variable itself, so we must have an independent instance for each function's call.
+ const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g'));
+
+ const splitText: TextWithEmoji[] = [];
+ let regexResult: RegExpExecArray | null;
+ let lastMatchIndexEnd = 0;
+
+ do {
+ regexResult = emojisRegex.exec(text);
+
+ if (regexResult?.indices) {
+ const matchIndexStart = regexResult.indices[0][0];
+ const matchIndexEnd = regexResult.indices[0][1];
+
+ if (matchIndexStart > lastMatchIndexEnd) {
+ splitText.push({
+ text: text.slice(lastMatchIndexEnd, matchIndexStart),
+ isEmoji: false,
+ });
+ }
+
+ splitText.push({
+ text: text.slice(matchIndexStart, matchIndexEnd),
+ isEmoji: true,
+ });
+
+ lastMatchIndexEnd = matchIndexEnd;
+ }
+ } while (regexResult !== null);
+
+ if (lastMatchIndexEnd < text.length) {
+ splitText.push({
+ text: text.slice(lastMatchIndexEnd, text.length),
+ isEmoji: false,
+ });
+ }
+
+ return splitText;
+}
+
+function getProcessedText(processedTextArray: TextWithEmoji[], style: StyleProp): Array {
+ return processedTextArray.map(({text, isEmoji}, index) =>
+ isEmoji ? (
+
+ {text}
+
+ ) : (
+ text
+ ),
+ );
+}
+
export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem};
export {
@@ -602,6 +681,7 @@ export {
findEmojiByCode,
getEmojiName,
getLocalizedEmojiName,
+ getProcessedText,
getHeaderEmojis,
mergeEmojisWithFrequentlyUsedEmojis,
containsOnlyEmojis,
@@ -620,4 +700,5 @@ export {
hasAccountIDEmojiReacted,
getRemovedSkinToneEmoji,
getSpacersIndexes,
+ splitTextWithEmojis,
};
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/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index 0923287d6cdb..40b4742ca5de 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -1,5 +1,6 @@
+import {findFocusedRoute} from '@react-navigation/native';
import React, {memo, useEffect, useMemo, useRef, useState} from 'react';
-import {View} from 'react-native';
+import {NativeModules, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx, {withOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
@@ -22,9 +23,10 @@ import KeyboardShortcut from '@libs/KeyboardShortcut';
import Log from '@libs/Log';
import getCurrentUrl from '@libs/Navigation/currentUrl';
import getOnboardingModalScreenOptions from '@libs/Navigation/getOnboardingModalScreenOptions';
-import Navigation from '@libs/Navigation/Navigation';
+import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom';
import type {AuthScreensParamList, CentralPaneName, CentralPaneScreensParamList} from '@libs/Navigation/types';
+import {isOnboardingFlowName} from '@libs/NavigationUtils';
import NetworkConnection from '@libs/NetworkConnection';
import onyxSubscribe from '@libs/onyxSubscribe';
import * as Pusher from '@libs/Pusher/pusher';
@@ -274,13 +276,16 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
setDidPusherInit(true);
});
- // If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app
- // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp().
- if (SessionUtils.didUserLogInDuringSession()) {
- App.openApp();
- } else {
- Log.info('[AuthScreens] Sending ReconnectApp');
- App.reconnectApp(initialLastUpdateIDAppliedToClient);
+ // In Hybrid App we decide to call one of those method when booting ND and we don't want to duplicate calls
+ if (!NativeModules.HybridAppModule) {
+ // If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app
+ // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp().
+ if (SessionUtils.didUserLogInDuringSession()) {
+ App.openApp();
+ } else {
+ Log.info('[AuthScreens] Sending ReconnectApp');
+ App.reconnectApp(initialLastUpdateIDAppliedToClient);
+ }
}
PriorityMode.autoSwitchToFocusMode();
@@ -348,6 +353,11 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie
searchShortcutConfig.shortcutKey,
() => {
Session.checkIfActionIsAllowed(() => {
+ const state = navigationRef.getRootState();
+ const currentFocusedRoute = findFocusedRoute(state);
+ if (isOnboardingFlowName(currentFocusedRoute?.name)) {
+ return;
+ }
toggleSearchRouter();
})();
},
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..2acfde557d5d 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -327,7 +327,6 @@ type OptimisticChatReport = Pick<
| 'description'
| 'writeCapability'
| 'avatarUrl'
- | 'avatarFileName'
| 'invoiceReceiver'
| 'isHidden'
> & {
@@ -441,7 +440,6 @@ type TransactionDetails = {
type OptimisticIOUReport = Pick<
Report,
- | 'cachedTotal'
| 'type'
| 'chatReportID'
| 'currency'
@@ -2699,7 +2697,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 +2708,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 +4551,6 @@ function buildOptimisticIOUReport(
return {
type: CONST.REPORT.TYPE.IOU,
- cachedTotal: formattedTotal,
chatReportID,
currency,
managerID: payerAccountID,
@@ -5427,7 +5424,6 @@ function buildOptimisticChatReport(
parentReportID = '',
description = '',
avatarUrl = '',
- avatarFileName = '',
optimisticReportID = '',
): OptimisticChatReport {
const isWorkspaceChatType = chatType && isWorkspaceChat(chatType);
@@ -5468,7 +5464,6 @@ function buildOptimisticChatReport(
description,
writeCapability,
avatarUrl,
- avatarFileName,
};
if (chatType === CONST.REPORT.CHAT_TYPE.INVOICE) {
@@ -5486,7 +5481,6 @@ function buildOptimisticGroupChatReport(
participantAccountIDs: number[],
reportName: string,
avatarUri: string,
- avatarFilename: string,
optimisticReportID?: string,
notificationPreference?: NotificationPreference,
) {
@@ -5505,7 +5499,6 @@ function buildOptimisticGroupChatReport(
undefined,
undefined,
avatarUri,
- avatarFilename,
optimisticReportID,
);
}
@@ -6030,7 +6023,6 @@ function buildOptimisticWorkspaceChats(policyID: string, policyName: string, exp
undefined,
undefined,
undefined,
- undefined,
expenseReportId,
);
const expenseChatReportID = expenseChatData.reportID;
@@ -8327,6 +8319,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 +8332,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/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 0367325db6b1..fac02bd2b4ca 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -49,7 +49,7 @@ function isValidAddress(value: FormValue): boolean {
return false;
}
- if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) {
+ if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.ALL_EMOJIS)) {
return false;
}
@@ -343,7 +343,7 @@ function isValidRoutingNumber(routingNumber: string): boolean {
* Checks that the provided name doesn't contain any emojis
*/
function isValidCompanyName(name: string) {
- return !name.match(CONST.REGEX.EMOJIS);
+ return !name.match(CONST.REGEX.ALL_EMOJIS);
}
function isValidReportName(name: string) {
@@ -529,6 +529,31 @@ function isValidZipCodeInternational(zipCode: string): boolean {
return /^[a-z0-9][a-z0-9\- ]{0,10}[a-z0-9]$/.test(zipCode);
}
+/**
+ * Validates the given value if it is correct ownership percentage
+ * @param value
+ * @param totalOwnedPercentage
+ * @param ownerBeingModifiedID
+ */
+function isValidOwnershipPercentage(value: string, totalOwnedPercentage: Record, ownerBeingModifiedID: string): boolean {
+ const parsedValue = Number(value);
+ const isValidNumber = !Number.isNaN(parsedValue) && parsedValue >= 25 && parsedValue <= 100;
+
+ let totalOwnedPercentageSum = 0;
+ const totalOwnedPercentageKeys = Object.keys(totalOwnedPercentage);
+ totalOwnedPercentageKeys.forEach((key) => {
+ if (key === ownerBeingModifiedID) {
+ return;
+ }
+
+ totalOwnedPercentageSum += totalOwnedPercentage[key];
+ });
+
+ const isTotalSumValid = totalOwnedPercentageSum + parsedValue <= 100;
+
+ return isValidNumber && isTotalSumValid;
+}
+
export {
meetsMinimumAgeRequirement,
meetsMaximumAgeRequirement,
@@ -576,4 +601,5 @@ export {
isValidEmail,
isValidPhoneInternational,
isValidZipCodeInternational,
+ isValidOwnershipPercentage,
};
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index f778405ee6e8..f1f46aee0a93 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -180,6 +180,7 @@ function setSidebarLoaded() {
Onyx.set(ONYXKEYS.IS_SIDEBAR_LOADED, true);
Performance.markEnd(CONST.TIMING.SIDEBAR_LOADED);
+ Timing.end(CONST.TIMING.SIDEBAR_LOADED);
}
let appState: AppStateStatus;
diff --git a/src/libs/actions/AppUpdate/updateApp/index.android.ts b/src/libs/actions/AppUpdate/updateApp/index.android.ts
index 3f2cde77f466..aac98a1928aa 100644
--- a/src/libs/actions/AppUpdate/updateApp/index.android.ts
+++ b/src/libs/actions/AppUpdate/updateApp/index.android.ts
@@ -1,6 +1,10 @@
import {Linking, NativeModules} from 'react-native';
import CONST from '@src/CONST';
-export default function updateApp() {
+export default function updateApp(isProduction: boolean) {
+ if (isProduction) {
+ Linking.openURL(CONST.APP_DOWNLOAD_LINKS.OLD_DOT_ANDROID);
+ return;
+ }
Linking.openURL(NativeModules.HybridAppModule ? CONST.APP_DOWNLOAD_LINKS.OLD_DOT_ANDROID : CONST.APP_DOWNLOAD_LINKS.ANDROID);
}
diff --git a/src/libs/actions/AppUpdate/updateApp/index.desktop.ts b/src/libs/actions/AppUpdate/updateApp/index.desktop.ts
index 5c1ecbe05742..cbd961ff653b 100644
--- a/src/libs/actions/AppUpdate/updateApp/index.desktop.ts
+++ b/src/libs/actions/AppUpdate/updateApp/index.desktop.ts
@@ -1,5 +1,6 @@
import ELECTRON_EVENTS from '@desktop/ELECTRON_EVENTS';
-export default function updateApp() {
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export default function updateApp(isProduction: boolean) {
window.electron.send(ELECTRON_EVENTS.SILENT_UPDATE);
}
diff --git a/src/libs/actions/AppUpdate/updateApp/index.ios.ts b/src/libs/actions/AppUpdate/updateApp/index.ios.ts
index 930a57881128..608c7ab028ca 100644
--- a/src/libs/actions/AppUpdate/updateApp/index.ios.ts
+++ b/src/libs/actions/AppUpdate/updateApp/index.ios.ts
@@ -1,6 +1,10 @@
import {Linking, NativeModules} from 'react-native';
import CONST from '@src/CONST';
-export default function updateApp() {
+export default function updateApp(isProduction: boolean) {
+ if (isProduction) {
+ Linking.openURL(CONST.APP_DOWNLOAD_LINKS.OLD_DOT_IOS);
+ return;
+ }
Linking.openURL(NativeModules.HybridAppModule ? CONST.APP_DOWNLOAD_LINKS.OLD_DOT_IOS : CONST.APP_DOWNLOAD_LINKS.IOS);
}
diff --git a/src/libs/actions/AppUpdate/updateApp/index.ts b/src/libs/actions/AppUpdate/updateApp/index.ts
index 8c2b191029a2..3b6d9e666bfa 100644
--- a/src/libs/actions/AppUpdate/updateApp/index.ts
+++ b/src/libs/actions/AppUpdate/updateApp/index.ts
@@ -1,6 +1,7 @@
/**
* On web or mWeb we can simply refresh the page and the user should have the new version of the app downloaded.
*/
-export default function updateApp() {
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export default function updateApp(isProduction: boolean) {
window.location.reload();
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 5b3592407f89..dd6686b9ff7d 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/Link.ts b/src/libs/actions/Link.ts
index 4cda676d89e8..0250ea7b84a1 100644
--- a/src/libs/actions/Link.ts
+++ b/src/libs/actions/Link.ts
@@ -65,13 +65,13 @@ function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise openExternalLink(oldDotURL));
+ buildOldDotURL(url).then((oldDotURL) => openExternalLink(oldDotURL, undefined, shouldOpenInSameTab));
return;
}
@@ -82,6 +82,8 @@ function openOldDotLink(url: string) {
.then((response) => (response ? buildOldDotURL(url, response.shortLivedAuthToken) : buildOldDotURL(url)))
.catch(() => buildOldDotURL(url)),
(oldDotURL) => oldDotURL,
+ undefined,
+ shouldOpenInSameTab,
);
}
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 32c0a40876d7..2cace722dbdc 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,
});
@@ -728,7 +732,6 @@ function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageMani
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
avatarUrl: file ? file?.uri ?? '' : null,
- avatarFileName: file ? file?.name ?? '' : null,
pendingFields: {
avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
},
@@ -746,7 +749,6 @@ function updateGroupChatAvatar(reportID: string, file?: File | CustomRNImageMani
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
avatarUrl: fetchedReport?.avatarUrl ?? null,
- avatarFileName: fetchedReport?.avatarFileName ?? null,
pendingFields: {
avatar: null,
},
@@ -1087,14 +1089,7 @@ function navigateToAndOpenReport(
if (isEmptyObject(chat)) {
if (isGroupChat) {
// If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID
- newChat = ReportUtils.buildOptimisticGroupChatReport(
- participantAccountIDs,
- reportName ?? '',
- avatarUri ?? '',
- avatarFile?.name ?? '',
- optimisticReportID,
- CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
- );
+ newChat = ReportUtils.buildOptimisticGroupChatReport(participantAccountIDs, reportName ?? '', avatarUri ?? '', optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS);
} else {
newChat = ReportUtils.buildOptimisticChatReport(
[...participantAccountIDs, currentUserAccountID],
@@ -1525,8 +1520,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 +1533,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 +1663,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 +1730,7 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry {
+ return new Promise((resolve) => {
+ Onyx.connect({
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
+ callback: (value) => resolve(value ?? 0),
+ });
+ });
+}
+
+function signInAfterTransitionFromOldDot(transitionURL: string) {
const [route, queryParams] = transitionURL.split('?');
const {
@@ -530,11 +539,11 @@ function signInAfterTransitionFromOldDot(transitionURL: string, lastUpdateId?: n
)
.then(() => {
if (clearOnyxOnStart === 'true') {
- // We clear Onyx when this flag is set to true so we have to download all data
- App.openApp();
- } else {
- App.reconnectApp(lastUpdateId);
+ return App.openApp();
}
+ return getLastUpdateIDAppliedToClient().then((lastUpdateId) => {
+ return App.reconnectApp(lastUpdateId);
+ });
})
.catch((error) => {
Log.hmmm('[HybridApp] Initialization of HybridApp has failed. Forcing transition', {error});
diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts
index 664bdb3779a6..aec6c4bd9d30 100644
--- a/src/libs/actions/Task.ts
+++ b/src/libs/actions/Task.ts
@@ -971,9 +971,10 @@ function deleteTask(report: 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/libs/actions/User.ts b/src/libs/actions/User.ts
index f3b8b1a15c28..20bca969468a 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -583,7 +583,7 @@ function validateLogin(accountID: number, validateCode: string) {
/**
* Validates a secondary login / contact method
*/
-function validateSecondaryLogin(loginList: OnyxEntry, contactMethod: string, validateCode: string) {
+function validateSecondaryLogin(loginList: OnyxEntry, contactMethod: string, validateCode: string, shouldResetActionCode?: boolean) {
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -729,6 +729,19 @@ function validateSecondaryLogin(loginList: OnyxEntry, contactMethod:
},
];
+ // Sometimes we will also need to reset the validateCodeSent of ONYXKEYS.VALIDATE_ACTION_CODE in order to receive the magic code next time we open the ValidateCodeActionModal.
+ if (shouldResetActionCode) {
+ const optimisticResetActionCode = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.VALIDATE_ACTION_CODE,
+ value: {
+ validateCodeSent: null,
+ },
+ };
+ successData.push(optimisticResetActionCode);
+ failureData.push(optimisticResetActionCode);
+ }
+
const parameters: ValidateSecondaryLoginParams = {partnerUserID: contactMethod, validateCode};
API.write(WRITE_COMMANDS.VALIDATE_SECONDARY_LOGIN, parameters, {optimisticData, successData, failureData});
diff --git a/src/libs/asyncOpenURL/index.website.ts b/src/libs/asyncOpenURL/index.website.ts
index 4f6d95b76b8b..ba7da73616c2 100644
--- a/src/libs/asyncOpenURL/index.website.ts
+++ b/src/libs/asyncOpenURL/index.website.ts
@@ -1,22 +1,26 @@
import {Linking} from 'react-native';
+import type {Linking as LinkingWeb} from 'react-native-web';
+import getPlatform from '@libs/getPlatform';
import Log from '@libs/Log';
+import CONST from '@src/CONST';
import type AsyncOpenURL from './types';
/**
* Prevents Safari from blocking pop-up window when opened within async call.
* @param shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari.
*/
-const asyncOpenURL: AsyncOpenURL = (promise, url, shouldSkipCustomSafariLogic) => {
+const asyncOpenURL: AsyncOpenURL = (promise, url, shouldSkipCustomSafariLogic, shouldOpenInSameTab) => {
if (!url) {
return;
}
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+ const canOpenURLInSameTab = getPlatform() === CONST.PLATFORM.WEB;
- if (!isSafari || shouldSkipCustomSafariLogic) {
+ if (!isSafari || !!shouldSkipCustomSafariLogic || !!shouldOpenInSameTab) {
promise
.then((params) => {
- Linking.openURL(typeof url === 'string' ? url : url(params));
+ (Linking.openURL as LinkingWeb['openURL'])(typeof url === 'string' ? url : url(params), shouldOpenInSameTab && canOpenURLInSameTab ? '_self' : undefined);
})
.catch(() => {
Log.warn('[asyncOpenURL] error occured while opening URL', {url});
diff --git a/src/libs/asyncOpenURL/types.ts b/src/libs/asyncOpenURL/types.ts
index bf24756b0cc2..320e2606222e 100644
--- a/src/libs/asyncOpenURL/types.ts
+++ b/src/libs/asyncOpenURL/types.ts
@@ -1,3 +1,3 @@
-type AsyncOpenURL = (promise: Promise, url: string | ((params: T) => string), shouldSkipCustomSafariLogic?: boolean) => void;
+type AsyncOpenURL = (promise: Promise, url: string | ((params: T) => string), shouldSkipCustomSafariLogic?: boolean, shouldOpenInSameTab?: boolean) => void;
export default AsyncOpenURL;
diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts
index bdbb3ff142f4..35b1f1b4208c 100644
--- a/src/libs/onboardingSelectors.ts
+++ b/src/libs/onboardingSelectors.ts
@@ -15,6 +15,10 @@ function hasCompletedGuidedSetupFlowSelector(onboarding: OnyxValue;
-};
-
-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) => (
@@ -37,17 +42,21 @@ function UpdateRequiredView() {
- {translate('updateRequiredView.pleaseInstall')}
+
+ {isStandaloneNewAppProduction ? translate('updateRequiredView.pleaseInstallExpensifyClassic') : translate('updateRequiredView.pleaseInstall')}
+
- {translate('updateRequiredView.toGetLatestChanges')}
+
+ {isStandaloneNewAppProduction ? translate('updateRequiredView.newAppNotAvailable') : translate('updateRequiredView.toGetLatestChanges')}
+
diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx
index a259fc7b9ce1..ac3d25c015a1 100644
--- a/src/pages/workspace/WorkspaceInvitePage.tsx
+++ b/src/pages/workspace/WorkspaceInvitePage.tsx
@@ -16,6 +16,7 @@ import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as FormActions from '@libs/actions/FormActions';
import * as ReportActions from '@libs/actions/Report';
import {READ_COMMANDS} from '@libs/API/types';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
@@ -70,6 +71,7 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) {
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', () => {
Member.setWorkspaceInviteMembersDraft(route.params.policyID, {});
+ FormActions.clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM);
});
return unsubscribe;
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 96b6d31e5a2e..cb914591a59d 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -30,6 +30,7 @@ import usePrevious from '@hooks/usePrevious';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import * as FormActions from '@libs/actions/FormActions';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Log from '@libs/Log';
@@ -417,6 +418,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
const invitedEmails = Object.values(invitedEmailsToAccountIDsDraft).map(String);
selectionListRef.current?.scrollAndHighlightItem?.(invitedEmails, 1500);
Member.setWorkspaceInviteMembersDraft(route.params.policyID, {});
+ FormActions.clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM);
}, [invitedEmailsToAccountIDsDraft, route.params.policyID, isFocused, accountIDs, prevAccountIDs]);
const getHeaderMessage = () => {
diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx
index 4f0a84cffd9c..26175c9793d9 100644
--- a/src/pages/workspace/WorkspacePageWithSections.tsx
+++ b/src/pages/workspace/WorkspacePageWithSections.tsx
@@ -124,10 +124,11 @@ function WorkspacePageWithSections({
}: WorkspacePageWithSectionsProps) {
const styles = useThemeStyles();
const policyID = route.params?.policyID ?? '-1';
- useNetwork({onReconnect: () => fetchData(policyID, shouldSkipVBBACall)});
+ const {isOffline} = useNetwork({onReconnect: () => fetchData(policyID, shouldSkipVBBACall)});
const [user] = useOnyx(ONYXKEYS.USER);
const [reimbursementAccount = CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email});
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const isLoading = (reimbursementAccount?.isLoading || isPageLoading) ?? true;
@@ -150,19 +151,18 @@ function WorkspacePageWithSections({
}, [policyID, shouldSkipVBBACall]),
);
+ const shouldShowPolicy = useMemo(() => PolicyUtils.shouldShowPolicy(policy, isOffline, currentUserLogin), [policy, isOffline, currentUserLogin]);
+ const prevShouldShowPolicy = useMemo(() => PolicyUtils.shouldShowPolicy(prevPolicy, isOffline, currentUserLogin), [prevPolicy, isOffline, currentUserLogin]);
const shouldShow = useMemo(() => {
// If the policy object doesn't exist or contains only error data, we shouldn't display it.
if (((isEmptyObject(policy) || (Object.keys(policy).length === 1 && !isEmptyObject(policy.errors))) && isEmptyObject(policyDraft)) || shouldShowNotFoundPage) {
return true;
}
- // We check isPendingDelete for both policy and prevPolicy to prevent the NotFound view from showing right after we delete the workspace
- return (
- (!isEmptyObject(policy) && !PolicyUtils.isPolicyAdmin(policy) && !shouldShowNonAdmin) ||
- (PolicyUtils.isPendingDeletePolicy(policy) && PolicyUtils.isPendingDeletePolicy(prevPolicy))
- );
+ // We check shouldShowPolicy and prevShouldShowPolicy to prevent the NotFound view from showing right after we delete the workspace
+ return (!isEmptyObject(policy) && !PolicyUtils.isPolicyAdmin(policy) && !shouldShowNonAdmin) || (!shouldShowPolicy && !prevShouldShowPolicy);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [policy, shouldShowNonAdmin]);
+ }, [policy, shouldShowNonAdmin, shouldShowPolicy, prevShouldShowPolicy]);
return (
((result, report) => {
- if (!report?.reportID || !report.policyID) {
+ if (!report?.reportID || !report.policyID || report.parentReportID) {
return result;
}
diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx
index e202baf9b39d..5569d4fb3d70 100644
--- a/src/pages/workspace/WorkspacesListRow.tsx
+++ b/src/pages/workspace/WorkspacesListRow.tsx
@@ -12,6 +12,7 @@ import Text from '@components/Text';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import WorkspacesListRowDisplayName from '@components/WorkspacesListRowDisplayName';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
@@ -213,12 +214,10 @@ function WorkspacesListRow({
containerStyles={styles.workspaceOwnerAvatarWrapper}
/>
-
- {PersonalDetailsUtils.getDisplayNameOrDefault(ownerDetails)}
-
+ ;
@@ -22,10 +25,16 @@ function ImportCategoriesPage({route}: ImportCategoriesPageProps) {
}
return (
-
+
+
+
);
}
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
index 9fdfa7bec7b3..bee196031bda 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
@@ -112,7 +112,7 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
/>
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx
index 9e9e2bf5208f..75b4f44fc843 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx
@@ -60,7 +60,7 @@ function WorkspaceCompanyCardsList({cardsList, policyID}: WorkspaceCompanyCardsL
>
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
index 9378da7c6599..453be1f58a32 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx
@@ -5,6 +5,7 @@ import {ActivityIndicator} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import * as Illustrations from '@components/Icon/Illustrations';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as CardUtils from '@libs/CardUtils';
@@ -36,7 +37,6 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) {
const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${selectedFeed}`);
const companyCards = CardUtils.removeExpensifyCardFromCompanyCards(cardFeeds);
- const isLoading = !cardFeeds || !!(cardFeeds.isLoading && !companyCards);
const selectedFeedData = selectedFeed && companyCards[selectedFeed];
const isNoFeed = isEmptyObject(companyCards) && !selectedFeedData;
const isPending = !!selectedFeedData?.pending;
@@ -46,10 +46,13 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) {
CompanyCards.openPolicyCompanyCardsPage(policyID, workspaceAccountID);
}, [policyID, workspaceAccountID]);
+ const {isOffline} = useNetwork({onReconnect: fetchCompanyCards});
+ const isLoading = !isOffline && (!cardFeeds || cardFeeds.isLoading);
+
useFocusEffect(fetchCompanyCards);
useEffect(() => {
- if (isLoading || !selectedFeed || isPending) {
+ if (!!isLoading || !selectedFeed || isPending) {
return;
}
@@ -61,7 +64,7 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) {
policyID={route.params.policyID}
featureName={CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED}
>
- {isLoading && (
+ {!!isLoading && (
{translate('workspace.companyCards.addNewCard.enableFeed.title', {provider: CardUtils.getCardFeedName(feedProvider)})}
- {translate('workspace.companyCards.addNewCard.enableFeed.heading')}
+ {translate(translationKey)}
diff --git a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
index f9e552ac5afe..4b07e7a220b8 100644
--- a/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
+++ b/src/pages/workspace/companyCards/assignCard/CardSelectionStep.tsx
@@ -105,7 +105,7 @@ function CardSelectionStep({feed, policyID}: CardSelectionStepProps) {
const cardListOptions = Object.entries(filteredCardList).map(([cardNumber, encryptedCardNumber]) => ({
keyForList: encryptedCardNumber,
value: encryptedCardNumber,
- text: CardUtils.maskCardNumber(cardNumber),
+ text: CardUtils.maskCardNumber(cardNumber, feed),
isSelected: cardSelected === encryptedCardNumber,
leftElement: (
editStep(CONST.COMPANY_CARD.STEP.CARD)}
/>
diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx
index 3154913a3a3a..0d59d5d9c762 100644
--- a/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx
+++ b/src/pages/workspace/expensifyCard/WorkspaceCardListRow.tsx
@@ -47,7 +47,7 @@ function WorkspaceCardListRow({limit, cardholder, lastFourPAN, name, currency, i
type={CONST.ICON_TYPE_AVATAR}
size={CONST.AVATAR_SIZE.DEFAULT}
/>
-
+ !!user?.validated});
const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
const {paymentMethod, setPaymentMethod, resetSelectedPaymentMethodData} = usePaymentMethodState();
const addPaymentMethodAnchorRef = useRef(null);
@@ -164,7 +165,7 @@ function WorkspaceInvoiceVBASection({policyID}: WorkspaceInvoiceVBASectionProps)
const addPaymentMethodTypePressed = (paymentType: string) => {
hideAddPaymentMenu();
if (paymentType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT || paymentType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) {
- BankAccounts.openPersonalBankAccountSetupView();
+ BankAccounts.openPersonalBankAccountSetupView(undefined, isUserValidated);
return;
}
diff --git a/src/pages/workspace/members/ImportMembersPage.tsx b/src/pages/workspace/members/ImportMembersPage.tsx
index 6fff3085b472..34b55be31981 100644
--- a/src/pages/workspace/members/ImportMembersPage.tsx
+++ b/src/pages/workspace/members/ImportMembersPage.tsx
@@ -2,20 +2,32 @@ import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import ImportSpreedsheet from '@components/ImportSpreadsheet';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-type ImportMembersPageProps = StackScreenProps;
+type ImportMembersPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps;
-function ImportMembersPage({route}: ImportMembersPageProps) {
- const policyID = route.params.policyID;
+function ImportMembersPage({policy}: ImportMembersPageProps) {
+ const policyID = policy?.id ?? '';
return (
-
+
+
+
);
}
-export default ImportMembersPage;
+export default withPolicyAndFullscreenLoading(ImportMembersPage);
diff --git a/src/pages/workspace/tags/ImportTagsPage.tsx b/src/pages/workspace/tags/ImportTagsPage.tsx
index 1bbe22750210..7f5275e8d67b 100644
--- a/src/pages/workspace/tags/ImportTagsPage.tsx
+++ b/src/pages/workspace/tags/ImportTagsPage.tsx
@@ -1,22 +1,34 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import ImportSpreedsheet from '@components/ImportSpreadsheet';
+import usePolicy from '@hooks/usePolicy';
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
type ImportTagsPageProps = StackScreenProps;
function ImportTagsPage({route}: ImportTagsPageProps) {
const policyID = route.params.policyID;
+ const policy = usePolicy(policyID);
const backTo = route.params.backTo;
const isQuickSettingsFlow = !!backTo;
return (
-
+
+
+
);
}
diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx
index 16f05fa3e324..6f8d51316d94 100644
--- a/src/pages/workspace/withPolicy.tsx
+++ b/src/pages/workspace/withPolicy.tsx
@@ -78,7 +78,8 @@ export default function (
function WithPolicy(props: Omit, ref: ForwardedRef) {
const policyID = getPolicyIDFromRoute(props.route as PolicyRoute);
- const [policy, policyResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ // Disable reuseConnection to temporarily fix the infinite loading status after Onyx.set(null). Reference: https://github.com/Expensify/App/issues/52640
+ const [policy, policyResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {reuseConnection: false});
const [policyDraft, policyDraftResults] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`);
const isLoadingPolicy = isLoadingOnyxValue(policyResults, policyDraftResults);
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
index 77aaee13fb9e..61bdecd12d10 100644
--- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
+++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
@@ -54,6 +54,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const {isDevelopment} = useEnvironment();
+ const [isDebugModeEnabled] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.isDebugModeEnabled});
const policyApproverEmail = policy?.approver;
const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false);
@@ -232,8 +233,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
description={getPaymentMethodDescription(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, policy?.achAccount ?? {})}
onPress={() => {
if (!Policy.isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) {
- // TODO remove isDevelopment flag once nonUSD flow is complete and update isCurrencySupportedForDirectReimbursement, this will be updated in - https://github.com/Expensify/App/issues/50912
- if (isDevelopment) {
+ // TODO remove isDevelopment and isDebugModeEnabled flags once nonUSD flow is complete and update isCurrencySupportedForDirectReimbursement, this will be updated in - https://github.com/Expensify/App/issues/50912
+ if (isDevelopment || isDebugModeEnabled) {
navigateToBankAccountRoute(route.params.policyID, ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID));
return;
}
@@ -297,6 +298,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
displayNameForAuthorizedPayer,
route.params.policyID,
isDevelopment,
+ isDebugModeEnabled,
]);
const renderOptionItem = (item: ToggleSettingOptionRowProps, index: number) => (
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 154f8240b0c5..37371e1e9453 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -367,6 +367,9 @@ const styles = (theme: ThemeColors) =>
textAlign: 'left',
},
+ verticalAlignTopText: {
+ verticalAlign: 'text-top',
+ },
verticalAlignTop: {
verticalAlign: 'top',
},
@@ -415,7 +418,7 @@ const styles = (theme: ThemeColors) =>
color: theme.text,
...FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
fontSize: variables.fontSizeSmall,
- lineHeight: variables.lineHeightSmall,
+ lineHeight: variables.lineHeightNormal,
},
textMicroSupporting: {
@@ -1746,6 +1749,31 @@ const styles = (theme: ThemeColors) =>
lineHeight: variables.fontSizeOnlyEmojisHeight,
},
+ emojisWithTextFontSizeAligned: {
+ fontSize: variables.fontSizeEmojisWithinText,
+ marginVertical: -7,
+ },
+
+ emojisFontFamily: {
+ fontFamily: FontUtils.fontFamily.platform.SYSTEM.fontFamily,
+ },
+
+ emojisWithTextFontSize: {
+ fontSize: variables.fontSizeEmojisWithinText,
+ },
+
+ emojisWithTextFontFamily: {
+ fontFamily: FontUtils.fontFamily.platform.SYSTEM.fontFamily,
+ },
+
+ emojisWithTextLineHeight: {
+ lineHeight: variables.lineHeightXLarge,
+ },
+
+ initialSettingsUsernameEmoji: {
+ fontSize: variables.fontSizeUsernameEmoji,
+ },
+
createMenuPositionSidebar: (windowHeight: number) =>
({
horizontal: 18,
diff --git a/src/styles/utils/emojiDefaultStyles/index.ts b/src/styles/utils/emojiDefaultStyles/index.ts
index 88c42e7e95d1..45880b46005d 100644
--- a/src/styles/utils/emojiDefaultStyles/index.ts
+++ b/src/styles/utils/emojiDefaultStyles/index.ts
@@ -6,7 +6,7 @@ import type EmojiDefaultStyles from './types';
const emojiDefaultStyles: EmojiDefaultStyles = {
fontStyle: 'normal',
fontWeight: FontUtils.fontWeight.normal,
- ...display.dInlineFlex,
+ ...display.dInline,
};
export default emojiDefaultStyles;
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 5a8927ede6d0..8318b58012a9 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -48,8 +48,6 @@ export default {
defaultAvatarPreviewSize: 360,
fabBottom: 25,
breadcrumbsFontSize: getValueUsingPixelRatio(19, 32),
- fontSizeOnlyEmojis: 30,
- fontSizeOnlyEmojisHeight: 35,
fontSizeSmall: getValueUsingPixelRatio(11, 17),
fontSizeExtraSmall: 9,
fontSizeLabel: getValueUsingPixelRatio(13, 19),
@@ -89,8 +87,6 @@ export default {
sidebarAvatarSize: 28,
iconHeader: 48,
iconSection: 68,
- emojiSize: 20,
- emojiLineHeight: 28,
iouAmountTextSize: 40,
extraSmallMobileResponsiveWidthBreakpoint: 320,
extraSmallMobileResponsiveHeightBreakpoint: 667,
@@ -218,6 +214,14 @@ export default {
onboardingModalWidth: 500,
fontSizeToWidthRatio: getValueUsingPixelRatio(0.8, 1),
+ // Emoji related variables
+ fontSizeOnlyEmojis: 30,
+ fontSizeOnlyEmojisHeight: 35,
+ emojiSize: 20,
+ emojiLineHeight: 28,
+ fontSizeUsernameEmoji: 19,
+ fontSizeEmojisWithinText: getValueUsingPixelRatio(17, 19),
+
// The height of the empty list is 14px (2px for borders and 12px for vertical padding)
// This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility
googleEmptyListViewHeight: 14,
diff --git a/src/types/form/DebugReportForm.ts b/src/types/form/DebugReportForm.ts
index df1211cf453e..203a75f39722 100644
--- a/src/types/form/DebugReportForm.ts
+++ b/src/types/form/DebugReportForm.ts
@@ -11,7 +11,6 @@ const INPUT_IDS = {
HAS_OUTSTANDING_CHILD_REQUEST: 'hasOutstandingChildRequest',
HAS_OUTSTANDING_CHILD_TASK: 'hasOutstandingChildTask',
IS_CANCELLED_IOU: 'isCancelledIOU',
- IS_LOADING_PRIVATE_NOTES: 'isLoadingPrivateNotes',
IS_OWN_POLICY_EXPENSE_CHAT: 'isOwnPolicyExpenseChat',
IS_PINNED: 'isPinned',
IS_WAITING_ON_BANK_ACCOUNT: 'isWaitingOnBankAccount',
@@ -61,7 +60,6 @@ type DebugReportForm = Form<
[INPUT_IDS.HAS_OUTSTANDING_CHILD_REQUEST]: boolean;
[INPUT_IDS.HAS_OUTSTANDING_CHILD_TASK]: boolean;
[INPUT_IDS.IS_CANCELLED_IOU]: boolean;
- [INPUT_IDS.IS_LOADING_PRIVATE_NOTES]: boolean;
[INPUT_IDS.IS_OWN_POLICY_EXPENSE_CHAT]: boolean;
[INPUT_IDS.IS_PINNED]: boolean;
[INPUT_IDS.IS_WAITING_ON_BANK_ACCOUNT]: boolean;
diff --git a/src/types/form/ReimbursementAccountForm.ts b/src/types/form/ReimbursementAccountForm.ts
index c3a5f34215ef..6a2e881a35bd 100644
--- a/src/types/form/ReimbursementAccountForm.ts
+++ b/src/types/form/ReimbursementAccountForm.ts
@@ -58,7 +58,6 @@ const INPUT_IDS = {
ADDRESS_CITY: 'addressCity',
ADDRESS_STATE: 'addressState',
ADDRESS_ZIP_CODE: 'addressZipCode',
- ACCOUNT_HOLDER_COUNTRY: 'accountHolderCountry',
COUNTRY: 'country',
CORPAY: {
ACCOUNT_HOLDER_COUNTRY: 'accountHolderCountry',
@@ -86,15 +85,15 @@ const INPUT_IDS = {
CURRENCY_NEEDED: 'currencyNeeded',
TRADE_VOLUME: 'tradeVolume',
ANNUAL_VOLUME: 'annualVolume',
+ OWNS_MORE_THAN_25_PERCENT: 'ownsMoreThan25Percent',
+ ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE: 'anyIndividualOwn25PercentOrMore',
+ BENEFICIAL_OWNERS: 'beneficialOwners',
+ ENTITY_CHART: 'entityChart',
FUND_DESTINATION_COUNTRIES: 'fundDestinationCountries',
FUND_SOURCE_COUNTRIES: 'fundSourceCountries',
COMPANY_DIRECTORS_FULL_NAME: 'companyDirectorsFullName',
COMPANY_DIRECTORS_JOB_TITLE: 'companyDirectorsJobTitle',
COMPANY_DIRECTORS_OCCUPATION: 'companyDirectorsOccupation',
- OWNS_MORE_THAN_25_PERCENT: 'ownsMoreThan25Percent',
- ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE: 'anyIndividualOwn25PercentOrMore',
- BENEFICIAL_OWNERS: 'beneficialOwners',
- ENTITY_CHART: 'entityChart',
SIGNER_FULL_NAME: 'signerFullName',
SIGNER_DATE_OF_BIRTH: 'signerDateOfBirth',
SIGNER_JOB_TITLE: 'signerJobTitle',
@@ -196,6 +195,10 @@ type ReimbursementAccountProps = {
type NonUSDReimbursementAccountAdditionalProps = {
/** Country of the reimbursement account */
[INPUT_IDS.ADDITIONAL_DATA.COUNTRY]: Country | '';
+
+ /** Name of the account holder */
+ [INPUT_IDS.ADDITIONAL_DATA.ACCOUNT_HOLDER_NAME]: string;
+
/** Country of the account holder */
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.ACCOUNT_HOLDER_COUNTRY]: Country | '';
@@ -271,6 +274,18 @@ type NonUSDReimbursementAccountAdditionalProps = {
/** Annual volume */
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.ANNUAL_VOLUME]: string;
+ /** Current user owns more than 25 percent of company */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.OWNS_MORE_THAN_25_PERCENT]: boolean;
+
+ /** Any individual owns 25 percent or more of company */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: boolean;
+
+ /** Beneficial owners */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BENEFICIAL_OWNERS]: string;
+
+ /** Entity chart */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ENTITY_CHART]: FileObject[];
+
/** Fund destination countries */
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.FUND_DESTINATION_COUNTRIES]: string;
@@ -286,15 +301,6 @@ type NonUSDReimbursementAccountAdditionalProps = {
/** Company directors occupation */
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.COMPANY_DIRECTORS_OCCUPATION]: string;
- /** Owns more than 25 percent */
- [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: boolean;
-
- /** Beneficial owners */
- [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BENEFICIAL_OWNERS]: string;
-
- /** Entity chart */
- [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ENTITY_CHART]: FileObject[];
-
/** Signer full name */
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.SIGNER_FULL_NAME]: string;
@@ -371,7 +377,6 @@ type NonUSDReimbursementAccountAdditionalProps = {
type ReimbursementAccountForm = ReimbursementAccountFormExtraProps &
Form<
InputID,
- // @ts-expect-error TODO: fix it - I have no idea why it is complaining here
BeneficialOwnersStepBaseProps &
BankAccountStepProps &
CompanyStepProps &
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/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts
index 877f9aea65d3..0b2d8b0b97f2 100644
--- a/src/types/onyx/ReimbursementAccount.ts
+++ b/src/types/onyx/ReimbursementAccount.ts
@@ -129,6 +129,18 @@ type Corpay = {
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.AUTHORIZED_TO_BIND_CLIENT_TO_AGREEMENT]: boolean;
/** Bank statement */
[INPUT_IDS.ADDITIONAL_DATA.CORPAY.BANK_STATEMENT]: FileObject[];
+
+ /** Is user also an owner */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.OWNS_MORE_THAN_25_PERCENT]: boolean;
+
+ /** Are the more owners */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ANY_INDIVIDUAL_OWN_25_PERCENT_OR_MORE]: boolean;
+
+ /** Stringified array of owners data */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.BENEFICIAL_OWNERS]?: string;
+
+ /** Entity chart files */
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ENTITY_CHART]?: FileObject[];
};
/** Model of Additional data */
@@ -144,7 +156,7 @@ type AdditionalData = {
/** Account holder address - zip code */
[INPUT_IDS.ADDITIONAL_DATA.ADDRESS_ZIP_CODE]: string;
/** Account holder address - country */
- [INPUT_IDS.ADDITIONAL_DATA.ACCOUNT_HOLDER_COUNTRY]: string;
+ [INPUT_IDS.ADDITIONAL_DATA.CORPAY.ACCOUNT_HOLDER_COUNTRY]: string;
/** Country user selects in first step */
[INPUT_IDS.ADDITIONAL_DATA.COUNTRY]: Country | '';
/** Corpay fields */
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index eb6979e0ba36..58ebdacfdfef 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -70,9 +70,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** The URL of the Group Chat report custom avatar */
avatarUrl?: string;
- /** The filename of the avatar */
- avatarFileName?: string;
-
/** The specific type of chat */
chatType?: ValueOf;
@@ -151,9 +148,6 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** The report visibility */
visibility?: RoomVisibility;
- /** Report cached total */
- cachedTotal?: string;
-
/** Invoice room receiver data */
invoiceReceiver?: InvoiceReceiver;
@@ -223,15 +217,9 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** Collection of participant private notes, indexed by their accountID */
privateNotes?: Record;
- /** Whether participants private notes are being currently loaded */
- isLoadingPrivateNotes?: boolean;
-
/** Pending members of the report */
pendingChatMembers?: PendingChatMember[];
- /** The ID of the single transaction thread report associated with this report, if one exists */
- transactionThreadReportID?: string;
-
/** Collection of policy report fields, indexed by their fieldID */
fieldList?: Record;
diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts
index 65a6f153c51b..36c5aee1cb57 100644
--- a/src/types/onyx/ReportMetadata.ts
+++ b/src/types/onyx/ReportMetadata.ts
@@ -17,6 +17,9 @@ type ReportMetadata = {
/** The time when user last visited the report */
lastVisitTime?: string;
+
+ /** Whether participants private notes are being currently loaded */
+ isLoadingPrivateNotes?: boolean;
};
export default ReportMetadata;
diff --git a/src/types/utils/whitelistedReportKeys.ts b/src/types/utils/whitelistedReportKeys.ts
index 71510e498854..32aa0797d0f8 100644
--- a/src/types/utils/whitelistedReportKeys.ts
+++ b/src/types/utils/whitelistedReportKeys.ts
@@ -7,7 +7,6 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback<
{
avatarUrl: unknown;
- avatarFileName: unknown;
chatType: unknown;
hasOutstandingChildRequest: unknown;
hasOutstandingChildTask: unknown;
@@ -34,7 +33,6 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback<
writeCapability: unknown;
type: unknown;
visibility: unknown;
- cachedTotal: unknown;
invoiceReceiver: unknown;
lastMessageTranslationKey: unknown;
parentReportID: unknown;
@@ -58,9 +56,7 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback<
nonReimbursableTotal: unknown;
isHidden: unknown;
privateNotes: unknown;
- isLoadingPrivateNotes: unknown;
pendingChatMembers: unknown;
- transactionThreadReportID: unknown;
fieldList: unknown;
permissions: unknown;
tripData: {
diff --git a/tests/e2e/testRunner.ts b/tests/e2e/testRunner.ts
index 5485385ad8c9..f68a3d6a9fba 100644
--- a/tests/e2e/testRunner.ts
+++ b/tests/e2e/testRunner.ts
@@ -22,6 +22,7 @@ import compare from './compare/compare';
import defaultConfig from './config';
import createServerInstance from './server';
import reversePort from './utils/androidReversePort';
+import closeANRPopup from './utils/closeANRPopup';
import installApp from './utils/installApp';
import killApp from './utils/killApp';
import launchApp from './utils/launchApp';
@@ -287,6 +288,7 @@ const runTests = async (): Promise => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.error(`Warmup failed with error: ${e}`);
+ await closeANRPopup();
MeasureUtils.stop('error-warmup');
server.clearAllTestDoneListeners();
@@ -310,10 +312,11 @@ const runTests = async (): Promise => {
// We run each test multiple time to average out the results
for (let testIteration = 0; testIteration < config.RUNS; testIteration++) {
- const onError = (e: Error) => {
+ const onError = async (e: Error) => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.error(`Unexpected error during test execution: ${e}. `);
MeasureUtils.stop('error');
+ await closeANRPopup();
server.clearAllTestDoneListeners();
errorCountRef.errorCount += 1;
if (testIteration === 0 || errorCountRef.errorCount === errorCountRef.allowedExceptions) {
@@ -341,7 +344,7 @@ const runTests = async (): Promise => {
// Run the test on the delta app:
await runTestIteration(config.DELTA_APP_PACKAGE, deltaIterationText, config.BRANCH_DELTA, launchArgs);
} catch (e) {
- onError(e as Error);
+ await onError(e as Error);
}
}
} catch (exception) {
diff --git a/tests/e2e/utils/closeANRPopup.ts b/tests/e2e/utils/closeANRPopup.ts
new file mode 100644
index 000000000000..1763bc183ad4
--- /dev/null
+++ b/tests/e2e/utils/closeANRPopup.ts
@@ -0,0 +1,13 @@
+import execAsync from './execAsync';
+import type {PromiseWithAbort} from './execAsync';
+
+const closeANRPopup = function (platform = 'android'): PromiseWithAbort {
+ if (platform !== 'android') {
+ throw new Error(`closeANRPopup() missing implementation for platform: ${platform}`);
+ }
+
+ // Press "Enter" to close the ANR popup
+ return execAsync(`adb shell input keyevent KEYCODE_ENTER`);
+};
+
+export default closeANRPopup;
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/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts
index 09a36fcfb562..9d4af5aa3760 100644
--- a/tests/unit/CardUtilsTest.ts
+++ b/tests/unit/CardUtilsTest.ts
@@ -215,4 +215,41 @@ describe('CardUtils', () => {
expect(feedName).toBe(undefined);
});
});
+
+ describe('maskCardNumber', () => {
+ it("Should return the card number divided into chunks of 4, with 'X' replaced by '•' if it's provided in the '480801XXXXXX2554' format", () => {
+ const cardNumber = '480801XXXXXX2554';
+ const maskedCardNumber = CardUtils.maskCardNumber(cardNumber, CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD);
+ expect(maskedCardNumber).toBe('4808 01•• •••• 2554');
+ });
+
+ it('Should return card number without changes if it has empty space', () => {
+ const cardNumber = 'CREDIT CARD...6607';
+ const maskedCardNumber = CardUtils.maskCardNumber(cardNumber, CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE);
+ expect(maskedCardNumber).toBe(cardNumber);
+ });
+
+ it("Should return the Amex direct feed card number divided into 4/6/5 chunks, with 'X' replaced by '•' if it's provided in '211944XXXXX6557' format", () => {
+ const cardNumber = '211944XXXXX6557';
+ const maskedCardNumber = CardUtils.maskCardNumber(cardNumber, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT);
+ expect(maskedCardNumber).toBe('2119 44•••• •6557');
+ });
+
+ it("Should return the Amex custom feed card number divided into 4/6/5 chunks, with 'X' replaced by '•' if it's provided in '211944XXXXX6557' format", () => {
+ const cardNumber = '211944XXXXX6557';
+ const maskedCardNumber = CardUtils.maskCardNumber(cardNumber, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX);
+ expect(maskedCardNumber).toBe('2119 44•••• •6557');
+ });
+
+ it('Should return masked card number even if undefined feed was provided', () => {
+ const cardNumber = '480801XXXXXX2554';
+ const maskedCardNumber = CardUtils.maskCardNumber(cardNumber, undefined);
+ expect(maskedCardNumber).toBe('4808 01•• •••• 2554');
+ });
+
+ it('Should return empty string if invalid card name was provided', () => {
+ const maskedCardNumber = CardUtils.maskCardNumber('', CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD);
+ expect(maskedCardNumber).toBe('');
+ });
+ });
});
diff --git a/tests/unit/EmojiTest.ts b/tests/unit/EmojiTest.ts
index c96228b49fbc..d0547902f253 100644
--- a/tests/unit/EmojiTest.ts
+++ b/tests/unit/EmojiTest.ts
@@ -154,6 +154,11 @@ describe('EmojiTest', () => {
it('correct suggests emojis accounting for keywords', () => {
const thumbEmojisEn: Emoji[] = [
+ {
+ name: 'hand_with_index_finger_and_thumb_crossed',
+ code: '🫰',
+ types: ['🫰🏿', '🫰🏾', '🫰🏽', '🫰🏼', '🫰🏻'],
+ },
{
code: '👍',
name: '+1',
@@ -164,11 +169,6 @@ describe('EmojiTest', () => {
name: '-1',
types: ['👎🏿', '👎🏾', '👎🏽', '👎🏼', '👎🏻'],
},
- {
- name: 'hand_with_index_finger_and_thumb_crossed',
- code: '🫰',
- types: ['🫰🏿', '🫰🏾', '🫰🏽', '🫰🏼', '🫰🏻'],
- },
];
const thumbEmojisEs: Emoji[] = [
@@ -216,4 +216,57 @@ describe('EmojiTest', () => {
},
]);
});
+
+ describe('splitTextWithEmojis', () => {
+ it('should return empty array if no text provided', () => {
+ const processedTextArray = EmojiUtils.splitTextWithEmojis(undefined);
+ expect(processedTextArray).toEqual([]);
+ });
+
+ it('should return empty array if there are no emojis in the text', () => {
+ const text = 'Simple text example with several words without emojis.';
+ const processedTextArray = EmojiUtils.splitTextWithEmojis(text);
+ expect(processedTextArray).toEqual([]);
+ });
+
+ it('should split the text with emojis into array', () => {
+ const textWithOnlyEmojis = '🙂🙂🙂';
+ const textWithEmojis = 'Hello world 🙂🙂🙂 ! 🚀🚀 test2 👍👍🏿 test';
+ const textStartsAndEndsWithEmojis = '🙂 Hello world 🙂🙂🙂 ! 🚀🚀️ test2 👍👍🏿 test 🙂';
+
+ expect(EmojiUtils.splitTextWithEmojis(textWithOnlyEmojis)).toEqual([
+ {text: '🙂', isEmoji: true},
+ {text: '🙂', isEmoji: true},
+ {text: '🙂', isEmoji: true},
+ ]);
+ expect(EmojiUtils.splitTextWithEmojis(textWithEmojis)).toEqual([
+ {text: 'Hello world ', isEmoji: false},
+ {text: '🙂', isEmoji: true},
+ {text: '🙂', isEmoji: true},
+ {text: '🙂', isEmoji: true},
+ {text: ' ! ', isEmoji: false},
+ {text: '🚀', isEmoji: true},
+ {text: '🚀', isEmoji: true},
+ {text: ' test2 ', isEmoji: false},
+ {text: '👍', isEmoji: true},
+ {text: '👍🏿', isEmoji: true},
+ {text: ' test', isEmoji: false},
+ ]);
+ expect(EmojiUtils.splitTextWithEmojis(textStartsAndEndsWithEmojis)).toEqual([
+ {text: '🙂', isEmoji: true},
+ {text: ' Hello world ', isEmoji: false},
+ {text: '🙂', isEmoji: true},
+ {text: '🙂', isEmoji: true},
+ {text: '🙂', isEmoji: true},
+ {text: ' ! ', isEmoji: false},
+ {text: '🚀', isEmoji: true},
+ {text: '🚀️', isEmoji: true},
+ {text: ' test2 ', isEmoji: false},
+ {text: '👍', isEmoji: true},
+ {text: '👍🏿', isEmoji: true},
+ {text: ' test ', isEmoji: false},
+ {text: '🙂', isEmoji: true},
+ ]);
+ });
+ });
});
diff --git a/tests/unit/OnboardingSelectorsTest.ts b/tests/unit/OnboardingSelectorsTest.ts
new file mode 100644
index 000000000000..1fc5846b2472
--- /dev/null
+++ b/tests/unit/OnboardingSelectorsTest.ts
@@ -0,0 +1,36 @@
+import type {OnyxValue} from 'react-native-onyx';
+import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors';
+import CONST from '@src/CONST';
+import type ONYXKEYS from '@src/ONYXKEYS';
+
+describe('onboardingSelectors', () => {
+ // Not all users have this NVP defined as we did not run a migration to backfill it for existing accounts, hence we need to make sure
+ // the onboarding flow is only showed to the users with `hasCompletedGuidedSetupFlow` set to false
+ describe('hasCompletedGuidedSetupFlowSelector', () => {
+ // It might be the case that backend returns an empty array if the NVP is not defined on this particular account
+ it('Should return true if onboarding NVP is an array', () => {
+ const onboarding = [] as OnyxValue;
+ expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
+ });
+ it('Should return true if onboarding NVP is an empty object', () => {
+ const onboarding = {} as OnyxValue;
+ expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
+ });
+ it('Should return true if onboarding NVP contains only signupQualifier', () => {
+ const onboarding = {signupQualifier: CONST.ONBOARDING_SIGNUP_QUALIFIERS.VSB} as OnyxValue;
+ expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
+ });
+ it('Should return true if onboarding NVP contains hasCompletedGuidedSetupFlow = true', () => {
+ const onboarding = {hasCompletedGuidedSetupFlow: true} as OnyxValue;
+ expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
+ });
+ it('Should return false if onboarding NVP contains hasCompletedGuidedSetupFlow = false', () => {
+ const onboarding = {hasCompletedGuidedSetupFlow: false} as OnyxValue;
+ expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(false);
+ });
+ it('Should return true if onboarding NVP contains only selfTourViewed', () => {
+ const onboarding = {selfTourViewed: true} as OnyxValue;
+ expect(hasCompletedGuidedSetupFlowSelector(onboarding)).toBe(true);
+ });
+ });
+});
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,
};