diff --git a/android/app/build.gradle b/android/app/build.gradle
index 9a930427ecdf..9886cd5cccec 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001044501
- versionName "1.4.45-1"
+ versionCode 1001044600
+ versionName "1.4.46-0"
}
flavorDimensions "default"
diff --git a/assets/images/chatbubble-add.svg b/assets/images/chatbubble-add.svg
index 48eebf863cc3..3e2c270d681c 100644
--- a/assets/images/chatbubble-add.svg
+++ b/assets/images/chatbubble-add.svg
@@ -1 +1,10 @@
-
\ No newline at end of file
+
+
+
diff --git a/assets/images/chatbubble-reply.svg b/assets/images/chatbubble-reply.svg
new file mode 100644
index 000000000000..f8d53ebff807
--- /dev/null
+++ b/assets/images/chatbubble-reply.svg
@@ -0,0 +1,10 @@
+
+
+
diff --git a/assets/images/chatbubble-unread.svg b/assets/images/chatbubble-unread.svg
index 492616cf2ab5..f5a27a74f5fe 100644
--- a/assets/images/chatbubble-unread.svg
+++ b/assets/images/chatbubble-unread.svg
@@ -1 +1,9 @@
-
\ No newline at end of file
+
+
+
diff --git a/assets/images/product-illustrations/three_legged_laptop_woman.svg b/assets/images/product-illustrations/three_legged_laptop_woman.svg
new file mode 100644
index 000000000000..6be000b92e37
--- /dev/null
+++ b/assets/images/product-illustrations/three_legged_laptop_woman.svg
@@ -0,0 +1,173 @@
+
+
+
diff --git a/docs/articles/expensify-classic/getting-started/Create-a-company-workspace.md b/docs/articles/expensify-classic/getting-started/Create-a-company-workspace.md
new file mode 100644
index 000000000000..4cc95cdcf918
--- /dev/null
+++ b/docs/articles/expensify-classic/getting-started/Create-a-company-workspace.md
@@ -0,0 +1,171 @@
+---
+title: Create a company workspace
+description: Get started with Expensify by creating a workspace for your company
+---
+
+
+# Overview
+
+Welcome to Expensify! If you are creating an Expensify account for your company, follow the steps below to get started.
+
+{% include info.html %}
+You can also schedule a free private onboarding session where one of our Setup Specialists will walk you through the entire process. Check your email and notifications in Expensify for your unique signup link.
+{% include end-info.html %}
+
+# 1. Meet Concierge
+
+Your personal assistant, Concierge, lives on your Expensify Home page on both desktop and the mobile app.
+
+Concierge will walk you through setting up your account and also provide:
+- Reminders to do things like submit your expenses
+- Alerts when more information is needed on an expense report
+- Updates on new and improved account features
+
+You can also get support at any time by clicking the green chat bubble in the right corner. This will open a chat with Concierge where you can ask questions and receive direct support.
+
+# 2. Create a workspace
+
+
+
Hover over Settings, then click Workspaces.
+
Click the Group tab on the left.
+
Enter a name for the workspace (the name of your business or department is a great choice, if applicable), then click Select next to the workspace type that best fits your needs.
+
+
+# 3. Add a business bank account
+
+Connecting your business bank account allows you to:
+- Reimburse expenses via direct bank transfer
+- Pay bills
+- Collect invoice payments
+- Issue Expensify Cards
+
+{% include info.html %}
+The person who completes this process does not need to be a signer on the account, however they will be required to enter their own personal information as well. If someone other than the Expensify account holder enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed.
+{% include end-info.html %}
+
+To add a business bank account,
+
+
+
Hover over Settings, then click Workspaces.
+
Click the Group tab on the left.
+
Click the desired workspace name.
+
Click the Reimbursement tab.
+
Click the Direct box, then click Add Business Bank Account.
+
Click Connect to your bank.
+
Click Continue to continue to Plaid.
+
Select the bank and log in to your business bank account.
+
+
If the bank is not listed, close the Plaid window and select Connect Manually to enter your account and routing numbers.
+
+
Select the bank account if multiple are available.
+
Verify the bank account by entering some additional information:
+
+
Enter the legal business name.
+
Enter the company address (Must be a physical location in the U.S. Maildrop address, P.O. boxes, or UPS Store addresses are flagged for review and will create a delay verifying the bank account).
+
Enter the Tax Identification Number (TIN)
+
Enter the company website, formatted like https://www.expensify.com
+
Enter the Industry Classification Code. You can locate a list of Industry Classification Codes here.
+
Enter your personal information into the Requestor Information section, including your physical U.S. address and SSN issued from the U.S.
+
Upload photos of your ID. It must be issued by the U.S. and be current (i.e., the expiration date must be in the future).
+
Take a short video of yourself to verify your identity.
+
Check the appropriate box under Additional Information to accept the agreement terms and verify that all of the information is true and accurate.
+
+
A Beneficial Owner is an individual who owns 25% or more of the business. If you or someone else is a Beneficial Owner, check the appropriate box. If someone else is a Beneficial Owner, their personal information will need to be provided as well.
+
If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section.
+
+
+
Within 1-2 business days, Expensify will send three test transactions to your bank account that you’ll enter into Expensify to validate your bank account by either:
+
+
Clicking the validate task from Concierge on your Home page.
+
Going to Settings > Account > Payments and clicking Enter test transactions.
+
+
+
+{% include info.html %}
+If after two business days you do not see these test transactions, click the green chat bubble in the right corner to get support from Concierge.
+{% include end-info.html %}
+
+# 4. Connect your accounting system
+
+If you use an external accounting system like QuickBooks, you can link it with Expensify to help you import accounting data, code expenses, and more.
+
+To add an accounting system integration,
+
+
Hover over Settings, then click Workspaces.
+
Click the Group tab on the left.
+
Click the desired workspace name.
+
Click the Connections tab.
+
Under Accounting Integrations, click the name of your accounting system, select the “Connect to…” option, and click the related button.
+
Depending on the integration you selected, you’ll either be prompted with a login screen for the accounting system or additional steps for how to proceed.
+
+
+For a walkthrough for how to set up a specific accounting system, visit our [Integrations](https://help.expensify.com/expensify-classic/hubs/integrations/) articles.
+
+# 5. Set approval rules
+
+Determine the basic guidelines that apply to all submitted expenses. If a submitted expense does not meet these rules, it will be flagged as a violation. You can set rules for expenses, per diem, travel, and reports.
+
+
+
Hover over Settings, then click Workspaces.
+
Click the Group tab on the left.
+
Click the desired workspace name.
+
Click the Expenses tab and set the desired rules.
+
+
Determine if expense violations will be enforced. If enabled, expenses that exceed the set number of days or amount will be flagged as an expense violation.
+
Determine how cash expenses are treated.
+
Determine if expenses can be re-billed to someone else as an invoice.
+
Determine if eRecipts can be submitted as proof of an expense.
+
+{% include info.html %}
+If eReceipts are enabled, imported credit card expenses of $75 USD or less will be tracked automatically—no paper receipt is necessary.
+
+eReceipts meet IRS documentation requirements as per Publication 463; However, the IRS will not accept an eReceipt for lodging purchases (for example, hotel expenses will require a paper receipt).
+{% include end-info.html %}
+
+
Determine if receipts are visible to anyone with the URL.
+
+{% include info.html %}
+If disabled, receipts can be seen only by admins for the workspace or someone who has been sent the report that the receipts are related to.
+{% include end-info.html %}
+
+
Set your mileage rates for distance expenses.
+
Determine if time expenses can be submitted as an hourly rate.
+
+
Click the Reports tab and set the desired rules.
+
+
Set the currency that will be used for all reports.
+
Determine if Schedule Submit will be allowed. If enabled, all created expenses will be automatically assigned to a report. Concierge will then submit expenses for approval on the employee's behalf instantly either daily, weekly, etc., based on your frequency setting.
+
Determine if a default report title will be required. If enabled, all reports will be named based on the set formula.
+
Determine if additional fields should be added to each report or invoice.
+
+
Click the Travel tab and set the desired rules.
+
+
Determine what flight class and hotel rating Concierge should select when booking travel. Concierge can automatically book flights and hotels for your employees if they have an Expensify card.
+
+
Click the Per Diem tab and set the desired rules.
+
+
Determine if per diem expenses will be allowed. Then import your per diem expense rules spreadsheet that contains the list of location-based expense amounts (for example, an employee in California might receive a different amount for lunch than an employee in Louisiana).
+
+
+
+# 6. Secure your account
+
+Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication. This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in.
+
+
+
Hover over Settings, then click Account.
+
Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle.
+
Save a copy of your backup codes. This step is critical—You will lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
+
+
Click Download to save a copy of your backup codes to your computer.
+
Click Copy to paste the codes into a document or other secure location.
+
+
Click Continue.
+
Download or open your authenticator app and either:
+
+
Scan the QR code shown on your computer screen.
+
Enter the 6-digit code from your authenticator app into Expensify and click Verify.
+
+
+
+When you log in to Expensify in the future, you’ll be emailed a magic code that you’ll use to log in with. Then you’ll be prompted to open your authenticator app to get the 6-digit code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed.
diff --git a/docs/articles/expensify-classic/settings/Account-Details.md b/docs/articles/expensify-classic/settings/Account-Details.md
deleted file mode 100644
index 535e74eeb701..000000000000
--- a/docs/articles/expensify-classic/settings/Account-Details.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-title: Account Details
-description: The Account Details section of your account is where you can update your profile photo, enable 2FA, and change the email address associated with your Expensify account.
----
-
-# Overview
-The Account Details section of your account is where you can update your profile photo, enable 2FA, and change the email address associated with your account.
-
-You can have multiple email addresses tied to your account to make it easier to submit expenses or manage your account. Let’s go over how to configure the various account settings located under the Account Details section of your Expensify account.
-
-# How to add a profile photo
-To update your name or add a profile photo, navigate to **Settings** > **Account** > **Account Details.** Under “your profile” you’ll notice a profile picture thumbnail, click “edit photo” underneath to update the profile image.
-
-# How to enable Two-Factor Authentication
-Setting up Two-factor Authentication is one of the best ways to secure your account. This can be enabled individually in your account settings by following **Settings** > **Accounts** > **Account Details** > **Two Factor Authentication** and toggle the switch to **Enabled.**
-
-Save or download your **Recovery Codes.** It’s important to keep these safe! You WILL lose access to your account if you cannot use your authenticator app and do not have your recovery codes.
-
-Use your favorite authenticator app to connect to Expensify using the QR code or click the link to enter the secret key manually.
-
-Once connected, quickly enter the code generated by your app into Expensify before the timeframe runs out!
-
-# How to manage your devices
-You can access your Expensify account on multiple devices, which allows for easy access to your account data. By heading to **Settings** > **Account** > **Account Details** > **Device Management**, you can review the devices that have access to your account.
-
-From that same place in your account, you can remove any devices that should no longer have access. To do this, select the **Revoke** button next to each device you wish to remove access to your account.
-
-# How to add a Secondary Login
-A Secondary Login is helpful if you have multiple email addresses and don’t necessarily need multiple Expensify accounts. By adding additional emails to your Expensify account, you can use them to forward receipts to receipts@expensify.com and they will be uploaded to your main Expensify account. To get this added to your account, follow these steps:
-
-1. Log in to your Expensify account through a web browser at www.expensify.com. Please note that this process cannot be completed using the mobile app; it must be done from the website at expensify.com.
-2. Navigate to **Settings** > **Account** > **Account Details**. Scroll down to find the 'Secondary Logins' section, then click the 'Add Secondary Login' button.
-3. Input the email address or mobile phone number you wish to add, ensuring you include the international code if applicable.
-4. You will receive a prompt to enter the Magic Code, which will be sent to the email address you're adding as a secondary login.
-
-# How to update your email address
-Once a Secondary Login is added to your account, you can make it your primary email address. The primary address on an Expensify account is the address that will receive email notifications and updates regarding the account. Any new email addresses must be added as a secondary login before they can be made a primary address.
-
-1. Log in to your Expensify account through a web browser at www.expensify.com. Please note that this process cannot be completed using the mobile app; it must be done from the website at expensify.com.
-2. Navigate to **Settings** > **Account** > **Account Details**. Scroll down to find the 'Secondary Logins' section, then select the **"Make Primary"** button next to the email address.
-3. You can keep the old address as a secondary login or delete email addresses by selecting the **"Remove"** button.
-
-
-# Deep Dive
-## Managing emails connected to other Expensify accounts
-A secondary login can only be added if it is not linked to an existing account. If you have two email addresses with Expensify accounts linked to them, you'll need to merge them instead.
-
-Alternatively, you can remove a personal email address from a previous work/organization account to use it elsewhere.
-
-Is your Secondary Login (personal email) validated in your company account? If so, do the following:
-1. Navigate to expensify.com
-2. Log in using your validated Secondary Login
-3. Navigate to **Account** > **Settings** > **Account Details** > **Secondary Logins**
-4. Remove your personal email address from the account by clicking the **"Remove"** button next to your email
-
-Is your Secondary Login (personal email) invalidated in your company account? If so, do the following:
-1. Navigate to expensify.com
-2. Enter your invalidated secondary login email address
-3. You will be presented with a confirmation message saying Expensify sent you an email with a validation link
-4. Head to your personal email account and follow the prompts
-5. You'll receive a link in the email to click that will unlink the two accounts
-
-{% include faq-begin.md %}
-## The profile picture on my account updated automatically. Why did this happen?
-Our focus is always on making your experience user-friendly and saving you valuable time. One of the ways we achieve this is by utilizing a public API to retrieve public data linked to your email address.
-
-This tool searches for public accounts or profiles associated with your email address, such as on LinkedIn. When it identifies one, it pulls in the uploaded profile picture and name to Expensify.
-
-While this automated process is generally accurate, there may be instances where it's not entirely correct. If this happens, we apologize for any inconvenience caused. The good news is that rectifying such situations is a straightforward process. You can quickly update your information manually by following the directions provided above, ensuring your data is accurate and up to date in no time.
-
-{% include faq-end.md %}
diff --git a/docs/articles/expensify-classic/settings/Merge-Accounts.md b/docs/articles/expensify-classic/settings/Merge-Accounts.md
deleted file mode 100644
index 34bf422aa983..000000000000
--- a/docs/articles/expensify-classic/settings/Merge-Accounts.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-title: Merge Accounts
-description: How to merge two Expensify accounts and why this is useful.
----
-
-# Overview
-
-Merging accounts allows you to combine two accounts. When you combine two accounts, all receipts, expenses, expense reports, invoices, bills, imported cards, secondary logins, co-pilots, and group workspace settings will be combined into one account.
-This can be useful if you start off with an account of your own but your organization creates a separate account for you. You can then track both personal and business expenses via one account.
-
-# How to merge accounts
-Merging two accounts together is fairly straightforward. Let’s go over how to do that below:
-1. Navigate to [expensify.com](https://www.expensify.com)
-2. Log into the account you want to set as the Primary account
-3. Navigate to **Settings > Account > Account Details**
-4. Scroll down to Merge Accounts and fill in the fields
-6. Click Merge Accounts
-7. Once you click Merge, a magic code is sent to you via email
-8. Paste the code into the required field
-If you have any questions about this process, feel free to reach out to Concierge for some assistance!
-
-{% include faq-begin.md %}
-## Can you merge accounts from the mobile app?
-No, accounts can only be merged from the full website at expensify.com.
-## Can I administratively merge two accounts together?
-No, only the account holder (member) can perform account merging.
-## Is merging accounts reversible?
-No, merging accounts is not reversible. It is a permanent action that cannot be undone.
-## I have open expenses in the account I'm merging from. Will those expenses merge into the new account?
-All expenses must be reported and submitted for them to merge into the new account. Any open expenses will not merge.
-## Are there any restrictions on account merging?
-Yes! Please see below:
-- If your email address belongs to a verified domain (verified in Expensify), you must start the process from the email account under the verified domain. You cannot merge a verified company email account into a personal account.
-- If you have two accounts with two different verified domains, you cannot merge them together.
-## What happens to my “personal” Individual workspace when merging accounts?
-The old “personal” Individual workspace is deleted. If you plan to submit reports under a different workspace in the future, ensure that any reports on the Individual workspace in the old account are marked as Open before merging the accounts. You can typically do this by selecting “Undo Submit” on any submitted reports.
-
-{% include faq-end.md %}
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index cf7cc7cc3caa..dff05f61933e 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.45
+ 1.4.46CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.45.1
+ 1.4.46.0ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 22bc17b167f6..fa6995f65b5a 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.45
+ 1.4.46CFBundleSignature????CFBundleVersion
- 1.4.45.1
+ 1.4.46.0
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 8055f08b8d7c..e8cd0ebb4e0a 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 1.4.45
+ 1.4.46CFBundleVersion
- 1.4.45.1
+ 1.4.46.0NSExtensionNSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index 066c140d915f..5f55ddd82868 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.45-1",
+ "version": "1.4.46-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.45-1",
+ "version": "1.4.46-0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -52,7 +52,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#83ae6194b3e4feb363ea9d061085a7ab76e35ffb",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
"expo-image": "1.10.1",
@@ -25920,9 +25920,9 @@
}
},
"node_modules/classnames": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.4.0.tgz",
- "integrity": "sha512-lWxiIlphgAhTLN657pwU/ofFxsUTOWc2CRIFeoV5st0MGRJHStUnWIUJgDHxjUO/F0mXzGufXIM4Lfu/8h+MpA=="
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz",
+ "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA=="
},
"node_modules/clean-css": {
"version": "5.3.2",
@@ -30758,11 +30758,11 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#83ae6194b3e4feb363ea9d061085a7ab76e35ffb",
- "integrity": "sha512-nAe0fPbfRn/VYHe6mCp/APmMbda/NiHE3aZq7q0kWhPmz1LVTukeaREmZ7SN8auyLOy9/mS0RIQLeV0AR8vsrA==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167",
+ "integrity": "sha512-3d/JHWgeS+LFPRahCAXdLwnBYQk4XUYybtgCm7VsdmMDtCeGUTksLsEY7F1Zqm+ULqZjmCtYwAi8IPKy0fsSOw==",
"license": "MIT",
"dependencies": {
- "classnames": "2.4.0",
+ "classnames": "2.5.0",
"clipboard": "2.0.11",
"html-entities": "^2.4.0",
"jquery": "3.6.0",
@@ -30771,7 +30771,7 @@
"prop-types": "15.8.1",
"react": "16.12.0",
"react-dom": "16.12.0",
- "semver": "^7.5.2",
+ "semver": "^7.6.0",
"simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5",
"ua-parser-js": "^1.0.37",
"underscore": "1.13.6"
@@ -47000,9 +47000,9 @@
}
},
"node_modules/semver": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
- "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dependencies": {
"lru-cache": "^6.0.0"
},
diff --git a/package.json b/package.json
index 10b4f107aa9d..e3c23d4538d3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.45-1",
+ "version": "1.4.46-0",
"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.",
@@ -100,7 +100,7 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#83ae6194b3e4feb363ea9d061085a7ab76e35ffb",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#a8ed0f8e1be3a1e09016e07a74cfd13c85bbc167",
"expo": "^50.0.3",
"expo-av": "~13.10.4",
"expo-image": "1.10.1",
diff --git a/src/App.tsx b/src/App.tsx
index cbe5948f8d4e..0e247d5faa53 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -83,7 +83,6 @@ function App({url}: AppProps) {
- {/* @ts-expect-error TODO: Remove this once Expensify (https://github.com/Expensify/App/issues/25231) is migrated to TypeScript. */}
diff --git a/src/CONST.ts b/src/CONST.ts
index cf0b54bceb42..8d4eaac44a38 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1543,6 +1543,8 @@ const CONST = {
PATH_WITHOUT_POLICY_ID: /\/w\/[a-zA-Z0-9]+(\/|$)/,
POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/,
+
+ SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'),
},
PRONOUNS: {
@@ -3277,6 +3279,12 @@ const CONST = {
REPORT_FIELD_TITLE_FIELD_ID: 'text_title',
+ /** Dimensions for illustration shown in Confirmation Modal */
+ CONFIRM_CONTENT_SVG_SIZE: {
+ HEIGHT: 220,
+ WIDTH: 130,
+ },
+
DEBUG_CONSOLE: {
LEVELS: {
INFO: 'INFO',
@@ -3320,6 +3328,10 @@ const CONST = {
PREFER_CLASSIC: 'preferClassic',
},
},
+
+ SESSION_STORAGE_KEYS: {
+ INITIAL_URL: 'INITIAL_URL',
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/Expensify.js b/src/Expensify.tsx
similarity index 64%
rename from src/Expensify.js
rename to src/Expensify.tsx
index dfb59a0f8848..f822862ec434 100644
--- a/src/Expensify.js
+++ b/src/Expensify.tsx
@@ -1,9 +1,8 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
+import type {NativeEventSubscription} from 'react-native';
import {AppState, Linking} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import Onyx, {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import ConfirmModal from './components/ConfirmModal';
import DeeplinkWrapper from './components/DeeplinkWrapper';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
@@ -12,14 +11,13 @@ import GrowlNotification from './components/GrowlNotification';
import AppleAuthWrapper from './components/SignInButtons/AppleAuthWrapper';
import SplashScreenHider from './components/SplashScreenHider';
import UpdateAppModal from './components/UpdateAppModal';
-import withLocalize, {withLocalizePropTypes} from './components/withLocalize';
import CONST from './CONST';
+import useLocalize from './hooks/useLocalize';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import * as Report from './libs/actions/Report';
import * as User from './libs/actions/User';
import * as ActiveClientManager from './libs/ActiveClientManager';
import BootSplash from './libs/BootSplash';
-import compose from './libs/compose';
import * as Growl from './libs/Growl';
import Log from './libs/Log';
import migrateOnyx from './libs/migrateOnyx';
@@ -27,16 +25,18 @@ import Navigation from './libs/Navigation/Navigation';
import NavigationRoot from './libs/Navigation/NavigationRoot';
import NetworkConnection from './libs/NetworkConnection';
import PushNotification from './libs/Notification/PushNotification';
-// eslint-disable-next-line no-unused-vars
-import subscribePushNotification from './libs/Notification/PushNotification/subscribePushNotification';
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import './libs/Notification/PushNotification/subscribePushNotification';
import StartupTimer from './libs/StartupTimer';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
-// eslint-disable-next-line no-unused-vars
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
import UnreadIndicatorUpdater from './libs/UnreadIndicatorUpdater';
import Visibility from './libs/Visibility';
import ONYXKEYS from './ONYXKEYS';
import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu';
import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu';
+import type {Route} from './ROUTES';
+import type {ScreenShareRequest, Session} from './types/onyx';
Onyx.registerLogger(({level, message}) => {
if (level === 'alert') {
@@ -47,82 +47,63 @@ Onyx.registerLogger(({level, message}) => {
}
});
-const propTypes = {
- /* Onyx Props */
+type ExpensifyOnyxProps = {
+ /** Whether the app is waiting for the server's response to determine if a room is public */
+ isCheckingPublicRoom: OnyxEntry;
/** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user authToken */
- authToken: PropTypes.string,
-
- /** Currently logged in user accountID */
- accountID: PropTypes.number,
- }),
+ session: OnyxEntry;
/** Whether a new update is available and ready to install. */
- updateAvailable: PropTypes.bool,
+ updateAvailable: OnyxEntry;
- /** Tells us if the sidebar has rendered - TODO: We don't use it as temporary solution to fix not hidding splashscreen */
- // eslint-disable-next-line react/no-unused-prop-types
- isSidebarLoaded: PropTypes.bool,
+ /** Tells us if the sidebar has rendered */
+ isSidebarLoaded: OnyxEntry;
/** Information about a screen share call requested by a GuidesPlus agent */
- screenShareRequest: PropTypes.shape({
- /** Access token required to join a screen share room, generated by the backend */
- accessToken: PropTypes.string,
-
- /** Name of the screen share room to join */
- roomName: PropTypes.string,
- }),
-
- /** Whether the app is waiting for the server's response to determine if a room is public */
- isCheckingPublicRoom: PropTypes.bool,
+ screenShareRequest: OnyxEntry;
/** True when the user must update to the latest minimum version of the app */
- updateRequired: PropTypes.bool,
+ updateRequired: OnyxEntry;
/** Whether we should display the notification alerting the user that focus mode has been auto-enabled */
- focusModeNotification: PropTypes.bool,
+ focusModeNotification: OnyxEntry;
/** Last visited path in the app */
- lastVisitedPath: PropTypes.string,
-
- ...withLocalizePropTypes,
+ lastVisitedPath: OnyxEntry;
};
-const defaultProps = {
- session: {
- authToken: null,
- accountID: null,
- },
- updateAvailable: false,
- isSidebarLoaded: false,
- screenShareRequest: null,
- isCheckingPublicRoom: true,
- updateRequired: false,
- focusModeNotification: false,
- lastVisitedPath: undefined,
-};
+type ExpensifyProps = ExpensifyOnyxProps;
const SplashScreenHiddenContext = React.createContext({});
-function Expensify(props) {
- const appStateChangeListener = useRef(null);
+function Expensify({
+ isCheckingPublicRoom = true,
+ session,
+ updateAvailable,
+ isSidebarLoaded = false,
+ screenShareRequest,
+ updateRequired = false,
+ focusModeNotification = false,
+ lastVisitedPath,
+}: ExpensifyProps) {
+ const appStateChangeListener = useRef(null);
const [isNavigationReady, setIsNavigationReady] = useState(false);
const [isOnyxMigrated, setIsOnyxMigrated] = useState(false);
const [isSplashHidden, setIsSplashHidden] = useState(false);
const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false);
- const [initialUrl, setInitialUrl] = useState(null);
+ const {translate} = useLocalize();
+ const [initialUrl, setInitialUrl] = useState(null);
useEffect(() => {
- if (props.isCheckingPublicRoom) {
+ if (isCheckingPublicRoom) {
return;
}
setAttemptedToOpenPublicRoom(true);
- }, [props.isCheckingPublicRoom]);
+ }, [isCheckingPublicRoom]);
- const isAuthenticated = useMemo(() => Boolean(lodashGet(props.session, 'authToken', null)), [props.session]);
- const autoAuthState = useMemo(() => lodashGet(props.session, 'autoAuthState', ''), [props.session]);
+ const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]);
+ const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]);
const contextValue = useMemo(
() => ({
@@ -168,8 +149,16 @@ function Expensify(props) {
Log.info('[BootSplash] splash screen status', false, {appState, status});
if (status === 'visible') {
- const propsToLog = _.omit(props, ['children', 'session']);
- propsToLog.isAuthenticated = isAuthenticated;
+ const propsToLog: Omit = {
+ isCheckingPublicRoom,
+ updateRequired,
+ updateAvailable,
+ isSidebarLoaded,
+ screenShareRequest,
+ focusModeNotification,
+ isAuthenticated,
+ lastVisitedPath,
+ };
Log.alert('[BootSplash] splash screen is still visible', {propsToLog}, false);
}
});
@@ -194,7 +183,7 @@ function Expensify(props) {
// If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
Linking.getInitialURL().then((url) => {
setInitialUrl(url);
- Report.openReportFromDeepLink(url, isAuthenticated);
+ Report.openReportFromDeepLink(url ?? '', isAuthenticated);
});
// Open chat report from a deep link (only mobile native)
@@ -216,7 +205,7 @@ function Expensify(props) {
return null;
}
- if (props.updateRequired) {
+ if (updateRequired) {
throw new Error(CONST.ERROR.UPDATE_REQUIRED);
}
@@ -231,20 +220,19 @@ function Expensify(props) {
{/* We include the modal for showing a new update at the top level so the option is always present. */}
- {/* If the update is required we won't show this option since a full screen update view will be displayed instead. */}
- {props.updateAvailable && !props.updateRequired ? : null}
- {props.screenShareRequest ? (
+ {updateAvailable && !updateRequired ? : null}
+ {screenShareRequest ? (
User.joinScreenShare(props.screenShareRequest.accessToken, props.screenShareRequest.roomName)}
+ title={translate('guides.screenShare')}
+ onConfirm={() => User.joinScreenShare(screenShareRequest.accessToken, screenShareRequest.roomName)}
onCancel={User.clearScreenShareRequest}
- prompt={props.translate('guides.screenShareRequest')}
- confirmText={props.translate('common.join')}
- cancelText={props.translate('common.decline')}
+ prompt={translate('guides.screenShareRequest')}
+ confirmText={translate('common.join')}
+ cancelText={translate('common.decline')}
isVisible
/>
) : null}
- {props.focusModeNotification ? : null}
+ {focusModeNotification ? : null}
>
)}
@@ -254,7 +242,7 @@ function Expensify(props) {
@@ -265,40 +253,35 @@ function Expensify(props) {
);
}
-Expensify.propTypes = propTypes;
-Expensify.defaultProps = defaultProps;
-export default compose(
- withLocalize,
- withOnyx({
- isCheckingPublicRoom: {
- key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM,
- initWithStoredValues: false,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- updateAvailable: {
- key: ONYXKEYS.UPDATE_AVAILABLE,
- initWithStoredValues: false,
- },
- isSidebarLoaded: {
- key: ONYXKEYS.IS_SIDEBAR_LOADED,
- },
- screenShareRequest: {
- key: ONYXKEYS.SCREEN_SHARE_REQUEST,
- },
- updateRequired: {
- key: ONYXKEYS.UPDATE_REQUIRED,
- initWithStoredValues: false,
- },
- focusModeNotification: {
- key: ONYXKEYS.FOCUS_MODE_NOTIFICATION,
- initWithStoredValues: false,
- },
- lastVisitedPath: {
- key: ONYXKEYS.LAST_VISITED_PATH,
- },
- }),
-)(Expensify);
+export default withOnyx({
+ isCheckingPublicRoom: {
+ key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM,
+ initWithStoredValues: false,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ updateAvailable: {
+ key: ONYXKEYS.UPDATE_AVAILABLE,
+ initWithStoredValues: false,
+ },
+ updateRequired: {
+ key: ONYXKEYS.UPDATE_REQUIRED,
+ initWithStoredValues: false,
+ },
+ isSidebarLoaded: {
+ key: ONYXKEYS.IS_SIDEBAR_LOADED,
+ },
+ screenShareRequest: {
+ key: ONYXKEYS.SCREEN_SHARE_REQUEST,
+ },
+ focusModeNotification: {
+ key: ONYXKEYS.FOCUS_MODE_NOTIFICATION,
+ initWithStoredValues: false,
+ },
+ lastVisitedPath: {
+ key: ONYXKEYS.LAST_VISITED_PATH,
+ },
+})(Expensify);
export {SplashScreenHiddenContext};
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 56425f64a51c..f6a56dc73088 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -160,8 +160,9 @@ function AttachmentView({
const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source) : source;
const onPDFLoadComplete = (path) => {
- if (path && (transaction.transactionID || reportActionID)) {
- CachedPDFPaths.add(transaction.transactionID || reportActionID, path);
+ const id = (transaction && transaction.transactionID) || reportActionID;
+ if (path && id) {
+ CachedPDFPaths.add(id, path);
}
if (!loadComplete) {
setLoadComplete(true);
diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx
index 8aa3a5f0b9f0..9466da601825 100644
--- a/src/components/ButtonWithDropdownMenu.tsx
+++ b/src/components/ButtonWithDropdownMenu.tsx
@@ -10,30 +10,33 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import type {AnchorPosition} from '@styles/index';
import CONST from '@src/CONST';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
+import type DeepValueOf from '@src/types/utils/DeepValueOf';
import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import PopoverMenu from './PopoverMenu';
-type DropdownOption = {
- value: T;
+type PaymentType = DeepValueOf;
+
+type DropdownOption = {
+ value: PaymentType;
text: string;
- icon?: IconAsset;
+ icon: IconAsset;
iconWidth?: number;
iconHeight?: number;
iconDescription?: string;
};
-type ButtonWithDropdownMenuProps = {
+type ButtonWithDropdownMenuProps = {
/** Text to display for the menu header */
menuHeaderText?: string;
/** Callback to execute when the main button is pressed */
- onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: T) => void;
+ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: PaymentType) => void;
/** Callback to execute when a dropdown option is selected */
- onOptionSelected?: (option: DropdownOption) => void;
+ onOptionSelected?: (option: DropdownOption) => void;
/** Call the onPress function on main button when Enter key is pressed */
pressOnEnter?: boolean;
@@ -52,19 +55,19 @@ type ButtonWithDropdownMenuProps = {
/** Menu options to display */
/** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
- options: Array>;
+ options: DropdownOption[];
/** The anchor alignment of the popover menu */
anchorAlignment?: AnchorAlignment;
/* ref for the button */
- buttonRef?: RefObject;
+ buttonRef: RefObject;
/** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */
enterKeyEventListenerPriority?: number;
};
-function ButtonWithDropdownMenu({
+function ButtonWithDropdownMenu({
isLoading = false,
isDisabled = false,
pressOnEnter = false,
@@ -80,7 +83,7 @@ function ButtonWithDropdownMenu({
options,
onOptionSelected,
enterKeyEventListenerPriority = 0,
-}: ButtonWithDropdownMenuProps) {
+}: ButtonWithDropdownMenuProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx
index 94146a2c2957..c0c146bbae08 100644
--- a/src/components/ConfirmContent.tsx
+++ b/src/components/ConfirmContent.tsx
@@ -4,13 +4,17 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
+import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import colors from '@styles/theme/colors';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
import Header from './Header';
import Icon from './Icon';
+import ImageSVG from './ImageSVG';
import Text from './Text';
type ConfirmContentProps = {
@@ -64,6 +68,9 @@ type ConfirmContentProps = {
/** Styles for icon */
iconAdditionalStyles?: StyleProp;
+
+ /** Image to display with content */
+ image?: IconAsset;
};
function ConfirmContent({
@@ -84,79 +91,95 @@ function ConfirmContent({
promptStyles,
contentStyles,
iconAdditionalStyles,
+ image,
}: ConfirmContentProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const theme = useTheme();
const {isOffline} = useNetwork();
+ const StyleUtils = useStyleUtils();
const isCentered = shouldCenterContent;
return (
-
-
- {typeof iconSource === 'function' && (
-
-
+ {!!image && (
+
+
+
+ )}
+
+
+
+ {typeof iconSource === 'function' && (
+
+
+
+ )}
+
+
- )}
-
-
+ {typeof prompt === 'string' ? {prompt} : prompt}
- {typeof prompt === 'string' ? {prompt} : prompt}
-
- {shouldStackButtons ? (
- <>
-
- {shouldShowCancelButton && (
+ {shouldStackButtons ? (
+ <>
- )}
- >
- ) : (
-
- {shouldShowCancelButton && (
+ {shouldShowCancelButton && (
+
+ )}
+ >
+ ) : (
+
+ {shouldShowCancelButton && (
+
+ )}
- )}
-
-
- )}
-
+
+ )}
+
+ >
);
}
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index f25fc978e3ee..6a875e1fbce8 100755
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -1,6 +1,7 @@
import type {ReactNode} from 'react';
import React from 'react';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
+import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
@@ -64,6 +65,9 @@ type ConfirmModalProps = {
/** Whether to stack the buttons */
shouldStackButtons?: boolean;
+
+ /** Image to display with content */
+ image?: IconAsset;
};
function ConfirmModal({
@@ -86,8 +90,10 @@ function ConfirmModal({
shouldStackButtons = true,
isVisible,
onConfirm,
+ image,
}: ConfirmModalProps) {
const {isSmallScreenWidth} = useWindowDimensions();
+ const styles = useThemeStyles();
return (
);
diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx
index da8c0ed86a84..7f05b45bca30 100644
--- a/src/components/ConfirmedRoute.tsx
+++ b/src/components/ConfirmedRoute.tsx
@@ -25,13 +25,13 @@ type ConfirmedRoutePropsOnyxProps = {
type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & {
/** Transaction that stores the distance request data */
- transaction: Transaction | undefined;
+ transaction: Transaction;
};
function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) {
const {isOffline} = useNetwork();
- const {route0: route} = transaction?.routes ?? {};
- const waypoints = transaction?.comment?.waypoints ?? {};
+ const {route0: route} = transaction.routes ?? {};
+ const waypoints = transaction.comment?.waypoints ?? {};
const coordinates = route?.geometry?.coordinates ?? [];
const theme = useTheme();
const styles = useThemeStyles();
diff --git a/src/components/FocusModeNotification.js b/src/components/FocusModeNotification.js
index e846c1f188e2..9ec16beead15 100644
--- a/src/components/FocusModeNotification.js
+++ b/src/components/FocusModeNotification.js
@@ -6,6 +6,7 @@ import * as Link from '@userActions/Link';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ConfirmModal from './ConfirmModal';
+import * as Illustrations from './Icon/Illustrations';
import Text from './Text';
import TextLinkWithRef from './TextLink';
@@ -34,12 +35,14 @@ function FocusModeNotification() {
Link.openLink(href, environmentURL);
}}
>
- {translate('common.here')}
+ {translate('focusModeUpdateModal.settings')}
.
}
isVisible
+ image={Illustrations.ThreeLeggedLaptopWoman}
+ titleStyles={[styles.textHeadline, styles.mbn3]}
/>
);
}
diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx
index 3a64a3df9af9..5615f3b87cfa 100644
--- a/src/components/Form/FormWrapper.tsx
+++ b/src/components/Form/FormWrapper.tsx
@@ -108,7 +108,7 @@ function FormWrapper({
buttonText={submitButtonText}
isAlertVisible={((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage}
isLoading={!!formState?.isLoading}
- message={isEmptyObject(formState?.errorFields) ? errorMessage : undefined}
+ message={typeof errorMessage === 'string' && isEmptyObject(formState?.errorFields) ? errorMessage : undefined}
onSubmit={onSubmit}
footerContent={footerContent}
onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed}
diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts
index ba147dfa81a2..b300c73533b6 100644
--- a/src/components/Form/types.ts
+++ b/src/components/Form/types.ts
@@ -6,6 +6,7 @@ import type AmountForm from '@components/AmountForm';
import type AmountTextInput from '@components/AmountTextInput';
import type CheckboxWithLabel from '@components/CheckboxWithLabel';
import type CountrySelector from '@components/CountrySelector';
+import type DatePicker from '@components/DatePicker';
import type Picker from '@components/Picker';
import type RadioButtons from '@components/RadioButtons';
import type RoomNameInput from '@components/RoomNameInput';
@@ -13,8 +14,8 @@ import type SingleChoiceQuestion from '@components/SingleChoiceQuestion';
import type StatePicker from '@components/StatePicker';
import type TextInput from '@components/TextInput';
import type ValuePicker from '@components/ValuePicker';
+import type {MaybePhraseKey} from '@libs/Localize';
import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker';
-import type {TranslationPaths} from '@src/languages/types';
import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS';
import type {BaseForm} from '@src/types/form/Form';
@@ -38,6 +39,7 @@ type ValidInputs =
| typeof StatePicker
| typeof RoomNameInput
| typeof ValuePicker
+ | typeof DatePicker
| typeof RadioButtons;
type ValueTypeKey = 'string' | 'boolean' | 'date';
@@ -66,6 +68,9 @@ type InputComponentBaseProps = Input
isFocused?: boolean;
measureLayout?: (ref: unknown, callback: MeasureLayoutOnSuccessCallback) => void;
focus?: () => void;
+ label?: string;
+ minDate?: Date;
+ maxDate?: Date;
onTouched?: (event: GestureResponderEvent) => void;
onBlur?: (event: FocusEvent | NativeSyntheticEvent) => void;
onPressOut?: (event: GestureResponderEvent) => void;
@@ -123,6 +128,6 @@ type FormProps = {
type InputRefs = Record>;
-type FormInputErrors = Partial, TranslationPaths>>;
+type FormInputErrors = Partial, MaybePhraseKey>>;
export type {FormProps, ValidInputs, InputComponentValueProps, FormValue, ValueTypeKey, FormOnyxValues, FormOnyxKeys, FormInputErrors, InputRefs, InputComponentBaseProps, ValueTypeMap};
diff --git a/src/components/FormElement.tsx b/src/components/FormElement/index.native.tsx
similarity index 80%
rename from src/components/FormElement.tsx
rename to src/components/FormElement/index.native.tsx
index da98d4dc565a..d0413c5244c1 100644
--- a/src/components/FormElement.tsx
+++ b/src/components/FormElement/index.native.tsx
@@ -2,12 +2,10 @@ import type {ForwardedRef} from 'react';
import React, {forwardRef} from 'react';
import type {ViewProps} from 'react-native';
import {View} from 'react-native';
-import * as ComponentUtils from '@libs/ComponentUtils';
function FormElement(props: ViewProps, ref: ForwardedRef) {
return (
{
+ // When Enter is pressed, the form is submitted to the action URL (POST /).
+ // As we are using a controlled component, we need to disable this behavior here.
+ event.preventDefault();
+};
+
+function FormElement(props: ViewProps, outerRef: ForwardedRef) {
+ const formRef = useRef(null);
+ const mergedRef = mergeRefs(formRef, outerRef);
+
+ useEffect(() => {
+ const formCurrent = formRef.current;
+
+ if (!formCurrent) {
+ return;
+ }
+
+ // Prevent the browser from applying its own validation, which affects the email input
+ formCurrent.setAttribute('novalidate', '');
+
+ // Password Managers need these attributes to be able to identify the form elements properly.
+ formCurrent.setAttribute('method', 'post');
+ formCurrent.setAttribute('action', '/');
+ formCurrent.addEventListener('submit', preventFormDefault);
+
+ return () => {
+ formCurrent.removeEventListener('submit', preventFormDefault);
+ };
+ }, []);
+
+ return (
+
+ );
+}
+
+FormElement.displayName = 'FormElement';
+
+export default forwardRef(FormElement);
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index 97e915d47b22..3a1c35d46c94 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -128,7 +128,7 @@ function HeaderWithBackButton({
)}
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 2a7ed30abf1a..4db22dee8256 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -30,6 +30,7 @@ import CardsAndDomains from '@assets/images/cards-and-domains.svg';
import Cash from '@assets/images/cash.svg';
import Chair from '@assets/images/chair.svg';
import ChatBubbleAdd from '@assets/images/chatbubble-add.svg';
+import ChatBubbleReply from '@assets/images/chatbubble-reply.svg';
import ChatBubbleUnread from '@assets/images/chatbubble-unread.svg';
import ChatBubble from '@assets/images/chatbubble.svg';
import ChatBubbles from '@assets/images/chatbubbles.svg';
@@ -301,5 +302,6 @@ export {
Instagram,
ChatBubbleAdd,
ChatBubbleUnread,
+ ChatBubbleReply,
Lightbulb,
};
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index e03b393dc81f..f8c048ebc4c0 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -27,6 +27,7 @@ import SmartScan from '@assets/images/product-illustrations/simple-illustration_
import TadaBlue from '@assets/images/product-illustrations/tada--blue.svg';
import TadaYellow from '@assets/images/product-illustrations/tada--yellow.svg';
import TeleScope from '@assets/images/product-illustrations/telescope.svg';
+import ThreeLeggedLaptopWoman from '@assets/images/product-illustrations/three_legged_laptop_woman.svg';
import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg';
import Approval from '@assets/images/simple-illustrations/simple-illustration__approval.svg';
import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg';
@@ -143,5 +144,6 @@ export {
Approval,
WalletAlt,
Workflows,
+ ThreeLeggedLaptopWoman,
House,
};
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index 1c2a8a3197fe..f9ce70e851bc 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -609,7 +609,7 @@ function MenuItem(
/>
)}
- {!!rightLabel && (
+ {!title && !!rightLabel && (
{rightLabel}
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
new file mode 100755
index 000000000000..df2781d3ea89
--- /dev/null
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -0,0 +1,898 @@
+import {useIsFocused} from '@react-navigation/native';
+import {format} from 'date-fns';
+import {isEmpty} from 'lodash';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import compose from '@libs/compose';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import * as IOUUtils from '@libs/IOUUtils';
+import Log from '@libs/Log';
+import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReceiptUtils from '@libs/ReceiptUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
+import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes';
+import {policyPropTypes} from '@pages/workspace/withPolicy';
+import * as IOU from '@userActions/IOU';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
+import categoryPropTypes from './categoryPropTypes';
+import ConfirmedRoute from './ConfirmedRoute';
+import FormHelpMessage from './FormHelpMessage';
+import Image from './Image';
+import MenuItemWithTopDescription from './MenuItemWithTopDescription';
+import optionPropTypes from './optionPropTypes';
+import OptionsSelector from './OptionsSelector';
+import ReceiptEmptyState from './ReceiptEmptyState';
+import SettlementButton from './SettlementButton';
+import ShowMoreButton from './ShowMoreButton';
+import Switch from './Switch';
+import tagPropTypes from './tagPropTypes';
+import Text from './Text';
+import transactionPropTypes from './transactionPropTypes';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails';
+
+const propTypes = {
+ /** Callback to inform parent modal of success */
+ onConfirm: PropTypes.func,
+
+ /** Callback to parent modal to send money */
+ onSendMoney: PropTypes.func,
+
+ /** Callback to inform a participant is selected */
+ onSelectParticipant: PropTypes.func,
+
+ /** Should we request a single or multiple participant selection from user */
+ hasMultipleParticipants: PropTypes.bool.isRequired,
+
+ /** IOU amount */
+ iouAmount: PropTypes.number.isRequired,
+
+ /** IOU comment */
+ iouComment: PropTypes.string,
+
+ /** IOU currency */
+ iouCurrencyCode: PropTypes.string,
+
+ /** IOU type */
+ iouType: PropTypes.string,
+
+ /** IOU date */
+ iouCreated: PropTypes.string,
+
+ /** IOU merchant */
+ iouMerchant: PropTypes.string,
+
+ /** IOU Category */
+ iouCategory: PropTypes.string,
+
+ /** IOU Tag */
+ iouTag: PropTypes.string,
+
+ /** IOU isBillable */
+ iouIsBillable: PropTypes.bool,
+
+ /** Callback to toggle the billable state */
+ onToggleBillable: PropTypes.func,
+
+ /** Selected participants from MoneyRequestModal with login / accountID */
+ selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired,
+
+ /** Payee of the money request with login */
+ payeePersonalDetails: optionPropTypes,
+
+ /** Can the participants be modified or not */
+ canModifyParticipants: PropTypes.bool,
+
+ /** Should the list be read only, and not editable? */
+ isReadOnly: PropTypes.bool,
+
+ /** Depending on expense report or personal IOU report, respective bank account route */
+ bankAccountRoute: PropTypes.string,
+
+ ...withCurrentUserPersonalDetailsPropTypes,
+
+ /** Current user session */
+ session: PropTypes.shape({
+ email: PropTypes.string.isRequired,
+ }),
+
+ /** The policyID of the request */
+ policyID: PropTypes.string,
+
+ /** The reportID of the request */
+ reportID: PropTypes.string,
+
+ /** File path of the receipt */
+ receiptPath: PropTypes.string,
+
+ /** File name of the receipt */
+ receiptFilename: PropTypes.string,
+
+ /** List styles for OptionsSelector */
+ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+
+ /** ID of the transaction that represents the money request */
+ transactionID: PropTypes.string,
+
+ /** Transaction that represents the money request */
+ transaction: transactionPropTypes,
+
+ /** Unit and rate used for if the money request is a distance request */
+ mileageRate: PropTypes.shape({
+ /** Unit used to represent distance */
+ unit: PropTypes.oneOf([CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]),
+
+ /** Rate used to calculate the distance request amount */
+ rate: PropTypes.number,
+
+ /** The currency of the rate */
+ currency: PropTypes.string,
+ }),
+
+ /** Whether the money request is a distance request */
+ isDistanceRequest: PropTypes.bool,
+
+ /** Whether the money request is a scan request */
+ isScanRequest: PropTypes.bool,
+
+ /** Whether we're editing a split bill */
+ isEditingSplitBill: PropTypes.bool,
+
+ /** Whether we should show the amount, date, and merchant fields. */
+ shouldShowSmartScanFields: PropTypes.bool,
+
+ /** A flag for verifying that the current report is a sub-report of a workspace chat */
+ isPolicyExpenseChat: PropTypes.bool,
+
+ /* Onyx Props */
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
+
+ /** Collection of tags attached to a policy */
+ policyTags: tagPropTypes,
+
+ /* Onyx Props */
+ /** The policy of the report */
+ policy: policyPropTypes.policy,
+
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ iou: iouPropTypes,
+};
+
+const defaultProps = {
+ onConfirm: () => {},
+ onSendMoney: () => {},
+ onSelectParticipant: () => {},
+ iouType: CONST.IOU.TYPE.REQUEST,
+ iouCategory: '',
+ iouTag: '',
+ iouIsBillable: false,
+ onToggleBillable: () => {},
+ payeePersonalDetails: null,
+ canModifyParticipants: false,
+ isReadOnly: false,
+ bankAccountRoute: '',
+ session: {
+ email: null,
+ },
+ policyID: '',
+ reportID: '',
+ ...withCurrentUserPersonalDetailsDefaultProps,
+ receiptPath: '',
+ receiptFilename: '',
+ listStyles: [],
+ policy: {},
+ policyCategories: {},
+ policyTags: {},
+ transactionID: '',
+ transaction: {},
+ mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'},
+ isDistanceRequest: false,
+ isScanRequest: false,
+ shouldShowSmartScanFields: true,
+ isPolicyExpenseChat: false,
+ iou: iouDefaultProps,
+};
+
+function MoneyRequestConfirmationList(props) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks.
+ // Prop functions pass props itself as a "this" value to the function which means they change every time props change.
+ const {onSendMoney, onConfirm, onSelectParticipant} = props;
+ const {translate, toLocaleDigit} = useLocalize();
+ const transaction = props.transaction;
+ const {canUseViolations} = usePermissions();
+
+ const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST;
+ const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT;
+ const isTypeSend = props.iouType === CONST.IOU.TYPE.SEND;
+
+ const isSplitWithScan = isSplitBill && props.isScanRequest;
+
+ const {unit, rate, currency} = props.mileageRate;
+ const distance = lodashGet(transaction, 'routes.route0.distance', 0);
+ const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0;
+ const taxRates = lodashGet(props.policy, 'taxRates', {});
+
+ // A flag for showing the categories field
+ const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)));
+ // A flag and a toggler for showing the rest of the form fields
+ const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
+
+ // Do not hide fields in case of send money request
+ const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill;
+
+ // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item
+ const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan;
+ const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !props.isDistanceRequest && !isSplitWithScan;
+
+ const policyTagLists = useMemo(() => PolicyUtils.getTagLists(props.policyTags), [props.policyTags]);
+
+ // A flag for showing the tags field
+ const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledTags(policyTagLists));
+
+ // A flag for showing tax fields - tax rate and tax amount
+ const shouldShowTax = props.isPolicyExpenseChat && lodashGet(props.policy, 'tax.trackingEnabled', props.policy.isTaxTrackingEnabled);
+
+ // A flag for showing the billable field
+ const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true);
+
+ const hasRoute = TransactionUtils.hasRoute(transaction);
+ const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate);
+ const formattedAmount = isDistanceRequestWithPendingRoute
+ ? ''
+ : CurrencyUtils.convertToDisplayString(
+ shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount,
+ props.isDistanceRequest ? currency : props.iouCurrencyCode,
+ );
+ const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode);
+
+ const defaultTaxKey = taxRates.defaultExternalID;
+ const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
+ const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName;
+
+ const isFocused = useIsFocused();
+ const [formError, setFormError] = useState('');
+
+ const [didConfirm, setDidConfirm] = useState(false);
+ const [didConfirmSplit, setDidConfirmSplit] = useState(false);
+
+ const shouldDisplayFieldError = useMemo(() => {
+ if (!props.isEditingSplitBill) {
+ return false;
+ }
+
+ return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction));
+ }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]);
+
+ const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty;
+
+ useEffect(() => {
+ if (shouldDisplayFieldError && didConfirmSplit) {
+ setFormError('iou.error.genericSmartscanFailureMessage');
+ return;
+ }
+ if (shouldDisplayFieldError && props.hasSmartScanFailed) {
+ setFormError('iou.receiptScanningFailed');
+ return;
+ }
+ // reset the form error whenever the screen gains or loses focus
+ setFormError('');
+ }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]);
+
+ useEffect(() => {
+ if (!shouldCalculateDistanceAmount) {
+ return;
+ }
+
+ const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate);
+ IOU.setMoneyRequestAmount(amount);
+ }, [shouldCalculateDistanceAmount, distance, rate, unit]);
+
+ /**
+ * Returns the participants with amount
+ * @param {Array} participants
+ * @returns {Array}
+ */
+ const getParticipantsWithAmount = useCallback(
+ (participantsList) => {
+ const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode);
+ return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(
+ participantsList,
+ props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode) : '',
+ );
+ },
+ [props.iouAmount, props.iouCurrencyCode],
+ );
+
+ // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again
+ if (props.isEditingSplitBill && didConfirm) {
+ setDidConfirm(false);
+ }
+
+ const splitOrRequestOptions = useMemo(() => {
+ let text;
+ if (isSplitBill && props.iouAmount === 0) {
+ text = translate('iou.split');
+ } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
+ text = translate('iou.request');
+ if (props.iouAmount !== 0) {
+ text = translate('iou.requestAmount', {amount: formattedAmount});
+ }
+ } else {
+ const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount';
+ text = translate(translationKey, {amount: formattedAmount});
+ }
+ return [
+ {
+ text: text[0].toUpperCase() + text.slice(1),
+ value: props.iouType,
+ },
+ ];
+ }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]);
+
+ const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]);
+ const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]);
+ const canModifyParticipants = !props.isReadOnly && props.canModifyParticipants && props.hasMultipleParticipants;
+ const shouldDisablePaidBySection = canModifyParticipants;
+
+ const optionSelectorSections = useMemo(() => {
+ const sections = [];
+ const unselectedParticipants = _.filter(props.selectedParticipants, (participant) => !participant.selected);
+ if (props.hasMultipleParticipants) {
+ const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants);
+ let formattedParticipantsList = _.union(formattedSelectedParticipants, unselectedParticipants);
+
+ if (!canModifyParticipants) {
+ formattedParticipantsList = _.map(formattedParticipantsList, (participant) => ({
+ ...participant,
+ isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID),
+ }));
+ }
+
+ const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true);
+ const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
+ payeePersonalDetails,
+ props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode) : '',
+ );
+
+ sections.push(
+ {
+ title: translate('moneyRequestConfirmationList.paidBy'),
+ data: [formattedPayeeOption],
+ shouldShow: true,
+ indexOffset: 0,
+ isDisabled: shouldDisablePaidBySection,
+ },
+ {
+ title: translate('moneyRequestConfirmationList.splitWith'),
+ data: formattedParticipantsList,
+ shouldShow: true,
+ indexOffset: 1,
+ },
+ );
+ } else {
+ const formattedSelectedParticipants = _.map(props.selectedParticipants, (participant) => ({
+ ...participant,
+ isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID),
+ }));
+ sections.push({
+ title: translate('common.to'),
+ data: formattedSelectedParticipants,
+ shouldShow: true,
+ indexOffset: 0,
+ });
+ }
+ return sections;
+ }, [
+ props.selectedParticipants,
+ props.hasMultipleParticipants,
+ props.iouAmount,
+ props.iouCurrencyCode,
+ getParticipantsWithAmount,
+ selectedParticipants,
+ payeePersonalDetails,
+ translate,
+ shouldDisablePaidBySection,
+ canModifyParticipants,
+ ]);
+
+ const selectedOptions = useMemo(() => {
+ if (!props.hasMultipleParticipants) {
+ return [];
+ }
+ return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)];
+ }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]);
+
+ useEffect(() => {
+ if (!props.isDistanceRequest) {
+ return;
+ }
+
+ /*
+ Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
+ When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
+ In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
+ */
+ IOU.setMoneyRequestPendingFields(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});
+
+ const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit);
+ IOU.setMoneyRequestMerchant(props.transactionID, distanceMerchant, false);
+ }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]);
+
+ /**
+ * @param {Object} option
+ */
+ const selectParticipant = useCallback(
+ (option) => {
+ // Return early if selected option is currently logged in user.
+ if (option.accountID === props.session.accountID) {
+ return;
+ }
+ onSelectParticipant(option);
+ },
+ [props.session.accountID, onSelectParticipant],
+ );
+
+ /**
+ * Navigate to report details or profile of selected user
+ * @param {Object} option
+ */
+ const navigateToReportOrUserDetail = (option) => {
+ if (option.accountID) {
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
+
+ Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
+ } else if (option.reportID) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID));
+ }
+ };
+
+ /**
+ * @param {String} paymentMethod
+ */
+ const confirm = useCallback(
+ (paymentMethod) => {
+ if (_.isEmpty(selectedParticipants)) {
+ return;
+ }
+ if (props.iouCategory && props.iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) {
+ setFormError('iou.error.invalidCategoryLength');
+ return;
+ }
+ if (props.iouType === CONST.IOU.TYPE.SEND) {
+ if (!paymentMethod) {
+ return;
+ }
+
+ setDidConfirm(true);
+
+ Log.info(`[IOU] Sending money via: ${paymentMethod}`);
+ onSendMoney(paymentMethod);
+ } else {
+ // validate the amount for distance requests
+ const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode);
+ if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) {
+ setFormError('common.error.invalidAmount');
+ return;
+ }
+
+ if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) {
+ setDidConfirmSplit(true);
+ return;
+ }
+
+ setDidConfirm(true);
+ onConfirm(selectedParticipants);
+ }
+ },
+ [
+ selectedParticipants,
+ onSendMoney,
+ onConfirm,
+ props.isEditingSplitBill,
+ props.iouType,
+ props.isDistanceRequest,
+ props.iouCategory,
+ isDistanceRequestWithPendingRoute,
+ props.iouCurrencyCode,
+ props.iouAmount,
+ transaction,
+ ],
+ );
+
+ const footerContent = useMemo(() => {
+ if (props.isReadOnly) {
+ return;
+ }
+
+ const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND;
+ const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError;
+
+ const button = shouldShowSettlementButton ? (
+
+ ) : (
+ confirm(value)}
+ options={splitOrRequestOptions}
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
+ enterKeyEventListenerPriority={1}
+ />
+ );
+
+ return (
+ <>
+ {!_.isEmpty(formError) && (
+
+ )}
+ {button}
+ >
+ );
+ }, [
+ props.isReadOnly,
+ props.iouType,
+ props.bankAccountRoute,
+ props.iouCurrencyCode,
+ props.policyID,
+ selectedParticipants.length,
+ shouldDisplayMerchantError,
+ confirm,
+ splitOrRequestOptions,
+ formError,
+ styles.ph1,
+ styles.mb2,
+ ]);
+
+ const {image: receiptImage, thumbnail: receiptThumbnail} =
+ props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {};
+ return (
+
+ {props.isDistanceRequest && (
+
+
+
+ )}
+ {receiptImage || receiptThumbnail ? (
+
+ ) : (
+ // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate")
+ PolicyUtils.isPaidGroupPolicy(props.policy) &&
+ !props.isDistanceRequest &&
+ props.iouType === CONST.IOU.TYPE.REQUEST && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
+ CONST.IOU.ACTION.CREATE,
+ props.iouType,
+ transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ )
+ }
+ />
+ )
+ )}
+ {props.shouldShowSmartScanFields && (
+ {
+ if (props.isDistanceRequest) {
+ return;
+ }
+ if (props.isEditingSplitBill) {
+ Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT));
+ return;
+ }
+ Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID));
+ }}
+ style={[styles.moneyRequestMenuItem, styles.mt2]}
+ titleStyle={styles.moneyRequestConfirmationAmount}
+ disabled={didConfirm}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''}
+ />
+ )}
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ props.iouType,
+ transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={[styles.moneyRequestMenuItem]}
+ titleStyle={styles.flex1}
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ numberOfLinesTitle={2}
+ />
+ {!shouldShowAllFields && (
+
+ )}
+ {shouldShowAllFields && (
+ <>
+ {shouldShowDate && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ props.iouType,
+ transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''}
+ />
+ )}
+ {props.isDistanceRequest && (
+ Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))}
+ disabled={didConfirm || !isTypeRequest}
+ interactive={!props.isReadOnly}
+ />
+ )}
+ {shouldShowMerchant && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ props.iouType,
+ transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ brickRoadIndicator={
+ props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''
+ }
+ error={
+ shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction))
+ ? translate('common.error.enterMerchant')
+ : ''
+ }
+ />
+ )}
+ {shouldShowCategories && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ props.iouType,
+ props.transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={[styles.moneyRequestMenuItem]}
+ titleStyle={styles.flex1}
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''}
+ />
+ )}
+ {shouldShowTags &&
+ _.map(policyTagLists, ({name}, index) => (
+ {
+ if (props.isEditingSplitBill) {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ CONST.IOU.TYPE.SPLIT,
+ index,
+ props.transaction.transactionID,
+ props.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ return;
+ }
+ Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID));
+ }}
+ style={[styles.moneyRequestMenuItem]}
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''}
+ />
+ ))}
+
+ {shouldShowTax && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()),
+ )
+ }
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ />
+ )}
+
+ {shouldShowTax && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()),
+ )
+ }
+ disabled={didConfirm}
+ interactive={!props.isReadOnly}
+ />
+ )}
+
+ {shouldShowBillable && (
+
+ {translate('common.billable')}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+MoneyRequestConfirmationList.propTypes = propTypes;
+MoneyRequestConfirmationList.defaultProps = defaultProps;
+MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
+
+export default compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ policyCategories: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ },
+ policyTags: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ },
+ mileageRate: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ selector: DistanceRequestUtils.getDefaultMileageRate,
+ },
+ splitTransactionDraft: {
+ key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`,
+ },
+ policy: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ },
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+ }),
+)(MoneyRequestConfirmationList);
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
deleted file mode 100755
index 773e98b6462e..000000000000
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ /dev/null
@@ -1,904 +0,0 @@
-import {useIsFocused} from '@react-navigation/native';
-import {format} from 'date-fns';
-import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
-import type {StyleProp, ViewStyle} from 'react-native';
-import {View} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
-import type {ValueOf} from 'type-fest';
-import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as CurrencyUtils from '@libs/CurrencyUtils';
-import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import * as IOUUtils from '@libs/IOUUtils';
-import Log from '@libs/Log';
-import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import * as ReceiptUtils from '@libs/ReceiptUtils';
-import * as ReportUtils from '@libs/ReportUtils';
-import * as TransactionUtils from '@libs/TransactionUtils';
-import * as IOU from '@userActions/IOU';
-import CONST from '@src/CONST';
-import type {TranslationPaths} from '@src/languages/types';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {Route} from '@src/ROUTES';
-import ROUTES from '@src/ROUTES';
-import type * as OnyxTypes from '@src/types/onyx';
-import type {Participant} from '@src/types/onyx/IOU';
-import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
-import type {MileageRate} from '@src/types/onyx/Policy';
-import type DeepValueOf from '@src/types/utils/DeepValueOf';
-import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
-import ConfirmedRoute from './ConfirmedRoute';
-import FormHelpMessage from './FormHelpMessage';
-import Image from './Image';
-import MenuItemWithTopDescription from './MenuItemWithTopDescription';
-import OptionsSelector from './OptionsSelector';
-import ReceiptEmptyState from './ReceiptEmptyState';
-import SettlementButton from './SettlementButton';
-import ShowMoreButton from './ShowMoreButton';
-import Switch from './Switch';
-import Text from './Text';
-import type {WithCurrentUserPersonalDetailsProps} from './withCurrentUserPersonalDetails';
-import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails';
-
-type DropdownOption = {
- text: string;
- value: DeepValueOf;
-};
-
-type Option = Partial;
-
-type CategorySection = {
- title: string | undefined;
- shouldShow: boolean;
- indexOffset: number;
- data: Option[];
-};
-
-type MoneyRequestConfirmationListOnyxProps = {
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: OnyxEntry;
-
- /** Unit and rate used for if the money request is a distance request */
- mileageRate: OnyxEntry;
-
- /** Collection of categories attached to a policy */
- policyCategories: OnyxEntry;
-
- /** Collection of tags attached to a policy */
- policyTags: OnyxEntry;
-
- /** The policy of root parent report */
- policy: OnyxEntry;
-
- /** The session of the logged in user */
- session: OnyxEntry;
-};
-
-type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps &
- WithCurrentUserPersonalDetailsProps & {
- /** Callback to inform parent modal of success */
- onConfirm?: (selectedParticipants: Participant[]) => void;
-
- /** Callback to parent modal to send money */
- onSendMoney?: (paymentMethod: PaymentMethodType) => void;
-
- /** Callback to inform a participant is selected */
- onSelectParticipant?: (option: Participant) => void;
-
- /** Should we request a single or multiple participant selection from user */
- hasMultipleParticipants: boolean;
-
- /** IOU amount */
- iouAmount: number;
-
- /** IOU comment */
- iouComment?: string;
-
- /** IOU currency */
- iouCurrencyCode?: string;
-
- /** IOU type */
- iouType?: ValueOf;
-
- /** IOU date */
- iouCreated?: string;
-
- /** IOU merchant */
- iouMerchant?: string;
-
- /** IOU Category */
- iouCategory?: string;
-
- /** IOU Tag */
- iouTag?: string;
-
- /** IOU isBillable */
- iouIsBillable?: boolean;
-
- /** Callback to toggle the billable state */
- onToggleBillable?: () => void;
-
- /** Selected participants from MoneyRequestModal with login / accountID */
- selectedParticipants: Participant[];
-
- /** Payee of the money request with login */
- payeePersonalDetails?: OnyxEntry;
-
- /** Can the participants be modified or not */
- canModifyParticipants?: boolean;
-
- /** Should the list be read only, and not editable? */
- isReadOnly?: boolean;
-
- /** Depending on expense report or personal IOU report, respective bank account route */
- bankAccountRoute?: Route;
-
- /** The policyID of the request */
- policyID?: string;
-
- /** The reportID of the request */
- reportID?: string;
-
- /** File path of the receipt */
- receiptPath?: string;
-
- /** File name of the receipt */
- receiptFilename?: string;
-
- /** List styles for OptionsSelector */
- listStyles?: StyleProp;
-
- /** ID of the transaction that represents the money request */
- transactionID?: string;
-
- /** Whether the money request is a distance request */
- isDistanceRequest?: boolean;
-
- /** Whether the money request is a scan request */
- isScanRequest?: boolean;
-
- /** Whether we're editing a split bill */
- isEditingSplitBill?: boolean;
-
- /** Whether we should show the amount, date, and merchant fields. */
- shouldShowSmartScanFields?: boolean;
-
- /** A flag for verifying that the current report is a sub-report of a workspace chat */
- isPolicyExpenseChat?: boolean;
-
- /** Whether there is smartscan failed */
- hasSmartScanFailed?: boolean;
-
- /** ID of the report action */
- reportActionID?: string;
-
- /** Transaction object */
- transaction?: OnyxTypes.Transaction;
- };
-
-function MoneyRequestConfirmationList({
- onConfirm = () => {},
- onSendMoney = () => {},
- onSelectParticipant = () => {},
- iouType = CONST.IOU.TYPE.REQUEST,
- iouCategory = '',
- iouTag = '',
- iouIsBillable = false,
- onToggleBillable = () => {},
- payeePersonalDetails,
- canModifyParticipants = false,
- isReadOnly = false,
- bankAccountRoute,
- policyID,
- reportID,
- receiptPath,
- receiptFilename,
- transactionID,
- mileageRate,
- isDistanceRequest = false,
- isScanRequest = false,
- shouldShowSmartScanFields = true,
- isPolicyExpenseChat = false,
- transaction,
- iouAmount,
- policyTags,
- policyCategories,
- policy,
- iouCurrencyCode,
- isEditingSplitBill,
- hasSmartScanFailed,
- iouMerchant,
- currentUserPersonalDetails,
- hasMultipleParticipants,
- selectedParticipants,
- session,
- iou,
- reportActionID,
- iouCreated,
- listStyles,
- iouComment,
-}: MoneyRequestConfirmationListProps) {
- const theme = useTheme();
- const styles = useThemeStyles();
- const {translate, toLocaleDigit} = useLocalize();
- const {canUseViolations} = usePermissions();
-
- const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST;
- const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
- const isTypeSend = iouType === CONST.IOU.TYPE.SEND;
-
- const isSplitWithScan = isSplitBill && isScanRequest;
-
- const distance = transaction?.routes?.route0.distance ?? 0;
- const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0;
- const taxRates = policy?.taxRates;
-
- // A flag for showing the categories field
- const shouldShowCategories = isPolicyExpenseChat && (iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
-
- // A flag and a toggler for showing the rest of the form fields
- const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
-
- // Do not hide fields in case of send money request
- const shouldShowAllFields = isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || isEditingSplitBill;
-
- // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item
- const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan;
- const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !isDistanceRequest && !isSplitWithScan;
- const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]);
- // A flag for showing the tags field
- const shouldShowTags = isPolicyExpenseChat && (iouTag || OptionsListUtils.hasEnabledTags(policyTagLists));
-
- // A flag for showing tax fields - tax rate and tax amount
- const shouldShowTax = isPolicyExpenseChat && (policy?.tax?.trackingEnabled ?? policy?.isTaxTrackingEnabled);
-
- // A flag for showing the billable field
- const shouldShowBillable = !policy?.disabledFields?.defaultBillable ?? true;
-
- const hasRoute = TransactionUtils.hasRoute(transaction ?? null);
- const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !mileageRate?.rate);
- const formattedAmount = isDistanceRequestWithPendingRoute
- ? ''
- : CurrencyUtils.convertToDisplayString(
- shouldCalculateDistanceAmount
- ? DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0)
- : iouAmount,
- isDistanceRequest ? mileageRate?.currency : iouCurrencyCode,
- );
- const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode);
-
- const defaultTaxKey = taxRates?.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates?.taxes[defaultTaxKey].name} (${taxRates?.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) ?? '';
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const taxRateTitle = transaction?.taxRate?.text || defaultTaxName;
-
- const isFocused = useIsFocused();
- const [formError, setFormError] = useState(null);
-
- const [didConfirm, setDidConfirm] = useState(false);
- const [didConfirmSplit, setDidConfirmSplit] = useState(false);
-
- const shouldDisplayFieldError = useMemo(() => {
- if (!isEditingSplitBill) {
- return false;
- }
-
- return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction ?? null)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null));
- }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]);
-
- const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
- const shouldDisplayMerchantError = isPolicyExpenseChat && !isScanRequest && isMerchantEmpty;
-
- useEffect(() => {
- if (shouldDisplayFieldError && didConfirmSplit) {
- setFormError('iou.error.genericSmartscanFailureMessage');
- return;
- }
- if (shouldDisplayFieldError && hasSmartScanFailed) {
- setFormError('iou.receiptScanningFailed');
- return;
- }
- // reset the form error whenever the screen gains or loses focus
- setFormError(null);
- }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]);
-
- useEffect(() => {
- if (!shouldCalculateDistanceAmount) {
- return;
- }
-
- const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0);
- IOU.setMoneyRequestAmount(amount);
- }, [shouldCalculateDistanceAmount, distance, mileageRate?.rate, mileageRate?.unit]);
-
- /**
- * Returns the participants with amount
- */
- const getParticipantsWithAmount = useCallback(
- (participantsList: Participant[]) => {
- const calculatedIouAmount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? '');
- return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(
- participantsList,
- calculatedIouAmount > 0 ? CurrencyUtils.convertToDisplayString(calculatedIouAmount, iouCurrencyCode) : '',
- );
- },
- [iouAmount, iouCurrencyCode],
- );
-
- // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again
- if (isEditingSplitBill && didConfirm) {
- setDidConfirm(false);
- }
-
- const splitOrRequestOptions: DropdownOption[] = useMemo(() => {
- let text;
- if (isSplitBill && iouAmount === 0) {
- text = translate('iou.split');
- } else if (!!(receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
- text = translate('iou.request');
- if (iouAmount !== 0) {
- text = translate('iou.requestAmount', {amount: formattedAmount});
- }
- } else {
- const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount';
- text = translate(translationKey, {amount: formattedAmount});
- }
- return [
- {
- text: text[0].toUpperCase() + text.slice(1),
- value: iouType,
- },
- ];
- }, [isSplitBill, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]);
-
- const selectedParticipantsMemo = useMemo(() => selectedParticipants.filter((participant) => participant.selected), [selectedParticipants]);
- const payeePersonalDetailsMemo = useMemo(() => payeePersonalDetails ?? currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]);
- const canModifyParticipantsValue = !isReadOnly && canModifyParticipants && hasMultipleParticipants;
-
- const optionSelectorSections: CategorySection[] = useMemo(() => {
- const sections = [];
- const unselectedParticipants = selectedParticipants.filter((participant) => !participant.selected);
- if (hasMultipleParticipants) {
- const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipantsMemo);
- let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])];
-
- if (!canModifyParticipantsValue) {
- formattedParticipantsList = formattedParticipantsList.map((participant) => ({
- ...participant,
- isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1),
- }));
- }
-
- const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true);
- const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
- payeePersonalDetailsMemo,
- iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '',
- );
-
- sections.push(
- {
- title: translate('moneyRequestConfirmationList.paidBy'),
- data: [formattedPayeeOption],
- shouldShow: true,
- indexOffset: 0,
- isDisabled: canModifyParticipantsValue,
- },
- {
- title: translate('moneyRequestConfirmationList.splitWith'),
- data: formattedParticipantsList,
- shouldShow: true,
- indexOffset: 1,
- },
- );
- } else {
- const formattedSelectedParticipants = selectedParticipants.map((participant) => ({
- ...participant,
- isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1),
- }));
- sections.push({
- title: translate('common.to'),
- data: formattedSelectedParticipants,
- shouldShow: true,
- indexOffset: 0,
- });
- }
- return sections;
- }, [
- selectedParticipants,
- hasMultipleParticipants,
- iouAmount,
- iouCurrencyCode,
- getParticipantsWithAmount,
- payeePersonalDetailsMemo,
- translate,
- canModifyParticipantsValue,
- selectedParticipantsMemo,
- ]);
-
- const selectedOptions = useMemo(() => {
- if (!hasMultipleParticipants) {
- return [];
- }
- const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true);
- return [
- ...selectedParticipantsMemo,
- OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetailsMemo, CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode)),
- ];
- }, [hasMultipleParticipants, selectedParticipantsMemo, iouAmount, iouCurrencyCode, payeePersonalDetailsMemo]);
-
- useEffect(() => {
- if (!isDistanceRequest) {
- return;
- }
- /*
- Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
- When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
- In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
- */
- IOU.setMoneyRequestPendingFields(transactionID ?? '', {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});
- const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(
- hasRoute,
- distance,
- mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
- mileageRate?.rate ?? 0,
- mileageRate?.currency ?? 'USD',
- translate,
- toLocaleDigit,
- );
- IOU.setMoneyRequestMerchant(transactionID ?? '', distanceMerchant, false);
- }, [hasRoute, distance, mileageRate?.unit, mileageRate?.rate, mileageRate?.currency, translate, toLocaleDigit, isDistanceRequest, transactionID, isDistanceRequestWithPendingRoute]);
-
- const selectParticipant = useCallback(
- (option: Participant) => {
- // Return early if selected option is currently logged in user.
- if (option.accountID === session?.accountID) {
- return;
- }
- onSelectParticipant(option);
- },
- [session?.accountID, onSelectParticipant],
- );
-
- /**
- * Navigate to report details or profile of selected user
- */
- const navigateToReportOrUserDetail = (option: Participant | OnyxTypes.Report) => {
- if ('accountID' in option && option.accountID) {
- const activeRoute = Navigation.getActiveRouteWithoutParams();
-
- Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
- } else if ('reportID' in option && option.reportID) {
- Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID));
- }
- };
-
- const confirm = useCallback(
- (paymentMethod: PaymentMethodType | undefined) => {
- if (selectedParticipantsMemo.length === 0) {
- return;
- }
- if (iouCategory && iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) {
- setFormError('iou.error.invalidCategoryLength');
- return;
- }
- if (iouType === CONST.IOU.TYPE.SEND) {
- if (!paymentMethod) {
- return;
- }
-
- setDidConfirm(true);
-
- Log.info(`[IOU] Sending money via: ${paymentMethod}`);
- onSendMoney(paymentMethod);
- } else {
- // validate the amount for distance requests
- const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode);
- if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) {
- setFormError('common.error.invalidAmount');
- return;
- }
-
- if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) {
- setDidConfirmSplit(true);
- return;
- }
-
- setDidConfirm(true);
- onConfirm(selectedParticipantsMemo);
- }
- },
- [
- selectedParticipantsMemo,
- iouCategory,
- iouType,
- onSendMoney,
- iouCurrencyCode,
- isDistanceRequest,
- isDistanceRequestWithPendingRoute,
- iouAmount,
- isEditingSplitBill,
- transaction,
- onConfirm,
- ],
- );
-
- const footerContent = useMemo(() => {
- if (isReadOnly) {
- return;
- }
-
- const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND;
- const shouldDisableButton = selectedParticipantsMemo.length === 0 || shouldDisplayMerchantError;
-
- const button = shouldShowSettlementButton ? (
-
- ) : (
- confirm(value as PaymentMethodType)}
- options={splitOrRequestOptions}
- buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
- enterKeyEventListenerPriority={1}
- />
- );
-
- return (
- <>
- {!!formError && (
-
- )}
- {button}
- >
- );
- }, [
- isReadOnly,
- iouType,
- selectedParticipantsMemo.length,
- shouldDisplayMerchantError,
- confirm,
- bankAccountRoute,
- iouCurrencyCode,
- policyID,
- splitOrRequestOptions,
- formError,
- styles.ph1,
- styles.mb2,
- ]);
-
- const receiptData = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : null;
- return (
- // @ts-expect-error TODO: Remove this once OptionsSelector (https://github.com/Expensify/App/issues/25125) is migrated to TypeScript.
-
- {isDistanceRequest && (
-
-
-
- )}
- {receiptData?.image ?? receiptData?.thumbnail ? (
-
- ) : (
- // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate")
- PolicyUtils.isPaidGroupPolicy(policy) &&
- !isDistanceRequest &&
- iouType === CONST.IOU.TYPE.REQUEST && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
- CONST.IOU.ACTION.CREATE,
- iouType,
- transaction?.transactionID ?? '',
- reportID ?? '',
- Navigation.getActiveRouteWithoutParams(),
- ),
- )
- }
- />
- )
- )}
- {shouldShowSmartScanFields && (
- {
- if (isDistanceRequest) {
- return;
- }
- if (isEditingSplitBill) {
- Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID ?? '', reportActionID ?? '', CONST.EDIT_REQUEST_FIELD.AMOUNT));
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(iouType, reportID));
- }}
- style={[styles.moneyRequestMenuItem, styles.mt2]}
- titleStyle={styles.moneyRequestConfirmationAmount}
- disabled={didConfirm}
- brickRoadIndicator={
- isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined
- }
- error={
- shouldDisplayMerchantError || (isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))
- ? translate('common.error.enterMerchant')
- : ''
- }
- />
- )}
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
- CONST.IOU.ACTION.EDIT,
- iouType,
- transaction?.transactionID ?? '',
- reportID ?? '',
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={styles.moneyRequestMenuItem}
- titleStyle={styles.flex1}
- disabled={didConfirm}
- interactive={!isReadOnly}
- numberOfLinesTitle={2}
- />
- {!shouldShowAllFields && (
-
- )}
- {shouldShowAllFields && (
- <>
- {shouldShowDate && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(
- CONST.IOU.ACTION.EDIT,
- iouType,
- transaction?.transactionID ?? '',
- reportID ?? '',
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- disabled={didConfirm}
- interactive={!isReadOnly}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
- error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? translate('common.error.enterDate') : ''}
- />
- )}
- {isDistanceRequest && (
- Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(iouType, reportID))}
- disabled={didConfirm || !isTypeRequest}
- interactive={!isReadOnly}
- />
- )}
- {shouldShowMerchant && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
- CONST.IOU.ACTION.EDIT,
- iouType,
- transactionID ?? '',
- reportID ?? '',
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- disabled={didConfirm}
- interactive={!isReadOnly}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
- error={
- shouldDisplayMerchantError || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))
- ? translate('common.error.enterMerchant')
- : ''
- }
- />
- )}
- {shouldShowCategories && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
- CONST.IOU.ACTION.EDIT,
- iouType,
- transaction?.transactionID ?? '',
- reportID ?? '',
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={styles.moneyRequestMenuItem}
- titleStyle={styles.flex1}
- disabled={didConfirm}
- interactive={!isReadOnly}
- rightLabel={canUseViolations && Boolean(policy?.requiresCategory) ? translate('common.required') : ''}
- />
- )}
- {shouldShowTags &&
- policyTagLists.map(({name}, index) => (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
- CONST.IOU.ACTION.EDIT,
- CONST.IOU.TYPE.SPLIT,
- index,
- transaction?.transactionID ?? '',
- reportID ?? '',
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={styles.moneyRequestMenuItem}
- disabled={didConfirm}
- interactive={!isReadOnly}
- rightLabel={canUseViolations && !!policy?.requiresTag ? translate('common.required') : ''}
- />
- ))}
-
- {shouldShowTax && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()),
- )
- }
- disabled={didConfirm}
- interactive={!isReadOnly}
- />
- )}
-
- {shouldShowTax && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()),
- )
- }
- disabled={didConfirm}
- interactive={!isReadOnly}
- />
- )}
-
- {shouldShowBillable && (
-
- {translate('common.billable')}
-
-
- )}
- >
- )}
-
- );
-}
-
-MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
-
-export default withCurrentUserPersonalDetails(
- withOnyx({
- policyCategories: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
- },
- policyTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
- },
- mileageRate: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- selector: DistanceRequestUtils.getDefaultMileageRate,
- },
- policy: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- },
- iou: {
- key: ONYXKEYS.IOU,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- })(MoneyRequestConfirmationList),
-);
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index 83da817da858..a391ff061baa 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -18,7 +18,7 @@ import Text from './Text';
type PopoverMenuItem = {
/** An icon element displayed on the left side */
- icon?: IconAsset;
+ icon: IconAsset;
/** Text label */
text: string;
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
index 1df56093d6a6..907aa2f8ae01 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
@@ -109,20 +109,27 @@ function GenericPressable(
if (ref && 'current' in ref) {
ref.current?.blur();
}
- onPress(event);
-
+ const onPressResult = onPress(event);
Accessibility.moveAccessibilityFocus(nextFocusRef);
+ return onPressResult;
},
[shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled],
);
+ const onKeyboardShortcutPressHandler = useCallback(
+ (event?: GestureResponderEvent | KeyboardEvent) => {
+ onPressHandler(event);
+ },
+ [onPressHandler],
+ );
+
useEffect(() => {
if (!keyboardShortcut) {
return () => {};
}
const {shortcutKey, descriptionKey, modifiers} = keyboardShortcut;
- return KeyboardShortcut.subscribe(shortcutKey, onPressHandler, descriptionKey, modifiers, true, false, 0, false);
- }, [keyboardShortcut, onPressHandler]);
+ return KeyboardShortcut.subscribe(shortcutKey, onKeyboardShortcutPressHandler, descriptionKey, modifiers, true, false, 0, false);
+ }, [keyboardShortcut, onKeyboardShortcutPressHandler]);
return (
) : (
-
-
-
- {getPreviewHeaderText() + (isSettled && !iouReport?.isCancelledIOU ? ` • ${getSettledMessage()}` : '')}
-
- {!isSettled && shouldShowRBR && (
-
- )}
-
-
-
-
- {displayAmount}
-
- {ReportUtils.isSettled(iouReport?.reportID) && !isBillSplit && (
-
+
+
+
+
+
+ {getPreviewHeaderText() + (isSettled && !iouReport?.isCancelledIOU ? ` • ${getSettledMessage()}` : '')}
+
+ {!isSettled && shouldShowRBR && (
-
- )}
-
- {isBillSplit && (
-
-
+ )}
- )}
-
-
-
- {!isCurrentUserManager && shouldShowPendingConversionMessage && (
- {translate('iou.pendingConversionMessage')}
- )}
- {shouldShowDescription && (
-
- ${parser.replace(merchantOrDescription)}`} />
+
+
+
+
+ {displayAmount}
+
+ {ReportUtils.isSettled(iouReport?.reportID) && !isBillSplit && (
+
+
+
+ )}
+
+ {isBillSplit && (
+
+
+
+ )}
+
+
+
+ {!isCurrentUserManager && shouldShowPendingConversionMessage && (
+ {translate('iou.pendingConversionMessage')}
+ )}
+ {shouldShowDescription && (
+
+ ${parser.replace(merchantOrDescription)}`} />
+
+ )}
+ {shouldShowMerchant && {merchantOrDescription}}
+
+ {isBillSplit && participantAccountIDs.length > 0 && !!requestAmount && requestAmount > 0 && (
+
+ {translate('iou.amountEach', {
+ amount: CurrencyUtils.convertToDisplayString(
+ IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency ?? ''),
+ requestCurrency,
+ ),
+ })}
+
+ )}
- )}
- {shouldShowMerchant && {merchantOrDescription}}
+
- {isBillSplit && participantAccountIDs.length > 0 && !!requestAmount && requestAmount > 0 && (
-
- {translate('iou.amountEach', {
- amount: CurrencyUtils.convertToDisplayString(
- IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency ?? ''),
- requestCurrency,
- ),
- })}
-
- )}
)}
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index 0af7140e1523..0bd18d8ee7ea 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -292,7 +292,7 @@ function MoneyRequestView({
shouldShowTitleIcon={isSettled}
titleIcon={Expensicons.Checkmark}
description={amountDescription}
- titleStyle={styles.newKansasLarge}
+ titleStyle={styles.textHeadlineH2}
interactive={canEditAmount}
shouldShowRightIcon={canEditAmount}
onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index cbc728ffd1ce..a84e4a041609 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -135,6 +135,7 @@ function ReportPreview({
const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(iouReport);
const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID);
const numberOfScanningReceipts = transactionsWithReceipts.filter((transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length;
+ const numberOfPendingRequests = transactionsWithReceipts.filter((transaction) => TransactionUtils.isPending(transaction) && TransactionUtils.isCardTransaction(transaction)).length;
const hasReceipts = transactionsWithReceipts.length > 0;
const isScanning = hasReceipts && areAllRequestsBeingSmartScanned;
@@ -151,8 +152,9 @@ function ReportPreview({
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
formattedMerchant ||
translate('iou.requestCount', {
- count: numberOfRequests - numberOfScanningReceipts,
+ count: numberOfRequests - numberOfScanningReceipts - numberOfPendingRequests,
scanningReceipts: numberOfScanningReceipts,
+ pendingReceipts: numberOfPendingRequests,
});
const shouldShowSubmitButton = isDraftExpenseReport && reimbursableSpend !== 0;
@@ -265,74 +267,77 @@ function ReportPreview({
)}
-
-
-
- {getPreviewMessage()}
-
- {!iouSettled && hasErrors && (
-
- )}
-
-
-
- {getDisplayAmount()}
- {ReportUtils.isSettled(iouReportID) && (
-
+
+
+
+
+
+ {getPreviewMessage()}
+
+ {!iouSettled && hasErrors && (
+ )}
+
+
+
+
+ {getDisplayAmount()}
+ {ReportUtils.isSettled(iouReportID) && (
+
+
+
+ )}
+
- )}
-
-
- {shouldShowSubtitle && (
-
-
- {previewSubtitle || moneyRequestComment}
+ {shouldShowSubtitle && (
+
+
+ {previewSubtitle || moneyRequestComment}
+
+
+ )}
- )}
- {shouldShowSettlementButton && (
- chatReport && iouReport && paymentType && IOU.payMoneyRequest(paymentType, chatReport, iouReport)}
- enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
- addBankAccountRoute={bankAccountRoute}
- shouldHidePaymentOptions={!shouldShowPayButton}
- shouldShowApproveButton={shouldShowApproveButton}
- style={[styles.mt3]}
- kycWallAnchorAlignment={{
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
- }}
- paymentMethodDropdownAnchorAlignment={{
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
- }}
- isDisabled={!canAllowSettlement}
- />
- )}
- {shouldShowSubmitButton && (
-
diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx
index 2acad7f40f20..e5f230065455 100644
--- a/src/components/SelectionList/TableListItem.tsx
+++ b/src/components/SelectionList/TableListItem.tsx
@@ -30,8 +30,8 @@ function TableListItem({
return (
selectPaymentType(event, iouPaymentType as PaymentMethodType, triggerKYCFlow)}
+ onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)}
pressOnEnter={pressOnEnter}
options={paymentButtonOptions}
- onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value as PaymentMethodType)}
+ onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value)}
style={style}
buttonSize={buttonSize}
anchorAlignment={paymentMethodDropdownAnchorAlignment}
diff --git a/src/components/SignInPageForm/index.native.tsx b/src/components/SignInPageForm/index.native.tsx
deleted file mode 100644
index 0d00c754d45a..000000000000
--- a/src/components/SignInPageForm/index.native.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import FormElement from '@components/FormElement';
-import type SignInPageFormProps from './types';
-
-function SignInPageForm(props: SignInPageFormProps) {
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
-}
-
-SignInPageForm.displayName = 'SignInPageForm';
-
-export default SignInPageForm;
diff --git a/src/components/SignInPageForm/index.tsx b/src/components/SignInPageForm/index.tsx
deleted file mode 100644
index b9f0fe202dd1..000000000000
--- a/src/components/SignInPageForm/index.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React, {useEffect, useRef} from 'react';
-import type {View} from 'react-native';
-import FormElement from '@components/FormElement';
-import type SignInPageFormProps from './types';
-
-const preventFormDefault = (event: SubmitEvent) => {
- // When enter is pressed form is submitted to action url (POST /).
- // As we are using controlled component, we need to disable it here.
- event.preventDefault();
-};
-
-function SignInPageForm(props: SignInPageFormProps) {
- const form = useRef(null);
-
- useEffect(() => {
- const formCurrent = form.current;
-
- if (!formCurrent) {
- return;
- }
-
- // Prevent the browser from applying its own validation, which affects the email input
- formCurrent.setAttribute('novalidate', '');
-
- // Password Managers need these attributes to be able to identify the form elements properly.
- formCurrent.setAttribute('method', 'post');
- formCurrent.setAttribute('action', '/');
- formCurrent.addEventListener('submit', preventFormDefault);
-
- return () => {
- if (!formCurrent) {
- return;
- }
- formCurrent.removeEventListener('submit', preventFormDefault);
- };
- }, []);
-
- return (
-
- );
-}
-
-SignInPageForm.displayName = 'SignInPageForm';
-
-export default SignInPageForm;
diff --git a/src/components/SignInPageForm/types.ts b/src/components/SignInPageForm/types.ts
deleted file mode 100644
index c7f71a3f7151..000000000000
--- a/src/components/SignInPageForm/types.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type {ViewProps} from 'react-native';
-
-type SignInPageFormProps = ViewProps;
-
-export default SignInPageFormProps;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 4fa22d8e255d..995279033a3e 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -604,8 +604,10 @@ export default {
receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.",
receiptScanningFailed: 'Receipt scanning failed. Enter the details manually.',
transactionPendingText: 'It takes a few days from the date the card was used for the transaction to post.',
- requestCount: ({count, scanningReceipts = 0}: RequestCountParams) =>
- `${count} ${Str.pluralize('request', 'requests', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}`,
+ requestCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) =>
+ `${count} ${Str.pluralize('request', 'requests', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}${
+ pendingReceipts > 0 ? `, ${pendingReceipts} pending` : ''
+ }`,
deleteRequest: 'Delete request',
deleteConfirmation: 'Are you sure that you want to delete this request?',
settledExpensify: 'Paid',
@@ -1290,7 +1292,8 @@ export default {
},
focusModeUpdateModal: {
title: 'Welcome to #focus mode!',
- prompt: "Read chats will be hidden, unless they have a green dot, which means there's an action you need to take on them. You can change this in your account settings ",
+ prompt: "Stay on top of things by only seeing unread chats or chats that need your attention. Don't worry, you can change this at any point in ",
+ settings: 'settings',
},
notFound: {
chatYouLookingForCannotBeFound: 'The chat you are looking for cannot be found.',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 7bea48d71d36..f4b4e6a2f21e 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -597,8 +597,10 @@ export default {
receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.',
receiptScanningFailed: 'El escaneo de recibo ha fallado. Introduce los detalles manualmente.',
transactionPendingText: 'La transacción tarda unos días en contabilizarse desde la fecha en que se utilizó la tarjeta.',
- requestCount: ({count, scanningReceipts = 0}: RequestCountParams) =>
- `${count} ${Str.pluralize('solicitude', 'solicitudes', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`,
+ requestCount: ({count, scanningReceipts = 0, pendingReceipts = 0}: RequestCountParams) =>
+ `${count} ${Str.pluralize('solicitude', 'solicitudes', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}${
+ pendingReceipts > 0 ? `, ${pendingReceipts} pendiente` : ''
+ }`,
deleteRequest: 'Eliminar solicitud',
deleteConfirmation: '¿Estás seguro de que quieres eliminar esta solicitud?',
settledExpensify: 'Pagado',
@@ -1291,7 +1293,8 @@ export default {
},
focusModeUpdateModal: {
title: '¡Bienvenido al modo #concentración!',
- prompt: 'Los mensajes leídos se ocultarán, a menos que tengan un punto verde, lo que significa que tienes que tomar una acción en ellos. Puedes cambiar esto en la configuración de tu cuenta ',
+ prompt: 'Mantente al tanto de todo viendo sólo los chats no leídos o los que necesitan tu atención. No te preocupes, puedes cambiar el ajuste en cualquier momento desde la ',
+ settings: 'configuración',
},
notFound: {
chatYouLookingForCannotBeFound: 'El chat que estás buscando no se pudo encontrar.',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 975dd8fd1570..2bb05b614483 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -99,6 +99,7 @@ type ReportArchiveReasonsPolicyDeletedParams = {
type RequestCountParams = {
count: number;
scanningReceipts: number;
+ pendingReceipts: number;
};
type SettleExpensifyCardParams = {
@@ -109,7 +110,7 @@ type RequestAmountParams = {amount: string};
type RequestedAmountMessageParams = {formattedAmount: string; comment?: string};
-type SplitAmountParams = {amount: string | number};
+type SplitAmountParams = {amount: number};
type DidSplitAmountMessageParams = {formattedAmount: string; comment: string};
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index 155a167f322e..a42cb6a8f756 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -1,11 +1,17 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
-import type {MileageRate, Unit} from '@src/types/onyx/Policy';
+import type {Unit} from '@src/types/onyx/Policy';
import type Policy from '@src/types/onyx/Policy';
import * as CurrencyUtils from './CurrencyUtils';
import * as PolicyUtils from './PolicyUtils';
+type DefaultMileageRate = {
+ rate?: number;
+ currency?: string;
+ unit: Unit;
+};
+
/**
* Retrieves the default mileage rate based on a given policy.
*
@@ -16,7 +22,7 @@ import * as PolicyUtils from './PolicyUtils';
* @returns [currency] - The currency associated with the rate.
* @returns [unit] - The unit of measurement for the distance.
*/
-function getDefaultMileageRate(policy: OnyxEntry): MileageRate | null {
+function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate | null {
if (!policy?.customUnits) {
return null;
}
@@ -33,7 +39,7 @@ function getDefaultMileageRate(policy: OnyxEntry): MileageRate | null {
return {
rate: distanceRate.rate,
- currency: distanceRate.currency ?? 'USD',
+ currency: distanceRate.currency,
unit: distanceUnit.attributes.unit,
};
}
diff --git a/src/libs/Environment/getEnvironment/index.native.ts b/src/libs/Environment/getEnvironment/index.native.ts
index 6d298c3fdae9..5dac0ad1d73e 100644
--- a/src/libs/Environment/getEnvironment/index.native.ts
+++ b/src/libs/Environment/getEnvironment/index.native.ts
@@ -1,3 +1,4 @@
+import {NativeModules} from 'react-native';
import Config from 'react-native-config';
import betaChecker from '@libs/Environment/betaChecker';
import CONST from '@src/CONST';
@@ -28,6 +29,12 @@ function getEnvironment(): Promise {
return;
}
+ // If we don't use Development, and we're in the HybridApp, we should use Production
+ if (NativeModules.HybridAppModule) {
+ environment = CONST.ENVIRONMENT.PRODUCTION;
+ return;
+ }
+
// If we haven't set the environment yet and we aren't on dev/adhoc, check to see if this is a beta build
betaChecker.isBetaBuild().then((isBeta) => {
environment = isBeta ? CONST.ENVIRONMENT.STAGING : CONST.ENVIRONMENT.PRODUCTION;
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index a997e783deb0..e0bbbe95802f 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -97,8 +97,13 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: undefined;
[SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: undefined;
[SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined;
- [SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined;
- [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined;
+ [SCREENS.SETTINGS.PROFILE.ADDRESS]: {
+ country?: string;
+ };
+ [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: {
+ backTo?: Routes;
+ country: string;
+ };
[SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: {
backTo: Routes;
};
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index 3c003ab03590..f03c34b1696e 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -1,6 +1,5 @@
import {format, lastDayOfMonth, setDate} from 'date-fns';
import Str from 'expensify-common/lib/str';
-import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
@@ -64,7 +63,7 @@ type BuildNextStepParameters = {
* @returns nextStep
*/
function buildNextStep(
- report: OnyxEntry | EmptyObject,
+ report: Report | EmptyObject,
predictedNextStatus: ValueOf,
{isPaidWithExpensify}: BuildNextStepParameters = {},
): ReportNextStep | null {
@@ -72,13 +71,13 @@ function buildNextStep(
return null;
}
- const {policyID = '', ownerAccountID = -1, managerID = -1} = report ?? {};
+ const {policyID = '', ownerAccountID = -1, managerID = -1} = report;
const policy = ReportUtils.getPolicy(policyID);
const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
const isOwner = currentUserAccountID === ownerAccountID;
const isManager = currentUserAccountID === managerID;
const isSelfApproval = currentUserAccountID === submitsTo;
- const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([report?.ownerAccountID ?? -1])[0] ?? '';
+ const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([ownerAccountID])[0] ?? '';
const managerDisplayName = isSelfApproval ? 'you' : ReportUtils.getDisplayNameForParticipant(submitsTo) ?? '';
const type: ReportNextStep['type'] = 'neutral';
let optimisticNextStep: ReportNextStep | null;
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 0bf55edd260d..79e67164a15a 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -31,7 +31,6 @@ import type {
import type {Participant} from '@src/types/onyx/IOU';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
-import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import Timing from './actions/Timing';
@@ -261,17 +260,6 @@ Onyx.connect({
},
});
-/**
- * Adds expensify SMS domain (@expensify.sms) if login is a phone number and if it's not included yet
- */
-function addSMSDomainIfPhoneNumber(login: string): string {
- const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(login);
- if (parsedPhoneNumber.possible && !Str.isValidEmail(login)) {
- return `${parsedPhoneNumber.number?.e164}${CONST.SMS.DOMAIN}`;
- }
- return login;
-}
-
/**
* @param defaultValues {login: accountID} In workspace invite page, when new user is added we pass available data to opt in
* @returns Returns avatar data for a list of user accountIDs
@@ -780,7 +768,7 @@ function isCurrentUser(userDetails: PersonalDetails): boolean {
}
// If user login is a mobile number, append sms domain if not appended already.
- const userDetailsLogin = addSMSDomainIfPhoneNumber(userDetails.login ?? '');
+ const userDetailsLogin = PhoneNumber.addSMSDomainIfPhoneNumber(userDetails.login ?? '');
if (currentUserLogin?.toLowerCase() === userDetailsLogin.toLowerCase()) {
return true;
@@ -1620,7 +1608,7 @@ function getOptions(
const noOptions = recentReportOptions.length + personalDetailsOptions.length === 0 && !currentUserOption;
const noOptionsMatchExactly = !personalDetailsOptions
.concat(recentReportOptions)
- .find((option) => option.login === addSMSDomainIfPhoneNumber(searchValue ?? '').toLowerCase() || option.login === searchValue?.toLowerCase());
+ .find((option) => option.login === PhoneNumber.addSMSDomainIfPhoneNumber(searchValue ?? '').toLowerCase() || option.login === searchValue?.toLowerCase());
if (
searchValue &&
@@ -1629,7 +1617,7 @@ function getOptions(
selectedOptions.every((option) => 'login' in option && option.login !== searchValue) &&
((Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN)) ||
(parsedPhoneNumber.possible && Str.isValidPhone(LoginUtils.getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')))) &&
- !optionsToExclude.find((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) &&
+ !optionsToExclude.find((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === PhoneNumber.addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) &&
(searchValue !== CONST.EMAIL.CHRONOS || Permissions.canUseChronos(betas)) &&
!excludeUnknownUsers
) {
@@ -1744,7 +1732,7 @@ function getShareLogOptions(reports: OnyxCollection, personalDetails: On
/**
* Build the IOUConfirmation options for showing the payee personalDetail
*/
-function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails | EmptyObject, amountText: string): PayeePersonalDetails {
+function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails, amountText: string): PayeePersonalDetails {
const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? '');
return {
text: PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, formattedLogin),
@@ -2010,7 +1998,6 @@ function formatSectionsFromSearchTerm(
}
export {
- addSMSDomainIfPhoneNumber,
getAvatarsForAccountIDs,
isCurrentUser,
isPersonalDetailsReady,
diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts
index 55aee10e611a..1c517f42637f 100644
--- a/src/libs/PersonalDetailsUtils.ts
+++ b/src/libs/PersonalDetailsUtils.ts
@@ -179,8 +179,8 @@ function getStreetLines(street = '') {
* @param privatePersonalDetails - details object
* @returns - formatted address
*/
-function getFormattedAddress(privatePersonalDetails: PrivatePersonalDetails): string {
- const {address} = privatePersonalDetails;
+function getFormattedAddress(privatePersonalDetails: OnyxEntry): string {
+ const {address} = privatePersonalDetails ?? {};
const [street1, street2] = getStreetLines(address?.street);
const formattedAddress =
formatPiece(street1) + formatPiece(street2) + formatPiece(address?.city) + formatPiece(address?.state) + formatPiece(address?.zip) + formatPiece(address?.country);
diff --git a/src/libs/PhoneNumber.ts b/src/libs/PhoneNumber.ts
index f92aade2c892..787b3634030a 100644
--- a/src/libs/PhoneNumber.ts
+++ b/src/libs/PhoneNumber.ts
@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import {parsePhoneNumber as originalParsePhoneNumber} from 'awesome-phonenumber';
import type {ParsedPhoneNumber, ParsedPhoneNumberInvalid, PhoneNumberParseOptions} from 'awesome-phonenumber';
+import Str from 'expensify-common/lib/str';
import CONST from '@src/CONST';
/**
@@ -39,5 +40,16 @@ function parsePhoneNumber(phoneNumber: string, options?: PhoneNumberParseOptions
} as ParsedPhoneNumberInvalid;
}
+/**
+ * Adds expensify SMS domain (@expensify.sms) if login is a phone number and if it's not included yet
+ */
+function addSMSDomainIfPhoneNumber(login: string): string {
+ const parsedPhoneNumber = parsePhoneNumber(login);
+ if (parsedPhoneNumber.possible && !Str.isValidEmail(login)) {
+ return `${parsedPhoneNumber.number?.e164}${CONST.SMS.DOMAIN}`;
+ }
+ return login;
+}
+
// eslint-disable-next-line import/prefer-default-export
-export {parsePhoneNumber};
+export {parsePhoneNumber, addSMSDomainIfPhoneNumber};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 541a2130bcfa..a814c91ea8b2 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -57,11 +57,13 @@ import isReportMessageAttachment from './isReportMessageAttachment';
import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
+import {isEmailPublicDomain} from './LoginUtils';
import linkingConfig from './Navigation/linkingConfig';
import Navigation from './Navigation/Navigation';
import * as NumberUtils from './NumberUtils';
import Permissions from './Permissions';
import * as PersonalDetailsUtils from './PersonalDetailsUtils';
+import * as PhoneNumber from './PhoneNumber';
import * as PolicyUtils from './PolicyUtils';
import type {LastVisibleMessage} from './ReportActionsUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
@@ -420,6 +422,7 @@ type AncestorIDs = {
};
let currentUserEmail: string | undefined;
+let currentUserPrivateDomain: string | undefined;
let currentUserAccountID: number | undefined;
let isAnonymousUser = false;
@@ -436,16 +439,19 @@ Onyx.connect({
currentUserEmail = value.email;
currentUserAccountID = value.accountID;
isAnonymousUser = value.authTokenType === 'anonymousAccount';
+ currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail ?? '') ? '' : Str.extractEmailDomain(currentUserEmail ?? '');
},
});
let allPersonalDetails: OnyxCollection;
+let allPersonalDetailLogins: string[];
let currentUserPersonalDetails: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
currentUserPersonalDetails = value?.[currentUserAccountID ?? -1] ?? null;
allPersonalDetails = value ?? {};
+ allPersonalDetailLogins = Object.values(allPersonalDetails).map((personalDetail) => personalDetail?.login ?? '');
},
});
@@ -1722,8 +1728,8 @@ function getDeletedParentActionMessageForChatReport(reportAction: OnyxEntry, report: OnyxEntry): string {
- const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, true) ?? '';
+function getReimbursementQueuedActionMessage(reportAction: OnyxEntry, report: OnyxEntry, shouldUseShortDisplayName = true): string {
+ const submitterDisplayName = getDisplayNameForParticipant(report?.ownerAccountID, shouldUseShortDisplayName) ?? '';
const originalMessage = reportAction?.originalMessage as IOUMessage | undefined;
let messageKey: TranslationPaths;
if (originalMessage?.paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) {
@@ -2655,7 +2661,26 @@ function hasReportNameError(report: OnyxEntry): boolean {
*/
function getParsedComment(text: string): string {
const parser = new ExpensiMark();
- return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text, {shouldEscapeText: !shouldAllowRawHTMLMessages()}) : lodashEscape(text);
+ const textWithMention = text.replace(CONST.REGEX.SHORT_MENTION, (match) => {
+ const mention = match.substring(1);
+
+ if (!Str.isValidEmail(mention) && currentUserPrivateDomain) {
+ const mentionWithEmailDomain = `${mention}@${currentUserPrivateDomain}`;
+ if (allPersonalDetailLogins.includes(mentionWithEmailDomain)) {
+ return `@${mentionWithEmailDomain}`;
+ }
+ }
+ if (Str.isValidPhone(mention)) {
+ const mentionWithSmsDomain = PhoneNumber.addSMSDomainIfPhoneNumber(mention);
+ if (allPersonalDetailLogins.includes(mentionWithSmsDomain)) {
+ return `@${mentionWithSmsDomain}`;
+ }
+ }
+
+ return match;
+ });
+
+ return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention, {shouldEscapeText: !shouldAllowRawHTMLMessages()}) : lodashEscape(text);
}
function getReportDescriptionText(report: Report): string {
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 2e1a283bc8b8..67e31c610369 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -140,11 +140,11 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean {
return !!transaction?.receipt?.state || hasEReceipt(transaction);
}
-function isMerchantMissing(transaction: OnyxEntry) {
- if (transaction?.modifiedMerchant && transaction?.modifiedMerchant !== '') {
+function isMerchantMissing(transaction: Transaction) {
+ if (transaction.modifiedMerchant && transaction.modifiedMerchant !== '') {
return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}
- const isMerchantEmpty = transaction?.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction?.merchant === '';
+ const isMerchantEmpty = transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === '';
return isMerchantEmpty;
}
@@ -156,15 +156,15 @@ function isPartialMerchant(merchant: string): boolean {
return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}
-function isAmountMissing(transaction: OnyxEntry) {
- return transaction?.amount === 0 && (!transaction?.modifiedAmount || transaction?.modifiedAmount === 0);
+function isAmountMissing(transaction: Transaction) {
+ return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0);
}
-function isCreatedMissing(transaction: OnyxEntry) {
- return transaction?.created === '' && (!transaction?.created || transaction?.modifiedCreated === '');
+function isCreatedMissing(transaction: Transaction) {
+ return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === '');
}
-function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean {
+function areRequiredFieldsEmpty(transaction: Transaction): boolean {
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null;
const isFromExpenseReport = parentReport?.type === CONST.REPORT.TYPE.EXPENSE;
return (isFromExpenseReport && isMerchantMissing(transaction)) || isAmountMissing(transaction) || isCreatedMissing(transaction);
@@ -487,7 +487,7 @@ function hasMissingSmartscanFields(transaction: OnyxEntry): boolean
/**
* Check if the transaction has a defined route
*/
-function hasRoute(transaction: OnyxEntry): boolean {
+function hasRoute(transaction: Transaction): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 0a46acbea102..669d10c4a1b8 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -11,6 +11,7 @@ import type {OnyxFormKey} from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';
import * as CardUtils from './CardUtils';
import DateUtils from './DateUtils';
+import type {MaybePhraseKey} from './Localize';
import * as LoginUtils from './LoginUtils';
import {parsePhoneNumber} from './PhoneNumber';
import StringUtils from './StringUtils';
@@ -190,7 +191,7 @@ function meetsMaximumAgeRequirement(date: string): boolean {
/**
* Validate that given date is in a specified range of years before now.
*/
-function getAgeRequirementError(date: string, minimumAge: number, maximumAge: number): string | Array> {
+function getAgeRequirementError(date: string, minimumAge: number, maximumAge: number): MaybePhraseKey {
const currentDate = startOfDay(new Date());
const testDate = parse(date, CONST.DATE.FNS_FORMAT_STRING, currentDate);
@@ -360,7 +361,7 @@ function isValidPersonName(value: string) {
/**
* Checks if the provided string includes any of the provided reserved words
*/
-function doesContainReservedWord(value: string, reservedWords: string[]): boolean {
+function doesContainReservedWord(value: string, reservedWords: typeof CONST.DISPLAY_NAME.RESERVED_NAMES): boolean {
const valueToCheck = value.trim().toLowerCase();
return reservedWords.some((reservedWord) => valueToCheck.includes(reservedWord.toLowerCase()));
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index ff7fa1d1d352..5f9657755b02 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -35,8 +35,8 @@ import * as Localize from '@libs/Localize';
import Navigation from '@libs/Navigation/Navigation';
import * as NextStepUtils from '@libs/NextStepUtils';
import * as NumberUtils from '@libs/NumberUtils';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
import Permissions from '@libs/Permissions';
+import * as PhoneNumber from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -801,7 +801,7 @@ function getMoneyRequestInformation(
payeeEmail = currentUserEmail,
moneyRequestReportID = '',
): MoneyRequestInformation {
- const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login ?? '');
+ const payerEmail = PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? '');
const payerAccountID = Number(participant.accountID);
const isPolicyExpenseChat = participant.isPolicyExpenseChat;
@@ -1646,7 +1646,7 @@ function createSplitsAndOnyxData(
existingSplitChatReportID = '',
billable = false,
): SplitsAndOnyxData {
- const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin);
+ const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin);
const participantAccountIDs = participants.map((participant) => Number(participant.accountID));
const existingSplitChatReport =
existingSplitChatReportID || participants[0].reportID
@@ -1814,7 +1814,7 @@ function createSplitsAndOnyxData(
// In case the participant is a workspace, email & accountID should remain undefined and won't be used in the rest of this code
// participant.login is undefined when the request is initiated from a group DM with an unknown user, so we need to add a default
- const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login ?? '').toLowerCase();
+ const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? '').toLowerCase();
const accountID = isOwnPolicyExpenseChat || isPolicyExpenseChat ? 0 : Number(participant.accountID);
if (email === currentUserEmailForIOUSplit) {
return;
@@ -2110,7 +2110,7 @@ function startSplitBill(
existingSplitChatReportID = '',
billable = false,
) {
- const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin);
+ const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(currentUserLogin);
const participantAccountIDs = participants.map((participant) => Number(participant.accountID));
const existingSplitChatReport =
existingSplitChatReportID || participants[0].reportID
@@ -2274,7 +2274,7 @@ function startSplitBill(
participants.forEach((participant) => {
// Disabling this line since participant.login can be an empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const email = participant.isOwnPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login || participant.text || '').toLowerCase();
+ const email = participant.isOwnPolicyExpenseChat ? '' : PhoneNumber.addSMSDomainIfPhoneNumber(participant.login || participant.text || '').toLowerCase();
const accountID = participant.isOwnPolicyExpenseChat ? 0 : Number(participant.accountID);
if (email === currentUserEmailForIOUSplit) {
return;
@@ -2383,7 +2383,7 @@ function startSplitBill(
* @param sessionEmail - email of the current user
*/
function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportAction, updatedTransaction: OnyxTypes.Transaction, sessionAccountID: number, sessionEmail: string) {
- const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail);
+ const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(sessionEmail);
const {transactionID} = updatedTransaction;
const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
@@ -3218,7 +3218,7 @@ function getSendMoneyParams(
managerID: number,
recipient: Participant,
): SendMoneyParamsData {
- const recipientEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(recipient.login ?? '');
+ const recipientEmail = PhoneNumber.addSMSDomainIfPhoneNumber(recipient.login ?? '');
const recipientAccountID = Number(recipient.accountID);
const newIOUReportDetails = JSON.stringify({
amount,
@@ -3673,14 +3673,14 @@ function sendMoneyWithWallet(report: OnyxTypes.Report, amount: number, currency:
Report.notifyNewAction(params.chatReportID, managerID);
}
-function approveMoneyRequest(expenseReport: OnyxEntry | EmptyObject) {
- const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`] ?? null;
- const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport?.total ?? 0, expenseReport?.currency ?? '', expenseReport?.reportID ?? '');
+function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
+ const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null;
+ const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID);
const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.APPROVED);
const optimisticReportActionsData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
value: {
[optimisticApprovedReportAction.reportActionID]: {
...(optimisticApprovedReportAction as OnyxTypes.ReportAction),
@@ -3690,7 +3690,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry | EmptyO
};
const optimisticIOUReportData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
value: {
...expenseReport,
lastMessageText: optimisticApprovedReportAction.message?.[0].text,
@@ -3701,7 +3701,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry | EmptyO
};
const optimisticNextStepData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
value: optimisticNextStep,
};
const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData];
@@ -3709,7 +3709,7 @@ function approveMoneyRequest(expenseReport: OnyxEntry | EmptyO
const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
value: {
[optimisticApprovedReportAction.reportActionID]: {
pendingAction: null,
@@ -3721,22 +3721,22 @@ function approveMoneyRequest(expenseReport: OnyxEntry | EmptyO
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
value: {
- [expenseReport?.reportActionID ?? '']: {
+ [expenseReport.reportActionID ?? '']: {
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
value: currentNextStep,
},
];
const parameters: ApproveMoneyRequestParams = {
- reportID: expenseReport?.reportID ?? '',
+ reportID: expenseReport.reportID,
approvedReportActionID: optimisticApprovedReportAction.reportActionID,
};
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 41288ce5eecd..ce222940c7ca 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -32,8 +32,8 @@ import DateUtils from '@libs/DateUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Log from '@libs/Log';
import * as NumberUtils from '@libs/NumberUtils';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as PhoneNumber from '@libs/PhoneNumber';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
@@ -650,7 +650,7 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I
Object.keys(invitedEmailsToAccountIDs).forEach((email) => {
const accountID = invitedEmailsToAccountIDs[email];
const cleanAccountID = Number(accountID);
- const login = OptionsListUtils.addSMSDomainIfPhoneNumber(email);
+ const login = PhoneNumber.addSMSDomainIfPhoneNumber(email);
const oldChat = ReportUtils.getChatByParticipantsAndPolicy([sessionAccountID, cleanAccountID], policyID);
@@ -731,7 +731,7 @@ function createPolicyExpenseChats(policyID: string, invitedEmailsToAccountIDs: I
*/
function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string) {
const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}` as const;
- const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin));
+ const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin));
const accountIDs = Object.values(invitedEmailsToAccountIDs);
const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs);
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 462f0280e86d..7ad12cf3e1ed 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -48,11 +48,12 @@ import DateUtils from '@libs/DateUtils';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as Environment from '@libs/Environment/Environment';
import * as ErrorUtils from '@libs/ErrorUtils';
+import getPlatform from '@libs/getPlatform';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import LocalNotification from '@libs/Notification/LocalNotification';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as PhoneNumber from '@libs/PhoneNumber';
import getPolicyMemberAccountIDs from '@libs/PolicyMembersUtils';
import {extractPolicyIDFromPath} from '@libs/PolicyUtils';
import * as Pusher from '@libs/Pusher/pusher';
@@ -177,10 +178,27 @@ const typingWatchTimers: Record = {};
let reportIDDeeplinkedFromOldDot: string | undefined;
Linking.getInitialURL().then((url) => {
- const params = new URLSearchParams(url ?? '');
- const exitToRoute = params.get('exitTo') ?? '';
- const {reportID} = ReportUtils.parseReportRouteParams(exitToRoute);
- reportIDDeeplinkedFromOldDot = reportID;
+ const isWeb = ([CONST.PLATFORM.WEB] as unknown as string).includes(getPlatform());
+ const currentParams = new URLSearchParams(url ?? '');
+ const currentExitToRoute = currentParams.get('exitTo') ?? '';
+ const {reportID: currentReportID} = ReportUtils.parseReportRouteParams(currentExitToRoute);
+
+ if (!isWeb) {
+ reportIDDeeplinkedFromOldDot = currentReportID;
+
+ return;
+ }
+
+ const prevUrl = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.INITIAL_URL);
+ const prevParams = new URLSearchParams(prevUrl ?? '');
+ const prevExitToRoute = prevParams.get('exitTo') ?? '';
+ const {reportID: prevReportID} = ReportUtils.parseReportRouteParams(prevExitToRoute);
+
+ reportIDDeeplinkedFromOldDot = currentReportID || prevReportID;
+
+ if (currentReportID && url) {
+ sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.INITIAL_URL, url);
+ }
});
let lastVisitedPath: string | undefined;
@@ -2380,7 +2398,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: Record typeof accountID === 'number',
);
- const logins = inviteeEmails.map((memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin));
+ const logins = inviteeEmails.map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin));
const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, inviteeAccountIDs);
const optimisticData: OnyxUpdate[] = [
diff --git a/src/libs/actions/TeachersUnite.ts b/src/libs/actions/TeachersUnite.ts
index ab48609e2d53..1f8b32724bd4 100644
--- a/src/libs/actions/TeachersUnite.ts
+++ b/src/libs/actions/TeachersUnite.ts
@@ -4,7 +4,7 @@ import * as API from '@libs/API';
import type {AddSchoolPrincipalParams, ReferTeachersUniteVolunteerParams} from '@libs/API/parameters';
import {WRITE_COMMANDS} from '@libs/API/types';
import Navigation from '@libs/Navigation/Navigation';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PhoneNumber from '@libs/PhoneNumber';
import * as ReportUtils from '@libs/ReportUtils';
import type {OptimisticCreatedReportAction} from '@libs/ReportUtils';
import CONST from '@src/CONST';
@@ -69,7 +69,7 @@ function referTeachersUniteVolunteer(partnerUserID: string, firstName: string, l
*/
function addSchoolPrincipal(firstName: string, partnerUserID: string, lastName: string, policyID: string) {
const policyName = CONST.TEACHERS_UNITE.POLICY_NAME;
- const loggedInEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail);
+ const loggedInEmail = PhoneNumber.addSMSDomainIfPhoneNumber(sessionEmail);
const reportCreationData: ReportCreationData = {};
const expenseChatData = ReportUtils.buildOptimisticChatReport([sessionAccountID], '', CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, policyID, sessionAccountID, true, policyName);
diff --git a/src/pages/LoadingPage.js b/src/pages/LoadingPage.tsx
similarity index 65%
rename from src/pages/LoadingPage.js
rename to src/pages/LoadingPage.tsx
index fc315495619a..9708aa28eb5e 100644
--- a/src/pages/LoadingPage.js
+++ b/src/pages/LoadingPage.tsx
@@ -1,28 +1,24 @@
-import PropTypes from 'prop-types';
import React from 'react';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import useThemeStyles from '@hooks/useThemeStyles';
-const propTypes = {
+type LoadingPageProps = {
/** Method to trigger when pressing back button of the header */
- onBackButtonPress: PropTypes.func,
- title: PropTypes.string.isRequired,
-};
+ onBackButtonPress?: () => void;
-const defaultProps = {
- onBackButtonPress: undefined,
+ title: string;
};
-function LoadingPage(props) {
+function LoadingPage({onBackButtonPress, title}: LoadingPageProps) {
const styles = useThemeStyles();
return (
@@ -30,7 +26,5 @@ function LoadingPage(props) {
}
LoadingPage.displayName = 'LoadingPage';
-LoadingPage.propTypes = propTypes;
-LoadingPage.defaultProps = defaultProps;
export default LoadingPage;
diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx
index 6300b8c4cdce..9d26de1da9f1 100644
--- a/src/pages/RoomDescriptionPage.tsx
+++ b/src/pages/RoomDescriptionPage.tsx
@@ -13,13 +13,11 @@ import TextInput from '@components/TextInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import updateMultilineInputRange from '@libs/updateMultilineInputRange';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/ReportDescriptionForm';
import type * as OnyxTypes from '@src/types/onyx';
@@ -69,10 +67,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) {
testID={RoomDescriptionPage.displayName}
>
- Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID))}
- />
+
[...PersonalDetailsUtils.getLoginsByAccountIDs(report?.visibleChatMemberAccountIDs ?? []), ...CONST.EXPENSIFY_EMAILS].map((participant) =>
- OptionsListUtils.addSMSDomainIfPhoneNumber(participant),
+ PhoneNumber.addSMSDomainIfPhoneNumber(participant),
),
[report],
);
@@ -109,7 +109,7 @@ function RoomInvitePage({betas, personalDetails, report, policies}: RoomInvitePa
filterSelectedOptions = selectedOptions.filter((option) => {
const accountID = option?.accountID;
const isOptionInPersonalDetails = invitePersonalDetails.some((personalDetail) => accountID && personalDetail?.accountID === accountID);
- const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
+ const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number ? parsedPhoneNumber.number.e164 : searchTerm.toLowerCase();
const isPartOfSearchTerm = option.text?.toLowerCase().includes(searchValue) ?? option.login?.toLowerCase().includes(searchValue);
return isPartOfSearchTerm ?? isOptionInPersonalDetails;
@@ -199,7 +199,9 @@ function RoomInvitePage({betas, personalDetails, report, policies}: RoomInvitePa
if (
!userToInvite &&
excludedUsers.includes(
- parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible ? OptionsListUtils.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue)) : searchValue,
+ PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible
+ ? PhoneNumber.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue))
+ : searchValue,
)
) {
return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName});
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index 51e6b25f1314..2fd0d0a964a8 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -26,7 +26,7 @@ import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
-import type {Beta, ReportAction, ReportActionReactions} from '@src/types/onyx';
+import type {Beta, ReportAction, ReportActionReactions, Report as ReportType} from '@src/types/onyx';
import type IconAsset from '@src/types/utils/IconAsset';
import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu';
@@ -180,7 +180,7 @@ const ContextMenuActions: ContextMenuAction[] = [
{
isAnonymousAction: false,
textTranslateKey: 'reportActionContextMenu.replyInThread',
- icon: Expensicons.ChatBubbleAdd,
+ icon: Expensicons.ChatBubbleReply,
shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => {
if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) {
return false;
@@ -367,6 +367,8 @@ const ContextMenuActions: ContextMenuAction[] = [
} else if (ReportActionsUtils.isMemberChangeAction(reportAction)) {
const logMessage = ReportActionsUtils.getMemberChangeMessageFragment(reportAction).html ?? '';
setClipboardMessage(logMessage);
+ } else if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) {
+ Clipboard.setString(ReportUtils.getReimbursementQueuedActionMessage(reportAction, ReportUtils.getReport(reportID) as OnyxEntry, false));
} else if (ReportActionsUtils.isActionableMentionWhisper(reportAction)) {
const mentionWhisperMessage = ReportActionsUtils.getActionableMentionWhisperMessage(reportAction);
setClipboardMessage(mentionWhisperMessage);
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
index 0b4154a15e80..47cbc559a67b 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx
@@ -31,7 +31,7 @@ function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEv
}
// eslint-disable-next-line @typescript-eslint/naming-convention
-function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef) {
+function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef) {
const {translate} = useLocalize();
const reportIDRef = useRef('0');
const typeRef = useRef();
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index ac52c06ee084..5f07dc66ea4d 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -1,3 +1,4 @@
+import Str from 'expensify-common/lib/str';
import lodashSortBy from 'lodash/sortBy';
import type {ForwardedRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
@@ -6,8 +7,10 @@ import type {Mention} from '@components/MentionSuggestions';
import MentionSuggestions from '@components/MentionSuggestions';
import {usePersonalDetails} from '@components/OnyxProvider';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
+import * as LoginUtils from '@libs/LoginUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import * as UserUtils from '@libs/UserUtils';
@@ -44,6 +47,7 @@ function SuggestionMention(
const previousValue = usePrevious(value);
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const isMentionSuggestionsMenuVisible = !!suggestionValues.suggestedMentions.length && suggestionValues.shouldShowSuggestionMenu;
const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({
@@ -55,6 +59,22 @@ function SuggestionMention(
// Used to decide whether to block the suggestions list from showing to prevent flickering
const shouldBlockCalc = useRef(false);
+ const formatLoginPrivateDomain = useCallback(
+ (displayText = '', userLogin = '') => {
+ if (userLogin !== displayText) {
+ return displayText;
+ }
+ // If the emails are not in the same private domain, we also return the displayText
+ if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, currentUserPersonalDetails.login ?? '')) {
+ return Str.removeSMSDomain(displayText);
+ }
+
+ // Otherwise, the emails must be of the same private domain, so we should remove the domain part
+ return displayText.split('@')[0];
+ },
+ [currentUserPersonalDetails.login],
+ );
+
/**
* Replace the code of mention and update selection
*/
@@ -62,7 +82,10 @@ function SuggestionMention(
(highlightedMentionIndexInner: number) => {
const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner];
- const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.login}`;
+ const mentionCode =
+ mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT
+ ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT
+ : `@${formatLoginPrivateDomain(mentionObject.login, mentionObject.login)}`;
const commentAfterMention = value.slice(suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1);
updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterMention)}`, true);
@@ -75,7 +98,7 @@ function SuggestionMention(
suggestedMentions: [],
}));
},
- [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, suggestionValues.mentionPrefix, updateComment, setSelection],
+ [value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, suggestionValues.mentionPrefix, updateComment, setSelection, formatLoginPrivateDomain],
);
/**
@@ -151,8 +174,8 @@ function SuggestionMention(
const sortedPersonalDetails = lodashSortBy(filteredPersonalDetails, (detail) => detail?.displayName || detail?.login);
sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length).forEach((detail) => {
suggestions.push({
- text: PersonalDetailsUtils.getDisplayNameOrDefault(detail),
- alternateText: formatPhoneNumber(detail?.login ?? ''),
+ text: formatLoginPrivateDomain(PersonalDetailsUtils.getDisplayNameOrDefault(detail), detail?.login),
+ alternateText: `@${formatLoginPrivateDomain(detail?.login, detail?.login)}`,
login: detail?.login,
icons: [
{
@@ -167,7 +190,7 @@ function SuggestionMention(
return suggestions;
},
- [translate, formatPhoneNumber],
+ [translate, formatPhoneNumber, formatLoginPrivateDomain],
);
const calculateMentionSuggestion = useCallback(
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index 91a8810e91ff..cd625a667a7f 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -283,6 +283,10 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
+ if (!_.isEqual(oldProps.parentReportAction, newProps.parentReportAction)) {
+ return false;
+ }
+
if (lodashGet(oldProps.network, 'isOffline') !== lodashGet(newProps.network, 'isOffline')) {
return false;
}
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
index 181d8edc22f2..338444d473c6 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
@@ -238,7 +238,7 @@ function IOURequestStepScan({
return;
}
- camera.current
+ return camera.current
.takePhoto({
qualityPrioritization: 'speed',
flash: flash ? 'on' : 'off',
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx
similarity index 62%
rename from src/pages/settings/Profile/PersonalDetails/AddressPage.js
rename to src/pages/settings/Profile/PersonalDetails/AddressPage.tsx
index 55d221085fcb..adc721ea0ea1 100644
--- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.tsx
@@ -1,6 +1,6 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import AddressForm from '@components/AddressForm';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
@@ -10,66 +10,47 @@ import useLocalize from '@hooks/useLocalize';
import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
import * as PersonalDetails from '@userActions/PersonalDetails';
import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {PrivatePersonalDetails} from '@src/types/onyx';
+import type {Address} from '@src/types/onyx/PrivatePersonalDetails';
-const propTypes = {
- /* Onyx Props */
-
+type AddressPageOnyxProps = {
/** User's private personal details */
- privatePersonalDetails: PropTypes.shape({
- /** User's home address */
- address: PropTypes.shape({
- street: PropTypes.string,
- city: PropTypes.string,
- state: PropTypes.string,
- zip: PropTypes.string,
- country: PropTypes.string,
- }),
- }),
-
- /** Route from navigation */
- route: PropTypes.shape({
- /** Params from the route */
- params: PropTypes.shape({
- /** Currently selected country */
- country: PropTypes.string,
- }),
- }).isRequired,
+ privatePersonalDetails: OnyxEntry;
};
-const defaultProps = {
- privatePersonalDetails: {
- address: {
- street: '',
- city: '',
- state: '',
- zip: '',
- country: '',
- },
- },
-};
+type AddressPageProps = StackScreenProps & AddressPageOnyxProps;
/**
* Submit form to update user's first and last legal name
- * @param {Object} values - form input values
+ * @param values - form input values
*/
-function updateAddress(values) {
- PersonalDetails.updateAddress(values.addressLine1.trim(), values.addressLine2.trim(), values.city.trim(), values.state.trim(), values.zipPostCode.trim().toUpperCase(), values.country);
+function updateAddress(values: Address) {
+ PersonalDetails.updateAddress(
+ values.addressLine1?.trim() ?? '',
+ values.addressLine2?.trim() ?? '',
+ values.city.trim(),
+ values.state.trim(),
+ values?.zipPostCode?.trim().toUpperCase() ?? '',
+ values.country,
+ );
}
-function AddressPage({privatePersonalDetails, route}) {
+function AddressPage({privatePersonalDetails, route}: AddressPageProps) {
const styles = useThemeStyles();
usePrivatePersonalDetails();
const {translate} = useLocalize();
- const address = useMemo(() => lodashGet(privatePersonalDetails, 'address') || {}, [privatePersonalDetails]);
- const countryFromUrl = lodashGet(route, 'params.country');
- const [currentCountry, setCurrentCountry] = useState(address.country);
- const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true);
- const [street1, street2] = (address.street || '').split('\n');
- const [state, setState] = useState(address.state);
- const [city, setCity] = useState(address.city);
- const [zipcode, setZipcode] = useState(address.zip);
+ const address = useMemo(() => privatePersonalDetails?.address, [privatePersonalDetails]);
+ const countryFromUrl = route.params?.country;
+ const [currentCountry, setCurrentCountry] = useState(address?.country);
+ const isLoadingPersonalDetails = privatePersonalDetails?.isLoading ?? true;
+ const [street1, street2] = (address?.street ?? '').split('\n');
+ const [state, setState] = useState(address?.state);
+ const [city, setCity] = useState(address?.city);
+ const [zipcode, setZipcode] = useState(address?.zip);
useEffect(() => {
if (!address) {
@@ -81,7 +62,7 @@ function AddressPage({privatePersonalDetails, route}) {
setZipcode(address.zip);
}, [address]);
- const handleAddressChange = useCallback((value, key) => {
+ const handleAddressChange = useCallback((value: string, key: keyof Address) => {
if (key !== 'country' && key !== 'state' && key !== 'city' && key !== 'zipPostCode') {
return;
}
@@ -143,11 +124,9 @@ function AddressPage({privatePersonalDetails, route}) {
);
}
-AddressPage.propTypes = propTypes;
-AddressPage.defaultProps = defaultProps;
AddressPage.displayName = 'AddressPage';
-export default withOnyx({
+export default withOnyx({
privatePersonalDetails: {
key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
},
diff --git a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx
similarity index 66%
rename from src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js
rename to src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx
index d8327041538d..f07d560ab454 100644
--- a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx
@@ -1,46 +1,31 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useMemo, useState} from 'react';
-import _ from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import type {CountryData} from '@libs/searchCountryOptions';
import searchCountryOptions from '@libs/searchCountryOptions';
import StringUtils from '@libs/StringUtils';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import type {Route} from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
-const propTypes = {
- /** Route from navigation */
- route: PropTypes.shape({
- /** Params from the route */
- params: PropTypes.shape({
- /** Currently selected country */
- country: PropTypes.string,
+type CountrySelectionPageProps = StackScreenProps;
- /** Route to navigate back after selecting a currency */
- backTo: PropTypes.string,
- }),
- }).isRequired,
-
- /** Navigation from react-navigation */
- navigation: PropTypes.shape({
- /** getState function retrieves the current navigation state from react-navigation's navigation property */
- getState: PropTypes.func.isRequired,
- }).isRequired,
-};
-
-function CountrySelectionPage({route, navigation}) {
+function CountrySelectionPage({route, navigation}: CountrySelectionPageProps) {
const [searchValue, setSearchValue] = useState('');
const {translate} = useLocalize();
- const currentCountry = lodashGet(route, 'params.country');
+ const currentCountry = route.params.country;
const countries = useMemo(
() =>
- _.map(_.keys(CONST.ALL_COUNTRIES), (countryISO) => {
- const countryName = translate(`allCountries.${countryISO}`);
+ Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => {
+ const countryName = translate(`allCountries.${countryISO}` as TranslationPaths);
return {
value: countryISO,
keyForList: countryISO,
@@ -56,19 +41,18 @@ function CountrySelectionPage({route, navigation}) {
const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '';
const selectCountry = useCallback(
- (option) => {
- const backTo = lodashGet(route, 'params.backTo', '');
-
+ (option: CountryData) => {
+ const backTo = route.params.backTo ?? '';
// Check the navigation state and "backTo" parameter to decide navigation behavior
- if (navigation.getState().routes.length === 1 && _.isEmpty(backTo)) {
+ if (navigation.getState().routes.length === 1 && !backTo) {
// If there is only one route and "backTo" is empty, go back in navigation
Navigation.goBack();
- } else if (!_.isEmpty(backTo) && navigation.getState().routes.length === 1) {
+ } else if (!!backTo && navigation.getState().routes.length === 1) {
// If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter
- Navigation.goBack(`${route.params.backTo}?country=${option.value}`);
+ Navigation.goBack(`${route.params.backTo}?country=${option.value}` as Route);
} else {
// Otherwise, navigate to the specific route defined in "backTo" with a country parameter
- Navigation.navigate(`${route.params.backTo}?country=${option.value}`);
+ Navigation.navigate(`${route.params.backTo}?country=${option.value}` as Route);
}
},
[route, navigation],
@@ -83,9 +67,9 @@ function CountrySelectionPage({route, navigation}) {
title={translate('common.country')}
shouldShowBackButton
onBackButtonPress={() => {
- const backTo = lodashGet(route, 'params.backTo', '');
- const backToRoute = backTo ? `${backTo}?country=${currentCountry}` : '';
- Navigation.goBack(backToRoute);
+ const backTo = route.params.backTo ?? '';
+ const backToRoute = backTo ? (`${backTo}?country=${currentCountry}` as const) : '';
+ Navigation.goBack(backToRoute as Route);
}}
/>
@@ -105,6 +89,5 @@ function CountrySelectionPage({route, navigation}) {
}
CountrySelectionPage.displayName = 'CountrySelectionPage';
-CountrySelectionPage.propTypes = propTypes;
export default CountrySelectionPage;
diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
deleted file mode 100644
index 943d9fe0bab7..000000000000
--- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
+++ /dev/null
@@ -1,110 +0,0 @@
-import {subYears} from 'date-fns';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import DatePicker from '@components/DatePicker';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import ScreenWrapper from '@components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import Navigation from '@libs/Navigation/Navigation';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import * as PersonalDetails from '@userActions/PersonalDetails';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import INPUT_IDS from '@src/types/form/DateOfBirthForm';
-
-const propTypes = {
- /* Onyx Props */
-
- /** User's private personal details */
- privatePersonalDetails: PropTypes.shape({
- dob: PropTypes.string,
- }),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- privatePersonalDetails: {
- dob: '',
- },
-};
-
-function DateOfBirthPage({translate, privatePersonalDetails}) {
- const styles = useThemeStyles();
- usePrivatePersonalDetails();
- const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true);
-
- /**
- * @param {Object} values
- * @param {String} values.dob - date of birth
- * @returns {Object} - An object containing the errors for each inputID
- */
- const validate = useCallback((values) => {
- const requiredFields = ['dob'];
- const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
-
- const minimumAge = CONST.DATE_BIRTH.MIN_AGE;
- const maximumAge = CONST.DATE_BIRTH.MAX_AGE;
- const dateError = ValidationUtils.getAgeRequirementError(values.dob, minimumAge, maximumAge);
-
- if (values.dob && dateError) {
- errors.dob = dateError;
- }
-
- return errors;
- }, []);
-
- return (
-
- Navigation.goBack()}
- />
- {isLoadingPersonalDetails ? (
-
- ) : (
-
-
-
- )}
-
- );
-}
-
-DateOfBirthPage.propTypes = propTypes;
-DateOfBirthPage.defaultProps = defaultProps;
-DateOfBirthPage.displayName = 'DateOfBirthPage';
-
-export default compose(
- withLocalize,
- withOnyx({
- privatePersonalDetails: {
- key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
- },
- }),
-)(DateOfBirthPage);
diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx
new file mode 100644
index 000000000000..5e8e4ea493a0
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.tsx
@@ -0,0 +1,136 @@
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import * as PersonalDetails from '@userActions/PersonalDetails';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/LegalNameForm';
+import type {PrivatePersonalDetails} from '@src/types/onyx';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
+
+type LegalNamePageOnyxProps = {
+ /** User's private personal details */
+ privatePersonalDetails: OnyxEntry;
+};
+
+type LegalNamePageProps = LegalNamePageOnyxProps;
+
+const updateLegalName = (values: PrivatePersonalDetails) => {
+ PersonalDetails.updateLegalName(values.legalFirstName?.trim() ?? '', values.legalLastName?.trim() ?? '');
+};
+
+function LegalNamePage({privatePersonalDetails}: LegalNamePageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ usePrivatePersonalDetails();
+ const legalFirstName = privatePersonalDetails?.legalFirstName ?? '';
+ const legalLastName = privatePersonalDetails?.legalLastName ?? '';
+ const isLoadingPersonalDetails = privatePersonalDetails?.isLoading ?? true;
+
+ const validate = useCallback((values: FormOnyxValues) => {
+ const errors: Errors = {};
+
+ if (typeof values.legalFirstName === 'string') {
+ if (!ValidationUtils.isValidLegalName(values.legalFirstName)) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'privatePersonalDetails.error.hasInvalidCharacter');
+ } else if (!values.legalFirstName) {
+ errors.legalFirstName = 'common.error.fieldRequired';
+ } else if (values.legalFirstName.length > CONST.TITLE_CHARACTER_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', [
+ 'common.error.characterLimitExceedCounter',
+ {length: values.legalFirstName.length, limit: CONST.TITLE_CHARACTER_LIMIT},
+ ]);
+ }
+ if (ValidationUtils.doesContainReservedWord(values.legalFirstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'personalDetails.error.containsReservedWord');
+ }
+ }
+
+ if (typeof values.legalLastName === 'string') {
+ if (!ValidationUtils.isValidLegalName(values.legalLastName)) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', 'privatePersonalDetails.error.hasInvalidCharacter');
+ } else if (!values.legalLastName) {
+ errors.legalLastName = 'common.error.fieldRequired';
+ } else if (values.legalLastName.length > CONST.TITLE_CHARACTER_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]);
+ }
+ if (ValidationUtils.doesContainReservedWord(values.legalLastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', 'personalDetails.error.containsReservedWord');
+ }
+ }
+
+ return errors;
+ }, []);
+
+ return (
+
+ Navigation.goBack()}
+ />
+ {isLoadingPersonalDetails ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+LegalNamePage.displayName = 'LegalNamePage';
+
+export default withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+})(LegalNamePage);
diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
deleted file mode 100644
index 0e81ea5194c1..000000000000
--- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback} from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import ScreenWrapper from '@components/ScreenWrapper';
-import TextInput from '@components/TextInput';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as ErrorUtils from '@libs/ErrorUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import * as ValidationUtils from '@libs/ValidationUtils';
-import * as PersonalDetails from '@userActions/PersonalDetails';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import INPUT_IDS from '@src/types/form/LegalNameForm';
-
-const propTypes = {
- /* Onyx Props */
-
- /** User's private personal details */
- privatePersonalDetails: PropTypes.shape({
- legalFirstName: PropTypes.string,
- legalLastName: PropTypes.string,
- }),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- privatePersonalDetails: {
- legalFirstName: '',
- legalLastName: '',
- },
-};
-
-const updateLegalName = (values) => {
- PersonalDetails.updateLegalName(values.legalFirstName.trim(), values.legalLastName.trim());
-};
-
-function LegalNamePage(props) {
- const styles = useThemeStyles();
- usePrivatePersonalDetails();
- const legalFirstName = lodashGet(props.privatePersonalDetails, 'legalFirstName', '');
- const legalLastName = lodashGet(props.privatePersonalDetails, 'legalLastName', '');
- const isLoadingPersonalDetails = lodashGet(props.privatePersonalDetails, 'isLoading', true);
-
- const validate = useCallback((values) => {
- const errors = {};
-
- if (!ValidationUtils.isValidLegalName(values.legalFirstName)) {
- ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'privatePersonalDetails.error.hasInvalidCharacter');
- } else if (_.isEmpty(values.legalFirstName)) {
- errors.legalFirstName = 'common.error.fieldRequired';
- } else if (values.legalFirstName.length > CONST.TITLE_CHARACTER_LIMIT) {
- ErrorUtils.addErrorMessage(errors, 'legalFirstName', ['common.error.characterLimitExceedCounter', {length: values.legalFirstName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]);
- }
- if (ValidationUtils.doesContainReservedWord(values.legalFirstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) {
- ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'personalDetails.error.containsReservedWord');
- }
-
- if (!ValidationUtils.isValidLegalName(values.legalLastName)) {
- ErrorUtils.addErrorMessage(errors, 'legalLastName', 'privatePersonalDetails.error.hasInvalidCharacter');
- } else if (_.isEmpty(values.legalLastName)) {
- errors.legalLastName = 'common.error.fieldRequired';
- } else if (values.legalLastName.length > CONST.TITLE_CHARACTER_LIMIT) {
- ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]);
- }
- if (ValidationUtils.doesContainReservedWord(values.legalLastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) {
- ErrorUtils.addErrorMessage(errors, 'legalLastName', 'personalDetails.error.containsReservedWord');
- }
-
- return errors;
- }, []);
-
- return (
-
- Navigation.goBack()}
- />
- {isLoadingPersonalDetails ? (
-
- ) : (
-
-
-
-
-
-
-
-
- )}
-
- );
-}
-
-LegalNamePage.propTypes = propTypes;
-LegalNamePage.defaultProps = defaultProps;
-LegalNamePage.displayName = 'LegalNamePage';
-
-export default compose(
- withLocalize,
- withOnyx({
- privatePersonalDetails: {
- key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
- },
- }),
-)(LegalNamePage);
diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx
new file mode 100644
index 000000000000..5e8e4ea493a0
--- /dev/null
+++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.tsx
@@ -0,0 +1,136 @@
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
+import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as ErrorUtils from '@libs/ErrorUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import * as PersonalDetails from '@userActions/PersonalDetails';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import INPUT_IDS from '@src/types/form/LegalNameForm';
+import type {PrivatePersonalDetails} from '@src/types/onyx';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
+
+type LegalNamePageOnyxProps = {
+ /** User's private personal details */
+ privatePersonalDetails: OnyxEntry;
+};
+
+type LegalNamePageProps = LegalNamePageOnyxProps;
+
+const updateLegalName = (values: PrivatePersonalDetails) => {
+ PersonalDetails.updateLegalName(values.legalFirstName?.trim() ?? '', values.legalLastName?.trim() ?? '');
+};
+
+function LegalNamePage({privatePersonalDetails}: LegalNamePageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ usePrivatePersonalDetails();
+ const legalFirstName = privatePersonalDetails?.legalFirstName ?? '';
+ const legalLastName = privatePersonalDetails?.legalLastName ?? '';
+ const isLoadingPersonalDetails = privatePersonalDetails?.isLoading ?? true;
+
+ const validate = useCallback((values: FormOnyxValues) => {
+ const errors: Errors = {};
+
+ if (typeof values.legalFirstName === 'string') {
+ if (!ValidationUtils.isValidLegalName(values.legalFirstName)) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'privatePersonalDetails.error.hasInvalidCharacter');
+ } else if (!values.legalFirstName) {
+ errors.legalFirstName = 'common.error.fieldRequired';
+ } else if (values.legalFirstName.length > CONST.TITLE_CHARACTER_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', [
+ 'common.error.characterLimitExceedCounter',
+ {length: values.legalFirstName.length, limit: CONST.TITLE_CHARACTER_LIMIT},
+ ]);
+ }
+ if (ValidationUtils.doesContainReservedWord(values.legalFirstName, CONST.DISPLAY_NAME.RESERVED_NAMES)) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'personalDetails.error.containsReservedWord');
+ }
+ }
+
+ if (typeof values.legalLastName === 'string') {
+ if (!ValidationUtils.isValidLegalName(values.legalLastName)) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', 'privatePersonalDetails.error.hasInvalidCharacter');
+ } else if (!values.legalLastName) {
+ errors.legalLastName = 'common.error.fieldRequired';
+ } else if (values.legalLastName.length > CONST.TITLE_CHARACTER_LIMIT) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.TITLE_CHARACTER_LIMIT}]);
+ }
+ if (ValidationUtils.doesContainReservedWord(values.legalLastName, CONST.DISPLAY_NAME.RESERVED_NAMES)) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', 'personalDetails.error.containsReservedWord');
+ }
+ }
+
+ return errors;
+ }, []);
+
+ return (
+
+ Navigation.goBack()}
+ />
+ {isLoadingPersonalDetails ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+LegalNamePage.displayName = 'LegalNamePage';
+
+export default withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+})(LegalNamePage);
diff --git a/src/pages/signin/SignInModal.tsx b/src/pages/signin/SignInModal.tsx
index b53092f5652a..60350adae26a 100644
--- a/src/pages/signin/SignInModal.tsx
+++ b/src/pages/signin/SignInModal.tsx
@@ -23,6 +23,7 @@ function SignInModal() {
style={[StyleUtils.getBackgroundColorStyle(theme.PAGE_THEMES[SCREENS.RIGHT_MODAL.SIGN_IN].backgroundColor)]}
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
+ shouldShowOfflineIndicator={false}
testID={SignInModal.displayName}
>
Navigation.goBack()} />
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index cdb400de9742..38da932af6e8 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -248,6 +248,7 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc
// Bottom SafeAreaView is removed so that login screen svg displays correctly on mobile.
// The SVG should flow under the Home Indicator on iOS.
-
+
@@ -51,7 +51,7 @@ function SignInPageContent({shouldShowWelcomeHeader, welcomeHeader, welcomeText,
) : null}
{children}
-
+
diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js
index db02a99db067..dbcb10d47f39 100644
--- a/src/pages/tasks/NewTaskDescriptionPage.js
+++ b/src/pages/tasks/NewTaskDescriptionPage.js
@@ -1,6 +1,6 @@
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import PropTypes from 'prop-types';
-import React, {useMemo} from 'react';
+import React from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
@@ -42,7 +42,6 @@ const parser = new ExpensiMark();
function NewTaskDescriptionPage(props) {
const styles = useThemeStyles();
const {inputCallbackRef} = useAutoFocusInput();
- const defaultDescriptionValue = useMemo(() => parser.htmlToMarkdown(parser.replace(props.task.description)), [props.task.description]);
const onSubmit = (values) => {
Task.setDescriptionValue(values.taskDescription);
@@ -86,7 +85,7 @@ function NewTaskDescriptionPage(props) {
parser.htmlToMarkdown(parser.replace(taskDescription)), [taskDescription]);
useEffect(() => {
setTaskTitle(props.task.title);
setTaskDescription(parser.htmlToMarkdown(parser.replace(props.task.description || '')));
- }, [props.task.title, props.task.description]);
+ }, [props.task]);
/**
* @param {Object} values - form input values passed by the Form component
@@ -119,7 +118,7 @@ function NewTaskDetailsPage(props) {
autoGrowHeight
shouldSubmitForm
containerStyles={[styles.autoGrowHeightMultilineInput]}
- defaultValue={defaultDescriptionValue}
+ defaultValue={parser.htmlToMarkdown(parser.replace(taskDescription))}
value={taskDescription}
onValueChange={(value) => setTaskDescription(value)}
/>
diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js
index 23a52384af6d..b8b48abd09ff 100644
--- a/src/pages/tasks/TaskDescriptionPage.js
+++ b/src/pages/tasks/TaskDescriptionPage.js
@@ -1,6 +1,6 @@
import {useFocusEffect} from '@react-navigation/native';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import React, {useCallback, useMemo, useRef} from 'react';
+import React, {useCallback, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
@@ -80,7 +80,6 @@ function TaskDescriptionPage(props) {
const isOpen = ReportUtils.isOpenTaskReport(props.report);
const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
const isTaskNonEditable = ReportUtils.isTaskReport(props.report) && (!canModifyTask || !isOpen);
- const defaultDescriptionValue = useMemo(() => parser.htmlToMarkdown((props.report && parser.replace(props.report.description)) || ''), [props.report]);
useFocusEffect(
useCallback(() => {
@@ -122,7 +121,7 @@ function TaskDescriptionPage(props) {
name={INPUT_IDS.DESCRIPTION}
label={props.translate('newTaskPage.descriptionOptional')}
accessibilityLabel={props.translate('newTaskPage.descriptionOptional')}
- defaultValue={defaultDescriptionValue}
+ defaultValue={parser.htmlToMarkdown((props.report && parser.replace(props.report.description)) || '')}
ref={(el) => {
if (!el) {
return;
diff --git a/src/pages/workspace/SearchInputManager.js b/src/pages/workspace/SearchInputManager.ts
similarity index 100%
rename from src/pages/workspace/SearchInputManager.js
rename to src/pages/workspace/SearchInputManager.ts
diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx
index ef8629e386d8..03fa78367eda 100644
--- a/src/pages/workspace/WorkspaceInvitePage.tsx
+++ b/src/pages/workspace/WorkspaceInvitePage.tsx
@@ -21,7 +21,7 @@ import * as LoginUtils from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import type {MemberForList} from '@libs/OptionsListUtils';
-import {parsePhoneNumber} from '@libs/PhoneNumber';
+import * as PhoneNumber from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
import type {SettingsNavigatorParamList} from '@navigation/types';
@@ -176,7 +176,7 @@ function WorkspaceInvitePage({
filterSelectedOptions = selectedOptions.filter((option) => {
const accountID = option.accountID;
const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID);
- const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
+ const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase();
const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue);
@@ -274,7 +274,9 @@ function WorkspaceInvitePage({
if (
usersToInvite.length === 0 &&
excludedUsers.includes(
- parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible ? OptionsListUtils.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue)) : searchValue,
+ PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible
+ ? PhoneNumber.addSMSDomainIfPhoneNumber(LoginUtils.appendCountryCode(searchValue))
+ : searchValue,
)
) {
return translate('messages.userIsAlreadyMember', {login: searchValue, name: policyName});
diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx
index d78ccb82d704..65be798f9520 100644
--- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx
+++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx
@@ -92,6 +92,7 @@ function WorkspaceRatePage(props: WorkspaceRatePageProps) {
shouldHideFixErrorsAlert
submitFlexEnabled={false}
submitButtonStyles={[styles.mh5, styles.mt0]}
+ disablePressOnEnter={false}
>
lineHeight: variables.lineHeightXXXLarge,
},
+ textHeadlineH2: {
+ ...headlineFont,
+ ...whiteSpace.preWrap,
+ color: theme.heading,
+ fontSize: variables.fontSizeh2,
+ lineHeight: variables.lineHeightSizeh2,
+ },
+
textHeadlineH1: {
...headlineFont,
...whiteSpace.preWrap,
color: theme.heading,
- fontSize: variables.fontSizeh1,
+ fontSize: variables.fontSizeXLarge,
lineHeight: variables.lineHeightSizeh1,
},
@@ -4028,14 +4036,26 @@ const styles = (theme: ThemeColors) =>
borderRadius: variables.componentBorderRadiusLarge,
},
- reportPreviewBoxBody: {
+ expenseAndReportPreviewBoxBody: {
padding: 16,
},
+ expenseAndReportPreviewTextContainer: {
+ gap: 8,
+ },
+
+ reportPreviewAmountSubtitleContainer: {
+ gap: 4,
+ },
+
+ expenseAndReportPreviewTextButtonContainer: {
+ gap: 16,
+ },
+
reportActionItemImages: {
flexDirection: 'row',
margin: 4,
- borderRadius: variables.componentBorderRadiusLarge,
+ borderRadius: 12,
overflow: 'hidden',
height: variables.reportActionImagesSingleImageHeight,
},
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index b807ee2bdf24..515be45eb3b3 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -243,6 +243,10 @@ export default {
marginTop: 'auto',
},
+ mtn1: {
+ marginTop: -4,
+ },
+
mtn2: {
marginTop: -8,
},
@@ -315,6 +319,10 @@ export default {
marginBottom: -4,
},
+ mbn3: {
+ marginBottom: -12,
+ },
+
mbn5: {
marginBottom: -20,
},
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 3d5115a31f54..63611eccb199 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -58,6 +58,7 @@ export default {
fontSizeHero: 36,
fontSizeHeroXL: 72,
fontSizeh1: 19,
+ fontSizeh2: 19,
fontSizeXLarge: 22,
fontSizeXXLarge: 28,
fontSizeXXXLarge: 32,
@@ -106,7 +107,8 @@ export default {
lineHeightXLarge: getValueUsingPixelRatio(20, 24),
lineHeightXXLarge: getValueUsingPixelRatio(27, 32),
lineHeightXXXLarge: getValueUsingPixelRatio(32, 37),
- lineHeightSizeh1: getValueUsingPixelRatio(23, 28),
+ lineHeightSizeh1: getValueUsingPixelRatio(28, 32),
+ lineHeightSizeh2: getValueUsingPixelRatio(24, 28),
lineHeightSignInHeroXSmall: getValueUsingPixelRatio(32, 37),
inputHeight: getValueUsingPixelRatio(52, 72),
inputHeightSmall: 28,
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 198c606cf9f2..06c2d2e6abce 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -3,6 +3,7 @@ import type CONST from '@src/CONST';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
type PaymentMethodType = DeepValueOf;
+
type ActionName = DeepValueOf;
type OriginalMessageActionName =
| 'ADDCOMMENT'
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 1eece2d3a1e0..fc07e1e1760a 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -17,12 +17,6 @@ type Attributes = {
unit: Unit;
};
-type MileageRate = {
- unit: Unit;
- rate?: number;
- currency: string;
-};
-
type CustomUnit = OnyxCommon.OnyxValueWithOfflineFeedback<{
name: string;
customUnitID: string;
@@ -253,4 +247,4 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
export default Policy;
-export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, MileageRate};
+export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault};
diff --git a/src/types/onyx/PolicyTag.ts b/src/types/onyx/PolicyTag.ts
index 031f255b4898..fe7b61cc8f9c 100644
--- a/src/types/onyx/PolicyTag.ts
+++ b/src/types/onyx/PolicyTag.ts
@@ -23,6 +23,9 @@ type PolicyTagList = Record<
/** Nested tags */
tags: PolicyTags;
+
+ /** Index by which the tag appears in the hierarchy of tags */
+ orderWeight: number;
}
>;
diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts
index 780e3f71b61d..5a9dae0a5523 100644
--- a/src/types/onyx/PrivatePersonalDetails.ts
+++ b/src/types/onyx/PrivatePersonalDetails.ts
@@ -5,6 +5,9 @@ type Address = {
state: string;
zip: string;
country: string;
+ zipPostCode?: string;
+ addressLine1?: string;
+ addressLine2?: string;
};
type PrivatePersonalDetails = {
@@ -21,3 +24,5 @@ type PrivatePersonalDetails = {
};
export default PrivatePersonalDetails;
+
+export type {Address};
diff --git a/tests/e2e/compare/output/markdownTable.js b/tests/e2e/compare/output/markdownTable.js
deleted file mode 100644
index 198ae17daba5..000000000000
--- a/tests/e2e/compare/output/markdownTable.js
+++ /dev/null
@@ -1,368 +0,0 @@
-/* eslint-disable */
-// copied from https://raw.githubusercontent.com/wooorm/markdown-table/main/index.js, turned into cmjs
-
-/**
- * @typedef Options
- * Configuration (optional).
- * @property {string|null|Array} [align]
- * One style for all columns, or styles for their respective columns.
- * Each style is either `'l'` (left), `'r'` (right), or `'c'` (center).
- * Other values are treated as `''`, which doesn’t place the colon in the
- * alignment row but does align left.
- * *Only the lowercased first character is used, so `Right` is fine.*
- * @property {boolean} [padding=true]
- * Whether to add a space of padding between delimiters and cells.
- *
- * When `true`, there is padding:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * When `false`, there is no padding:
- *
- * ```markdown
- * |Alpha|B |
- * |-----|-----|
- * |C |Delta|
- * ```
- * @property {boolean} [delimiterStart=true]
- * Whether to begin each row with the delimiter.
- *
- * > 👉 **Note**: please don’t use this: it could create fragile structures
- * > that aren’t understandable to some markdown parsers.
- *
- * When `true`, there are starting delimiters:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * When `false`, there are no starting delimiters:
- *
- * ```markdown
- * Alpha | B |
- * ----- | ----- |
- * C | Delta |
- * ```
- * @property {boolean} [delimiterEnd=true]
- * Whether to end each row with the delimiter.
- *
- * > 👉 **Note**: please don’t use this: it could create fragile structures
- * > that aren’t understandable to some markdown parsers.
- *
- * When `true`, there are ending delimiters:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * When `false`, there are no ending delimiters:
- *
- * ```markdown
- * | Alpha | B
- * | ----- | -----
- * | C | Delta
- * ```
- * @property {boolean} [alignDelimiters=true]
- * Whether to align the delimiters.
- * By default, they are aligned:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * Pass `false` to make them staggered:
- *
- * ```markdown
- * | Alpha | B |
- * | - | - |
- * | C | Delta |
- * ```
- * @property {(value: string) => number} [stringLength]
- * Function to detect the length of table cell content.
- * This is used when aligning the delimiters (`|`) between table cells.
- * Full-width characters and emoji mess up delimiter alignment when viewing
- * the markdown source.
- * To fix this, you can pass this function, which receives the cell content
- * and returns its “visible” size.
- * Note that what is and isn’t visible depends on where the text is displayed.
- *
- * Without such a function, the following:
- *
- * ```js
- * markdownTable([
- * ['Alpha', 'Bravo'],
- * ['中文', 'Charlie'],
- * ['👩❤️👩', 'Delta']
- * ])
- * ```
- *
- * Yields:
- *
- * ```markdown
- * | Alpha | Bravo |
- * | - | - |
- * | 中文 | Charlie |
- * | 👩❤️👩 | Delta |
- * ```
- *
- * With [`string-width`](https://github.com/sindresorhus/string-width):
- *
- * ```js
- * import stringWidth from 'string-width'
- *
- * markdownTable(
- * [
- * ['Alpha', 'Bravo'],
- * ['中文', 'Charlie'],
- * ['👩❤️👩', 'Delta']
- * ],
- * {stringLength: stringWidth}
- * )
- * ```
- *
- * Yields:
- *
- * ```markdown
- * | Alpha | Bravo |
- * | ----- | ------- |
- * | 中文 | Charlie |
- * | 👩❤️👩 | Delta |
- * ```
- */
-
-/**
- * @typedef {Options} MarkdownTableOptions
- * @todo
- * Remove next major.
- */
-
-/**
- * Generate a markdown ([GFM](https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables)) table..
- *
- * @param {Array>} table
- * Table data (matrix of strings).
- * @param {Options} [options]
- * Configuration (optional).
- * @returns {string}
- */
-function markdownTable(table, options = {}) {
- const align = (options.align || []).concat();
- const stringLength = options.stringLength || defaultStringLength;
- /** @type {Array} Character codes as symbols for alignment per column. */
- const alignments = [];
- /** @type {Array>} Cells per row. */
- const cellMatrix = [];
- /** @type {Array>} Sizes of each cell per row. */
- const sizeMatrix = [];
- /** @type {Array} */
- const longestCellByColumn = [];
- let mostCellsPerRow = 0;
- let rowIndex = -1;
-
- // This is a superfluous loop if we don’t align delimiters, but otherwise we’d
- // do superfluous work when aligning, so optimize for aligning.
- while (++rowIndex < table.length) {
- /** @type {Array} */
- const row = [];
- /** @type {Array} */
- const sizes = [];
- let columnIndex = -1;
-
- if (table[rowIndex].length > mostCellsPerRow) {
- mostCellsPerRow = table[rowIndex].length;
- }
-
- while (++columnIndex < table[rowIndex].length) {
- const cell = serialize(table[rowIndex][columnIndex]);
-
- if (options.alignDelimiters !== false) {
- const size = stringLength(cell);
- sizes[columnIndex] = size;
-
- if (longestCellByColumn[columnIndex] === undefined || size > longestCellByColumn[columnIndex]) {
- longestCellByColumn[columnIndex] = size;
- }
- }
-
- row.push(cell);
- }
-
- cellMatrix[rowIndex] = row;
- sizeMatrix[rowIndex] = sizes;
- }
-
- // Figure out which alignments to use.
- let columnIndex = -1;
-
- if (typeof align === 'object' && 'length' in align) {
- while (++columnIndex < mostCellsPerRow) {
- alignments[columnIndex] = toAlignment(align[columnIndex]);
- }
- } else {
- const code = toAlignment(align);
-
- while (++columnIndex < mostCellsPerRow) {
- alignments[columnIndex] = code;
- }
- }
-
- // Inject the alignment row.
- columnIndex = -1;
- /** @type {Array} */
- const row = [];
- /** @type {Array} */
- const sizes = [];
-
- while (++columnIndex < mostCellsPerRow) {
- const code = alignments[columnIndex];
- let before = '';
- let after = '';
-
- if (code === 99 /* `c` */) {
- before = ':';
- after = ':';
- } else if (code === 108 /* `l` */) {
- before = ':';
- } else if (code === 114 /* `r` */) {
- after = ':';
- }
-
- // There *must* be at least one hyphen-minus in each alignment cell.
- let size = options.alignDelimiters === false ? 1 : Math.max(1, longestCellByColumn[columnIndex] - before.length - after.length);
-
- const cell = before + '-'.repeat(size) + after;
-
- if (options.alignDelimiters !== false) {
- size = before.length + size + after.length;
-
- if (size > longestCellByColumn[columnIndex]) {
- longestCellByColumn[columnIndex] = size;
- }
-
- sizes[columnIndex] = size;
- }
-
- row[columnIndex] = cell;
- }
-
- // Inject the alignment row.
- cellMatrix.splice(1, 0, row);
- sizeMatrix.splice(1, 0, sizes);
-
- rowIndex = -1;
- /** @type {Array} */
- const lines = [];
-
- while (++rowIndex < cellMatrix.length) {
- const row = cellMatrix[rowIndex];
- const sizes = sizeMatrix[rowIndex];
- columnIndex = -1;
- /** @type {Array} */
- const line = [];
-
- while (++columnIndex < mostCellsPerRow) {
- const cell = row[columnIndex] || '';
- let before = '';
- let after = '';
-
- if (options.alignDelimiters !== false) {
- const size = longestCellByColumn[columnIndex] - (sizes[columnIndex] || 0);
- const code = alignments[columnIndex];
-
- if (code === 114 /* `r` */) {
- before = ' '.repeat(size);
- } else if (code === 99 /* `c` */) {
- if (size % 2) {
- before = ' '.repeat(size / 2 + 0.5);
- after = ' '.repeat(size / 2 - 0.5);
- } else {
- before = ' '.repeat(size / 2);
- after = before;
- }
- } else {
- after = ' '.repeat(size);
- }
- }
-
- if (options.delimiterStart !== false && !columnIndex) {
- line.push('|');
- }
-
- if (
- options.padding !== false &&
- // Don’t add the opening space if we’re not aligning and the cell is
- // empty: there will be a closing space.
- !(options.alignDelimiters === false && cell === '') &&
- (options.delimiterStart !== false || columnIndex)
- ) {
- line.push(' ');
- }
-
- if (options.alignDelimiters !== false) {
- line.push(before);
- }
-
- line.push(cell);
-
- if (options.alignDelimiters !== false) {
- line.push(after);
- }
-
- if (options.padding !== false) {
- line.push(' ');
- }
-
- if (options.delimiterEnd !== false || columnIndex !== mostCellsPerRow - 1) {
- line.push('|');
- }
- }
-
- lines.push(options.delimiterEnd === false ? line.join('').replace(/ +$/, '') : line.join(''));
- }
-
- return lines.join('\n');
-}
-
-/**
- * @param {string|null|undefined} [value]
- * @returns {string}
- */
-function serialize(value) {
- return value === null || value === undefined ? '' : String(value);
-}
-
-/**
- * @param {string} value
- * @returns {number}
- */
-function defaultStringLength(value) {
- return value.length;
-}
-
-/**
- * @param {string|null|undefined} value
- * @returns {number}
- */
-function toAlignment(value) {
- const code = typeof value === 'string' ? value.codePointAt(0) : 0;
-
- return code === 67 /* `C` */ || code === 99 /* `c` */
- ? 99 /* `c` */
- : code === 76 /* `L` */ || code === 108 /* `l` */
- ? 108 /* `l` */
- : code === 82 /* `R` */ || code === 114 /* `r` */
- ? 114 /* `r` */
- : 0;
-}
-
-export default markdownTable;
diff --git a/tests/e2e/compare/output/markdownTable.ts b/tests/e2e/compare/output/markdownTable.ts
new file mode 100644
index 000000000000..51f0beeb6979
--- /dev/null
+++ b/tests/e2e/compare/output/markdownTable.ts
@@ -0,0 +1,354 @@
+// copied from https://raw.githubusercontent.com/wooorm/markdown-table/main/index.js, turned into cmjs
+
+type MarkdownTableOptions = {
+ /**
+ * One style for all columns, or styles for their respective columns.
+ * Each style is either `'l'` (left), `'r'` (right), or `'c'` (center).
+ * Other values are treated as `''`, which doesn’t place the colon in the
+ * alignment row but does align left.
+ * *Only the lowercased first character is used, so `Right` is fine.*
+ */
+ align?: string | null | Array;
+
+ /**
+ * Whether to add a space of padding between delimiters and cells.
+ *
+ * When `true`, there is padding:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * When `false`, there is no padding:
+ *
+ * ```markdown
+ * |Alpha|B |
+ * |-----|-----|
+ * |C |Delta|
+ * ```
+ */
+ padding?: boolean;
+
+ /**
+ * Whether to begin each row with the delimiter.
+ *
+ * > 👉 **Note**: please don’t use this: it could create fragile structures
+ * > that aren’t understandable to some markdown parsers.
+ *
+ * When `true`, there are starting delimiters:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * When `false`, there are no starting delimiters:
+ *
+ * ```markdown
+ * Alpha | B |
+ * ----- | ----- |
+ * C | Delta |
+ * ```
+ */
+ delimiterStart?: boolean;
+ /**
+ * Whether to end each row with the delimiter.
+ *
+ * > 👉 **Note**: please don’t use this: it could create fragile structures
+ * > that aren’t understandable to some markdown parsers.
+ *
+ * When `true`, there are ending delimiters:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * When `false`, there are no ending delimiters:
+ *
+ * ```markdown
+ * | Alpha | B
+ * | ----- | -----
+ * | C | Delta
+ * ```
+ */
+ delimiterEnd?: boolean;
+
+ /**
+ * Whether to align the delimiters.
+ * By default, they are aligned:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * Pass `false` to make them staggered:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | - | - |
+ * | C | Delta |
+ * ```
+ */
+ alignDelimiters?: boolean;
+
+ /**
+ * Function to detect the length of table cell content.
+ * This is used when aligning the delimiters (`|`) between table cells.
+ * Full-width characters and emoji mess up delimiter alignment when viewing
+ * the markdown source.
+ * To fix this, you can pass this function, which receives the cell content
+ * and returns its “visible” size.
+ * Note that what is and isn’t visible depends on where the text is displayed.
+ *
+ * Without such a function, the following:
+ *
+ * ```js
+ * markdownTable([
+ * ['Alpha', 'Bravo'],
+ * ['中文', 'Charlie'],
+ * ['👩❤️👩', 'Delta']
+ * ])
+ * ```
+ *
+ * Yields:
+ *
+ * ```markdown
+ * | Alpha | Bravo |
+ * | - | - |
+ * | 中文 | Charlie |
+ * | 👩❤️👩 | Delta |
+ * ```
+ *
+ * With [`string-width`](https://github.com/sindresorhus/string-width):
+ *
+ * ```js
+ * import stringWidth from 'string-width'
+ *
+ * markdownTable(
+ * [
+ * ['Alpha', 'Bravo'],
+ * ['中文', 'Charlie'],
+ * ['👩❤️👩', 'Delta']
+ * ],
+ * {stringLength: stringWidth}
+ * )
+ * ```
+ *
+ * Yields:
+ *
+ * ```markdown
+ * | Alpha | Bravo |
+ * | ----- | ------- |
+ * | 中文 | Charlie |
+ * | 👩❤️👩 | Delta |
+ * ```
+ */
+ stringLength?: (value: string) => number;
+};
+
+function serialize(value: string | null | undefined): string {
+ return value === null || value === undefined ? '' : String(value);
+}
+
+function defaultStringLength(value: string): number {
+ return value.length;
+}
+
+function toAlignment(value: string | null | undefined): number {
+ const code = typeof value === 'string' ? value.codePointAt(0) : 0;
+
+ if (code === 67 /* `C` */ || code === 99 /* `c` */) {
+ return 99; /* `c` */
+ }
+
+ if (code === 76 /* `L` */ || code === 108 /* `l` */) {
+ return 108; /* `l` */
+ }
+
+ if (code === 82 /* `R` */ || code === 114 /* `r` */) {
+ return 114; /* `r` */
+ }
+
+ return 0;
+}
+
+/** Generate a markdown ([GFM](https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables)) table.. */
+function markdownTable(table: Array>, options: MarkdownTableOptions = {}) {
+ const align = (options.align ?? []).concat();
+ const stringLength = options.stringLength ?? defaultStringLength;
+ /** Character codes as symbols for alignment per column. */
+ const alignments: number[] = [];
+ /** Cells per row. */
+ const cellMatrix: string[][] = [];
+ /** Sizes of each cell per row. */
+ const sizeMatrix: number[][] = [];
+ const longestCellByColumn: number[] = [];
+ let mostCellsPerRow = 0;
+ let rowIndex = -1;
+
+ // This is a superfluous loop if we don’t align delimiters, but otherwise we’d
+ // do superfluous work when aligning, so optimize for aligning.
+ while (++rowIndex < table.length) {
+ const row: string[] = [];
+ const sizes: number[] = [];
+ let columnIndex = -1;
+
+ if (table[rowIndex].length > mostCellsPerRow) {
+ mostCellsPerRow = table[rowIndex].length;
+ }
+
+ while (++columnIndex < table[rowIndex].length) {
+ const cell = serialize(table[rowIndex][columnIndex]);
+
+ if (options.alignDelimiters !== false) {
+ const size = stringLength(cell);
+ sizes[columnIndex] = size;
+
+ if (longestCellByColumn[columnIndex] === undefined || size > longestCellByColumn[columnIndex]) {
+ longestCellByColumn[columnIndex] = size;
+ }
+ }
+
+ row.push(cell);
+ }
+
+ cellMatrix[rowIndex] = row;
+ sizeMatrix[rowIndex] = sizes;
+ }
+
+ // Figure out which alignments to use.
+ let columnIndex = -1;
+
+ if (typeof align === 'object' && 'length' in align) {
+ while (++columnIndex < mostCellsPerRow) {
+ alignments[columnIndex] = toAlignment(align[columnIndex]);
+ }
+ } else {
+ const code = toAlignment(align);
+
+ while (++columnIndex < mostCellsPerRow) {
+ alignments[columnIndex] = code;
+ }
+ }
+
+ // Inject the alignment row.
+ columnIndex = -1;
+ const row: string[] = [];
+ const sizes: number[] = [];
+
+ while (++columnIndex < mostCellsPerRow) {
+ const code = alignments[columnIndex];
+ let before = '';
+ let after = '';
+
+ if (code === 99 /* `c` */) {
+ before = ':';
+ after = ':';
+ } else if (code === 108 /* `l` */) {
+ before = ':';
+ } else if (code === 114 /* `r` */) {
+ after = ':';
+ }
+
+ // There *must* be at least one hyphen-minus in each alignment cell.
+ let size = options.alignDelimiters === false ? 1 : Math.max(1, longestCellByColumn[columnIndex] - before.length - after.length);
+
+ const cell = before + '-'.repeat(size) + after;
+
+ if (options.alignDelimiters !== false) {
+ size = before.length + size + after.length;
+
+ if (size > longestCellByColumn[columnIndex]) {
+ longestCellByColumn[columnIndex] = size;
+ }
+
+ sizes[columnIndex] = size;
+ }
+
+ row[columnIndex] = cell;
+ }
+
+ // Inject the alignment row.
+ cellMatrix.splice(1, 0, row);
+ sizeMatrix.splice(1, 0, sizes);
+
+ rowIndex = -1;
+ const lines: string[] = [];
+
+ while (++rowIndex < cellMatrix.length) {
+ const matrixRow = cellMatrix[rowIndex];
+ const matrixSizes = sizeMatrix[rowIndex];
+ columnIndex = -1;
+ const line: string[] = [];
+
+ while (++columnIndex < mostCellsPerRow) {
+ const cell = matrixRow[columnIndex] || '';
+ let before = '';
+ let after = '';
+
+ if (options.alignDelimiters !== false) {
+ const size = longestCellByColumn[columnIndex] - (matrixSizes[columnIndex] || 0);
+ const code = alignments[columnIndex];
+
+ if (code === 114 /* `r` */) {
+ before = ' '.repeat(size);
+ } else if (code === 99 /* `c` */) {
+ if (size % 2) {
+ before = ' '.repeat(size / 2 + 0.5);
+ after = ' '.repeat(size / 2 - 0.5);
+ } else {
+ before = ' '.repeat(size / 2);
+ after = before;
+ }
+ } else {
+ after = ' '.repeat(size);
+ }
+ }
+
+ if (options.delimiterStart !== false && !columnIndex) {
+ line.push('|');
+ }
+
+ if (
+ options.padding !== false &&
+ // Don’t add the opening space if we’re not aligning and the cell is
+ // empty: there will be a closing space.
+ !(options.alignDelimiters === false && cell === '') &&
+ (options.delimiterStart !== false || columnIndex)
+ ) {
+ line.push(' ');
+ }
+
+ if (options.alignDelimiters !== false) {
+ line.push(before);
+ }
+
+ line.push(cell);
+
+ if (options.alignDelimiters !== false) {
+ line.push(after);
+ }
+
+ if (options.padding !== false) {
+ line.push(' ');
+ }
+
+ if (options.delimiterEnd !== false || columnIndex !== mostCellsPerRow - 1) {
+ line.push('|');
+ }
+ }
+
+ lines.push(options.delimiterEnd === false ? line.join('').replace(/ +$/, '') : line.join(''));
+ }
+
+ return lines.join('\n');
+}
+
+export default markdownTable;