diff --git a/.eslintrc.js b/.eslintrc.js
index 75a74ed371c4..83e9479ce0c4 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -116,7 +116,7 @@ module.exports = {
},
{
selector: ['parameter', 'method'],
- format: ['camelCase'],
+ format: ['camelCase', 'PascalCase'],
},
],
'@typescript-eslint/ban-types': [
diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index 1e63fdcb2d52..b3adf0f59b9c 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -80,6 +80,10 @@
"/": "/search/*",
"comment": "Search"
},
+ {
+ "/": "/send/*",
+ "comment": "Send money"
+ },
{
"/": "/money2020/*",
"comment": "Money 2020"
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 1b8eac0c5c20..b8f59db4aecf 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001038500
- versionName "1.3.85-0"
+ versionCode 1001038601
+ versionName "1.3.86-1"
}
flavorDimensions "default"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7419d5b1e1a7..74e91caa91d5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -70,6 +70,7 @@
+
@@ -88,6 +89,7 @@
+
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 27656eeb68f0..de99bbcb48ef 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -256,6 +256,7 @@ GEM
PLATFORMS
arm64-darwin-22
+ arm64-darwin-23
x86_64-darwin-20
x86_64-darwin-21
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index 3ad2276713da..c887849ffd99 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -371,9 +371,26 @@ button {
flex-wrap: wrap;
}
+ h1 {
+ font-size: 1.5em;
+ padding: 20px 0 12px 0;
+ }
+
+ h2 {
+ font-size: 1.125em;
+ font-weight: 500;
+ font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ }
+
+ h3 {
+ font-size: 1em;
+ font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ }
+
h2,
h3 {
- font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ margin: 0;
+ padding: 12px 0 12px 0;
}
blockquote {
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
index 7c789942a2b3..b59f68a65ce6 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
@@ -1,5 +1,51 @@
---
-title: Business Bank Accounts - AUD
-description: Business Bank Accounts - AUD
+title: Add a Business Bank Account
+description: This article provides insight on setting up and using an Australian Business Bank account in Expensify.
---
-## Resource Coming Soon!
+
+# How to add an Australian business bank account (for admins)
+A withdrawal account is the business bank account that you want to use to pay your employee reimbursements.
+
+_Your policy currency must be set to AUD and reimbursement setting set to Indirect to continue. If your main policy is used for something other than AUD, then you will need to create a new one and set that policy to AUD._
+
+To set this up, you’ll run through the following steps:
+
+1. Go to **Settings > Your Account > Payments** and click **Add Verified Bank Account**
+![Click the Verified Bank Account button in the bottom right-hand corner of the screen](https://help.expensify.com/assets/images/add-vba-australian-account.png){:width="100%"}
+
+2. Enter the required information to connect to your business bank account. If you don't know your Bank User ID/Direct Entry ID/APCA Number, please contact your bank and they will be able to provide this.
+![Enter your information in each of the required fields](https://help.expensify.com/assets/images/add-vba-australian-account-modal.png){:width="100%"}
+
+3. Link the withdrawal account to your policy by heading to **Settings > Policies > Group > [Policy name] > Reimbursement**
+4. Click **Direct reimbursement**
+5. Set the default withdrawal account for processing reimbursements
+6. Tell your employees to add their deposit accounts and start reimbursing.
+
+# How to delete a bank account
+If you’re no longer using a bank account you previously connected to Expensify, you can delete it by doing the following:
+
+1. Navigate to Settings > Accounts > Payments
+2. Click **Delete**
+![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
+
+You can complete this process either via the web app (on a computer), or via the mobile app.
+
+# Deep Dive
+## Bank-specific batch payment support
+
+If you are new to using Batch Payments in Australia, to reimburse your staff or process payroll, you may want to check out these bank-specific instructions for how to upload your .aba file:
+
+- ANZ Bank - [Import a file for payroll payments](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
+- CommBank - [Importing and using
Direct Entry (EFT) files](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
+- Westpac - [Importing Payment Files](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
+- NAB - [Quick Reference Guide - Upload a payment file](https://www.nab.com.au/business/online-banking/nab-connect/help)
+- Bendigo Bank - [Bulk payments user guide](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
+- Bank of Queensland - [Payments file upload facility FAQ](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
+
+**Note:** Some financial institutions require an ABA file to include a *self-balancing transaction*. If you are unsure, please check with your bank to ensure whether to tick this option or not, as selecting an incorrect option will result in the ABA file not working with your bank's internet banking platform.
+
+## Enable Global Reimbursement
+
+If you have employees in other countries outside of Australia, you can now reimburse them directly using Global Reimbursement.
+
+To do this, you’ll first need to delete any existing Australian business bank accounts. Then, you’ll want to follow the instructions to enable Global Reimbursements
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
similarity index 83%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
rename to docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
index 7273e5ece879..6114e98883e0 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
@@ -1,12 +1,12 @@
---
-title: Add a Deposit Account (AUD)
+title: Deposit Accounts (AUD)
description: Expensify allows you to add a personal bank account to receive reimbursements for your expenses. We never take money out of this account — it is only a place for us to deposit funds from your employer. This article covers deposit accounts for Australian banks.
---
## How-to add your Australian personal deposit account information
1. Confirm with your Policy Admin that they’ve set up Global Reimbursment
2. Set your default policy (by selecting the correct policy after clicking on your profile picture) before adding your deposit account.
-3. Go to *Settings > Account > Payments* and click *Add Deposit-Only Bank Account*
+3. Go to **Settings > Account > Payments** and click **Add Deposit-Only Bank Account**
![Click the Add Deposit-Only Bank Account button](https://help.expensify.com/assets/images/add-australian-deposit-only-account.png){:width="100%"}
4. Enter your BSB, account number and name. If your screen looks different than the image below, that means your company hasn't enabled reimbursements through Expensify. Please contact your administrator and ask them to enable reimbursements.
@@ -14,7 +14,7 @@ description: Expensify allows you to add a personal bank account to receive reim
![Fill in the required fields](https://help.expensify.com/assets/images/add-australian-deposit-only-account-modal.png){:width="100%"}
# How-to delete a bank account
-Bank accounts are easy to delete! Simply click the red “Delete” button in the bank account under *Settings > Account > Payments*.
+Bank accounts are easy to delete! Simply click the red **Delete** button in the bank account under **Settings > Account > Payments**.
![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md
deleted file mode 100644
index 61e6dfd95e38..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Deposit Accounts - AUD
-description: Deposit Accounts - AUD
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
index a7553e6ae179..d933e66cc2d1 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
@@ -3,18 +3,18 @@ title: Expensify Playbook for Small to Medium-Sized Businesses
description: Best practices for how to deploy Expensify for your business
redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses/
---
-## Overview
+# Overview
This guide provides practical tips and recommendations for small businesses with 100 to 250 employees to effectively use Expensify to improve spend visibility, facilitate employee reimbursements, and reduce the risk of fraudulent expenses.
- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups) if you are more concerned with top-line revenue growth
-## Who you are
+# Who you are
As a small to medium-sized business owner, your main aim is to achieve success and grow your business. To achieve your goals, it is crucial that you make worthwhile investments in both your workforce and your business processes. This means providing your employees with the resources they need to generate revenue effectively, while also adopting measures to guarantee that expenses are compliant.
-## Step-by-step instructions for setting up Expensify
+# Step-by-step instructions for setting up Expensify
This playbook is built on best practices we’ve developed after processing expenses for tens of thousands of companies around the world. As such, use this playbook as your starting point, knowing that you can customize Expensify to suit your business needs. Every company is different, and your dedicated Setup Specialist is always one chat away with any questions you may have.
-### Step 1: Create your Expensify account
+## Step 1: Create your Expensify account
If you don't already have one, go to *[new.expensify.com](https://new.expensify.com)* and sign up for an account with your work email address. The account is free so don’t worry about the cost at this stage.
> _Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical_
@@ -22,7 +22,7 @@ If you don't already have one, go to *[new.expensify.com](https://new.expensify.
> **Robyn Gresham**
> Senior Accounting Systems Manager at SunCommon
-### Step 2: Create a Control Policy
+## Step 2: Create a Control Policy
There are three policy types, but for your small business needs we recommend the *Control Plan* for the following reasons:
- *The Control Plan* is designed for organizations with a high volume of employee expense submissions, who also rely on compliance controls
@@ -40,7 +40,7 @@ To create your Control Policy:
The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider.
-### Step 3: Connect your accounting system
+## Step 3: Connect your accounting system
As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as:
- Every purchase is categorized into the correct account in your chart of accounts
@@ -65,7 +65,7 @@ Check out the links below for more information on how to connect to your account
*“Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical.”*
- Robyn Gresham, Senior Accounting Systems Manager at SunCommon
-### Step 4: Set category-specific compliance controls
+## Step 4: Set category-specific compliance controls
Head over to the *Categories* tab to set compliance controls on your newly imported list of categories. More specifically, we recommend the following:
1. First, enable *People Must Categorize Expenses*. Employees must select a category for each expense, otherwise, in most cases, it’s more work on you and our accounting connections will simply reject any attempt to export.
@@ -78,7 +78,7 @@ Head over to the *Categories* tab to set compliance controls on your newly impor
3. Disable any irrelevant expense categories that aren’t associated with employee spend
4. Configure *auto-categorization*, located just below your category list in the same tab. The section is titled *Default Categories*. Just find the right category, and match it with the presented category groups to allow for MCC (merchant category code) automated category selection with every imported connected card transaction.
-### Step 5: Make sure tags are required, or defaults are set
+## Step 5: Make sure tags are required, or defaults are set
Tags in Expensify often relate to departments, projects/customers, classes, and so on. And in some cases they are *required* to be selected on every transactions. And in others, something like *departments* is a static field, meaning we could set it as an employee default and not enforce the tag selection with each expense.
*Make Tags Required*
@@ -89,7 +89,7 @@ In the tags tab in your policy settings, you’ll notice the option to enable th
*Set Tags as an Employee Default*
Separately, if your policy is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense.
-### Step 6: Set rules for all expenses regardless of categorization
+## Step 6: Set rules for all expenses regardless of categorization
In the Expenses tab in your group Control policy, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your policy. We recommend the following confiuration:
*Max Expense Age: 90 days (or leave it blank)*
@@ -105,7 +105,7 @@ Receipts are important, and in most cases you prefer an itemized receipt. Howeve
At this point, you’ve set enough compliance controls around categorical spend and general expenses for all employees, such that you can put trust in our solution to audit all expenses up front so you don’t have to. Next, let’s dive into how we can comfortably take on more automation, while relying on compliance controls to capture bad behavior (or better yet, instill best practices in our employees).
-### Step 7: Set up scheduled submit
+## Step 7: Set up scheduled submit
For an efficient company, we recommend setting up [Scheduled Submit](https://community.expensify.com/discussion/4476/how-to-enable-scheduled-submit-for-a-group-policy) on a *Daily* frequency:
- Click *Settings > Policies*
@@ -125,7 +125,7 @@ Expenses with violations will stay behind for the employee to fix, while expense
> Kevin Valuska
> AP/AR at Road Trippers
-### Step 8: Connect your business bank account (US only)
+## Step 8: Connect your business bank account (US only)
If you’re located in the US, you can utilize Expensify’s payment processing and reimbursement features.
*Note:* Before you begin, you’ll need the following to validate your business bank account:
@@ -145,7 +145,7 @@ Let’s walk through the process of linking your business bank account:
You only need to do this once: you are fully set up for not only reimbursing expense reports, but issuing Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online.
-### Step 9: Invite employees and set an approval workflow
+## Step 9: Invite employees and set an approval workflow
*Select an Approval Mode*
We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of a approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://community.expensify.com/discussion/5643/deep-dive-submit-and-approve). But if *Advanced Approval* if your jam, keep reading!
@@ -159,13 +159,13 @@ In most cases, at this stage, approvers prefer to review all expenses for a few
In this case we recommend setting *Manually approve all expenses over: $0*
-### Step 10: Configure Auto-Approval
+## Step 10: Configure Auto-Approval
Knowing you have all the control you need to review reports, we recommend configuring auto-approval for *all reports*. Why? Because you’ve already put reports through an entire approval workflow, and manually triggering reimbursement is an unnecessary action at this stage.
1. Navigate to *Settings > Policies > Group > [Policy Name] > Reimbursement*
2. Set your *Manual Reimbursement threshold to $20,0000*
-### Step 11: Enable Domains and set up your corporate card feed for employees
+## Step 11: Enable Domains and set up your corporate card feed for employees
Expensify is optimized to work with corporate cards from all banks – or even better, use our own perfectly integrated *[Expensify Card](https://use.expensify.com/company-credit-card)*. The first step for connecting to any bank you use for corporate cards, and the Expensify Card is to validate your company’s domain in Domain settings.
To do this:
@@ -173,7 +173,7 @@ To do this:
- Click *Settings*
- Then select *Domains*
-#### If you have an existing corporate card
+### If you have an existing corporate card
Expensify supports direct card feeds from most financial institutions. Setting up a corporate card feed will pull in the transactions from the connected cards on a daily basis. To set this up, do the following:
1. Go to *Company Cards >* Select your bank
@@ -187,7 +187,7 @@ Expensify supports direct card feeds from most financial institutions. Setting u
As mentioned above, we’ll be able to pull in transactions as they post (daily) and handle receipt matching for you and your employees. One benefit of the Expensify Card for your company is being able to see transactions at the point of purchase which provides you with real-time compliance. We even send users push notifications to SmartScan their receipt when it’s required and generate IRS-compliant e-receipts as a backup wherever applicable.
-#### If you don't have a corporate card, use the Expensify Card (US only)
+### If you don't have a corporate card, use the Expensify Card (US only)
Expensify provides a corporate card with the following features:
- Up to 2% cash back (up to 4% in your first 3 months!)
@@ -214,7 +214,7 @@ Once the Expensify Cards have been assigned, each employee will be prompted to e
If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period.
-### Step 12: Set up Bill Pay and Invoicing
+## Step 12: Set up Bill Pay and Invoicing
As a small business, managing bills and invoices can be a complex and time-consuming task. Whether you receive bills from vendors or need to invoice clients, it's important to have a solution that makes the process simple, efficient, and cost-effective.
Here are some of the key benefits of using Expensify for bill payments and invoicing:
@@ -246,7 +246,7 @@ Reports, invoices, and bills are largely the same, in theory, just with differen
You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your policy settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card.
-### Step 13: Run monthly, quarterly and annual reporting
+## Step 13: Run monthly, quarterly and annual reporting
At this stage, reporting is important and given that Expensify is the primary point of entry for all employee spend, we make reporting visually appealing and wildly customizable.
1. Head to the *Expenses* tab on the far left of your left-hand navigation
@@ -261,7 +261,7 @@ We recommend reporting:
![Expenses!](https://help.expensify.com/assets/images/playbook-expenses.png){:width="100%"}
-### Step 14: Set your Subscription Size and Add a Payment card
+## Step 14: Set your Subscription Size and Add a Payment card
Our pricing model is unique in the sense that you are in full control of your billing. Meaning, you have the ability to set a minimum number of employees you know will be active each month and you can choose which level of commitment fits best. We recommend setting your subscription to *Annual* to get an additional 50% off on your monthly Expensify bill. In the end, you've spent enough time getting your company fully set up with Expensify, and you've seen how well it supports you and your employees. Committing annually just makes sense.
To set your subscription, head to:
@@ -280,5 +280,5 @@ Now that we’ve gone through all of the steps for setting up your account, let
3. Enter your name, card number, postal code, expiration and CVV
4. Click *Accept Terms*
-## You’re all set!
+# You’re all set!
Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you.
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md b/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md b/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
index 14ade143a35b..1a567dbe6fa3 100644
--- a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
+++ b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
@@ -6,7 +6,7 @@ description: A help article that covers Third Party Payment options including Pa
Expensify offers convenient third party payment options that allow you to streamline the process of reimbursing expenses and managing your finances. With these options, you can pay your expenses and get reimbursed faster and more efficiently. In this guide, we'll walk you through the steps to set up and use Expensify's third party payment options.
-## Overview
+# Overview
Expensify offers integration with various third party payment providers, making it easy to reimburse employees and manage your expenses seamlessly. Some of the key benefits of using third-party payment options in Expensify include:
@@ -14,7 +14,7 @@ Expensify offers integration with various third party payment providers, making
- Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers.
- Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow.
-## Setting Up Third Party Payments
+# Setting Up Third Party Payments
To get started with third party payments in Expensify, follow these steps:
@@ -30,7 +30,7 @@ To get started with third party payments in Expensify, follow these steps:
6. **Verify Your Account**: Confirm your linked account to ensure it's correctly integrated with Expensify.
-## Using Third Party Payments
+# Using Third Party Payments
Once you've set up your third party payment option, you can start using it to reimburse expenses and manage payments:
@@ -42,22 +42,18 @@ Once you've set up your third party payment option, you can start using it to re
4. **Track Payment Status**: You can track the status of payments and view transaction details within your Expensify account.
-## FAQ’s
+# FAQ’s
-### Q: Are there any fees associated with using third party payment options in Expensify?
+## Q: Are there any fees associated with using third party payment options in Expensify?
A: The fees associated with third party payments may vary depending on the payment provider you choose. Be sure to review the terms and conditions of your chosen provider for details on any applicable fees.
-### Q: Can I use multiple third party payment providers with Expensify?
+## Q: Can I use multiple third party payment providers with Expensify?
A: Expensify allows you to link multiple payment providers if needed. You can select the most suitable payment method for each expense report.
-### Q: Is there a limit on the amount I can reimburse using third party payments?
+## Q: Is there a limit on the amount I can reimburse using third party payments?
A: The reimbursement limit may depend on the policies and settings configured within your Expensify account and the limits imposed by your chosen payment provider.
With Expensify's third party payment options, you can simplify your expense management and reimbursement processes. By following the steps outlined in this guide, you can set up and use third party payments efficiently.
-
-
-
-
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
index 996d7896502f..17c7a60b8e5a 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
@@ -4,16 +4,16 @@ description: Best Practices for Admins settings up Expensify Chat
redirect_from: articles/other/Expensify-Chat-For-Admins/
---
-## Overview
+# Overview
Expensify Chat is an incredible way to build a community and foster long-term relationships between event producers and attendees, or attendees with each other. Admins are a huge factor in the success of the connections built in Expensify Chat during the events, as they are generally the drivers of the conference schedule, and help ensure safety and respect is upheld by all attendees both on and offline.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Admin Best Practices
+# Admin Best Practices
In order to get the most out of Expensify Chat, we created a list of best practices for admins to review in order to use the tool to its fullest capabilities.
**During the conference:**
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
index 20e15aaa6c72..30eeb4158902 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
@@ -4,19 +4,19 @@ description: Best Practices for Conference Attendees
redirect_from: articles/other/Expensify-Chat-For-Conference-Attendees/
---
-## Overview
+# Overview
Expensify Chat is the best way to meet and network with other event attendees. No more hunting down your contacts by walking the floor or trying to find someone in crowds at a party. Instead, you can use Expensify Chat to network and collaborate with others throughout the conference.
To help get you set up for a great event, we’ve created a guide to help you get the most out of using Expensify Chat at the event you’re attending.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your fellow attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Chat Best Practices
+# Chat Best Practices
To get the most out of your experience at your conference and engage people in a meaningful conversation that will fulfill your goals instead of turning people off, here are some tips on what to do and not to do as an event attendee using Expensify Chat:
**Do:**
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
index 3e19cf6fe26a..652fc2ee4d2b 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
@@ -4,17 +4,17 @@ description: Best Practices for Conference Speakers
redirect_from: articles/other/Expensify-Chat-For-Conference-Speakers/
---
-## Overview
+# Overview
Are you a speaker at an event? Great! We're delighted to provide you with an extraordinary opportunity to connect with your session attendees using Expensify Chat — before, during, and after the event. Expensify Chat offers a powerful platform for introducing yourself and your topic, fostering engaging discussions about your presentation, and maintaining the conversation with attendees even after your session is over.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Setting Up a Chatroom for Your Session: Checklist
+# Setting Up a Chatroom for Your Session: Checklist
To make the most of Expensify Chat for your session, here's a handy checklist:
- Confirm that your session has an Expensify Chat room, and have the URL link ready to share with attendees in advance.
- You can find the link by clicking on the avatar for your chatroom > “Share Code” > “Copy URL to dashboard”
@@ -22,7 +22,7 @@ To make the most of Expensify Chat for your session, here's a handy checklist:
- Consider having a session moderator with you on the day to assist with questions and discussions while you're presenting.
- Include the QR code for your session's chat room in your presentation slides. Displaying it prominently on every slide ensures that attendees can easily join the chat throughout your presentation.
-## Tips to Enhance Engagement Around Your Session
+# Tips to Enhance Engagement Around Your Session
By following these steps and utilizing Expensify Chat, you can elevate your session to promote valuable interactions with your audience, and leave a lasting impact beyond the conference. We can't wait to see your sessions thrive with the power of Expensify Chat!
**Before the event:**
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
index a81aef2044a2..caeccd1920b1 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
@@ -3,10 +3,10 @@ title: Expensify Chat Playbook for Conferences
description: Best practices for how to deploy Expensify Chat for your conference
redirect_from: articles/playbooks/Expensify-Chat-Playbook-for-Conferences/
---
-## Overview
+# Overview
To help make setting up Expensify Chat for your event and your attendees super simple, we’ve created a guide for all of the technical setup details.
-## Who you are
+# Who you are
As a conference organizer, you’re expected to amaze and inspire attendees. You want attendees to get to the right place on time, engage with the speakers, and create relationships with each other that last long after the conference is done. Enter Expensify Chat, a free feature that allows attendees to interact with organizers and other attendees in realtime. With Expensify Chat, you can:
- Communicate logistics and key information
@@ -21,20 +21,20 @@ Sounds good? Great! In order to ensure your team, your speakers, and your attend
*Let’s get started!*
-## Support
+# Support
Connect with your dedicated account manager in any new.expensify.com #admins room. Your account manager is excited to brainstorm the best ways to make the most out of your event and work through any questions you have about the setup steps below.
We also have a number of [moderation tools](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) available to admins to help make sure your event is seamless, safe, and fun!
-## Step by step instructions for setting up your conference on Expensify Chat
+# Step by step instructions for setting up your conference on Expensify Chat
Based on our experience running conferences atop Expensify Chat, we recommend the following simple steps:
-### Step 1: Create your event workspace in Expensify
+## Step 1: Create your event workspace in Expensify
To create your event workspace in Expensify:
1. In [new.expensify.com](https://new.expensify.com): “+” > “New workspace”
1. Name the workspace (e.g. “ExpensiCon”)
-### Step 2: Set up all the Expensify Chat rooms you want to feature at your event
+## Step 2: Set up all the Expensify Chat rooms you want to feature at your event
**Protip**: Your Expensify account manager can complete this step with you. Chat them in #admins on new.expensify.com to coordinate!
To create a new chat room:
@@ -54,7 +54,7 @@ For an easy-to-follow event, we recommend creating these chat rooms:
**Protip** Check out our [moderation tools](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) to help flag comments deemed to be spam, inconsiderate, intimidating, bullying, harassment, assault. On any comment just click the flag icon to moderate conversation.
-### Step 3: Add chat room QR codes to the applicable session slide deck
+## Step 3: Add chat room QR codes to the applicable session slide deck
Gather QR codes:
1. Go to [new.expensify.com](https://new.expensify.com)
1. Click into a room and click the room name or avatar in the top header
@@ -63,7 +63,7 @@ Gather QR codes:
Add the QR code to every slide so that if folks forget to scan the QR code at the beginning of the presentation, they can still join the discussion.
-### Step 4: Plan out your messaging and cadence before the event begins
+## Step 4: Plan out your messaging and cadence before the event begins
Expensify Chat is a great place to provide updates leading up to your event -- share news, get folks excited about speakers, and let attendees know of crucial event information like recommended attire, travel info, and more. For example, you might consider:
**Prep your announcements:**
@@ -80,15 +80,15 @@ Expensify Chat is a great place to provide updates leading up to your event -- s
**Protip**: Your account manager can help you create this document, and would be happy to send each message at the appointed time for you.
-### Step 5: Share Expensify Chat How-To Resources with Speakers, Attendees, Admins
+## Step 5: Share Expensify Chat How-To Resources with Speakers, Attendees, Admins
We’ve created a few helpful best practice docs for your speakers, admins, and attendees to help navigate using Expensify Chat at your event. Feel free to share the links below with them!
- [Expensify Chat for Conference Attendees](https://help.expensify.com/articles/other/Expensify-Chat-For-Conference-Attendees)
- [Expensify Chat for Conference Speakers](https://help.expensify.com/articles/other/Expensify-Chat-For-Conference-Speakers)
- [Expensify Chat for Admins](https://help.expensify.com/articles/other/Expensify-Chat-For-Admins)
-### Step 6: Follow up with attendees after the event
+## Step 6: Follow up with attendees after the event
Continue the connections by using Expensify Chat to keep your conference community connected. Encourage attendees to share photos, their favorite memories, funny stories, and more.
-## Conclusion
+# Conclusion
Once you have completed the above steps you are ready to host your conference on Expensify Chat! Let your account manager know any questions you have over in your [new.expensify.com](https://new.expensify.com) #admins room and start driving activity in your Expensify Chat rooms. Once you’ve reviewed this doc you should have the foundations in place, so a great next step is to start training your speakers on how to use Expensify Chat for their sessions. Coordinate with your account manager to make sure everything goes smoothly!
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index f022cf3452a8..32d356a96cf8 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.85
+ 1.3.86
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.85.0
+ 1.3.86.1
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index db42a9dc6d55..7fa3d841d5d8 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.3.85
+ 1.3.86
CFBundleSignature
????
CFBundleVersion
- 1.3.85.0
+ 1.3.86.1
diff --git a/package-lock.json b/package-lock.json
index 922c2a158654..b575c151364d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.85-0",
+ "version": "1.3.86-1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.85-0",
+ "version": "1.3.86-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index cd5c6034161a..993b8d165ed0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.85-0",
+ "version": "1.3.86-1",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.ts b/src/CONST.ts
index 501bc2e3aa19..e2f3fea08215 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -131,7 +131,6 @@ const CONST = {
DESKTOP: `${ACTIVE_EXPENSIFY_URL}NewExpensify.dmg`,
},
DATE: {
- MOMENT_FORMAT_STRING: 'YYYY-MM-DD',
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
LOCAL_TIME_FORMAT: 'h:mm a',
@@ -1298,7 +1297,7 @@ const CONST = {
SPECIAL_CHAR: /[,/?"{}[\]()&^%;`$=#<>!*]/g,
get SPECIAL_CHAR_OR_EMOJI() {
- return new RegExp(`[_~\\n\\s]|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
+ return new RegExp(`[~\\n\\s]|(_\\b(?!$))|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
},
get SPACE_OR_EMOJI() {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index db7af59a30e8..a9c41c1fc205 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1,4 +1,5 @@
import {ValueOf} from 'type-fest';
+import {OnyxEntry} from 'react-native-onyx/lib/types';
import DeepValueOf from './types/utils/DeepValueOf';
import * as OnyxTypes from './types/onyx';
import CONST from './CONST';
@@ -431,5 +432,7 @@ type OnyxValues = {
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form;
};
+type OnyxKeyValue = OnyxEntry;
+
export default ONYXKEYS;
-export type {OnyxKey, OnyxCollectionKey, OnyxValues};
+export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index b7896225557d..a677b7192fac 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -292,6 +292,11 @@ export default {
I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher',
INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal',
+ ERECEIPT: {
+ route: 'eReceipt/:transactionID',
+ getRoute: (transactionID: string) => `eReceipt/${transactionID}`,
+ },
+
WORKSPACE_NEW: 'workspace/new',
WORKSPACE_NEW_ROOM: 'workspace/new-room',
WORKSPACE_INITIAL: {
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
index 8a623a44709f..dae0191b2158 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -53,7 +53,7 @@ function extractAttachmentsFromReport(report, reportActions) {
const transaction = TransactionUtils.getTransaction(transactionID);
if (TransactionUtils.hasReceipt(transaction)) {
- const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction);
attachments.unshift({
source: tryResolveUrlFromApiRoot(image),
isAuthTokenRequired: true,
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index a1b07fb99dd8..34ff45160ce9 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -1,5 +1,5 @@
import React, {memo, useState} from 'react';
-import {View, ActivityIndicator} from 'react-native';
+import {View, ScrollView, ActivityIndicator} from 'react-native';
import _ from 'underscore';
import PropTypes from 'prop-types';
import Str from 'expensify-common/lib/str';
@@ -22,6 +22,7 @@ import * as TransactionUtils from '../../../libs/TransactionUtils';
import DistanceEReceipt from '../../DistanceEReceipt';
import useNetwork from '../../../hooks/useNetwork';
import ONYXKEYS from '../../../ONYXKEYS';
+import EReceipt from '../../EReceipt';
const propTypes = {
...attachmentViewPropTypes,
@@ -101,6 +102,19 @@ function AttachmentView({
);
}
+ if (TransactionUtils.hasEReceipt(transaction)) {
+ return (
+
+
+
+
+
+ );
+ }
+
// Check both source and file.name since PDFs dragged into the text field
// will appear with a source that is a blob
if ((_.isString(source) && Str.isPDF(source)) || (file && Str.isPDF(file.name || translate('attachmentView.unknownFilename')))) {
diff --git a/src/components/Button/index.js b/src/components/Button/index.js
index dc12a4ded5c2..16654ce87d30 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.js
@@ -9,8 +9,6 @@ import Icon from '../Icon';
import CONST from '../../CONST';
import * as StyleUtils from '../../styles/StyleUtils';
import HapticFeedback from '../../libs/HapticFeedback';
-import withNavigationFallback from '../withNavigationFallback';
-import compose from '../../libs/compose';
import * as Expensicons from '../Icon/Expensicons';
import withNavigationFocus from '../withNavigationFocus';
import validateSubmitShortcut from './validateSubmitShortcut';
@@ -328,10 +326,7 @@ class Button extends Component {
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;
-export default compose(
- withNavigationFallback,
- withNavigationFocus,
-)(
+export default withNavigationFocus(
React.forwardRef((props, ref) => (
0, false, searchValue);
+ const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
const shouldShowTextInput = !isCategoriesCountBelowThreshold;
return (
diff --git a/src/components/ComposeProviders.js b/src/components/ComposeProviders.js
deleted file mode 100644
index edcc0a917c51..000000000000
--- a/src/components/ComposeProviders.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import _ from 'underscore';
-import React from 'react';
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** Provider components go here */
- components: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.object, PropTypes.func])).isRequired,
-
- /** Rendered child component */
- children: PropTypes.node.isRequired,
-};
-
-function ComposeProviders(props) {
- return (
- <>
- {_.reduceRight(
- props.components,
- (memo, Component) => (
- {memo}
- ),
- props.children,
- )}
- >
- );
-}
-
-ComposeProviders.propTypes = propTypes;
-ComposeProviders.displayName = 'ComposeProviders';
-export default ComposeProviders;
diff --git a/src/components/ComposeProviders.tsx b/src/components/ComposeProviders.tsx
new file mode 100644
index 000000000000..bff36db25533
--- /dev/null
+++ b/src/components/ComposeProviders.tsx
@@ -0,0 +1,14 @@
+import React, {ComponentType, ReactNode} from 'react';
+import ChildrenProps from '../types/utils/ChildrenProps';
+
+type ComposeProvidersProps = ChildrenProps & {
+ /** Provider components go here */
+ components: Array>;
+};
+
+function ComposeProviders(props: ComposeProvidersProps): ReactNode {
+ return props.components.reduceRight((memo, Component) => {memo} , props.children);
+}
+
+ComposeProviders.displayName = 'ComposeProviders';
+export default ComposeProviders;
diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js
index 8bd5d890c42c..f896023d386b 100644
--- a/src/components/DatePicker/datepickerPropTypes.js
+++ b/src/components/DatePicker/datepickerPropTypes.js
@@ -6,13 +6,13 @@ const propTypes = {
...fieldPropTypes,
/**
- * The datepicker supports any value that `moment` can parse.
+ * The datepicker supports any value that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
*/
value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
/**
- * The datepicker supports any defaultValue that `moment` can parse.
+ * The datepicker supports any defaultValue that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
*/
defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js
index 5bdda580d357..24faf2b19745 100644
--- a/src/components/DatePicker/index.android.js
+++ b/src/components/DatePicker/index.android.js
@@ -1,7 +1,7 @@
import React from 'react';
import {Keyboard} from 'react-native';
import RNDatePicker from '@react-native-community/datetimepicker';
-import moment from 'moment';
+import {format} from 'date-fns';
import _ from 'underscore';
import TextInput from '../TextInput';
import CONST from '../../CONST';
@@ -28,8 +28,7 @@ class DatePicker extends React.Component {
this.setState({isPickerVisible: false});
if (event.type === 'set') {
- const asMoment = moment(selectedDate, true);
- this.props.onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ this.props.onInputChange(format(selectedDate, CONST.DATE.FNS_FORMAT_STRING));
}
}
@@ -39,7 +38,8 @@ class DatePicker extends React.Component {
}
render() {
- const dateAsText = this.props.value || this.props.defaultValue ? moment(this.props.value || this.props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+ const date = this.props.value || this.props.defaultValue;
+ const dateAsText = date ? format(new Date(date), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
@@ -73,7 +73,7 @@ class DatePicker extends React.Component {
/>
{this.state.isPickerVisible && (
{
setIsPickerVisible(false);
- const asMoment = moment(selectedDate, true);
- onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ onInputChange(format(selectedDate, CONST.DATE.FNS_FORMAT_STRING));
};
/**
@@ -77,7 +77,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca
setSelectedDate(date);
};
- const dateAsText = value || defaultValue ? moment(value || defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+ const dateAsText = dateValue ? format(new Date(dateValue), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index d14886fd1c59..e0672f847295 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,5 +1,5 @@
import React, {useEffect, useRef} from 'react';
-import moment from 'moment';
+import {format, isValid} from 'date-fns';
import _ from 'underscore';
import TextInput from '../TextInput';
import CONST from '../../CONST';
@@ -13,8 +13,8 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
useEffect(() => {
// Adds nice native datepicker on web/desktop. Not possible to set this through props
inputRef.current.setAttribute('type', 'date');
- inputRef.current.setAttribute('max', moment(maxDate).format(CONST.DATE.MOMENT_FORMAT_STRING));
- inputRef.current.setAttribute('min', moment(minDate).format(CONST.DATE.MOMENT_FORMAT_STRING));
+ inputRef.current.setAttribute('max', format(new Date(maxDate), CONST.DATE.FNS_FORMAT_STRING));
+ inputRef.current.setAttribute('min', format(new Date(minDate), CONST.DATE.FNS_FORMAT_STRING));
inputRef.current.classList.add('expensify-datepicker');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -29,9 +29,9 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
return;
}
- const asMoment = moment(text, true);
- if (asMoment.isValid()) {
- onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ const date = new Date(text);
+ if (isValid(date)) {
+ onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING));
}
};
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.js
index c96adfee9ba0..b0f6e0410ad5 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.js
+++ b/src/components/DistanceRequest/DistanceRequestFooter.js
@@ -115,7 +115,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
pitchEnabled={false}
initialState={{
zoom: CONST.MAPBOX.DEFAULT_ZOOM,
- location: CONST.MAPBOX.DEFAULT_COORDINATE,
+ location: lodashGet(waypointMarkers, [0, 'coordinate'], CONST.MAPBOX.DEFAULT_COORDINATE),
}}
directionCoordinates={lodashGet(transaction, 'routes.route0.geometry.coordinates', [])}
style={styles.mapView}
diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js
index 416fefc5af89..db0571cdcdaf 100644
--- a/src/components/DistanceRequest/index.js
+++ b/src/components/DistanceRequest/index.js
@@ -2,7 +2,6 @@ import React, {useCallback, useEffect, useMemo, useState, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import lodashIsEmpty from 'lodash/isEmpty';
import PropTypes from 'prop-types';
import _ from 'underscore';
import ROUTES from '../../ROUTES';
@@ -169,8 +168,7 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe
const newWaypoints = {};
_.each(data, (waypoint, index) => {
- const newWaypoint = lodashGet(waypoints, waypoint, {});
- newWaypoints[`waypoint${index}`] = lodashIsEmpty(newWaypoint) ? null : newWaypoint;
+ newWaypoints[`waypoint${index}`] = lodashGet(waypoints, waypoint, {});
});
setOptimisticWaypoints(newWaypoints);
diff --git a/src/components/EReceipt.js b/src/components/EReceipt.js
index e6b3a9809c7e..84daabb96c9b 100644
--- a/src/components/EReceipt.js
+++ b/src/components/EReceipt.js
@@ -59,7 +59,7 @@ function EReceipt({transaction, transactionID}) {
-
+
{currency}
diff --git a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
index 3023a9abf95c..2f84d38ccbc6 100644
--- a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
+++ b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
@@ -28,12 +28,18 @@ function EmojiPickerButtonDropdown(props) {
const emojiPopoverAnchor = useRef(null);
useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []);
- const onPress = () =>
+ const onPress = () => {
+ if (EmojiPickerAction.isEmojiPickerVisible()) {
+ EmojiPickerAction.hideEmojiPicker();
+ return;
+ }
+
EmojiPickerAction.showEmojiPicker(props.onModalHide, (emoji) => props.onInputChange(emoji), emojiPopoverAnchor.current, {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
shiftVertical: 4,
});
+ };
return (
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 3a7551a872e9..0d7826ff3783 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -1,4 +1,4 @@
-import React, {Component} from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View, FlatList} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
@@ -8,7 +8,7 @@ import CONST from '../../../CONST';
import ONYXKEYS from '../../../ONYXKEYS';
import styles from '../../../styles/styles';
import * as StyleUtils from '../../../styles/StyleUtils';
-import emojis from '../../../../assets/emojis';
+import emojiAssets from '../../../../assets/emojis';
import EmojiPickerMenuItem from '../EmojiPickerMenuItem';
import Text from '../../Text';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions';
@@ -18,6 +18,7 @@ import getOperatingSystem from '../../../libs/getOperatingSystem';
import * as User from '../../../libs/actions/User';
import EmojiSkinToneList from '../EmojiSkinToneList';
import * as EmojiUtils from '../../../libs/EmojiUtils';
+import * as Browser from '../../../libs/Browser';
import CategoryShortcutBar from '../CategoryShortcutBar';
import TextInput from '../../TextInput';
import isEnterWhileComposition from '../../../libs/KeyboardShortcut/isEnterWhileComposition';
@@ -32,7 +33,6 @@ const propTypes = {
/** Stores user's preferred skin tone */
preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-
/** Stores user's frequently used emojis */
// eslint-disable-next-line react/forbid-prop-types
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.object),
@@ -49,105 +49,35 @@ const defaultProps = {
frequentlyUsedEmojis: [],
};
-class EmojiPickerMenu extends Component {
- constructor(props) {
- super(props);
-
- // Ref for the emoji search input
- this.searchInput = undefined;
-
- // Ref for emoji FlatList
- this.emojiList = undefined;
-
- // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
- // prevent auto focus when open picker for mobile device
- this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
-
- this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
- this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
- this.setupEventHandlers = this.setupEventHandlers.bind(this);
- this.cleanupEventHandlers = this.cleanupEventHandlers.bind(this);
- this.renderItem = this.renderItem.bind(this);
- this.isMobileLandscape = this.isMobileLandscape.bind(this);
- this.onSelectionChange = this.onSelectionChange.bind(this);
- this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this);
- this.setFirstNonHeaderIndex = this.setFirstNonHeaderIndex.bind(this);
- this.getItemLayout = this.getItemLayout.bind(this);
- this.scrollToHeader = this.scrollToHeader.bind(this);
-
- this.firstNonHeaderIndex = 0;
-
- const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices();
- this.emojis = filteredEmojis;
- this.headerEmojis = headerEmojis;
- this.headerRowIndices = headerRowIndices;
-
- this.state = {
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- highlightedIndex: -1,
- arePointerEventsDisabled: false,
- selection: {
- start: 0,
- end: 0,
- },
- isFocused: false,
- isUsingKeyboardMovement: false,
- };
- }
+const throttleTime = Browser.isMobile() ? 200 : 50;
- componentDidMount() {
- // This callback prop is used by the parent component using the constructor to
- // get a ref to the inner textInput element e.g. if we do
- // this.textInput = el} /> this will not
- // return a ref to the component, but rather the HTML element by default
- if (this.shouldFocusInputOnScreenFocus && this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
- this.props.forwardedRef(this.searchInput);
- }
- this.setupEventHandlers();
- this.setFirstNonHeaderIndex(this.emojis);
- }
+function EmojiPickerMenu(props) {
+ const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, isSmallScreenWidth, windowHeight, translate} = props;
- componentDidUpdate(prevProps) {
- if (prevProps.frequentlyUsedEmojis === this.props.frequentlyUsedEmojis) {
- return;
- }
+ // Ref for the emoji search input
+ const searchInputRef = useRef(null);
- const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices();
- this.emojis = filteredEmojis;
- this.headerEmojis = headerEmojis;
- this.headerRowIndices = headerRowIndices;
- this.setState({
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- });
- }
+ // Ref for emoji FlatList
+ const emojiListRef = useRef(null);
- componentWillUnmount() {
- this.cleanupEventHandlers();
- }
+ // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+ // prevent auto focus when open picker for mobile device
+ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
- /**
- * On text input selection change
- *
- * @param {Event} event
- */
- onSelectionChange(event) {
- this.setState({selection: event.nativeEvent.selection});
- }
+ const firstNonHeaderIndex = useRef(0);
/**
* Calculate the filtered + header emojis and header row indices
* @returns {Object}
*/
- getEmojisAndHeaderRowIndices() {
+ function getEmojisAndHeaderRowIndices() {
// If we're on Windows, don't display the flag emojis (the last category),
// since Windows doesn't support them
- const flagHeaderIndex = _.findIndex(emojis, (emoji) => emoji.header && emoji.code === 'flags');
+ const flagHeaderIndex = _.findIndex(emojiAssets, (emoji) => emoji.header && emoji.code === 'flags');
const filteredEmojis =
getOperatingSystem() === CONST.OS.WINDOWS
- ? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis.slice(0, flagHeaderIndex))
- : EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis);
+ ? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets.slice(0, flagHeaderIndex))
+ : EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets);
// Get the header emojis along with the code, index and icon.
// index is the actual header index starting at the first emoji and counting each one
@@ -161,76 +91,57 @@ class EmojiPickerMenu extends Component {
return {filteredEmojis, headerEmojis, headerRowIndices};
}
+ const emojis = useRef([]);
+ if (emojis.current.length === 0) {
+ emojis.current = getEmojisAndHeaderRowIndices().filteredEmojis;
+ }
+ const headerRowIndices = useRef([]);
+ if (headerRowIndices.current.length === 0) {
+ headerRowIndices.current = getEmojisAndHeaderRowIndices().headerRowIndices;
+ }
+ const [headerEmojis, setHeaderEmojis] = useState(() => getEmojisAndHeaderRowIndices().headerEmojis);
+
+ const [filteredEmojis, setFilteredEmojis] = useState(emojis.current);
+ const [headerIndices, setHeaderIndices] = useState(headerRowIndices.current);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false);
+ const [selection, setSelection] = useState({start: 0, end: 0});
+ const [isFocused, setIsFocused] = useState(false);
+ const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false);
+ const [selectTextOnFocus, setSelectTextOnFocus] = useState(false);
+
+ useEffect(() => {
+ const emojisAndHeaderRowIndices = getEmojisAndHeaderRowIndices();
+ emojis.current = emojisAndHeaderRowIndices.filteredEmojis;
+ headerRowIndices.current = emojisAndHeaderRowIndices.headerRowIndices;
+ setHeaderEmojis(emojisAndHeaderRowIndices.headerEmojis);
+ setFilteredEmojis(emojis.current);
+ setHeaderIndices(headerRowIndices.current);
+ }, [frequentlyUsedEmojis]);
+
/**
- * Find and store index of the first emoji item
- * @param {Array} filteredEmojis
+ * On text input selection change
+ *
+ * @param {Event} event
*/
- setFirstNonHeaderIndex(filteredEmojis) {
- this.firstNonHeaderIndex = _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header);
- }
+ const onSelectionChange = useCallback((event) => {
+ setSelection(event.nativeEvent.selection);
+ }, []);
/**
- * Setup and attach keypress/mouse handlers for highlight navigation.
+ * Find and store index of the first emoji item
+ * @param {Array} filteredEmojisArr
*/
- setupEventHandlers() {
- if (!document) {
+ function updateFirstNonHeaderIndex(filteredEmojisArr) {
+ firstNonHeaderIndex.current = _.findIndex(filteredEmojisArr, (item) => !item.spacer && !item.header);
+ }
+
+ const mouseMoveHandler = useCallback(() => {
+ if (!arePointerEventsDisabled) {
return;
}
-
- this.keyDownHandler = (keyBoardEvent) => {
- if (keyBoardEvent.key.startsWith('Arrow')) {
- if (!this.state.isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
- keyBoardEvent.preventDefault();
- }
-
- // Move the highlight when arrow keys are pressed
- this.highlightAdjacentEmoji(keyBoardEvent.key);
- return;
- }
-
- // Select the currently highlighted emoji if enter is pressed
- if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && this.state.highlightedIndex !== -1) {
- const item = this.state.filteredEmojis[this.state.highlightedIndex];
- if (!item) {
- return;
- }
- const emoji = lodashGet(item, ['types', this.props.preferredSkinTone], item.code);
- this.props.onEmojiSelected(emoji, item);
- return;
- }
-
- // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input
- // is not focused, so that the navigation and tab cycling can be done using the keyboard without
- // interfering with the input behaviour.
- if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && this.searchInput && !this.searchInput.isFocused())) {
- this.setState({isUsingKeyboardMovement: true});
- return;
- }
-
- // We allow typing in the search box if any key is pressed apart from Arrow keys.
- if (this.searchInput && !this.searchInput.isFocused()) {
- this.setState({selectTextOnFocus: false});
- this.searchInput.focus();
-
- // Re-enable selection on the searchInput
- this.setState({selectTextOnFocus: true});
- }
- };
-
- // Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
- // event handler attached to document root. To fix this, trigger event handler in Capture phase.
- document.addEventListener('keydown', this.keyDownHandler, true);
-
- // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
- this.mouseMoveHandler = () => {
- if (!this.state.arePointerEventsDisabled) {
- return;
- }
-
- this.setState({arePointerEventsDisabled: false});
- };
- document.addEventListener('mousemove', this.mouseMoveHandler);
- }
+ setArePointerEventsDisabled(false);
+ }, [arePointerEventsDisabled]);
/**
* This function will be used with FlatList getItemLayout property for optimization purpose that allows skipping
@@ -242,179 +153,254 @@ class EmojiPickerMenu extends Component {
* @param {Number} index row index
* @returns {Object}
*/
- getItemLayout(data, index) {
- return {length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index};
- }
+ const getItemLayout = useCallback((data, index) => ({length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}), []);
/**
- * Cleanup all mouse/keydown event listeners that we've set up
+ * Focuses the search Input and has the text selected
*/
- cleanupEventHandlers() {
- if (!document) {
+ function focusInputWithTextSelect() {
+ if (!searchInputRef.current) {
return;
}
- document.removeEventListener('keydown', this.keyDownHandler, true);
- document.removeEventListener('mousemove', this.mouseMoveHandler);
+ setSelectTextOnFocus(true);
+ searchInputRef.current.focus();
}
- /**
- * Focuses the search Input and has the text selected
- */
- focusInputWithTextSelect() {
- if (!this.searchInput) {
+ const filterEmojis = _.throttle((searchTerm) => {
+ const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
+ if (emojiListRef.current) {
+ emojiListRef.current.scrollToOffset({offset: 0, animated: false});
+ }
+ if (normalizedSearchTerm === '') {
+ // There are no headers when searching, so we need to re-make them sticky when there is no search term
+ setFilteredEmojis(emojis.current);
+ setHeaderIndices(headerRowIndices.current);
+ setHighlightedIndex(-1);
+ updateFirstNonHeaderIndex(emojis.current);
return;
}
+ const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, emojis.current.length);
- this.setState({selectTextOnFocus: true});
- this.searchInput.focus();
- }
+ // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
+ setFilteredEmojis(newFilteredEmojiList);
+ setHeaderIndices([]);
+ setHighlightedIndex(0);
+ updateFirstNonHeaderIndex(newFilteredEmojiList);
+ }, throttleTime);
/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
- highlightAdjacentEmoji(arrowKey) {
- if (this.state.filteredEmojis.length === 0) {
- return;
- }
-
- // Arrow Down and Arrow Right enable arrow navigation when search is focused
- if (this.searchInput && this.searchInput.isFocused()) {
- if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
+ const highlightAdjacentEmoji = useCallback(
+ (arrowKey) => {
+ if (filteredEmojis.length === 0) {
return;
}
- if (arrowKey === 'ArrowRight' && !(this.searchInput.value.length === this.state.selection.start && this.state.selection.start === this.state.selection.end)) {
+ // Arrow Down and Arrow Right enable arrow navigation when search is focused
+ if (searchInputRef.current && searchInputRef.current.isFocused()) {
+ if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
+ return;
+ }
+
+ if (arrowKey === 'ArrowRight' && !(searchInputRef.current.value.length === selection.start && selection.start === selection.end)) {
+ return;
+ }
+
+ // Blur the input, change the highlight type to keyboard, and disable pointer events
+ searchInputRef.current.blur();
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
+
+ // We only want to hightlight the Emoji if none was highlighted already
+ // If we already have a highlighted Emoji, lets just skip the first navigation
+ if (highlightedIndex !== -1) {
+ return;
+ }
+ }
+
+ // If nothing is highlighted and an arrow key is pressed
+ // select the first emoji, apply keyboard movement styles, and disable pointer events
+ if (highlightedIndex === -1) {
+ setHighlightedIndex(firstNonHeaderIndex.current);
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
return;
}
- // Blur the input, change the highlight type to keyboard, and disable pointer events
- this.searchInput.blur();
- this.setState({isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
+ let newIndex = highlightedIndex;
+ const move = (steps, boundsCheck, onBoundReached = () => {}) => {
+ if (boundsCheck()) {
+ onBoundReached();
+ return;
+ }
- // We only want to hightlight the Emoji if none was highlighted already
- // If we already have a highlighted Emoji, lets just skip the first navigation
- if (this.state.highlightedIndex !== -1) {
- return;
+ // Move in the prescribed direction until we reach an element that isn't a header
+ const isHeader = (e) => e.header || e.spacer;
+ do {
+ newIndex += steps;
+ if (newIndex < 0) {
+ break;
+ }
+ } while (isHeader(filteredEmojis[newIndex]));
+ };
+
+ switch (arrowKey) {
+ case 'ArrowDown':
+ move(CONST.EMOJI_NUM_PER_ROW, () => highlightedIndex + CONST.EMOJI_NUM_PER_ROW > filteredEmojis.length - 1);
+ break;
+ case 'ArrowLeft':
+ move(
+ -1,
+ () => highlightedIndex - 1 < firstNonHeaderIndex.current,
+ () => {
+ // Reaching start of the list, arrow left set the focus to searchInput.
+ focusInputWithTextSelect();
+ newIndex = -1;
+ },
+ );
+ break;
+ case 'ArrowRight':
+ move(1, () => highlightedIndex + 1 > filteredEmojis.length - 1);
+ break;
+ case 'ArrowUp':
+ move(
+ -CONST.EMOJI_NUM_PER_ROW,
+ () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex.current,
+ () => {
+ // Reaching start of the list, arrow up set the focus to searchInput.
+ focusInputWithTextSelect();
+ newIndex = -1;
+ },
+ );
+ break;
+ default:
+ break;
}
- }
- // If nothing is highlighted and an arrow key is pressed
- // select the first emoji, apply keyboard movement styles, and disable pointer events
- if (this.state.highlightedIndex === -1) {
- this.setState({highlightedIndex: this.firstNonHeaderIndex, isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
- return;
- }
+ // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
+ if (newIndex !== highlightedIndex) {
+ setHighlightedIndex(newIndex);
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
+ }
+ },
+ [filteredEmojis, highlightedIndex, selection.end, selection.start],
+ );
- let newIndex = this.state.highlightedIndex;
- const move = (steps, boundsCheck, onBoundReached = () => {}) => {
- if (boundsCheck()) {
- onBoundReached();
+ const keyDownHandler = useCallback(
+ (keyBoardEvent) => {
+ if (keyBoardEvent.key.startsWith('Arrow')) {
+ if (!isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
+ keyBoardEvent.preventDefault();
+ }
+
+ // Move the highlight when arrow keys are pressed
+ highlightAdjacentEmoji(keyBoardEvent.key);
return;
}
- // Move in the prescribed direction until we reach an element that isn't a header
- const isHeader = (e) => e.header || e.spacer;
- do {
- newIndex += steps;
- if (newIndex < 0) {
- break;
+ // Select the currently highlighted emoji if enter is pressed
+ if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && highlightedIndex !== -1) {
+ const item = filteredEmojis[highlightedIndex];
+ if (!item) {
+ return;
}
- } while (isHeader(this.state.filteredEmojis[newIndex]));
- };
+ const emoji = lodashGet(item, ['types', preferredSkinTone], item.code);
+ onEmojiSelected(emoji, item);
+ return;
+ }
- switch (arrowKey) {
- case 'ArrowDown':
- move(CONST.EMOJI_NUM_PER_ROW, () => this.state.highlightedIndex + CONST.EMOJI_NUM_PER_ROW > this.state.filteredEmojis.length - 1);
- break;
- case 'ArrowLeft':
- move(
- -1,
- () => this.state.highlightedIndex - 1 < this.firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow left set the focus to searchInput.
- this.focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- case 'ArrowRight':
- move(1, () => this.state.highlightedIndex + 1 > this.state.filteredEmojis.length - 1);
- break;
- case 'ArrowUp':
- move(
- -CONST.EMOJI_NUM_PER_ROW,
- () => this.state.highlightedIndex - CONST.EMOJI_NUM_PER_ROW < this.firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow up set the focus to searchInput.
- this.focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- default:
- break;
- }
+ // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input
+ // is not focused, so that the navigation and tab cycling can be done using the keyboard without
+ // interfering with the input behaviour.
+ if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) {
+ setIsUsingKeyboardMovement(true);
+ return;
+ }
- // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
- if (newIndex !== this.state.highlightedIndex) {
- this.setState({highlightedIndex: newIndex, isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
- }
- }
+ // We allow typing in the search box if any key is pressed apart from Arrow keys.
+ if (searchInputRef.current && !searchInputRef.current.isFocused()) {
+ setSelectTextOnFocus(false);
+ searchInputRef.current.focus();
- scrollToHeader(headerIndex) {
- const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
- this.emojiList.flashScrollIndicators();
- this.emojiList.scrollToOffset({offset: calculatedOffset, animated: true});
- }
+ // Re-enable selection on the searchInput
+ setSelectTextOnFocus(true);
+ }
+ },
+ [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone],
+ );
/**
- * Filter the entire list of emojis to only emojis that have the search term in their keywords
- *
- * @param {String} searchTerm
+ * Setup and attach keypress/mouse handlers for highlight navigation.
*/
- filterEmojis(searchTerm) {
- const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
- if (this.emojiList) {
- this.emojiList.scrollToOffset({offset: 0, animated: false});
- }
- if (normalizedSearchTerm === '') {
- // There are no headers when searching, so we need to re-make them sticky when there is no search term
- this.setState({
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- highlightedIndex: -1,
- });
- this.setFirstNonHeaderIndex(this.emojis);
+ const setupEventHandlers = useCallback(() => {
+ if (!document) {
return;
}
- const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, this.props.preferredLocale, this.emojis.length);
- // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
- this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: [], highlightedIndex: 0});
- this.setFirstNonHeaderIndex(newFilteredEmojiList);
- }
+ // Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
+ // event handler attached to document root. To fix this, trigger event handler in Capture phase.
+ document.addEventListener('keydown', keyDownHandler, true);
+
+ // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
+ document.addEventListener('mousemove', mouseMoveHandler);
+ }, [keyDownHandler, mouseMoveHandler]);
/**
- * Check if its a landscape mode of mobile device
- *
- * @returns {Boolean}
+ * Cleanup all mouse/keydown event listeners that we've set up
*/
- isMobileLandscape() {
- return this.props.isSmallScreenWidth && this.props.windowWidth >= this.props.windowHeight;
- }
+ const cleanupEventHandlers = useCallback(() => {
+ if (!document) {
+ return;
+ }
+
+ document.removeEventListener('keydown', keyDownHandler, true);
+ document.removeEventListener('mousemove', mouseMoveHandler);
+ }, [keyDownHandler, mouseMoveHandler]);
+
+ useEffect(() => {
+ // This callback prop is used by the parent component using the constructor to
+ // get a ref to the inner textInput element e.g. if we do
+ // this.textInput = el} /> this will not
+ // return a ref to the component, but rather the HTML element by default
+ if (shouldFocusInputOnScreenFocus && forwardedRef && _.isFunction(forwardedRef)) {
+ forwardedRef(searchInputRef.current);
+ }
+
+ setupEventHandlers();
+ updateFirstNonHeaderIndex(emojis.current);
+
+ return () => {
+ cleanupEventHandlers();
+ };
+ }, [forwardedRef, shouldFocusInputOnScreenFocus, cleanupEventHandlers, setupEventHandlers]);
+
+ const scrollToHeader = useCallback((headerIndex) => {
+ if (!emojiListRef.current) {
+ return;
+ }
+
+ const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
+ emojiListRef.current.flashScrollIndicators();
+ emojiListRef.current.scrollToOffset({offset: calculatedOffset, animated: true});
+ }, []);
/**
* @param {Number} skinTone
*/
- updatePreferredSkinTone(skinTone) {
- if (this.props.preferredSkinTone === skinTone) {
- return;
- }
+ const updatePreferredSkinTone = useCallback(
+ (skinTone) => {
+ if (Number(preferredSkinTone) === Number(skinTone)) {
+ return;
+ }
- User.updatePreferredSkinTone(skinTone);
- }
+ User.updatePreferredSkinTone(skinTone);
+ },
+ [preferredSkinTone],
+ );
/**
* Return a unique key for each emoji item
@@ -423,9 +409,7 @@ class EmojiPickerMenu extends Component {
* @param {Number} index
* @returns {String}
*/
- keyExtractor(item, index) {
- return `emoji_picker_${item.code}_${index}`;
- }
+ const keyExtractor = useCallback((item, index) => `emoji_picker_${item.code}_${index}`, []);
/**
* Given an emoji item object, render a component based on its type.
@@ -436,109 +420,112 @@ class EmojiPickerMenu extends Component {
* @param {Number} index
* @returns {*}
*/
- renderItem({item, index}) {
- const {code, header, types} = item;
- if (item.spacer) {
- return null;
- }
+ const renderItem = useCallback(
+ ({item, index}) => {
+ const {code, header, types} = item;
+ if (item.spacer) {
+ return null;
+ }
- if (header) {
- return (
-
- {this.props.translate(`emojiPicker.headers.${code}`)}
-
- );
- }
+ if (header) {
+ return (
+
+ {translate(`emojiPicker.headers.${code}`)}
+
+ );
+ }
- const emojiCode = types && types[this.props.preferredSkinTone] ? types[this.props.preferredSkinTone] : code;
+ const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code;
- const isEmojiFocused = index === this.state.highlightedIndex && this.state.isUsingKeyboardMovement;
+ const isEmojiFocused = index === highlightedIndex && isUsingKeyboardMovement;
- return (
- this.props.onEmojiSelected(emoji, item)}
- onHoverIn={() => {
- if (!this.state.isUsingKeyboardMovement) {
- return;
- }
- this.setState({isUsingKeyboardMovement: false});
- }}
- emoji={emojiCode}
- onFocus={() => this.setState({highlightedIndex: index})}
- onBlur={() =>
- this.setState((prevState) => ({
+ return (
+ onEmojiSelected(emoji, item)}
+ onHoverIn={() => {
+ if (!isUsingKeyboardMovement) {
+ return;
+ }
+ setIsUsingKeyboardMovement(false);
+ }}
+ emoji={emojiCode}
+ onFocus={() => setHighlightedIndex(index)}
+ onBlur={() =>
// Only clear the highlighted index if the highlighted index is the same,
// meaning that the focus changed to an element that is not an emoji item.
- highlightedIndex: prevState.highlightedIndex === index ? -1 : prevState.highlightedIndex,
- }))
- }
- isFocused={isEmojiFocused}
- />
- );
- }
-
- render() {
- const isFiltered = this.emojis.length !== this.state.filteredEmojis.length;
- const listStyle = StyleUtils.getEmojiPickerListHeight(isFiltered, this.props.windowHeight);
- const height = !listStyle.maxHeight || listStyle.height < listStyle.maxHeight ? listStyle.height : listStyle.maxHeight;
- const overflowLimit = Math.floor(height / CONST.EMOJI_PICKER_ITEM_HEIGHT) * 8;
- return (
-
-
- (this.searchInput = el)}
- autoFocus={this.shouldFocusInputOnScreenFocus}
- selectTextOnFocus={this.state.selectTextOnFocus}
- onSelectionChange={this.onSelectionChange}
- onFocus={() => this.setState({isFocused: true, highlightedIndex: -1, isUsingKeyboardMovement: false})}
- onBlur={() => this.setState({isFocused: false})}
- autoCorrect={false}
- blurOnSubmit={this.state.filteredEmojis.length > 0}
- />
-
- {!isFiltered && (
-
- )}
- (this.emojiList = el)}
- data={this.state.filteredEmojis}
- renderItem={this.renderItem}
- keyExtractor={this.keyExtractor}
- numColumns={CONST.EMOJI_NUM_PER_ROW}
- style={[
- listStyle,
- // This prevents elastic scrolling when scroll reaches the start or end
- {overscrollBehaviorY: 'contain'},
- // Set overflow to hidden to prevent elastic scrolling when there are not enough contents to scroll in FlatList
- {overflowY: this.state.filteredEmojis.length > overflowLimit ? 'auto' : 'hidden'},
- // Set scrollPaddingTop to consider sticky headers while scrolling
- {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT},
- ]}
- extraData={[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]}
- stickyHeaderIndices={this.state.headerIndices}
- getItemLayout={this.getItemLayout}
- contentContainerStyle={styles.flexGrow1}
- ListEmptyComponent={{this.props.translate('common.noResultsFound')} }
+ setHighlightedIndex((prevState) => (prevState === index ? -1 : prevState))
+ }
+ isFocused={isEmojiFocused}
/>
-
+
+ {
+ setHighlightedIndex(-1);
+ setIsFocused(true);
+ setIsUsingKeyboardMovement(false);
+ }}
+ onBlur={() => setIsFocused(false)}
+ autoCorrect={false}
+ blurOnSubmit={filteredEmojis.length > 0}
/>
- );
- }
+ {!isFiltered && (
+
+ )}
+ overflowLimit ? 'auto' : 'hidden'},
+ // Set scrollPaddingTop to consider sticky headers while scrolling
+ {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT},
+ ]}
+ extraData={[filteredEmojis, highlightedIndex, preferredSkinTone]}
+ stickyHeaderIndices={headerIndices}
+ getItemLayout={getItemLayout}
+ contentContainerStyle={styles.flexGrow1}
+ ListEmptyComponent={{translate('common.noResultsFound')} }
+ />
+
+
+ );
}
EmojiPickerMenu.propTypes = propTypes;
diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
index 5c753120301a..c5ca5463aec4 100644
--- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
@@ -69,6 +69,8 @@ class EmojiPickerMenuItem extends PureComponent {
this.props.onPress(this.props.emoji)}
+ // In order to prevent haptic feedback, pass empty callback as onLongPress props. Please refer https://github.com/necolas/react-native-web/issues/2349#issuecomment-1195564240
+ onLongPress={Browser.isMobileChrome() ? () => {} : undefined}
onPressOut={Browser.isMobile() ? this.props.onHoverOut : undefined}
onHoverIn={() => {
if (this.props.onHoverIn) {
diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index 5cba52db5a7b..2ded0e52e94d 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,197 +1,216 @@
import _ from 'underscore';
-import React, {Component} from 'react';
+import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {propTypes, defaultProps} from './hoverablePropTypes';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import CONST from '../../CONST';
+/**
+ * Maps the children of a Hoverable component to
+ * - a function that is called with the parameter
+ * - the child itself if it is the only child
+ * @param {Array|Function|ReactNode} children - The children to map.
+ * @param {Object} callbackParam - The parameter to pass to the children function.
+ * @returns {ReactNode} The mapped children.
+ */
+function mapChildren(children, callbackParam) {
+ if (_.isArray(children) && children.length === 1) {
+ return children[0];
+ }
+
+ if (_.isFunction(children)) {
+ return children(callbackParam);
+ }
+
+ return children;
+}
+
+/**
+ * Assigns a ref to an element, either by setting the current property of the ref object or by calling the ref function
+ * @param {Object|Function} ref - The ref object or function.
+ * @param {HTMLElement} el - The element to assign the ref to.
+ */
+function assignRef(ref, el) {
+ if (!ref) {
+ return;
+ }
+
+ if (_.has(ref, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ ref.current = el;
+ }
+
+ if (_.isFunction(ref)) {
+ ref(el);
+ }
+}
+
/**
* It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state,
* because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the
* parent. https://github.com/necolas/react-native-web/issues/1875
*/
-class Hoverable extends Component {
- constructor(props) {
- super(props);
- this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
- this.checkHover = this.checkHover.bind(this);
+const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnter, onMouseLeave, children, shouldHandleScroll}, outerRef) => {
+ const [isHovered, setIsHovered] = useState(false);
- this.state = {
- isHovered: false,
- };
+ const isScrolling = useRef(false);
+ const isHoveredRef = useRef(false);
+ const ref = useRef(null);
- this.isHoveredRef = false;
- this.isScrollingRef = false;
- this.wrapperView = null;
- }
+ const updateIsHoveredOnScrolling = useCallback(
+ (hovered) => {
+ if (disabled) {
+ return;
+ }
- componentDidMount() {
- document.addEventListener('visibilitychange', this.handleVisibilityChange);
- document.addEventListener('mouseover', this.checkHover);
+ isHoveredRef.current = hovered;
- /**
- * Only add the scrolling listener if the shouldHandleScroll prop is true
- * and the scrollingListener is not already set.
- */
- if (!this.scrollingListener && this.props.shouldHandleScroll) {
- this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
- /**
- * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state.
- */
- if (!scrolling && this.isHoveredRef) {
- this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn);
- } else if (scrolling && this.isHoveredRef) {
- /**
- * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false.
- * This is to hide the existing hover and reaction bar.
- */
- this.setState({isHovered: false}, this.props.onHoverOut);
- }
- this.isScrollingRef = scrolling;
- });
- }
- }
+ if (shouldHandleScroll && isScrolling.current) {
+ return;
+ }
+ setIsHovered(hovered);
+ },
+ [disabled, shouldHandleScroll],
+ );
+
+ useEffect(() => {
+ const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
+
+ document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
- componentDidUpdate(prevProps) {
- if (prevProps.disabled === this.props.disabled) {
+ return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+ }, []);
+
+ useEffect(() => {
+ if (!shouldHandleScroll) {
return;
}
- if (this.props.disabled && this.state.isHovered) {
- this.setState({isHovered: false});
- }
- }
+ const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+ isScrolling.current = scrolling;
+ if (!scrolling) {
+ setIsHovered(isHoveredRef.current);
+ }
+ });
- componentWillUnmount() {
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- document.removeEventListener('mouseover', this.checkHover);
- if (this.scrollingListener) {
- this.scrollingListener.remove();
- }
- }
+ return () => scrollingListener.remove();
+ }, [shouldHandleScroll]);
- /**
- * Sets the hover state of this component to true and execute the onHoverIn callback.
- *
- * @param {Boolean} isHovered - Whether or not this component is hovered.
- */
- setIsHovered(isHovered) {
- if (this.props.disabled) {
+ useEffect(() => {
+ if (!DeviceCapabilities.hasHoverSupport()) {
return;
}
/**
- * Capture whther or not the user is hovering over the component.
- * We will use this to determine if we should update the hover state when the user has stopped scrolling.
+ * Checks the hover state of a component and updates it based on the event target.
+ * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
+ * such as when an element is removed before the mouseleave event is triggered.
+ * @param {Event} e - The hover event object.
*/
- this.isHoveredRef = isHovered;
+ const unsetHoveredIfOutside = (e) => {
+ if (!ref.current || !isHovered) {
+ return;
+ }
- /**
- * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state.
- */
- if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) {
- return;
- }
+ if (ref.current.contains(e.target)) {
+ return;
+ }
- if (isHovered !== this.state.isHovered) {
- this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
- }
- }
+ setIsHovered(false);
+ };
- /**
- * Checks the hover state of a component and updates it based on the event target.
- * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
- * such as when an element is removed before the mouseleave event is triggered.
- * @param {Event} e - The hover event object.
- */
- checkHover(e) {
- if (!this.wrapperView || !this.state.isHovered) {
- return;
- }
+ document.addEventListener('mouseover', unsetHoveredIfOutside);
- if (this.wrapperView.contains(e.target)) {
- return;
- }
-
- this.setIsHovered(false);
- }
+ return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
+ }, [isHovered]);
- handleVisibilityChange() {
- if (document.visibilityState !== 'hidden') {
+ useEffect(() => {
+ if (!disabled || !isHovered) {
return;
}
+ setIsHovered(false);
+ }, [disabled, isHovered]);
- this.setIsHovered(false);
- }
-
- render() {
- let child = this.props.children;
- if (_.isArray(this.props.children) && this.props.children.length === 1) {
- child = this.props.children[0];
+ useEffect(() => {
+ if (disabled) {
+ return;
}
-
- if (_.isFunction(child)) {
- child = child(this.state.isHovered);
+ if (onHoverIn && isHovered) {
+ return onHoverIn();
}
-
- if (!DeviceCapabilities.hasHoverSupport()) {
- return child;
+ if (onHoverOut && !isHovered) {
+ return onHoverOut();
}
-
- return React.cloneElement(React.Children.only(child), {
- ref: (el) => {
- this.wrapperView = el;
-
- // Call the original ref, if any
- const {ref} = child;
- if (_.isFunction(ref)) {
- ref(el);
- return;
- }
-
- if (_.isObject(ref)) {
- ref.current = el;
- }
- },
- onMouseEnter: (el) => {
- if (_.isFunction(this.props.onMouseEnter)) {
- this.props.onMouseEnter(el);
- }
-
- this.setIsHovered(true);
-
- if (_.isFunction(child.props.onMouseEnter)) {
- child.props.onMouseEnter(el);
- }
- },
- onMouseLeave: (el) => {
- if (_.isFunction(this.props.onMouseLeave)) {
- this.props.onMouseLeave(el);
- }
-
- this.setIsHovered(false);
-
- if (_.isFunction(child.props.onMouseLeave)) {
- child.props.onMouseLeave(el);
- }
- },
- onBlur: (el) => {
- // Check if the blur event occurred due to clicking outside the element
- // and the wrapperView contains the element that caused the blur and reset isHovered
- if (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) {
- this.setIsHovered(false);
- }
-
- if (_.isFunction(child.props.onBlur)) {
- child.props.onBlur(el);
- }
- },
- });
+ }, [disabled, isHovered, onHoverIn, onHoverOut]);
+
+ // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
+ useImperativeHandle(outerRef, () => ref.current, []);
+
+ const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]);
+
+ const enableHoveredOnMouseEnter = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(true);
+
+ if (_.isFunction(onMouseEnter)) {
+ onMouseEnter(el);
+ }
+
+ if (_.isFunction(child.props.onMouseEnter)) {
+ child.props.onMouseEnter(el);
+ }
+ },
+ [child.props, onMouseEnter, updateIsHoveredOnScrolling],
+ );
+
+ const disableHoveredOnMouseLeave = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(false);
+
+ if (_.isFunction(onMouseLeave)) {
+ onMouseLeave(el);
+ }
+
+ if (_.isFunction(child.props.onMouseLeave)) {
+ child.props.onMouseLeave(el);
+ }
+ },
+ [child.props, onMouseLeave, updateIsHoveredOnScrolling],
+ );
+
+ const disableHoveredOnBlur = useCallback(
+ (el) => {
+ // Check if the blur event occurred due to clicking outside the element
+ // and the wrapperView contains the element that caused the blur and reset isHovered
+ if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) {
+ setIsHovered(false);
+ }
+
+ if (_.isFunction(child.props.onBlur)) {
+ child.props.onBlur(el);
+ }
+ },
+ [child.props],
+ );
+
+ if (!DeviceCapabilities.hasHoverSupport()) {
+ return child;
}
-}
+
+ return React.cloneElement(child, {
+ ref: (el) => {
+ ref.current = el;
+ assignRef(child.ref, el);
+ },
+ onMouseEnter: enableHoveredOnMouseEnter,
+ onMouseLeave: disableHoveredOnMouseLeave,
+ onBlur: disableHoveredOnBlur,
+ });
+});
Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
+Hoverable.displayName = 'Hoverable';
export default Hoverable;
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index 17c2ef0c1998..ba035c8b3baf 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -186,7 +186,9 @@ function OptionRowLHN(props) {
onSecondaryInteraction={(e) => {
showPopover(e);
// Ensure that we blur the composer when opening context menu, so that only one component is focused at a time
- DomUtils.getActiveElement().blur();
+ if (DomUtils.getActiveElement()) {
+ DomUtils.getActiveElement().blur();
+ }
}}
withoutFocusOnSecondaryInteraction
activeOpacity={0.8}
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index dcaa0273f96a..3a9cc6845194 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -103,6 +103,7 @@ function MagicCodeInput(props) {
const [input, setInput] = useState('');
const [focusedIndex, setFocusedIndex] = useState(0);
const [editIndex, setEditIndex] = useState(0);
+ const [wasSubmitted, setWasSubmitted] = useState(false);
const blurMagicCodeInput = () => {
inputRefs.current[editIndex].blur();
@@ -124,9 +125,12 @@ function MagicCodeInput(props) {
const validateAndSubmit = () => {
const numbers = decomposeString(props.value, props.maxLength);
- if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
+ if (wasSubmitted || !props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
return;
}
+ if (!wasSubmitted) {
+ setWasSubmitted(true);
+ }
// Blurs the input and removes focus from the last input and, if it should submit
// on complete, it will call the onFulfill callback.
blurMagicCodeInput();
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 6c41290f1d17..16181ee00abb 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -79,6 +79,7 @@ const defaultProps = {
shouldRenderAsHTML: false,
rightComponent: undefined,
shouldShowRightComponent: false,
+ shouldCheckActionAllowedOnPress: true,
};
const MenuItem = React.forwardRef((props, ref) => {
@@ -117,39 +118,42 @@ const MenuItem = React.forwardRef((props, ref) => {
return;
}
const parser = new ExpensiMark();
- setHtml(parser.replace(convertToLTR(props.title)));
+ setHtml(parser.replace(props.title));
titleRef.current = props.title;
}, [props.title, props.shouldParseTitle]);
const getProcessedTitle = useMemo(() => {
+ let title = '';
if (props.shouldRenderAsHTML) {
- return convertToLTR(props.title);
+ title = convertToLTR(props.title);
}
if (props.shouldParseTitle) {
- return html;
+ title = html;
}
- return '';
+ return title ? `${title} ` : '';
}, [props.title, props.shouldRenderAsHTML, props.shouldParseTitle, html]);
const hasPressableRightComponent = props.iconRight || (props.rightComponent && props.shouldShowRightComponent);
+ const onPressAction = (e) => {
+ if (props.disabled || !props.interactive) {
+ return;
+ }
+
+ if (e && e.type === 'click') {
+ e.currentTarget.blur();
+ }
+
+ props.onPress(e);
+ };
+
return (
{(isHovered) => (
{
- if (props.disabled || !props.interactive) {
- return;
- }
-
- if (e && e.type === 'click') {
- e.currentTarget.blur();
- }
-
- props.onPress(e);
- }, props.isAnonymousAction)}
+ onPress={props.shouldCheckActionAllowedOnPress ? Session.checkIfActionIsAllowed(onPressAction, props.isAnonymousAction) : onPressAction}
onPressIn={() => props.shouldBlockSelection && isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={ControlSelection.unblock}
onSecondaryInteraction={props.onSecondaryInteraction}
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index 6b2b4e16db65..49681f396181 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -62,7 +62,7 @@ const defaultProps = {
function MoneyReportHeader({session, personalDetails, policy, chatReport, report: moneyRequestReport, isSmallScreenWidth}) {
const {translate} = useLocalize();
- const reportTotal = ReportUtils.getMoneyRequestTotal(moneyRequestReport);
+ const reimbursableTotal = ReportUtils.getMoneyRequestReimbursableTotal(moneyRequestReport);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const policyType = lodashGet(policy, 'type');
@@ -71,8 +71,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
const isPayer = policyType === CONST.POLICY.TYPE.CORPORATE ? isPolicyAdmin && isApproved : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager);
const isDraft = ReportUtils.isReportDraft(moneyRequestReport);
const shouldShowSettlementButton = useMemo(
- () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
- [isPayer, isDraft, isSettled, moneyRequestReport, reportTotal, chatReport],
+ () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
+ [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport],
);
const shouldShowApproveButton = useMemo(() => {
if (policyType !== CONST.POLICY.TYPE.CORPORATE) {
@@ -80,10 +80,10 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
}
return isManager && !isDraft && !isApproved && !isSettled;
}, [policyType, isManager, isDraft, isApproved, isSettled]);
- const shouldShowSubmitButton = isDraft && reportTotal !== 0;
+ const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0;
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton;
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
- const formattedAmount = CurrencyUtils.convertToDisplayString(reportTotal, moneyRequestReport.currency);
+ const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableTotal, moneyRequestReport.currency);
return (
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 42fa1db48220..fefacc385116 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -536,8 +536,7 @@ function MoneyRequestConfirmationList(props) {
);
}, [confirm, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions, translate, formError]);
- const {image: receiptImage, thumbnail: receiptThumbnail} =
- props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(props.receiptPath, props.receiptFilename) : {};
+ const {image: receiptImage, thumbnail: receiptThumbnail} = props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction) : {};
return (
{},
};
@@ -46,16 +47,15 @@ class CalendarPicker extends React.PureComponent {
if (props.minDate >= props.maxDate) {
throw new Error('Minimum date cannot be greater than the maximum date.');
}
-
- let currentDateView = moment(props.value, CONST.DATE.MOMENT_FORMAT_STRING).toDate();
+ let currentDateView = new Date(props.value);
if (props.maxDate < currentDateView) {
currentDateView = props.maxDate;
} else if (props.minDate > currentDateView) {
currentDateView = props.minDate;
}
- const minYear = moment(this.props.minDate).year();
- const maxYear = moment(this.props.maxDate).year();
+ const minYear = getYear(new Date(this.props.minDate));
+ const maxYear = getYear(new Date(this.props.maxDate));
this.state = {
currentDateView,
@@ -79,7 +79,7 @@ class CalendarPicker extends React.PureComponent {
onYearSelected(year) {
this.setState((prev) => {
- const newCurrentDateView = moment(prev.currentDateView).set('year', year).toDate();
+ const newCurrentDateView = setYear(new Date(prev.currentDateView), year);
return {
currentDateView: newCurrentDateView,
@@ -99,9 +99,9 @@ class CalendarPicker extends React.PureComponent {
onDayPressed(day) {
this.setState(
(prev) => ({
- currentDateView: moment(prev.currentDateView).set('date', day).toDate(),
+ currentDateView: setDate(new Date(prev.currentDateView), day),
}),
- () => this.props.onSelected(moment(this.state.currentDateView).format('YYYY-MM-DD')),
+ () => this.props.onSelected(format(new Date(this.state.currentDateView), CONST.DATE.FNS_FORMAT_STRING)),
);
}
@@ -109,24 +109,24 @@ class CalendarPicker extends React.PureComponent {
* Handles the user pressing the previous month arrow of the calendar picker.
*/
moveToPrevMonth() {
- this.setState((prev) => ({currentDateView: moment(prev.currentDateView).subtract(1, 'months').toDate()}));
+ this.setState((prev) => ({currentDateView: subMonths(new Date(prev.currentDateView), 1)}));
}
/**
* Handles the user pressing the next month arrow of the calendar picker.
*/
moveToNextMonth() {
- this.setState((prev) => ({currentDateView: moment(prev.currentDateView).add(1, 'months').toDate()}));
+ this.setState((prev) => ({currentDateView: addMonths(new Date(prev.currentDateView), 1)}));
}
render() {
- const monthNames = _.map(moment.localeData(this.props.preferredLocale).months(), Str.recapitalize);
- const daysOfWeek = _.map(moment.localeData(this.props.preferredLocale).weekdays(), (day) => day.toUpperCase());
+ const monthNames = _.map(DateUtils.getMonthNames(this.props.preferredLocale), Str.recapitalize);
+ const daysOfWeek = _.map(DateUtils.getDaysOfWeek(this.props.preferredLocale), (day) => day.toUpperCase());
const currentMonthView = this.state.currentDateView.getMonth();
const currentYearView = this.state.currentDateView.getFullYear();
const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView);
- const hasAvailableDatesNextMonth = moment(this.props.maxDate).endOf('month').endOf('day') >= moment(this.state.currentDateView).add(1, 'months');
- const hasAvailableDatesPrevMonth = moment(this.props.minDate).startOf('month').startOf('day') <= moment(this.state.currentDateView).subtract(1, 'months');
+ const hasAvailableDatesNextMonth = startOfDay(endOfMonth(new Date(this.props.maxDate))) > addMonths(new Date(this.state.currentDateView), 1);
+ const hasAvailableDatesPrevMonth = startOfDay(new Date(this.props.minDate)) < endOfMonth(subMonths(new Date(this.state.currentDateView), 1));
return (
@@ -201,11 +201,11 @@ class CalendarPicker extends React.PureComponent {
style={styles.flexRow}
>
{_.map(week, (day, index) => {
- const currentDate = moment([currentYearView, currentMonthView, day]);
- const isBeforeMinDate = currentDate < moment(this.props.minDate).startOf('day');
- const isAfterMaxDate = currentDate > moment(this.props.maxDate).startOf('day');
+ const currentDate = new Date(currentYearView, currentMonthView, day);
+ const isBeforeMinDate = currentDate < startOfDay(new Date(this.props.minDate));
+ const isAfterMaxDate = currentDate > startOfDay(new Date(this.props.maxDate));
const isDisabled = !day || isBeforeMinDate || isAfterMaxDate;
- const isSelected = moment(this.props.value).isSame(moment([currentYearView, currentMonthView, day]), 'day');
+ const isSelected = isSameDay(new Date(this.props.value), new Date(currentYearView, currentMonthView, day));
return (
-
-
-
-
-
+ {props.canDismissError && (
+
+
+
+
+
+ )}
)}
diff --git a/src/components/OnyxProvider.js b/src/components/OnyxProvider.tsx
similarity index 91%
rename from src/components/OnyxProvider.js
rename to src/components/OnyxProvider.tsx
index 380328cf8137..3bd4ca52c3be 100644
--- a/src/components/OnyxProvider.js
+++ b/src/components/OnyxProvider.tsx
@@ -1,12 +1,11 @@
import React from 'react';
-import PropTypes from 'prop-types';
import ONYXKEYS from '../ONYXKEYS';
import createOnyxContext from './createOnyxContext';
import ComposeProviders from './ComposeProviders';
// Set up any providers for individual keys. This should only be used in cases where many components will subscribe to
// the same key (e.g. FlatList renderItem components)
-const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK, {});
+const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK);
const [withPersonalDetails, PersonalDetailsProvider] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE);
const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS);
@@ -15,12 +14,12 @@ const [withBetas, BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETA
const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT);
const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME);
-const propTypes = {
+type OnyxProviderProps = {
/** Rendered child component */
- children: PropTypes.node.isRequired,
+ children: React.ReactNode;
};
-function OnyxProvider(props) {
+function OnyxProvider(props: OnyxProviderProps) {
return (
selectItem(menuIndex)}
focused={focusedIndex === menuIndex}
diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js
index 3b194ad4b9cf..d35637958f1d 100644
--- a/src/components/PopoverWithoutOverlay/index.js
+++ b/src/components/PopoverWithoutOverlay/index.js
@@ -8,6 +8,7 @@ import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import getModalStyles from '../../styles/getModalStyles';
import withWindowDimensions from '../withWindowDimensions';
+import usePrevious from '../../hooks/usePrevious';
function Popover(props) {
const {onOpen, close} = React.useContext(PopoverContext);
@@ -24,6 +25,8 @@ function Popover(props) {
props.outerStyle,
);
+ const prevIsVisible = usePrevious(props.isVisible);
+
React.useEffect(() => {
if (props.isVisible) {
props.onModalShow();
@@ -40,7 +43,7 @@ function Popover(props) {
Modal.willAlertModalBecomeVisible(props.isVisible);
// We prevent setting closeModal function to null when the component is invisible the first time it is rendered
- if (!firstRenderRef.current || !props.isVisible) {
+ if (prevIsVisible === props.isVisible && (!firstRenderRef.current || !props.isVisible)) {
firstRenderRef.current = false;
return;
}
@@ -49,7 +52,7 @@ function Popover(props) {
// We want this effect to run strictly ONLY when isVisible prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.isVisible]);
+ }, [props.isVisible, prevIsVisible]);
if (!props.isVisible) {
return null;
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
index 79ce5629c9e9..24d81f59f4f8 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
@@ -5,7 +5,6 @@ import _ from 'underscore';
import Accessibility from '../../../libs/Accessibility';
import HapticFeedback from '../../../libs/HapticFeedback';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
-import * as Browser from '../../../libs/Browser';
import styles from '../../../styles/styles';
import genericPressablePropTypes from './PropTypes';
import CONST from '../../../CONST';
@@ -129,15 +128,13 @@ const GenericPressable = forwardRef((props, ref) => {
return KeyboardShortcut.subscribe(shortcutKey, onPressHandler, descriptionKey, modifiers, true, false, 0, false);
}, [keyboardShortcut, onPressHandler]);
- const defaultLongPressHandler = Browser.isMobileChrome() ? () => {} : undefined;
return (
-
+
{translate('common.total')}
@@ -59,10 +67,50 @@ function MoneyReportView(props) {
numberOfLines={1}
style={[styles.taskTitleMenuItem, styles.alignSelfCenter]}
>
- {formattedAmount}
+ {formattedTotalAmount}
+ {shouldShowBreakdown ? (
+ <>
+
+
+
+ {translate('cardTransactions.outOfPocket')}
+
+
+
+
+ {formattedOutOfPocketAmount}
+
+
+
+
+
+
+ {translate('cardTransactions.companySpend')}
+
+
+
+
+ {formattedCompanySpendAmount}
+
+
+
+ >
+ ) : undefined}
{
if (isExpensifyCardTransaction) {
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 289cd70c3332..707ef419d8b3 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -151,7 +151,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
let receiptURIs;
let hasErrors = false;
if (hasReceipt) {
- receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction);
hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction);
}
@@ -170,6 +170,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js
index 98bdede0fe26..f17a1f1929fe 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.js
+++ b/src/components/ReportActionItem/ReportActionItemImage.js
@@ -1,5 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import _ from 'underscore';
import styles from '../../styles/styles';
import Image from '../Image';
import ThumbnailImage from '../ThumbnailImage';
@@ -10,6 +12,9 @@ import {ShowContextMenuContext} from '../ShowContextMenuContext';
import Navigation from '../../libs/Navigation/Navigation';
import PressableWithoutFocus from '../Pressable/PressableWithoutFocus';
import useLocalize from '../../hooks/useLocalize';
+import EReceiptThumbnail from '../EReceiptThumbnail';
+import transactionPropTypes from '../transactionPropTypes';
+import * as TransactionUtils from '../../libs/TransactionUtils';
const propTypes = {
/** thumbnail URI for the image */
@@ -20,10 +25,14 @@ const propTypes = {
/** whether or not to enable the image preview modal */
enablePreviewModal: PropTypes.bool,
+
+ /* The transaction associated with this image, if any. Passed for handling eReceipts. */
+ transaction: transactionPropTypes,
};
const defaultProps = {
thumbnail: null,
+ transaction: {},
enablePreviewModal: false,
};
@@ -33,24 +42,37 @@ const defaultProps = {
* and optional preview modal as well.
*/
-function ReportActionItemImage({thumbnail, image, enablePreviewModal}) {
+function ReportActionItemImage({thumbnail, image, enablePreviewModal, transaction}) {
const {translate} = useLocalize();
const imageSource = tryResolveUrlFromApiRoot(image || '');
const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail || '');
+ const isEReceipt = !_.isEmpty(transaction) && TransactionUtils.hasEReceipt(transaction);
+
+ let receiptImageComponent;
- const receiptImageComponent = thumbnail ? (
-
- ) : (
-
- );
+ if (isEReceipt) {
+ receiptImageComponent = (
+
+
+
+ );
+ } else if (thumbnail) {
+ receiptImageComponent = (
+
+ );
+ } else {
+ receiptImageComponent = (
+
+ );
+ }
if (enablePreviewModal) {
return (
diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js
index 773c66d6e7b6..bd1ee6d45a07 100644
--- a/src/components/ReportActionItem/ReportActionItemImages.js
+++ b/src/components/ReportActionItem/ReportActionItemImages.js
@@ -7,6 +7,7 @@ import Text from '../Text';
import ReportActionItemImage from './ReportActionItemImage';
import * as StyleUtils from '../../styles/StyleUtils';
import variables from '../../styles/variables';
+import transactionPropTypes from '../transactionPropTypes';
const propTypes = {
/** array of image and thumbnail URIs */
@@ -14,6 +15,7 @@ const propTypes = {
PropTypes.shape({
thumbnail: PropTypes.string,
image: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ transaction: transactionPropTypes,
}),
).isRequired,
@@ -68,7 +70,7 @@ function ReportActionItemImages({images, size, total, isHovered}) {
return (
- {_.map(shownImages, ({thumbnail, image}, index) => {
+ {_.map(shownImages, ({thumbnail, image, transaction}, index) => {
const isLastImage = index === numberOfShownImages - 1;
// Show a border to separate multiple images. Shown to the right for each except the last.
@@ -82,6 +84,7 @@ function ReportActionItemImages({images, size, total, isHovered}) {
{isLastImage && remaining > 0 && (
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index d4d839183e07..2147f0a4362e 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -111,7 +111,7 @@ function ReportPreview(props) {
const managerID = props.iouReport.managerID || 0;
const isCurrentUserManager = managerID === lodashGet(props.session, 'accountID');
- const reportTotal = ReportUtils.getMoneyRequestTotal(props.iouReport);
+ const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(props.iouReport);
const iouSettled = ReportUtils.isSettled(props.iouReportID);
const iouCanceled = ReportUtils.isArchivedRoom(props.chatReport);
@@ -125,8 +125,8 @@ function ReportPreview(props) {
const hasReceipts = transactionsWithReceipts.length > 0;
const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action);
const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID);
- const lastThreeTransactionsWithReceipts = ReportUtils.getReportPreviewDisplayTransactions(props.action);
- const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename}) => ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || ''));
+ const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3);
+ const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, (transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction));
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(props.iouReportID);
const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts;
const previewSubtitle = hasOnlyOneReceiptRequest
@@ -136,11 +136,11 @@ function ReportPreview(props) {
scanningReceipts: numberOfScanningReceipts,
});
- const shouldShowSubmitButton = isReportDraft && reportTotal !== 0;
+ const shouldShowSubmitButton = isReportDraft && reimbursableSpend !== 0;
const getDisplayAmount = () => {
- if (reportTotal) {
- return CurrencyUtils.convertToDisplayString(reportTotal, props.iouReport.currency);
+ if (totalDisplaySpend) {
+ return CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.iouReport.currency);
}
if (isScanning) {
return props.translate('iou.receiptScanning');
@@ -176,7 +176,7 @@ function ReportPreview(props) {
const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
const shouldShowSettlementButton = ReportUtils.isControlPolicyExpenseChat(props.chatReport)
? props.policy.role === CONST.POLICY.ROLE.ADMIN && ReportUtils.isReportApproved(props.iouReport) && !iouSettled && !iouCanceled
- : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
+ : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reimbursableSpend !== 0;
return (
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index 8e7cf11f7e5a..05eca664bd0f 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -53,7 +53,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
[searchValue, selectedOptions, policyTagList, policyRecentlyUsedTagsList],
);
- const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, '');
+ const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, '');
return (
{
- if (!isRendered) {
- setIsRendered(true);
- }
-
+ setIsRendered(true);
setIsVisible(true);
animation.current.stopAnimation();
@@ -109,7 +106,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
});
}
TooltipSense.activate();
- }, [isRendered]);
+ }, []);
// eslint-disable-next-line rulesdir/prefer-early-return
useEffect(() => {
@@ -130,11 +127,17 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
if (bounds.width === 0) {
setIsRendered(false);
}
+ if (!target.current) {
+ return;
+ }
// Choose a bounding box for the tooltip to target.
// In the case when the target is a link that has wrapped onto
// multiple lines, we want to show the tooltip over the part
// of the link that the user is hovering over.
const betterBounds = chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y);
+ if (!betterBounds) {
+ return;
+ }
setWrapperWidth(betterBounds.width);
setWrapperHeight(betterBounds.height);
setXOffset(betterBounds.x);
@@ -144,7 +147,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
/**
* Hide the tooltip in an animation.
*/
- const hideTooltip = () => {
+ const hideTooltip = useCallback(() => {
animation.current.stopAnimation();
if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) {
@@ -162,7 +165,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
TooltipSense.deactivate();
setIsVisible(false);
- };
+ }, []);
// Skip the tooltip and return the children if the text is empty,
// we don't have a render function or the device does not support hovering
diff --git a/src/components/createOnyxContext.js b/src/components/createOnyxContext.js
deleted file mode 100644
index 3dbc07a7032e..000000000000
--- a/src/components/createOnyxContext.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, {createContext, forwardRef} from 'react';
-import PropTypes from 'prop-types';
-import {withOnyx} from 'react-native-onyx';
-import Str from 'expensify-common/lib/str';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-
-const propTypes = {
- /** Rendered child component */
- children: PropTypes.node.isRequired,
-};
-
-export default (onyxKeyName, defaultValue) => {
- const Context = createContext();
- function Provider(props) {
- return {props.children} ;
- }
-
- Provider.propTypes = propTypes;
- Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`;
-
- // eslint-disable-next-line rulesdir/onyx-props-must-have-default
- const ProviderWithOnyx = withOnyx({
- [onyxKeyName]: {
- key: onyxKeyName,
- },
- })(Provider);
-
- const withOnyxKey =
- ({propName = onyxKeyName, transformValue} = {}) =>
- (WrappedComponent) => {
- const Consumer = forwardRef((props, ref) => (
-
- {(value) => {
- const propsToPass = {
- ...props,
- [propName]: transformValue ? transformValue(value, props) : value,
- };
-
- if (propsToPass[propName] === undefined && defaultValue) {
- propsToPass[propName] = defaultValue;
- }
- return (
-
- );
- }}
-
- ));
-
- Consumer.displayName = `with${Str.UCFirst(onyxKeyName)}(${getComponentDisplayName(WrappedComponent)})`;
- return Consumer;
- };
-
- return [withOnyxKey, ProviderWithOnyx, Context];
-};
diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx
new file mode 100644
index 000000000000..d142e551012f
--- /dev/null
+++ b/src/components/createOnyxContext.tsx
@@ -0,0 +1,81 @@
+import React, {ComponentType, ForwardRefExoticComponent, ForwardedRef, PropsWithoutRef, ReactNode, RefAttributes, createContext, forwardRef} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import Str from 'expensify-common/lib/str';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+import {OnyxCollectionKey, OnyxKey, OnyxKeyValue, OnyxValues} from '../ONYXKEYS';
+import ChildrenProps from '../types/utils/ChildrenProps';
+
+type OnyxKeys = (OnyxKey | OnyxCollectionKey) & keyof OnyxValues;
+
+// Provider types
+type ProviderOnyxProps = Record>;
+
+type ProviderPropsWithOnyx = ChildrenProps & ProviderOnyxProps;
+
+// withOnyxKey types
+type WithOnyxKeyProps = {
+ propName?: TOnyxKey | TNewOnyxKey;
+ // It's not possible to infer the type of props of the wrapped component, so we have to use `any` here
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ transformValue?: (value: OnyxKeyValue, props: any) => TTransformedValue;
+};
+
+type WrapComponentWithConsumer = , TRef>(
+ WrappedComponent: ComponentType>,
+) => ForwardRefExoticComponent> & RefAttributes>;
+
+type WithOnyxKey = >(
+ props?: WithOnyxKeyProps,
+) => WrapComponentWithConsumer;
+
+// createOnyxContext return type
+type CreateOnyxContext = [WithOnyxKey, ComponentType, TOnyxKey>>, React.Context>];
+
+export default (onyxKeyName: TOnyxKey): CreateOnyxContext => {
+ const Context = createContext>(null);
+ function Provider(props: ProviderPropsWithOnyx): ReactNode {
+ return {props.children} ;
+ }
+
+ Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`;
+
+ const ProviderWithOnyx = withOnyx, ProviderOnyxProps>({
+ [onyxKeyName]: {
+ key: onyxKeyName,
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as Record)(Provider);
+
+ function withOnyxKey>({
+ propName,
+ transformValue,
+ }: WithOnyxKeyProps = {}) {
+ return , TRef>(WrappedComponent: ComponentType>) => {
+ function Consumer(props: Omit, ref: ForwardedRef): ReactNode {
+ return (
+
+ {(value) => {
+ const propsToPass = {
+ ...props,
+ [propName ?? onyxKeyName]: transformValue ? transformValue(value, props) : value,
+ } as TProps;
+
+ return (
+
+ );
+ }}
+
+ );
+ }
+
+ Consumer.displayName = `with${Str.UCFirst(onyxKeyName)}(${getComponentDisplayName(WrappedComponent)})`;
+ return forwardRef(Consumer);
+ };
+ }
+
+ return [withOnyxKey, ProviderWithOnyx, Context];
+};
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index e33170ac67f4..a5b5b3a8eba8 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -153,6 +153,9 @@ const propTypes = {
/** Should render component on the right */
shouldShowRightComponent: PropTypes.bool,
+
+ /** Should check anonymous user in onPress function */
+ shouldCheckActionAllowedOnPress: PropTypes.bool,
};
export default propTypes;
diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js
deleted file mode 100644
index e82946c9e049..000000000000
--- a/src/components/withNavigationFallback.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import React, {forwardRef, useContext, useMemo} from 'react';
-import {NavigationContext} from '@react-navigation/core';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-import refPropTypes from './refPropTypes';
-
-export default function (WrappedComponent) {
- function WithNavigationFallback(props) {
- const context = useContext(NavigationContext);
-
- const navigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []);
-
- return context ? (
-
- ) : (
-
-
-
- );
- }
- WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`;
- WithNavigationFallback.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithNavigationFallback.defaultProps = {
- forwardedRef: undefined,
- };
-
- return forwardRef((props, ref) => (
-
- ));
-}
diff --git a/src/hooks/useNetwork.js b/src/hooks/useNetwork.ts
similarity index 74%
rename from src/hooks/useNetwork.js
rename to src/hooks/useNetwork.ts
index a4e973d0194d..4405dd7126a5 100644
--- a/src/hooks/useNetwork.js
+++ b/src/hooks/useNetwork.ts
@@ -1,16 +1,17 @@
import {useRef, useContext, useEffect} from 'react';
import {NetworkContext} from '../components/OnyxProvider';
-/**
- * @param {Object} [options]
- * @param {Function} [options.onReconnect]
- * @returns {Object}
- */
-export default function useNetwork({onReconnect = () => {}} = {}) {
+type UseNetworkProps = {
+ onReconnect?: () => void;
+};
+
+type UseNetwork = {isOffline?: boolean};
+
+export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork {
const callback = useRef(onReconnect);
callback.current = onReconnect;
- const {isOffline} = useContext(NetworkContext);
+ const {isOffline} = useContext(NetworkContext) ?? {};
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
diff --git a/src/hooks/useWindowDimensions/index.native.js b/src/hooks/useWindowDimensions/index.native.ts
similarity index 89%
rename from src/hooks/useWindowDimensions/index.native.js
rename to src/hooks/useWindowDimensions/index.native.ts
index 358e43f1b75d..5b0ec2002201 100644
--- a/src/hooks/useWindowDimensions/index.native.js
+++ b/src/hooks/useWindowDimensions/index.native.ts
@@ -1,17 +1,18 @@
// eslint-disable-next-line no-restricted-imports
import {useWindowDimensions} from 'react-native';
import variables from '../../styles/variables';
+import WindowDimensions from './types';
/**
* A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints.
- * @returns {Object}
*/
-export default function () {
+export default function (): WindowDimensions {
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
const isExtraSmallScreenHeight = windowHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint;
const isSmallScreenWidth = true;
const isMediumScreenWidth = false;
const isLargeScreenWidth = false;
+
return {
windowWidth,
windowHeight,
diff --git a/src/hooks/useWindowDimensions/index.js b/src/hooks/useWindowDimensions/index.ts
similarity index 93%
rename from src/hooks/useWindowDimensions/index.js
rename to src/hooks/useWindowDimensions/index.ts
index 1a1f7eed5a67..f9fee6301d06 100644
--- a/src/hooks/useWindowDimensions/index.js
+++ b/src/hooks/useWindowDimensions/index.ts
@@ -1,12 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import {Dimensions, useWindowDimensions} from 'react-native';
import variables from '../../styles/variables';
+import WindowDimensions from './types';
/**
* A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints.
- * @returns {Object}
*/
-export default function () {
+export default function (): WindowDimensions {
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
// When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight.
const screenHeight = Dimensions.get('screen').height;
@@ -14,6 +14,7 @@ export default function () {
const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint;
const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint;
const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint;
+
return {
windowWidth,
windowHeight,
diff --git a/src/hooks/useWindowDimensions/types.ts b/src/hooks/useWindowDimensions/types.ts
new file mode 100644
index 000000000000..9b59d4968935
--- /dev/null
+++ b/src/hooks/useWindowDimensions/types.ts
@@ -0,0 +1,10 @@
+type WindowDimensions = {
+ windowWidth: number;
+ windowHeight: number;
+ isExtraSmallScreenHeight: boolean;
+ isSmallScreenWidth: boolean;
+ isMediumScreenWidth: boolean;
+ isLargeScreenWidth: boolean;
+};
+
+export default WindowDimensions;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 4e4bd9376c03..e7f71e755dd8 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1852,7 +1852,7 @@ export default {
},
cardTransactions: {
notActivated: 'Not activated',
- outOfPocketSpend: 'Out-of-pocket spend',
+ outOfPocket: 'Out of pocket',
companySpend: 'Company spend',
},
distance: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 403643b992dd..6020ded30b92 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2337,7 +2337,7 @@ export default {
},
cardTransactions: {
notActivated: 'No activado',
- outOfPocketSpend: 'Gastos por cuenta propia',
+ outOfPocket: 'Por cuenta propia',
companySpend: 'Gastos de empresa',
},
distance: {
diff --git a/src/libs/API.js b/src/libs/API.ts
similarity index 64%
rename from src/libs/API.js
rename to src/libs/API.ts
index 2ad1f32347d9..ce3d6bab19bc 100644
--- a/src/libs/API.js
+++ b/src/libs/API.ts
@@ -1,5 +1,5 @@
-import _ from 'underscore';
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
import Log from './Log';
import * as Request from './Request';
import * as Middleware from './Middleware';
@@ -7,6 +7,8 @@ import * as SequentialQueue from './Network/SequentialQueue';
import pkg from '../../package.json';
import CONST from '../CONST';
import * as Pusher from './Pusher/pusher';
+import OnyxRequest from '../types/onyx/Request';
+import Response from '../types/onyx/Response';
// Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next).
// Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next.
@@ -28,25 +30,34 @@ Request.use(Middleware.HandleUnusedOptimisticID);
// middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state.
Request.use(Middleware.SaveResponseInOnyx);
+type OnyxData = {
+ optimisticData?: OnyxUpdate[];
+ successData?: OnyxUpdate[];
+ failureData?: OnyxUpdate[];
+};
+
+type ApiRequestType = ValueOf;
+
/**
* All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData.
* This is so that if the network is unavailable or the app is closed, we can send the WRITE request later.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
*/
-function write(command, apiCommandParameters = {}, onyxData = {}) {
+function write(command: string, apiCommandParameters: Record = {}, onyxData: OnyxData = {}) {
Log.info('Called API write', false, {command, ...apiCommandParameters});
+ const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
// Optimistically update Onyx
- if (onyxData.optimisticData) {
- Onyx.update(onyxData.optimisticData);
+ if (optimisticData) {
+ Onyx.update(optimisticData);
}
// Assemble the data we'll send to the API
@@ -61,7 +72,7 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
};
// Assemble all the request data we'll be storing in the queue
- const request = {
+ const request: OnyxRequest = {
command,
data: {
...data,
@@ -70,7 +81,7 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
shouldRetry: true,
canCancel: true,
},
- ..._.omit(onyxData, 'optimisticData'),
+ ...onyxDataWithoutOptimisticData,
};
// Write commands can be saved and retried, so push it to the SequentialQueue
@@ -85,24 +96,30 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
* Using this method is discouraged and will throw an ESLint error. Use it sparingly and only when all other alternatives have been exhausted.
* It is best to discuss it in Slack anytime you are tempted to use this method.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
- * @param {String} [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained
* response back to the caller or to trigger reconnection callbacks when re-authentication is required.
- * @returns {Promise}
+ * @returns
*/
-function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData = {}, apiRequestType = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS) {
+function makeRequestWithSideEffects(
+ command: string,
+ apiCommandParameters = {},
+ onyxData: OnyxData = {},
+ apiRequestType: ApiRequestType = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS,
+): Promise {
Log.info('Called API makeRequestWithSideEffects', false, {command, ...apiCommandParameters});
+ const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
// Optimistically update Onyx
- if (onyxData.optimisticData) {
- Onyx.update(onyxData.optimisticData);
+ if (optimisticData) {
+ Onyx.update(optimisticData);
}
// Assemble the data we'll send to the API
@@ -113,10 +130,10 @@ function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData
};
// Assemble all the request data we'll be storing
- const request = {
+ const request: OnyxRequest = {
command,
data,
- ..._.omit(onyxData, 'optimisticData'),
+ ...onyxDataWithoutOptimisticData,
};
// Return a promise containing the response from HTTPS
@@ -126,16 +143,16 @@ function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData
/**
* Requests made with this method are not be persisted to disk. If there is no network connectivity, the request is ignored and discarded.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
*/
-function read(command, apiCommandParameters, onyxData) {
+function read(command: string, apiCommandParameters: Record, onyxData: OnyxData = {}) {
// Ensure all write requests on the sequential queue have finished responding before running read requests.
// Responses from read requests can overwrite the optimistic data inserted by
// write requests that use the same Onyx keys and haven't responded yet.
diff --git a/src/libs/Authentication.js b/src/libs/Authentication.ts
similarity index 83%
rename from src/libs/Authentication.js
rename to src/libs/Authentication.ts
index 9f1967ecf0d8..cec20504dd04 100644
--- a/src/libs/Authentication.js
+++ b/src/libs/Authentication.ts
@@ -7,20 +7,20 @@ import redirectToSignIn from './actions/SignInRedirect';
import CONST from '../CONST';
import Log from './Log';
import * as ErrorUtils from './ErrorUtils';
+import Response from '../types/onyx/Response';
-/**
- * @param {Object} parameters
- * @param {Boolean} [parameters.useExpensifyLogin]
- * @param {String} parameters.partnerName
- * @param {String} parameters.partnerPassword
- * @param {String} parameters.partnerUserID
- * @param {String} parameters.partnerUserSecret
- * @param {String} [parameters.twoFactorAuthCode]
- * @param {String} [parameters.email]
- * @param {String} [parameters.authToken]
- * @returns {Promise}
- */
-function Authenticate(parameters) {
+type Parameters = {
+ useExpensifyLogin?: boolean;
+ partnerName: string;
+ partnerPassword: string;
+ partnerUserID?: string;
+ partnerUserSecret?: string;
+ twoFactorAuthCode?: string;
+ email?: string;
+ authToken?: string;
+};
+
+function Authenticate(parameters: Parameters): Promise {
const commandName = 'Authenticate';
requireParameters(['partnerName', 'partnerPassword', 'partnerUserID', 'partnerUserSecret'], parameters, commandName);
@@ -48,11 +48,9 @@ function Authenticate(parameters) {
/**
* Reauthenticate using the stored credentials and redirect to the sign in page if unable to do so.
- *
- * @param {String} [command] command name for logging purposes
- * @returns {Promise}
+ * @param [command] command name for logging purposes
*/
-function reauthenticate(command = '') {
+function reauthenticate(command = ''): Promise {
// Prevent any more requests from being processed while authentication happens
NetworkStore.setIsAuthenticating(true);
@@ -61,8 +59,8 @@ function reauthenticate(command = '') {
useExpensifyLogin: false,
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
- partnerUserID: credentials.autoGeneratedLogin,
- partnerUserSecret: credentials.autoGeneratedPassword,
+ partnerUserID: credentials?.autoGeneratedLogin,
+ partnerUserSecret: credentials?.autoGeneratedPassword,
}).then((response) => {
if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
// If authentication fails, then the network can be unpaused
@@ -92,7 +90,7 @@ function reauthenticate(command = '') {
// Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
// reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
// enough to do the updateSessionAuthTokens() call above.
- NetworkStore.setAuthToken(response.authToken);
+ NetworkStore.setAuthToken(response.authToken ?? null);
// The authentication process is finished so the network can be unpaused to continue processing requests
NetworkStore.setIsAuthenticating(false);
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 8df554dd4dbf..c8ea03cc86c0 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,6 +1,5 @@
import lodash from 'lodash';
import Onyx from 'react-native-onyx';
-import {Card} from '../types/onyx';
import CONST from '../CONST';
import * as Localize from './Localize';
import * as OnyxTypes from '../types/onyx';
@@ -47,7 +46,7 @@ function getCardDescription(cardID: number) {
return '';
}
const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN;
- return `${card.bank} - ${cardDescriptor}`;
+ return cardDescriptor ? `${card.bank} - ${cardDescriptor}` : `${card.bank}`;
}
/**
@@ -60,13 +59,6 @@ function getYearFromExpirationDateString(expirationDateString: string) {
return cardYear.length === 2 ? `20${cardYear}` : cardYear;
}
-function getCompanyCards(cardList: {string: Card}) {
- if (!cardList) {
- return [];
- }
- return Object.values(cardList).filter((card) => card.bank !== CONST.EXPENSIFY_CARD.BANK);
-}
-
/**
* @param cardList - collection of assigned cards
* @returns collection of assigned cards grouped by domain
@@ -96,4 +88,4 @@ function maskCard(lastFour = ''): string {
return maskedString.replace(/(.{4})/g, '$1 ').trim();
}
-export {isExpensifyCard, getDomainCards, getCompanyCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription};
+export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription};
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index a6f2860310c2..76cf8f15e522 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -15,6 +15,8 @@ import {
isSameDay,
isAfter,
isSameYear,
+ eachMonthOfInterval,
+ eachDayOfInterval,
} from 'date-fns';
import Onyx from 'react-native-onyx';
@@ -255,6 +257,38 @@ function getCurrentTimezone(): Required {
return timezone;
}
+/**
+ * @returns [January, Fabruary, March, April, May, June, July, August, ...]
+ */
+function getMonthNames(preferredLocale: string): string[] {
+ if (preferredLocale) {
+ setLocale(preferredLocale);
+ }
+ const fullYear = new Date().getFullYear();
+ const monthsArray = eachMonthOfInterval({
+ start: new Date(fullYear, 0, 1), // January 1st of the current year
+ end: new Date(fullYear, 11, 31), // December 31st of the current year
+ });
+
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ return monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
+}
+
+/**
+ * @returns [Monday, Thuesday, Wednesday, ...]
+ */
+function getDaysOfWeek(preferredLocale: string): string[] {
+ if (preferredLocale) {
+ setLocale(preferredLocale);
+ }
+ const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const endOfCurrentWeek = endOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const daysOfWeek = eachDayOfInterval({start: startOfCurrentWeek, end: endOfCurrentWeek});
+
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ return daysOfWeek.map((date) => format(date, 'eeee'));
+}
+
// Used to throttle updates to the timezone when necessary
let lastUpdatedTimezoneTime = new Date();
@@ -357,6 +391,8 @@ const DateUtils = {
isToday,
isTomorrow,
isYesterday,
+ getMonthNames,
+ getDaysOfWeek,
};
export default DateUtils;
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index a44a69f087ab..344d0c3bd397 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -110,6 +110,23 @@ function trimEmojiUnicode(emojiCode) {
return emojiCode.replace(/(fe0f|1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)$/, '').trim();
}
+/**
+ * Validates first character is emoji in text string
+ *
+ * @param {String} message
+ * @returns {Boolean}
+ */
+function isFirstLetterEmoji(message) {
+ const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
+ const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+
+ if (!match) {
+ return false;
+ }
+
+ return trimmedMessage.indexOf(match[0]) === 0;
+}
+
/**
* Validates that this message contains only emojis
*
@@ -497,4 +514,5 @@ export {
replaceAndExtractEmojis,
extractEmojis,
getAddedEmojis,
+ isFirstLetterEmoji,
};
diff --git a/src/libs/Middleware/Logging.js b/src/libs/Middleware/Logging.ts
similarity index 82%
rename from src/libs/Middleware/Logging.js
rename to src/libs/Middleware/Logging.ts
index fdc9f0083abb..171cb4b9ab4c 100644
--- a/src/libs/Middleware/Logging.js
+++ b/src/libs/Middleware/Logging.ts
@@ -1,30 +1,26 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
import Log from '../Log';
import CONST from '../../CONST';
+import Request from '../../types/onyx/Request';
+import Response from '../../types/onyx/Response';
+import Middleware from './types';
-/**
- * @param {String} message
- * @param {Object} request
- * @param {Object} [response]
- */
-function logRequestDetails(message, request, response = {}) {
+function logRequestDetails(message: string, request: Request, response?: Response | void) {
// Don't log about log or else we'd cause an infinite loop
if (request.command === 'Log') {
return;
}
- const logParams = {
+ const logParams: Record = {
command: request.command,
shouldUseSecure: request.shouldUseSecure,
};
- const returnValueList = lodashGet(request, 'data.returnValueList');
+ const returnValueList = request?.data?.returnValueList;
if (returnValueList) {
logParams.returnValueList = returnValueList;
}
- const nvpNames = lodashGet(request, 'data.nvpNames');
+ const nvpNames = request?.data?.nvpNames;
if (nvpNames) {
logParams.nvpNames = nvpNames;
}
@@ -37,14 +33,7 @@ function logRequestDetails(message, request, response = {}) {
Log.info(message, false, logParams);
}
-/**
- * Logging middleware
- *
- * @param {Promise} response
- * @param {Object} request
- * @returns {Promise}
- */
-function Logging(response, request) {
+const Logging: Middleware = (response, request) => {
logRequestDetails('Making API request', request);
return response
.then((data) => {
@@ -52,7 +41,7 @@ function Logging(response, request) {
return data;
})
.catch((error) => {
- const logParams = {
+ const logParams: Record = {
message: error.message,
status: error.status,
title: error.title,
@@ -73,21 +62,18 @@ function Logging(response, request) {
// incorrect url, bad cors headers returned by the server, DNS lookup failure etc.
Log.hmmm('[Network] API request error: Failed to fetch', logParams);
} else if (
- _.contains(
- [
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST,
- CONST.ERROR.NETWORK_REQUEST_FAILED,
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_RUSSIAN,
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SWEDISH,
- CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SPANISH,
- ],
- error.message,
- )
+ [
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST,
+ CONST.ERROR.NETWORK_REQUEST_FAILED,
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_RUSSIAN,
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SWEDISH,
+ CONST.ERROR.IOS_NETWORK_CONNECTION_LOST_SPANISH,
+ ].includes(error.message)
) {
// These errors seem to happen for native devices with interrupted connections. Often we will see logs about Pusher disconnecting together with these.
// This type of error may also indicate a problem with SSL certs.
Log.hmmm('[Network] API request error: Connection interruption likely', logParams);
- } else if (_.contains([CONST.ERROR.FIREFOX_DOCUMENT_LOAD_ABORTED, CONST.ERROR.SAFARI_DOCUMENT_LOAD_ABORTED], error.message)) {
+ } else if ([CONST.ERROR.FIREFOX_DOCUMENT_LOAD_ABORTED, CONST.ERROR.SAFARI_DOCUMENT_LOAD_ABORTED].includes(error.message)) {
// This message can be observed page load is interrupted (closed or navigated away).
Log.hmmm('[Network] API request error: User likely navigated away from or closed browser', logParams);
} else if (error.message === CONST.ERROR.IOS_LOAD_FAILED) {
@@ -123,6 +109,6 @@ function Logging(response, request) {
// Re-throw this error so the next handler can manage it
throw error;
});
-}
+};
export default Logging;
diff --git a/src/libs/Middleware/Reauthentication.js b/src/libs/Middleware/Reauthentication.ts
similarity index 86%
rename from src/libs/Middleware/Reauthentication.js
rename to src/libs/Middleware/Reauthentication.ts
index dfe4e1b7fda8..aec09227e441 100644
--- a/src/libs/Middleware/Reauthentication.js
+++ b/src/libs/Middleware/Reauthentication.ts
@@ -1,4 +1,3 @@
-import lodashGet from 'lodash/get';
import CONST from '../../CONST';
import * as NetworkStore from '../Network/NetworkStore';
import * as MainQueue from '../Network/MainQueue';
@@ -6,15 +5,12 @@ import * as Authentication from '../Authentication';
import * as Request from '../Request';
import Log from '../Log';
import NetworkConnection from '../NetworkConnection';
+import Middleware from './types';
// We store a reference to the active authentication request so that we are only ever making one request to authenticate at a time.
-let isAuthenticating = null;
+let isAuthenticating: Promise | null = null;
-/**
- * @param {String} commandName
- * @returns {Promise}
- */
-function reauthenticate(commandName) {
+function reauthenticate(commandName?: string): Promise {
if (isAuthenticating) {
return isAuthenticating;
}
@@ -32,16 +28,8 @@ function reauthenticate(commandName) {
return isAuthenticating;
}
-/**
- * Reauthentication middleware
- *
- * @param {Promise} response
- * @param {Object} request
- * @param {Boolean} isFromSequentialQueue
- * @returns {Promise}
- */
-function Reauthentication(response, request, isFromSequentialQueue) {
- return response
+const Reauthentication: Middleware = (response, request, isFromSequentialQueue) =>
+ response
.then((data) => {
// If there is no data for some reason then we cannot reauthenticate
if (!data) {
@@ -58,13 +46,13 @@ function Reauthentication(response, request, isFromSequentialQueue) {
// There are some API requests that should not be retried when there is an auth failure like
// creating and deleting logins. In those cases, they should handle the original response instead
// of the new response created by handleExpiredAuthToken.
- const shouldRetry = lodashGet(request, 'data.shouldRetry');
- const apiRequestType = lodashGet(request, 'data.apiRequestType');
+ const shouldRetry = request?.data?.shouldRetry;
+ const apiRequestType = request?.data?.apiRequestType;
// For the SignInWithShortLivedAuthToken command, if the short token expires, the server returns a 407 error,
// and credentials are still empty at this time, which causes reauthenticate to throw an error (requireParameters),
// and the subsequent SaveResponseInOnyx also cannot be executed, so we need this parameter to skip the reauthentication logic.
- const skipReauthentication = lodashGet(request, 'data.skipReauthentication');
+ const skipReauthentication = request?.data?.skipReauthentication;
if ((!shouldRetry && !apiRequestType) || skipReauthentication) {
if (isFromSequentialQueue) {
return data;
@@ -82,7 +70,7 @@ function Reauthentication(response, request, isFromSequentialQueue) {
return data;
}
- return reauthenticate(request.commandName)
+ return reauthenticate(request?.commandName)
.then((authenticateResponse) => {
if (isFromSequentialQueue || apiRequestType === CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS) {
return Request.processWithMiddleware(request, isFromSequentialQueue);
@@ -128,6 +116,5 @@ function Reauthentication(response, request, isFromSequentialQueue) {
request.resolve({jsonCode: CONST.JSON_CODE.UNABLE_TO_RETRY});
}
});
-}
export default Reauthentication;
diff --git a/src/libs/Middleware/RecheckConnection.js b/src/libs/Middleware/RecheckConnection.ts
similarity index 83%
rename from src/libs/Middleware/RecheckConnection.js
rename to src/libs/Middleware/RecheckConnection.ts
index 58f5cfa601c8..5a685d66fd02 100644
--- a/src/libs/Middleware/RecheckConnection.js
+++ b/src/libs/Middleware/RecheckConnection.ts
@@ -1,20 +1,17 @@
import CONST from '../../CONST';
import NetworkConnection from '../NetworkConnection';
+import Middleware from './types';
/**
- * @returns {Function} cancel timer
+ * @returns cancel timer
*/
-function startRecheckTimeoutTimer() {
+function startRecheckTimeoutTimer(): () => void {
// If request is still in processing after this time, we might be offline
const timerID = setTimeout(NetworkConnection.recheckNetworkConnection, CONST.NETWORK.MAX_PENDING_TIME_MS);
return () => clearTimeout(timerID);
}
-/**
- * @param {Promise} response
- * @returns {Promise}
- */
-function RecheckConnection(response) {
+const RecheckConnection: Middleware = (response) => {
// When the request goes past a certain amount of time we trigger a re-check of the connection
const cancelRequestTimeoutTimer = startRecheckTimeoutTimer();
return response
@@ -27,6 +24,6 @@ function RecheckConnection(response) {
throw error;
})
.finally(cancelRequestTimeoutTimer);
-}
+};
export default RecheckConnection;
diff --git a/src/libs/Middleware/SaveResponseInOnyx.js b/src/libs/Middleware/SaveResponseInOnyx.ts
similarity index 74%
rename from src/libs/Middleware/SaveResponseInOnyx.js
rename to src/libs/Middleware/SaveResponseInOnyx.ts
index d8c47d4c01dd..0a279a7425b4 100644
--- a/src/libs/Middleware/SaveResponseInOnyx.js
+++ b/src/libs/Middleware/SaveResponseInOnyx.ts
@@ -1,21 +1,16 @@
-import _ from 'underscore';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as MemoryOnlyKeys from '../actions/MemoryOnlyKeys/MemoryOnlyKeys';
import * as OnyxUpdates from '../actions/OnyxUpdates';
+import Middleware from './types';
// If we're executing any of these requests, we don't need to trigger our OnyxUpdates flow to update the current data even if our current value is out of
// date because all these requests are updating the app to the most current state.
const requestsToIgnoreLastUpdateID = ['OpenApp', 'ReconnectApp', 'GetMissingOnyxMessages'];
-/**
- * @param {Promise} requestResponse
- * @param {Object} request
- * @returns {Promise}
- */
-function SaveResponseInOnyx(requestResponse, request) {
- return requestResponse.then((response = {}) => {
- const onyxUpdates = response.onyxData;
+const SaveResponseInOnyx: Middleware = (requestResponse, request) =>
+ requestResponse.then((response = {}) => {
+ const onyxUpdates = response?.onyxData ?? [];
// Sometimes we call requests that are successfull but they don't have any response or any success/failure data to set. Let's return early since
// we don't need to store anything here.
@@ -24,7 +19,7 @@ function SaveResponseInOnyx(requestResponse, request) {
}
// If there is an OnyxUpdate for using memory only keys, enable them
- _.find(onyxUpdates, ({key, value}) => {
+ onyxUpdates?.find(({key, value}) => {
if (key !== ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS || !value) {
return false;
}
@@ -35,13 +30,13 @@ function SaveResponseInOnyx(requestResponse, request) {
const responseToApply = {
type: CONST.ONYX_UPDATE_TYPES.HTTPS,
- lastUpdateID: Number(response.lastUpdateID || 0),
- previousUpdateID: Number(response.previousUpdateID || 0),
+ lastUpdateID: Number(response?.lastUpdateID ?? 0),
+ previousUpdateID: Number(response?.previousUpdateID ?? 0),
request,
- response,
+ response: response ?? {},
};
- if (_.includes(requestsToIgnoreLastUpdateID, request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response.previousUpdateID || 0))) {
+ if (requestsToIgnoreLastUpdateID.includes(request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response?.previousUpdateID ?? 0))) {
return OnyxUpdates.apply(responseToApply);
}
@@ -54,6 +49,5 @@ function SaveResponseInOnyx(requestResponse, request) {
shouldPauseQueue: true,
});
});
-}
export default SaveResponseInOnyx;
diff --git a/src/libs/Middleware/index.js b/src/libs/Middleware/index.ts
similarity index 100%
rename from src/libs/Middleware/index.js
rename to src/libs/Middleware/index.ts
diff --git a/src/libs/Middleware/types.ts b/src/libs/Middleware/types.ts
new file mode 100644
index 000000000000..ece210ffe2af
--- /dev/null
+++ b/src/libs/Middleware/types.ts
@@ -0,0 +1,6 @@
+import Request from '../../types/onyx/Request';
+import Response from '../../types/onyx/Response';
+
+type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise;
+
+export default Middleware;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index a4d934faec43..dd7175dbc6f6 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -35,6 +35,7 @@ import * as SessionUtils from '../../SessionUtils';
import NotFoundPage from '../../../pages/ErrorPage/NotFoundPage';
import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions';
import DemoSetupPage from '../../../pages/DemoSetupPage';
+import getCurrentUrl from '../currentUrl';
let timezone;
let currentAccountID;
@@ -145,6 +146,15 @@ class AuthScreens extends React.Component {
}
componentDidMount() {
+ const currentUrl = getCurrentUrl();
+ const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(currentUrl, this.props.session.email);
+ // Sign out the current user if we're transitioning with a different user
+ const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS);
+ if (isLoggingInAsNewUser && isTransitioning) {
+ Session.signOutAndRedirectToSignIn();
+ return;
+ }
+
NetworkConnection.listenForReconnect();
NetworkConnection.onReconnect(() => {
if (isLoadingApp) {
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js
index a3d8398a22b0..890db2b45ad4 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js
@@ -1,6 +1,8 @@
import _ from 'underscore';
import {StackRouter} from '@react-navigation/native';
+import lodashFindLast from 'lodash/findLast';
import NAVIGATORS from '../../../../NAVIGATORS';
+import SCREENS from '../../../../SCREENS';
/**
* @param {Object} state - react-navigation state
@@ -8,6 +10,30 @@ import NAVIGATORS from '../../../../NAVIGATORS';
*/
const isAtLeastOneCentralPaneNavigatorInState = (state) => _.find(state.routes, (r) => r.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+/**
+ * @param {Object} state - react-navigation state
+ * @returns {String|undefined}
+ */
+const getTopMostReportIDFromRHP = (state) => {
+ if (!state) {
+ return;
+ }
+ const topmostRightPane = lodashFindLast(state.routes, (route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR);
+
+ if (topmostRightPane) {
+ return getTopMostReportIDFromRHP(topmostRightPane.state);
+ }
+
+ const topmostRoute = lodashFindLast(state.routes);
+
+ if (topmostRoute.state) {
+ return getTopMostReportIDFromRHP(topmostRoute.state);
+ }
+
+ if (topmostRoute.params && topmostRoute.params.reportID) {
+ return topmostRoute.params.reportID;
+ }
+};
/**
* Adds report route without any specific reportID to the state.
* The report screen will self set proper reportID param based on the helper function findLastAccessedReport (look at ReportScreenWrapper for more info)
@@ -15,7 +41,21 @@ const isAtLeastOneCentralPaneNavigatorInState = (state) => _.find(state.routes,
* @param {Object} state - react-navigation state
*/
const addCentralPaneNavigatorRoute = (state) => {
- state.routes.splice(1, 0, {name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR});
+ const reportID = getTopMostReportIDFromRHP(state);
+ const centralPaneNavigatorRoute = {
+ name: NAVIGATORS.CENTRAL_PANE_NAVIGATOR,
+ state: {
+ routes: [
+ {
+ name: SCREENS.REPORT,
+ params: {
+ reportID,
+ },
+ },
+ ],
+ },
+ };
+ state.routes.splice(1, 0, centralPaneNavigatorRoute);
// eslint-disable-next-line no-param-reassign
state.index = state.routes.length - 1;
};
diff --git a/src/libs/Network/MainQueue.js b/src/libs/Network/MainQueue.ts
similarity index 71%
rename from src/libs/Network/MainQueue.js
rename to src/libs/Network/MainQueue.ts
index 5b5b928d3284..5f069e2d0ed4 100644
--- a/src/libs/Network/MainQueue.js
+++ b/src/libs/Network/MainQueue.ts
@@ -1,42 +1,28 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
import * as NetworkStore from './NetworkStore';
import * as SequentialQueue from './SequentialQueue';
import * as Request from '../Request';
+import OnyxRequest from '../../types/onyx/Request';
// Queue for network requests so we don't lose actions done by the user while offline
-let networkRequestQueue = [];
+let networkRequestQueue: OnyxRequest[] = [];
/**
* Checks to see if a request can be made.
- *
- * @param {Object} request
- * @param {String} request.type
- * @param {String} request.command
- * @param {Object} [request.data]
- * @param {Boolean} request.data.forceNetworkRequest
- * @return {Boolean}
*/
-function canMakeRequest(request) {
+function canMakeRequest(request: OnyxRequest): boolean {
// Some requests are always made even when we are in the process of authenticating (typically because they require no authToken e.g. Log, BeginSignIn)
// However, if we are in the process of authenticating we always want to queue requests until we are no longer authenticating.
- return request.data.forceNetworkRequest === true || (!NetworkStore.isAuthenticating() && !SequentialQueue.isRunning());
+ return request.data?.forceNetworkRequest === true || (!NetworkStore.isAuthenticating() && !SequentialQueue.isRunning());
}
-/**
- * @param {Object} request
- */
-function push(request) {
+function push(request: OnyxRequest) {
networkRequestQueue.push(request);
}
-/**
- * @param {Object} request
- */
-function replay(request) {
+function replay(request: OnyxRequest) {
push(request);
- // eslint-disable-next-line no-use-before-define
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
process();
}
@@ -57,12 +43,12 @@ function process() {
// - we are in the process of authenticating and the request is retryable (most are)
// - the request does not have forceNetworkRequest === true (this will trigger it to process immediately)
// - the request does not have shouldRetry === false (specified when we do not want to retry, defaults to true)
- const requestsToProcessOnNextRun = [];
+ const requestsToProcessOnNextRun: OnyxRequest[] = [];
- _.each(networkRequestQueue, (queuedRequest) => {
+ networkRequestQueue.forEach((queuedRequest) => {
// Check if we can make this request at all and if we can't see if we should save it for the next run or chuck it into the ether
if (!canMakeRequest(queuedRequest)) {
- const shouldRetry = lodashGet(queuedRequest, 'data.shouldRetry');
+ const shouldRetry = queuedRequest?.data?.shouldRetry;
if (shouldRetry) {
requestsToProcessOnNextRun.push(queuedRequest);
} else {
@@ -84,13 +70,10 @@ function process() {
* Non-cancellable requests like Log would not be cleared
*/
function clear() {
- networkRequestQueue = _.filter(networkRequestQueue, (request) => !request.data.canCancel);
+ networkRequestQueue = networkRequestQueue.filter((request) => !request.data?.canCancel);
}
-/**
- * @returns {Array}
- */
-function getAll() {
+function getAll(): OnyxRequest[] {
return networkRequestQueue;
}
diff --git a/src/libs/Network/NetworkStore.js b/src/libs/Network/NetworkStore.ts
similarity index 61%
rename from src/libs/Network/NetworkStore.js
rename to src/libs/Network/NetworkStore.ts
index 5ab46a4d65fa..0910031bdb63 100644
--- a/src/libs/Network/NetworkStore.js
+++ b/src/libs/Network/NetworkStore.ts
@@ -1,32 +1,28 @@
-import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';
+import Credentials from '../../types/onyx/Credentials';
-let credentials;
-let authToken;
-let supportAuthToken;
-let currentUserEmail;
+let credentials: Credentials | null = null;
+let authToken: string | null = null;
+let supportAuthToken: string | null = null;
+let currentUserEmail: string | null = null;
let offline = false;
let authenticating = false;
// Allow code that is outside of the network listen for when a reconnection happens so that it can execute any side-effects (like flushing the sequential network queue)
-let reconnectCallback;
+let reconnectCallback: () => void;
function triggerReconnectCallback() {
- if (!_.isFunction(reconnectCallback)) {
+ if (typeof reconnectCallback !== 'function') {
return;
}
return reconnectCallback();
}
-/**
- * @param {Function} callbackFunction
- */
-function onReconnection(callbackFunction) {
+function onReconnection(callbackFunction: () => void) {
reconnectCallback = callbackFunction;
}
-let resolveIsReadyPromise;
+let resolveIsReadyPromise: (args?: unknown[]) => void;
let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
@@ -36,7 +32,7 @@ let isReadyPromise = new Promise((resolve) => {
* If the values are undefined we haven't read them yet. If they are null or have a value then we have and the network is "ready".
*/
function checkRequiredData() {
- if (_.isUndefined(authToken) || _.isUndefined(credentials)) {
+ if (authToken === undefined || credentials === undefined) {
return;
}
@@ -53,9 +49,9 @@ function resetHasReadRequiredDataFromStorage() {
Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => {
- authToken = lodashGet(val, 'authToken', null);
- supportAuthToken = lodashGet(val, 'supportAuthToken', null);
- currentUserEmail = lodashGet(val, 'email', null);
+ authToken = val?.authToken ?? null;
+ supportAuthToken = val?.supportAuthToken ?? null;
+ currentUserEmail = val?.email ?? null;
checkRequiredData();
},
});
@@ -63,7 +59,7 @@ Onyx.connect({
Onyx.connect({
key: ONYXKEYS.CREDENTIALS,
callback: (val) => {
- credentials = val || {};
+ credentials = val;
checkRequiredData();
},
});
@@ -82,85 +78,51 @@ Onyx.connect({
triggerReconnectCallback();
}
- offline = Boolean(network.shouldForceOffline) || network.isOffline;
+ offline = Boolean(network.shouldForceOffline) || !!network.isOffline;
},
});
-/**
- * @returns {Object}
- */
-function getCredentials() {
+function getCredentials(): Credentials | null {
return credentials;
}
-/**
- * @returns {Boolean}
- */
-function isOffline() {
+function isOffline(): boolean {
return offline;
}
-/**
- * @returns {String}
- */
-function getAuthToken() {
+function getAuthToken(): string | null {
return authToken;
}
-/**
- * @param {String} command
- * @returns {[String]}
- */
-function isSupportRequest(command) {
- return _.contains(['OpenApp', 'ReconnectApp', 'OpenReport'], command);
+function isSupportRequest(command: string): boolean {
+ return ['OpenApp', 'ReconnectApp', 'OpenReport'].includes(command);
}
-/**
- * @returns {String}
- */
-function getSupportAuthToken() {
+function getSupportAuthToken(): string | null {
return supportAuthToken;
}
-/**
- * @param {String} newSupportAuthToken
- */
-function setSupportAuthToken(newSupportAuthToken) {
+function setSupportAuthToken(newSupportAuthToken: string) {
supportAuthToken = newSupportAuthToken;
}
-/**
- * @param {String} newAuthToken
- */
-function setAuthToken(newAuthToken) {
+function setAuthToken(newAuthToken: string | null) {
authToken = newAuthToken;
}
-/**
- * @returns {String}
- */
-function getCurrentUserEmail() {
+function getCurrentUserEmail(): string | null {
return currentUserEmail;
}
-/**
- * @returns {Promise}
- */
-function hasReadRequiredDataFromStorage() {
+function hasReadRequiredDataFromStorage(): Promise {
return isReadyPromise;
}
-/**
- * @returns {Boolean}
- */
-function isAuthenticating() {
+function isAuthenticating(): boolean {
return authenticating;
}
-/**
- * @param {Boolean} val
- */
-function setIsAuthenticating(val) {
+function setIsAuthenticating(val: boolean) {
authenticating = val;
}
diff --git a/src/libs/Network/SequentialQueue.js b/src/libs/Network/SequentialQueue.ts
similarity index 90%
rename from src/libs/Network/SequentialQueue.js
rename to src/libs/Network/SequentialQueue.ts
index 5c74f791e073..980bbc0efc44 100644
--- a/src/libs/Network/SequentialQueue.js
+++ b/src/libs/Network/SequentialQueue.ts
@@ -1,4 +1,3 @@
-import _ from 'underscore';
import Onyx from 'react-native-onyx';
import * as PersistedRequests from '../actions/PersistedRequests';
import * as NetworkStore from './NetworkStore';
@@ -8,17 +7,18 @@ import * as Request from '../Request';
import * as RequestThrottle from '../RequestThrottle';
import CONST from '../../CONST';
import * as QueuedOnyxUpdates from '../actions/QueuedOnyxUpdates';
+import OnyxRequest from '../../types/onyx/Request';
-let resolveIsReadyPromise;
+let resolveIsReadyPromise: ((args?: unknown[]) => void) | undefined;
let isReadyPromise = new Promise((resolve) => {
resolveIsReadyPromise = resolve;
});
// Resolve the isReadyPromise immediately so that the queue starts working as soon as the page loads
-resolveIsReadyPromise();
+resolveIsReadyPromise?.();
let isSequentialQueueRunning = false;
-let currentRequest = null;
+let currentRequest: Promise | null = null;
let isQueuePaused = false;
/**
@@ -52,16 +52,15 @@ function flushOnyxUpdatesQueue() {
* is successfully returned. The first time a request fails we set a random, small, initial wait time. After waiting, we retry the request. If there are subsequent failures the request wait
* time is doubled creating an exponential back off in the frequency of requests hitting the server. Since the initial wait time is random and it increases exponentially, the load of
* requests to our backend is evenly distributed and it gradually decreases with time, which helps the servers catch up.
- * @returns {Promise}
*/
-function process() {
+function process(): Promise {
// When the queue is paused, return early. This prevents any new requests from happening. The queue will be flushed again when the queue is unpaused.
if (isQueuePaused) {
return Promise.resolve();
}
const persistedRequests = PersistedRequests.getAll();
- if (_.isEmpty(persistedRequests) || NetworkStore.isOffline()) {
+ if (persistedRequests.length === 0 || NetworkStore.isOffline()) {
return Promise.resolve();
}
const requestToProcess = persistedRequests[0];
@@ -71,7 +70,7 @@ function process() {
.then((response) => {
// A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and
// that gap needs resolved before the queue can continue.
- if (response.shouldPauseQueue) {
+ if (response?.shouldPauseQueue) {
pause();
}
PersistedRequests.remove(requestToProcess);
@@ -89,12 +88,13 @@ function process() {
return RequestThrottle.sleep()
.then(process)
.catch(() => {
- Onyx.update(requestToProcess.failureData);
+ Onyx.update(requestToProcess.failureData ?? []);
PersistedRequests.remove(requestToProcess);
RequestThrottle.clear();
return process();
});
});
+
return currentRequest;
}
@@ -104,7 +104,7 @@ function flush() {
return;
}
- if (isSequentialQueueRunning || _.isEmpty(PersistedRequests.getAll())) {
+ if (isSequentialQueueRunning || PersistedRequests.getAll().length === 0) {
return;
}
@@ -128,7 +128,7 @@ function flush() {
Onyx.disconnect(connectionID);
process().finally(() => {
isSequentialQueueRunning = false;
- resolveIsReadyPromise();
+ resolveIsReadyPromise?.();
currentRequest = null;
flushOnyxUpdatesQueue();
});
@@ -151,20 +151,14 @@ function unpause() {
flush();
}
-/**
- * @returns {Boolean}
- */
-function isRunning() {
+function isRunning(): boolean {
return isSequentialQueueRunning;
}
// Flush the queue when the connection resumes
NetworkStore.onReconnection(flush);
-/**
- * @param {Object} request
- */
-function push(request) {
+function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
PersistedRequests.save([request]);
@@ -182,10 +176,7 @@ function push(request) {
flush();
}
-/**
- * @returns {Promise}
- */
-function getCurrentRequest() {
+function getCurrentRequest(): OnyxRequest | Promise {
if (currentRequest === null) {
return Promise.resolve();
}
@@ -194,9 +185,8 @@ function getCurrentRequest() {
/**
* Returns a promise that resolves when the sequential queue is done processing all persisted write requests.
- * @returns {Promise}
*/
-function waitForIdle() {
+function waitForIdle(): Promise {
return isReadyPromise;
}
diff --git a/src/libs/Network/enhanceParameters.js b/src/libs/Network/enhanceParameters.ts
similarity index 72%
rename from src/libs/Network/enhanceParameters.js
rename to src/libs/Network/enhanceParameters.ts
index 778be881cb98..54d72a7c6c99 100644
--- a/src/libs/Network/enhanceParameters.js
+++ b/src/libs/Network/enhanceParameters.ts
@@ -1,27 +1,18 @@
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
import CONFIG from '../../CONFIG';
import getPlatform from '../getPlatform';
import * as NetworkStore from './NetworkStore';
/**
* Does this command require an authToken?
- *
- * @param {String} command
- * @return {Boolean}
*/
-function isAuthTokenRequired(command) {
- return !_.contains(['Log', 'Authenticate', 'BeginSignIn', 'SetPassword'], command);
+function isAuthTokenRequired(command: string): boolean {
+ return !['Log', 'Authenticate', 'BeginSignIn', 'SetPassword'].includes(command);
}
/**
* Adds default values to our request data
- *
- * @param {String} command
- * @param {Object} parameters
- * @returns {Object}
*/
-export default function enhanceParameters(command, parameters) {
+export default function enhanceParameters(command: string, parameters: Record): Record {
const finalParameters = {...parameters};
if (isAuthTokenRequired(command)) {
@@ -44,7 +35,7 @@ export default function enhanceParameters(command, parameters) {
finalParameters.api_setCookie = false;
// Include current user's email in every request and the server logs
- finalParameters.email = lodashGet(parameters, 'email', NetworkStore.getCurrentUserEmail());
+ finalParameters.email = parameters.email ?? NetworkStore.getCurrentUserEmail();
return finalParameters;
}
diff --git a/src/libs/Network/index.js b/src/libs/Network/index.ts
similarity index 77%
rename from src/libs/Network/index.js
rename to src/libs/Network/index.ts
index 2f5dc9460e60..bf38bc33e95a 100644
--- a/src/libs/Network/index.js
+++ b/src/libs/Network/index.ts
@@ -1,9 +1,10 @@
-import lodashGet from 'lodash/get';
import * as ActiveClientManager from '../ActiveClientManager';
import CONST from '../../CONST';
import * as MainQueue from './MainQueue';
import * as SequentialQueue from './SequentialQueue';
import pkg from '../../../package.json';
+import {Request} from '../../types/onyx';
+import Response from '../../types/onyx/Response';
// We must wait until the ActiveClientManager is ready so that we ensure only the "leader" tab processes any persisted requests
ActiveClientManager.isReady().then(() => {
@@ -15,16 +16,10 @@ ActiveClientManager.isReady().then(() => {
/**
* Perform a queued post request
- *
- * @param {String} command
- * @param {*} [data]
- * @param {String} [type]
- * @param {Boolean} [shouldUseSecure] - Whether we should use the secure API
- * @returns {Promise}
*/
-function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) {
+function post(command: string, data: Record = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise {
return new Promise((resolve, reject) => {
- const request = {
+ const request: Request = {
command,
data,
type,
@@ -35,8 +30,8 @@ function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSec
// (e.g. any requests currently happening when the user logs out are cancelled)
request.data = {
...data,
- shouldRetry: lodashGet(data, 'shouldRetry', true),
- canCancel: lodashGet(data, 'canCancel', true),
+ shouldRetry: data?.shouldRetry ?? true,
+ canCancel: data?.canCancel ?? true,
appversion: pkg.version,
};
@@ -50,7 +45,7 @@ function post(command, data = {}, type = CONST.NETWORK.METHOD.POST, shouldUseSec
// This check is mainly used to prevent API commands from triggering calls to MainQueue.process() from inside the context of a previous
// call to MainQueue.process() e.g. calling a Log command without this would cause the requests in mainQueue to double process
// since we call Log inside MainQueue.process().
- const shouldProcessImmediately = lodashGet(request, 'data.shouldProcessImmediately', true);
+ const shouldProcessImmediately = request?.data?.shouldProcessImmediately ?? true;
if (!shouldProcessImmediately) {
return;
}
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index fb6c6e4f493e..82714dbcbe11 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -376,6 +376,8 @@ function getLastMessageTextForReport(report) {
ReportActionUtils.isMoneyRequestAction(reportAction),
);
lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReport, true);
+ } else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) {
+ lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction);
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
@@ -522,7 +524,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
}
result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result);
- result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result);
+ result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result);
if (!hasMultipleParticipants) {
result.login = personalDetail.login;
@@ -1526,6 +1528,20 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma
return '';
}
+/**
+ * Helper method for non-user lists (eg. categories and tags) that returns the text to be used for the header's message and title (if any)
+ *
+ * @param {Boolean} hasSelectableOptions
+ * @param {String} searchValue
+ * @return {String}
+ */
+function getHeaderMessageForNonUserList(hasSelectableOptions, searchValue) {
+ if (searchValue && !hasSelectableOptions) {
+ return Localize.translate(preferredLocale, 'common.noResultsFound');
+ }
+ return '';
+}
+
/**
* Helper method to check whether an option can show tooltip or not
* @param {Object} option
@@ -1545,6 +1561,7 @@ export {
getShareDestinationOptions,
getMemberInviteOptions,
getHeaderMessage,
+ getHeaderMessageForNonUserList,
getPersonalDetailsForAccountIDs,
getIOUConfirmationOptionsFromPayeePersonalDetail,
getIOUConfirmationOptionsFromParticipants,
diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts
index 64260569639e..7ea935577fb1 100644
--- a/src/libs/PaymentUtils.ts
+++ b/src/libs/PaymentUtils.ts
@@ -1,19 +1,13 @@
-import {SvgProps} from 'react-native-svg';
import BankAccountModel from './models/BankAccount';
import getBankIcon from '../components/Icon/BankIcons';
import CONST from '../CONST';
import * as Localize from './Localize';
import Fund from '../types/onyx/Fund';
import BankAccount from '../types/onyx/BankAccount';
+import PaymentMethod from '../types/onyx/PaymentMethod';
type AccountType = BankAccount['accountType'] | Fund['accountType'];
-type PaymentMethod = (BankAccount | Fund) & {
- description: string;
- icon: React.FC;
- iconSize?: number;
-};
-
/**
* Check to see if user has either a debit card or personal bank account added
*/
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index cdc45cb119d5..9fa7ebdc6559 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -6,10 +6,13 @@ import ReceiptHTML from '../../assets/images/receipt-html.png';
import ReceiptDoc from '../../assets/images/receipt-doc.png';
import ReceiptGeneric from '../../assets/images/receipt-generic.png';
import ReceiptSVG from '../../assets/images/receipt-svg.png';
+import {Transaction} from '../types/onyx';
+import ROUTES from '../ROUTES';
type ThumbnailAndImageURI = {
image: ImageSourcePropType | string;
thumbnail: string | null;
+ transaction?: Transaction;
};
type FileNameAndExtension = {
@@ -20,12 +23,21 @@ type FileNameAndExtension = {
/**
* Grab the appropriate receipt image and thumbnail URIs based on file type
*
- * @param path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
- * @param filename of uploaded image or last part of remote URI
+ * @param transaction
*/
-function getThumbnailAndImageURIs(path: string, filename: string): ThumbnailAndImageURI {
+function getThumbnailAndImageURIs(transaction: Transaction): ThumbnailAndImageURI {
+ // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
+ const path = transaction?.receipt?.source ?? '';
+ // filename of uploaded image or last part of remote URI
+ const filename = transaction?.filename ?? '';
const isReceiptImage = Str.isImage(filename);
+ const hasEReceipt = transaction?.hasEReceipt;
+
+ if (hasEReceipt) {
+ return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
+ }
+
// For local files, we won't have a thumbnail yet
if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
return {thumbnail: null, image: path};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 9d7c87d1ec9d..1f71b290e386 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -325,10 +325,11 @@ function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxEntry {
- if (transactionIDs[transactionID] !== null) {
- const transaction = TransactionUtils.getTransaction(transactionID);
- if (TransactionUtils.hasReceipt(transaction)) {
- transactions.push(transaction);
- }
- }
- return transactions;
- },
- [],
- );
-}
-
/**
* Return iou report action display message
*
@@ -3912,7 +3967,8 @@ export {
hasExpensifyGuidesEmails,
isWaitingForIOUActionFromCurrentUser,
isIOUOwnedByCurrentUser,
- getMoneyRequestTotal,
+ getMoneyRequestReimbursableTotal,
+ getMoneyRequestSpendBreakdown,
canShowReportRecipientLocalTime,
formatReportLastMessageText,
chatIncludesConcierge,
@@ -3927,6 +3983,8 @@ export {
getReport,
getReportIDFromLink,
getRouteFromLink,
+ getDeletedParentActionMessageForChatReport,
+ getLastVisibleMessage,
navigateToDetailsPage,
generateReportID,
hasReportNameError,
@@ -4019,7 +4077,6 @@ export {
canEditMoneyRequest,
buildTransactionThread,
areAllRequestsBeingSmartScanned,
- getReportPreviewDisplayTransactions,
getTransactionsWithReceipts,
hasNonReimbursableTransactions,
hasMissingSmartscanFields,
diff --git a/src/libs/Request.ts b/src/libs/Request.ts
index 903e70358da9..9c4af4aa7e18 100644
--- a/src/libs/Request.ts
+++ b/src/libs/Request.ts
@@ -3,24 +3,24 @@ import enhanceParameters from './Network/enhanceParameters';
import * as NetworkStore from './Network/NetworkStore';
import Request from '../types/onyx/Request';
import Response from '../types/onyx/Response';
-
-type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise;
+import Middleware from './Middleware/types';
let middlewares: Middleware[] = [];
-function makeXHR(request: Request): Promise {
+function makeXHR(request: Request): Promise {
const finalParameters = enhanceParameters(request.command, request?.data ?? {});
- return NetworkStore.hasReadRequiredDataFromStorage().then(() => {
+ return NetworkStore.hasReadRequiredDataFromStorage().then((): Promise => {
// If we're using the Supportal token and this is not a Supportal request
// let's just return a promise that will resolve itself.
if (NetworkStore.getSupportAuthToken() && !NetworkStore.isSupportRequest(request.command)) {
return new Promise((resolve) => resolve());
}
- return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
- }) as Promise;
+
+ return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise;
+ });
}
-function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
+function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
return middlewares.reduce((last, middleware) => middleware(last, request, isFromSequentialQueue), makeXHR(request));
}
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index 314a1d63760e..dd6db33902fb 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -158,7 +158,7 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p
report.displayName = ReportUtils.getReportName(report);
// eslint-disable-next-line no-param-reassign
- report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, allReportsDict);
+ report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReportsDict);
});
// The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
@@ -384,7 +384,7 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
}
result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result);
- result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result);
+ result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result);
if (!hasMultipleParticipants) {
result.accountID = personalDetail.accountID;
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 77fc4f04f99d..31cad217666c 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -6,7 +6,7 @@ import DateUtils from './DateUtils';
import {isExpensifyCard} from './CardUtils';
import * as NumberUtils from './NumberUtils';
import {RecentWaypoint, ReportAction, Transaction} from '../types/onyx';
-import {Receipt, Comment, WaypointCollection} from '../types/onyx/Transaction';
+import {Receipt, Comment, WaypointCollection, Waypoint} from '../types/onyx/Transaction';
type AdditionalTransactionChanges = {comment?: string; waypoints?: WaypointCollection};
@@ -76,8 +76,15 @@ function buildOptimisticTransaction(
};
}
+/**
+ * Check if the transaction has an Ereceipt
+ */
+function hasEReceipt(transaction: Transaction | undefined | null): boolean {
+ return !!transaction?.hasEReceipt;
+}
+
function hasReceipt(transaction: Transaction | undefined | null): boolean {
- return !!transaction?.receipt?.state;
+ return !!transaction?.receipt?.state || hasEReceipt(transaction);
}
function isMerchantMissing(transaction: Transaction) {
@@ -365,13 +372,6 @@ function hasRoute(transaction: Transaction): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
-/**
- * Check if the transaction has an Ereceipt
- */
-function hasEreceipt(transaction: Transaction): boolean {
- return !!transaction?.hasEReceipt;
-}
-
/**
* Get the transactions related to a report preview with receipts
* Get the details linked to the IOU reportAction
@@ -399,7 +399,7 @@ function getAllReportTransactions(reportID?: string): Transaction[] {
/**
* Checks if a waypoint has a valid address
*/
-function waypointHasValidAddress(waypoint: RecentWaypoint | null): boolean {
+function waypointHasValidAddress(waypoint: RecentWaypoint | Waypoint): boolean {
return !!waypoint?.address?.trim();
}
@@ -423,7 +423,7 @@ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = fal
let lastWaypointIndex = -1;
- return waypointValues.reduce((acc, currentWaypoint, index) => {
+ return waypointValues.reduce((acc, currentWaypoint, index) => {
const previousWaypoint = waypointValues[lastWaypointIndex];
// Check if the waypoint has a valid address
@@ -472,7 +472,7 @@ export {
getLinkedTransaction,
getAllReportTransactions,
hasReceipt,
- hasEreceipt,
+ hasEReceipt,
hasRoute,
isReceiptBeingScanned,
getValidWaypoints,
diff --git a/src/libs/UpdateMultilineInputRange/index.ios.js b/src/libs/UpdateMultilineInputRange/index.ios.js
index 85ed529a33bc..4c10f768a2a2 100644
--- a/src/libs/UpdateMultilineInputRange/index.ios.js
+++ b/src/libs/UpdateMultilineInputRange/index.ios.js
@@ -8,8 +8,9 @@
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
+ * @param {boolean} shouldAutoFocus
*/
-export default function updateMultilineInputRange(input) {
+export default function updateMultilineInputRange(input, shouldAutoFocus = true) {
if (!input) {
return;
}
@@ -19,5 +20,7 @@ export default function updateMultilineInputRange(input) {
* Issue: does not scroll multiline input when text exceeds the maximum number of lines
* For more details: https://github.com/Expensify/App/pull/27702#issuecomment-1728651132
*/
- input.focus();
+ if (shouldAutoFocus) {
+ input.focus();
+ }
}
diff --git a/src/libs/UpdateMultilineInputRange/index.js b/src/libs/UpdateMultilineInputRange/index.js
index 179d30dc611d..66fb1889be21 100644
--- a/src/libs/UpdateMultilineInputRange/index.js
+++ b/src/libs/UpdateMultilineInputRange/index.js
@@ -8,8 +8,10 @@
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
+ * @param {boolean} shouldAutoFocus
*/
-export default function updateMultilineInputRange(input) {
+// eslint-disable-next-line no-unused-vars
+export default function updateMultilineInputRange(input, shouldAutoFocus = true) {
if (!input) {
return;
}
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index 2dbc1001f068..75520d483f98 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -423,9 +423,6 @@ function setUpPoliciesAndNavigate(session, shouldNavigateToAdminChat) {
// Sign out the current user if we're transitioning with a different user
const isTransitioning = Str.startsWith(url.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS));
- if (isLoggingInAsNewUser && isTransitioning) {
- Session.signOut();
- }
const shouldCreateFreePolicy = !isLoggingInAsNewUser && isTransitioning && exitTo === ROUTES.WORKSPACE_NEW;
if (shouldCreateFreePolicy) {
diff --git a/src/libs/actions/Chronos.ts b/src/libs/actions/Chronos.ts
index 1b46a68a1afe..ce821e524722 100644
--- a/src/libs/actions/Chronos.ts
+++ b/src/libs/actions/Chronos.ts
@@ -1,11 +1,11 @@
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import {ChronosOOOEvent} from '../../types/onyx/OriginalMessage';
const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => {
- const optimisticData = [
+ const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
@@ -20,7 +20,7 @@ const removeEvent = (reportID: string, reportActionID: string, eventID: string,
},
];
- const successData = [
+ const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
@@ -32,7 +32,7 @@ const removeEvent = (reportID: string, reportActionID: string, eventID: string,
},
];
- const failureData = [
+ const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js
index 29c983c35262..e7ce02d2796b 100644
--- a/src/libs/actions/DemoActions.js
+++ b/src/libs/actions/DemoActions.js
@@ -17,7 +17,7 @@ Onyx.connect({
function runMoney2020Demo() {
// Try to navigate to existing demo chat if it exists in Onyx
- const money2020AccountID = Number(Config ? Config.EXPENSIFY_ACCOUNT_ID_MONEY2020 : 15864555);
+ const money2020AccountID = Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_MONEY2020', 15864555));
const existingChatReport = ReportUtils.getChatByParticipants([money2020AccountID]);
if (existingChatReport) {
// We must call goBack() to remove the demo route from nav history
@@ -63,7 +63,7 @@ function runDemoByURL(url = '') {
});
} else {
// No demo is being run, so clear out demo info
- Onyx.set(ONYXKEYS.DEMO_INFO, null);
+ Onyx.set(ONYXKEYS.DEMO_INFO, {});
}
}
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 573452cd7de4..d84bb75a1c9c 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -2647,7 +2647,7 @@ function submitReport(expenseReport) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
value: {
- state: CONST.REPORT.STATE.OPEN,
+ statusNum: CONST.REPORT.STATUS.OPEN,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
},
},
diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js
deleted file mode 100644
index 0ed6f8b036bb..000000000000
--- a/src/libs/actions/PaymentMethods.js
+++ /dev/null
@@ -1,356 +0,0 @@
-import _ from 'underscore';
-import {createRef} from 'react';
-import Onyx from 'react-native-onyx';
-import ONYXKEYS from '../../ONYXKEYS';
-import * as API from '../API';
-import CONST from '../../CONST';
-import Navigation from '../Navigation/Navigation';
-import * as CardUtils from '../CardUtils';
-import ROUTES from '../../ROUTES';
-
-/**
- * Sets up a ref to an instance of the KYC Wall component.
- */
-const kycWallRef = createRef();
-
-/**
- * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set.
- */
-function continueSetup() {
- if (!kycWallRef.current || !kycWallRef.current.continue) {
- Navigation.goBack(ROUTES.HOME);
- return;
- }
-
- // Close the screen (Add Debit Card, Add Bank Account, or Enable Payments) on success and continue with setup
- Navigation.goBack(ROUTES.HOME);
- kycWallRef.current.continue();
-}
-
-function openWalletPage() {
- const onyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: true,
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: false,
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: false,
- },
- ],
- };
-
- return API.read('OpenPaymentsPage', {}, onyxData);
-}
-
-/**
- *
- * @param {Number} bankAccountID
- * @param {Number} fundID
- * @param {Object} previousPaymentMethod
- * @param {Object} currentPaymentMethod
- * @param {Boolean} isOptimisticData
- * @return {Array}
- *
- */
-function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, isOptimisticData = true) {
- const onyxData = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.USER_WALLET,
- value: {
- walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
- },
- },
- ];
-
- // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
- if (isOptimisticData) {
- onyxData[0].value.errors = null;
- }
-
- if (previousPaymentMethod) {
- onyxData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
- value: {
- [previousPaymentMethod.methodID]: {
- isDefault: !isOptimisticData,
- },
- },
- });
- }
-
- if (currentPaymentMethod) {
- onyxData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
- value: {
- [currentPaymentMethod.methodID]: {
- isDefault: isOptimisticData,
- },
- },
- });
- }
-
- return onyxData;
-}
-
-/**
- * Sets the default bank account or debit card for an Expensify Wallet
- *
- * @param {Number} bankAccountID
- * @param {Number} fundID
- * @param {Object} previousPaymentMethod
- * @param {Object} currentPaymentMethod
- *
- */
-function makeDefaultPaymentMethod(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod) {
- API.write(
- 'MakeDefaultPaymentMethod',
- {
- bankAccountID,
- fundID,
- },
- {
- optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true, ONYXKEYS.FUND_LIST),
- failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false, ONYXKEYS.FUND_LIST),
- },
- );
-}
-
-/**
- * Calls the API to add a new card.
- *
- * @param {Object} params
- */
-function addPaymentCard(params) {
- const cardMonth = CardUtils.getMonthFromExpirationDateString(params.expirationDate);
- const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate);
-
- API.write(
- 'AddPaymentCard',
- {
- cardNumber: params.cardNumber,
- cardYear,
- cardMonth,
- cardCVV: params.securityCode,
- addressName: params.nameOnCard,
- addressZip: params.addressZipCode,
- currency: CONST.CURRENCY.USD,
- isP2PDebitCard: true,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: true},
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: false},
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: false},
- },
- ],
- },
- );
-}
-
-/**
- * Resets the values for the add debit card form back to their initial states
- */
-function clearDebitCardFormErrorAndSubmit() {
- Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
- isLoading: false,
- errors: null,
- });
-}
-
-/**
- * Call the API to transfer wallet balance.
- * @param {Object} paymentMethod
- * @param {*} paymentMethod.methodID
- * @param {String} paymentMethod.accountType
- */
-function transferWalletBalance(paymentMethod) {
- const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
- const parameters = {
- [paymentMethodIDKey]: paymentMethod.methodID,
- };
-
- API.write('TransferWalletBalance', parameters, {
- optimisticData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: true,
- errors: null,
- },
- },
- ],
- successData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: false,
- shouldShowSuccess: true,
- paymentMethodType: paymentMethod.accountType,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: false,
- shouldShowSuccess: false,
- },
- },
- ],
- });
-}
-
-function resetWalletTransferData() {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {
- selectedAccountType: '',
- selectedAccountID: null,
- filterPaymentMethodType: null,
- loading: false,
- shouldShowSuccess: false,
- });
-}
-
-/**
- * @param {String} selectedAccountType
- * @param {String} selectedAccountID
- */
-function saveWalletTransferAccountTypeAndID(selectedAccountType, selectedAccountID) {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {selectedAccountType, selectedAccountID});
-}
-
-/**
- * Toggles the user's selected type of payment method (bank account or debit card) on the wallet transfer balance screen.
- * @param {String} filterPaymentMethodType
- */
-function saveWalletTransferMethodType(filterPaymentMethodType) {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {filterPaymentMethodType});
-}
-
-function dismissSuccessfulTransferBalancePage() {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
- Navigation.goBack(ROUTES.SETTINGS_WALLET);
-}
-
-/**
- * Looks through each payment method to see if there is an existing error
- * @param {Object} bankList
- * @param {Object} fundList
- * @returns {Boolean}
- */
-function hasPaymentMethodError(bankList, fundList) {
- const combinedPaymentMethods = {...bankList, ...fundList};
- return _.some(combinedPaymentMethods, (item) => !_.isEmpty(item.errors));
-}
-
-/**
- * Clears the error for the specified payment item
- * @param {String} paymentListKey The onyx key for the provided payment method
- * @param {String} paymentMethodID
- */
-function clearDeletePaymentMethodError(paymentListKey, paymentMethodID) {
- Onyx.merge(paymentListKey, {
- [paymentMethodID]: {
- pendingAction: null,
- errors: null,
- },
- });
-}
-
-/**
- * If there was a failure adding a payment method, clearing it removes the payment method from the list entirely
- * @param {String} paymentListKey The onyx key for the provided payment method
- * @param {String} paymentMethodID
- */
-function clearAddPaymentMethodError(paymentListKey, paymentMethodID) {
- Onyx.merge(paymentListKey, {
- [paymentMethodID]: null,
- });
-}
-
-/**
- * Clear any error(s) related to the user's wallet
- */
-function clearWalletError() {
- Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null});
-}
-
-/**
- * Clear any error(s) related to the user's wallet terms
- */
-function clearWalletTermsError() {
- Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null});
-}
-
-function deletePaymentCard(fundID) {
- API.write(
- 'DeletePaymentCard',
- {
- fundID,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.FUND_LIST}`,
- value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
- },
- ],
- },
- );
-}
-
-export {
- deletePaymentCard,
- addPaymentCard,
- openWalletPage,
- makeDefaultPaymentMethod,
- kycWallRef,
- continueSetup,
- clearDebitCardFormErrorAndSubmit,
- dismissSuccessfulTransferBalancePage,
- transferWalletBalance,
- resetWalletTransferData,
- saveWalletTransferAccountTypeAndID,
- saveWalletTransferMethodType,
- hasPaymentMethodError,
- clearDeletePaymentMethodError,
- clearAddPaymentMethodError,
- clearWalletError,
- clearWalletTermsError,
-};
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
new file mode 100644
index 000000000000..c532d0fbeb63
--- /dev/null
+++ b/src/libs/actions/PaymentMethods.ts
@@ -0,0 +1,393 @@
+import {createRef} from 'react';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
+import ONYXKEYS, {OnyxValues} from '../../ONYXKEYS';
+import * as API from '../API';
+import CONST from '../../CONST';
+import Navigation from '../Navigation/Navigation';
+import * as CardUtils from '../CardUtils';
+import ROUTES from '../../ROUTES';
+import {FilterMethodPaymentType} from '../../types/onyx/WalletTransfer';
+import PaymentMethod from '../../types/onyx/PaymentMethod';
+
+type KYCWallRef = {
+ continue?: () => void;
+};
+
+/**
+ * Sets up a ref to an instance of the KYC Wall component.
+ */
+const kycWallRef = createRef();
+
+/**
+ * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set.
+ */
+function continueSetup() {
+ if (!kycWallRef.current?.continue) {
+ Navigation.goBack(ROUTES.HOME);
+ return;
+ }
+
+ // Close the screen (Add Debit Card, Add Bank Account, or Enable Payments) on success and continue with setup
+ Navigation.goBack(ROUTES.HOME);
+ kycWallRef.current.continue();
+}
+
+function openWalletPage() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: true,
+ },
+ ];
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: false,
+ },
+ ];
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: false,
+ },
+ ];
+
+ return API.read(
+ 'OpenPaymentsPage',
+ {},
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
+}
+
+function getMakeDefaultPaymentOnyxData(
+ bankAccountID: number,
+ fundID: number,
+ previousPaymentMethod: PaymentMethod,
+ currentPaymentMethod: PaymentMethod,
+ isOptimisticData = true,
+): OnyxUpdate[] {
+ const onyxData: OnyxUpdate[] = [
+ isOptimisticData
+ ? {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER_WALLET,
+ value: {
+ walletLinkedAccountID: bankAccountID || fundID,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
+ errors: null,
+ },
+ }
+ : {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER_WALLET,
+ value: {
+ walletLinkedAccountID: bankAccountID || fundID,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ },
+ },
+ ];
+
+ if (previousPaymentMethod?.methodID) {
+ onyxData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ value: {
+ [previousPaymentMethod.methodID]: {
+ isDefault: !isOptimisticData,
+ },
+ },
+ });
+ }
+
+ if (currentPaymentMethod?.methodID) {
+ onyxData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ value: {
+ [currentPaymentMethod.methodID]: {
+ isDefault: isOptimisticData,
+ },
+ },
+ });
+ }
+
+ return onyxData;
+}
+
+/**
+ * Sets the default bank account or debit card for an Expensify Wallet
+ *
+ */
+function makeDefaultPaymentMethod(bankAccountID: number, fundID: number, previousPaymentMethod: PaymentMethod, currentPaymentMethod: PaymentMethod) {
+ type MakeDefaultPaymentMethodParams = {
+ bankAccountID: number;
+ fundID: number;
+ };
+
+ const parameters: MakeDefaultPaymentMethodParams = {
+ bankAccountID,
+ fundID,
+ };
+
+ API.write('MakeDefaultPaymentMethod', parameters, {
+ optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true),
+ failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false),
+ });
+}
+
+type PaymentCardParams = {expirationDate: string; cardNumber: string; securityCode: string; nameOnCard: string; addressZipCode: string};
+
+/**
+ * Calls the API to add a new card.
+ *
+ */
+function addPaymentCard(params: PaymentCardParams) {
+ const cardMonth = CardUtils.getMonthFromExpirationDateString(params.expirationDate);
+ const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate);
+
+ type AddPaymentCardParams = {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: ValueOf;
+ isP2PDebitCard: boolean;
+ };
+
+ const parameters: AddPaymentCardParams = {
+ cardNumber: params.cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV: params.securityCode,
+ addressName: params.nameOnCard,
+ addressZip: params.addressZipCode,
+ currency: CONST.CURRENCY.USD,
+ isP2PDebitCard: true,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: true},
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ API.write('AddPaymentCard', parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
+/**
+ * Resets the values for the add debit card form back to their initial states
+ */
+function clearDebitCardFormErrorAndSubmit() {
+ Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
+ isLoading: false,
+ errors: undefined,
+ setupComplete: true,
+ });
+}
+
+/**
+ * Call the API to transfer wallet balance.
+ *
+ */
+function transferWalletBalance(paymentMethod: PaymentMethod) {
+ const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
+
+ type TransferWalletBalanceParameters = Partial, number | undefined>>;
+
+ const parameters: TransferWalletBalanceParameters = {
+ [paymentMethodIDKey]: paymentMethod.methodID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: true,
+ errors: null,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: false,
+ shouldShowSuccess: true,
+ paymentMethodType: paymentMethod.accountType,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: false,
+ shouldShowSuccess: false,
+ },
+ },
+ ];
+
+ API.write('TransferWalletBalance', parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
+function resetWalletTransferData() {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {
+ selectedAccountType: '',
+ selectedAccountID: null,
+ filterPaymentMethodType: null,
+ loading: false,
+ shouldShowSuccess: false,
+ });
+}
+
+function saveWalletTransferAccountTypeAndID(selectedAccountType: string, selectedAccountID: string) {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {selectedAccountType, selectedAccountID});
+}
+
+/**
+ * Toggles the user's selected type of payment method (bank account or debit card) on the wallet transfer balance screen.
+ *
+ */
+function saveWalletTransferMethodType(filterPaymentMethodType?: FilterMethodPaymentType) {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {filterPaymentMethodType});
+}
+
+function dismissSuccessfulTransferBalancePage() {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
+}
+
+/**
+ * Looks through each payment method to see if there is an existing error
+ *
+ */
+function hasPaymentMethodError(bankList: OnyxValues[typeof ONYXKEYS.BANK_ACCOUNT_LIST], fundList: OnyxValues[typeof ONYXKEYS.FUND_LIST]): boolean {
+ const combinedPaymentMethods = {...bankList, ...fundList};
+
+ return Object.values(combinedPaymentMethods).some((item) => Object.keys(item.errors ?? {}).length);
+}
+
+type PaymentListKey = typeof ONYXKEYS.BANK_ACCOUNT_LIST | typeof ONYXKEYS.FUND_LIST;
+
+/**
+ * Clears the error for the specified payment item
+ * @param paymentListKey The onyx key for the provided payment method
+ * @param paymentMethodID
+ */
+function clearDeletePaymentMethodError(paymentListKey: PaymentListKey, paymentMethodID: string) {
+ Onyx.merge(paymentListKey, {
+ [paymentMethodID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ });
+}
+
+/**
+ * If there was a failure adding a payment method, clearing it removes the payment method from the list entirely
+ * @param paymentListKey The onyx key for the provided payment method
+ * @param paymentMethodID
+ */
+function clearAddPaymentMethodError(paymentListKey: PaymentListKey, paymentMethodID: string) {
+ Onyx.merge(paymentListKey, {
+ [paymentMethodID]: null,
+ });
+}
+
+/**
+ * Clear any error(s) related to the user's wallet
+ */
+function clearWalletError() {
+ Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null});
+}
+
+/**
+ * Clear any error(s) related to the user's wallet terms
+ */
+function clearWalletTermsError() {
+ Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null});
+}
+
+function deletePaymentCard(fundID: number) {
+ type DeletePaymentCardParams = {
+ fundID: number;
+ };
+
+ const parameters: DeletePaymentCardParams = {
+ fundID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.FUND_LIST}`,
+ value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
+ },
+ ];
+
+ API.write('DeletePaymentCard', parameters, {
+ optimisticData,
+ });
+}
+
+export {
+ deletePaymentCard,
+ addPaymentCard,
+ openWalletPage,
+ makeDefaultPaymentMethod,
+ kycWallRef,
+ continueSetup,
+ clearDebitCardFormErrorAndSubmit,
+ dismissSuccessfulTransferBalancePage,
+ transferWalletBalance,
+ resetWalletTransferData,
+ saveWalletTransferAccountTypeAndID,
+ saveWalletTransferMethodType,
+ hasPaymentMethodError,
+ clearDeletePaymentMethodError,
+ clearAddPaymentMethodError,
+ clearWalletError,
+ clearWalletTermsError,
+};
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 53753e193fb1..89324dd35485 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -73,6 +73,13 @@ Onyx.connect({
callback: (val) => (allRecentlyUsedCategories = val),
});
+let networkStatus = {};
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ waitForCollectionCallback: true,
+ callback: (val) => (networkStatus = val),
+});
+
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
* @param {String|null} policyID
@@ -766,7 +773,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom
'UpdateWorkspaceCustomUnitAndRate',
{
policyID,
- lastModified,
+ ...(!networkStatus.isOffline && {lastModified}),
customUnit: JSON.stringify(newCustomUnitParam),
customUnitRate: JSON.stringify(newCustomUnitParam.rates),
},
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index a50e021732b7..f0977345cdea 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1054,7 +1054,7 @@ function deleteReportComment(reportID, reportAction) {
isLastMessageDeletedParentAction: true,
};
} else {
- const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(originalReportID, optimisticReportActions);
+ const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions);
if (lastMessageText || lastMessageTranslationKey) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions);
const lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created');
diff --git a/src/libs/actions/Session/updateSessionAuthTokens.js b/src/libs/actions/Session/updateSessionAuthTokens.js
index 5be53c77a92c..e88b3b993c7a 100644
--- a/src/libs/actions/Session/updateSessionAuthTokens.js
+++ b/src/libs/actions/Session/updateSessionAuthTokens.js
@@ -2,8 +2,8 @@ import Onyx from 'react-native-onyx';
import ONYXKEYS from '../../../ONYXKEYS';
/**
- * @param {String} authToken
- * @param {String} encryptedAuthToken
+ * @param {String | undefined} authToken
+ * @param {String | undefined} encryptedAuthToken
*/
export default function updateSessionAuthTokens(authToken, encryptedAuthToken) {
Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken});
diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.ts
similarity index 74%
rename from src/libs/actions/SignInRedirect.js
rename to src/libs/actions/SignInRedirect.ts
index a010621c4eea..67f5f2d8586f 100644
--- a/src/libs/actions/SignInRedirect.js
+++ b/src/libs/actions/SignInRedirect.ts
@@ -1,7 +1,5 @@
import Onyx from 'react-native-onyx';
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
-import ONYXKEYS from '../../ONYXKEYS';
+import ONYXKEYS, {OnyxKey} from '../../ONYXKEYS';
import * as MainQueue from '../Network/MainQueue';
import * as PersistedRequests from './PersistedRequests';
import NetworkConnection from '../NetworkConnection';
@@ -12,27 +10,21 @@ import Navigation from '../Navigation/Navigation';
import * as ErrorUtils from '../ErrorUtils';
import * as SessionUtils from '../SessionUtils';
-let currentIsOffline;
-let currentShouldForceOffline;
+let currentIsOffline: boolean | undefined;
+let currentShouldForceOffline: boolean | undefined;
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
- if (!network) {
- return;
- }
- currentIsOffline = network.isOffline;
- currentShouldForceOffline = Boolean(network.shouldForceOffline);
+ currentIsOffline = network?.isOffline;
+ currentShouldForceOffline = network?.shouldForceOffline;
},
});
-/**
- * @param {String} errorMessage
- */
-function clearStorageAndRedirect(errorMessage) {
+function clearStorageAndRedirect(errorMessage?: string) {
// Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out.
// We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting
// flashes of unwanted default state.
- const keysToPreserve = [];
+ const keysToPreserve: OnyxKey[] = [];
keysToPreserve.push(ONYXKEYS.NVP_PREFERRED_LOCALE);
keysToPreserve.push(ONYXKEYS.ACTIVE_CLIENTS);
keysToPreserve.push(ONYXKEYS.DEVICE_ID);
@@ -58,15 +50,15 @@ function clearStorageAndRedirect(errorMessage) {
*/
function resetHomeRouteParams() {
Navigation.isNavigationReady().then(() => {
- const routes = navigationRef.current && lodashGet(navigationRef.current.getState(), 'routes');
- const homeRoute = _.find(routes, (route) => route.name === SCREENS.HOME);
+ const routes = navigationRef.current?.getState().routes;
+ const homeRoute = routes?.find((route) => route.name === SCREENS.HOME);
- const emptyParams = {};
- _.keys(lodashGet(homeRoute, 'params')).forEach((paramKey) => {
+ const emptyParams: Record = {};
+ Object.keys(homeRoute?.params ?? {}).forEach((paramKey) => {
emptyParams[paramKey] = undefined;
});
- Navigation.setParams(emptyParams, lodashGet(homeRoute, 'key', ''));
+ Navigation.setParams(emptyParams, homeRoute?.key ?? '');
Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false);
});
}
@@ -79,9 +71,9 @@ function resetHomeRouteParams() {
*
* Normally this method would live in Session.js, but that would cause a circular dependency with Network.js.
*
- * @param {String} [errorMessage] error message to be displayed on the sign in page
+ * @param [errorMessage] error message to be displayed on the sign in page
*/
-function redirectToSignIn(errorMessage) {
+function redirectToSignIn(errorMessage?: string) {
MainQueue.clear();
HttpUtils.cancelPendingRequests();
PersistedRequests.clear();
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index 8653b038e381..8a7f0f7bd533 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -32,8 +32,8 @@ function createInitialWaypoints(transactionID: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
comment: {
waypoints: {
- waypoint0: null,
- waypoint1: null,
+ waypoint0: {},
+ waypoint1: {},
},
},
});
@@ -107,15 +107,15 @@ function removeWaypoint(transactionID: string, currentIndex: string) {
const transaction = allTransactions?.[transactionID] ?? {};
const existingWaypoints = transaction?.comment?.waypoints ?? {};
const totalWaypoints = Object.keys(existingWaypoints).length;
- // Prevents removing the starting or ending waypoint but clear the stored address only if there are only two waypoints
- if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) {
- saveWaypoint(transactionID, index.toString(), null);
- return;
- }
const waypointValues = Object.values(existingWaypoints);
const removed = waypointValues.splice(index, 1);
- const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? null);
+ const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {});
+
+ // When there are only two waypoints we are adding empty waypoint back
+ if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) {
+ waypointValues.splice(index, 0, {});
+ }
const reIndexedWaypoints: WaypointCollection = {};
waypointValues.forEach((waypoint, idx) => {
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index 78bd52988cdf..f65c20cd7e5b 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -541,7 +541,7 @@ function subscribeToUserEvents() {
/**
* Sync preferredSkinTone with Onyx and Server
- * @param {String} skinTone
+ * @param {Number} skinTone
*/
function updatePreferredSkinTone(skinTone) {
const optimisticData = [
diff --git a/src/libs/getComponentDisplayName.ts b/src/libs/getComponentDisplayName.ts
index fd1bbcaea521..0bf52d543a84 100644
--- a/src/libs/getComponentDisplayName.ts
+++ b/src/libs/getComponentDisplayName.ts
@@ -1,6 +1,6 @@
import {ComponentType} from 'react';
/** Returns the display name of a component */
-export default function getComponentDisplayName(component: ComponentType): string {
+export default function getComponentDisplayName(component: ComponentType): string {
return component.displayName ?? component.name ?? 'Component';
}
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index 31f413af95a9..00bb27892792 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -160,7 +160,18 @@ function ReportDetailsPage(props) {
return (
-
+ {
+ const topMostReportID = Navigation.getTopmostReportId();
+ if (topMostReportID) {
+ Navigation.goBack(ROUTES.HOME);
+ return;
+ }
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID));
+ }}
+ />
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index d7f8c3605564..5e68e852c60b 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -30,6 +30,7 @@ import * as Link from '../../libs/actions/Link';
import * as Report from '../../libs/actions/Report';
import * as Task from '../../libs/actions/Task';
import compose from '../../libs/compose';
+import * as Session from '../../libs/actions/Session';
import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
import reportPropTypes from '../reportPropTypes';
@@ -101,7 +102,7 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Checkmark,
text: props.translate('task.markAsIncomplete'),
- onSelected: () => Task.reopenTask(props.report),
+ onSelected: Session.checkIfActionIsAllowed(() => Task.reopenTask(props.report)),
});
}
@@ -110,7 +111,7 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Trashcan,
text: props.translate('common.cancel'),
- onSelected: () => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum),
+ onSelected: Session.checkIfActionIsAllowed(() => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)),
});
}
}
@@ -120,13 +121,15 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.ChatBubbles,
text: props.translate('common.joinThread'),
- onSelected: () => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false),
+ onSelected: Session.checkIfActionIsAllowed(() =>
+ Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false),
+ ),
});
} else if (props.report.notificationPreference.length) {
threeDotMenuItems.push({
icon: Expensicons.ChatBubbles,
text: props.translate('common.leaveThread'),
- onSelected: () => Report.leaveRoom(props.report.reportID),
+ onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(props.report.reportID)),
});
}
}
@@ -137,24 +140,24 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Phone,
text: props.translate('videoChatButtonAndMenu.tooltip'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(props.guideCalendarLink);
- },
+ }),
});
} else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) {
threeDotMenuItems.push({
icon: ZoomIcon,
text: props.translate('videoChatButtonAndMenu.zoom'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL);
- },
+ }),
});
threeDotMenuItems.push({
icon: GoogleMeetIcon,
text: props.translate('videoChatButtonAndMenu.googleMeet'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL);
- },
+ }),
});
}
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
index 849db381a549..ffd7f65185ce 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -34,6 +34,7 @@ import withKeyboardState from '../../../../components/withKeyboardState';
import {propTypes, defaultProps} from './composerWithSuggestionsProps';
import focusWithDelay from '../../../../libs/focusWithDelay';
import useDebounce from '../../../../hooks/useDebounce';
+import updateMultilineInputRange from '../../../../libs/UpdateMultilineInputRange';
import * as InputFocus from '../../../../libs/actions/InputFocus';
const {RNTextInputReset} = NativeModules;
@@ -215,6 +216,10 @@ function ComposerWithSuggestions({
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
if (!_.isEmpty(newEmojis)) {
+ // Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed
+ if (suggestionsRef.current) {
+ suggestionsRef.current.resetSuggestions();
+ }
insertedEmojisRef.current = [...insertedEmojisRef.current, ...newEmojis];
debouncedUpdateFrequentlyUsedEmojis();
}
@@ -223,11 +228,6 @@ function ComposerWithSuggestions({
setIsCommentEmpty(!!newComment.match(/^(\s)*$/));
setValue(newComment);
if (commentValue !== newComment) {
- // Ensure emoji suggestions are hidden even when the selection is not changed (so calculateEmojiSuggestion would not be called).
- if (suggestionsRef.current) {
- suggestionsRef.current.resetSuggestions();
- }
-
const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
setSelection({
start: newComment.length - remainder,
@@ -496,9 +496,13 @@ function ComposerWithSuggestions({
focus();
}, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]);
useEffect(() => {
+ // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
+ updateMultilineInputRange(textInputRef.current, shouldAutoFocus);
+
if (value.length === 0) {
return;
}
+
Report.setReportWithDraft(reportID, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index d0e84499a443..f5ca7080249c 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -730,6 +730,7 @@ export default compose(
prevProps.report.managerEmail === nextProps.report.managerEmail &&
prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine &&
lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) &&
+ lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) &&
prevProps.linkedReportActionID === nextProps.linkedReportActionID,
),
);
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index 24501e307759..0b6333e31ef8 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -18,6 +18,7 @@ import CONST from '../../../CONST';
import editedLabelStyles from '../../../styles/editedLabelStyles';
import UserDetailsTooltip from '../../../components/UserDetailsTooltip';
import avatarPropTypes from '../../../components/avatarPropTypes';
+import * as Browser from '../../../libs/Browser';
const propTypes = {
/** Users accountID */
@@ -66,6 +67,9 @@ const propTypes = {
/** localization props */
...withLocalizePropTypes,
+
+ /** Should the comment have the appearance of being grouped with the previous comment? */
+ displayAsGroup: PropTypes.bool,
};
const defaultProps = {
@@ -82,9 +86,28 @@ const defaultProps = {
delegateAccountID: 0,
actorIcon: {},
isThreadParentMessage: false,
+ displayAsGroup: false,
};
function ReportActionItemFragment(props) {
+ /**
+ * Checks text element for presence of emoji as first character
+ * and insert Zero-Width character to avoid selection issue
+ * mentioned here https://github.com/Expensify/App/issues/29021
+ *
+ * @param {String} text
+ * @param {Boolean} displayAsGroup
+ * @returns {ReactNode | null} Text component with zero width character
+ */
+
+ const checkForEmojiForSelection = (text, displayAsGroup) => {
+ const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text);
+ if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) {
+ return ;
+ }
+ return null;
+ };
+
switch (props.fragment.type) {
case 'COMMENT': {
const {html, text} = props.fragment;
@@ -116,6 +139,7 @@ function ReportActionItemFragment(props) {
return (
+ {checkForEmojiForSelection(text, props.displayAsGroup)}
))
) : (
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 438b6e9b68d5..c673c06470f8 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -278,45 +278,57 @@ function ReportActionsList({
);
/**
- * @param {Object} args
- * @param {Number} args.index
- * @returns {React.Component}
+ * Evaluate new unread marker visibility for each of the report actions.
+ * @returns boolean
*/
- const renderItem = useCallback(
- ({item: reportAction, index}) => {
- let shouldDisplayNewMarker = false;
+ const shouldDisplayNewMarker = useCallback(
+ (reportAction, index) => {
+ let shouldDisplay = false;
if (!currentUnreadMarker) {
const nextMessage = sortedReportActions[index + 1];
const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
- shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
-
+ shouldDisplay = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
if (!messageManuallyMarkedUnread) {
- shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
- }
- const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
-
- if (!currentUnreadMarker && shouldDisplayNewMarker && canDisplayMarker) {
- setCurrentUnreadMarker(reportAction.reportActionID);
+ shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
}
} else {
- shouldDisplayNewMarker = reportAction.reportActionID === currentUnreadMarker;
+ shouldDisplay = reportAction.reportActionID === currentUnreadMarker;
}
- return (
-
- );
+ return shouldDisplay;
},
- [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
+ [currentUnreadMarker, sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread],
+ );
+
+ useEffect(() => {
+ // Iterate through the report actions and set appropriate unread marker.
+ // This is to avoid a warning of:
+ // Cannot update a component (ReportActionsList) while rendering a different component (CellRenderer).
+ _.each(sortedReportActions, (reportAction, index) => {
+ if (!shouldDisplayNewMarker(reportAction, index)) {
+ return;
+ }
+ if (!currentUnreadMarker && currentUnreadMarker !== reportAction.reportActionID) {
+ setCurrentUnreadMarker(reportAction.reportActionID);
+ }
+ });
+ }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]);
+
+ const renderItem = useCallback(
+ ({item: reportAction, index}) => (
+
+ ),
+ [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index f58c6644cd47..a3671faf194c 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -278,6 +278,10 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
+ if (lodashGet(newProps, 'report.nonReimbursableTotal') !== lodashGet(oldProps, 'report.nonReimbursableTotal')) {
+ return false;
+ }
+
if (lodashGet(newProps, 'report.writeCapability') !== lodashGet(oldProps, 'report.writeCapability')) {
return false;
}
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index 9dbdde14c50d..394f6c5ddc5a 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -141,6 +141,7 @@ const chatReportSelector = (report) =>
lastVisibleActionCreated: report.lastVisibleActionCreated,
iouReportID: report.iouReportID,
total: report.total,
+ nonReimbursableTotal: report.nonReimbursableTotal,
hasOutstandingIOU: report.hasOutstandingIOU,
isWaitingOnBankAccount: report.isWaitingOnBankAccount,
statusNum: report.statusNum,
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 46367e275af4..9061d4b1193c 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -67,6 +67,7 @@ function MoneyRequestConfirmPage(props) {
const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType.current, props.selectedTab);
const isScanRequest = MoneyRequestUtils.isScanRequest(props.selectedTab);
const reportID = useRef(lodashGet(props.route, 'params.reportID', ''));
+ const [receiptFile, setReceiptFile] = useState();
const participants = useMemo(
() =>
_.map(props.iou.participants, (participant) => {
@@ -94,6 +95,21 @@ function MoneyRequestConfirmPage(props) {
}
}, [isOffline, participants, props.iou.billable, props.policy]);
+ useEffect(() => {
+ if (!props.iou.receiptPath || !props.iou.receiptFilename) {
+ return;
+ }
+ FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => {
+ if (!file) {
+ Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType.current, reportID.current));
+ } else {
+ const receipt = file;
+ receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY;
+ setReceiptFile(receipt);
+ }
+ });
+ }, [props.iou.receiptPath, props.iou.receiptFilename, isManualRequestDM]);
+
useEffect(() => {
// ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
if (!isDistanceRequest && prevMoneyRequestId.current !== props.iou.id) {
@@ -240,12 +256,8 @@ function MoneyRequestConfirmPage(props) {
return;
}
- if (props.iou.receiptPath && props.iou.receiptFilename) {
- FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => {
- const receipt = file;
- receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY;
- requestMoney(selectedParticipants, trimmedComment, receipt);
- });
+ if (receiptFile) {
+ requestMoney(selectedParticipants, trimmedComment, receiptFile);
return;
}
@@ -268,7 +280,7 @@ function MoneyRequestConfirmPage(props) {
isDistanceRequest,
requestMoney,
createDistanceRequest,
- isManualRequestDM,
+ receiptFile,
],
);
diff --git a/src/pages/settings/Wallet/PaymentMethodList.js b/src/pages/settings/Wallet/PaymentMethodList.js
index 2943fa9544ae..2a533a784a62 100644
--- a/src/pages/settings/Wallet/PaymentMethodList.js
+++ b/src/pages/settings/Wallet/PaymentMethodList.js
@@ -27,6 +27,7 @@ import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import getBankIcon from '../../../components/Icon/BankIcons';
import assignedCardPropTypes from './assignedCardPropTypes';
+import * as CardUtils from '../../../libs/CardUtils';
const propTypes = {
/** What to do when a menu item is pressed */
@@ -199,14 +200,23 @@ function PaymentMethodList({
const paymentCardList = fundList || {};
if (shouldShowAssignedCards) {
- const assignedCards = _.filter(cardList, (card) => CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state));
+ const assignedCards = _.chain(cardList)
+ .filter((card) => CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state))
+ .sortBy((card) => (CardUtils.isExpensifyCard(card.cardID) ? 0 : 1))
+ .value();
+
return _.map(assignedCards, (card) => {
const icon = getBankIcon(card.bank);
+ const isExpensifyCard = CardUtils.isExpensifyCard(card.cardID);
return {
- key: card.key,
- title: translate('walletPage.expensifyCard'),
+ key: card.cardID,
+ title: isExpensifyCard ? translate('walletPage.expensifyCard') : card.cardName,
description: card.domainName,
- onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(card.domainName)),
+ onPress: isExpensifyCard ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(card.domainName)) : () => {},
+ shouldShowRightIcon: isExpensifyCard,
+ interactive: isExpensifyCard,
+ canDismissError: isExpensifyCard,
+ errors: card.errors,
...icon,
};
});
@@ -274,6 +284,7 @@ function PaymentMethodList({
pendingAction={item.pendingAction}
errors={item.errors}
errorRowStyles={styles.ph6}
+ canDismissError={item.canDismissError}
>
),
- [filteredPaymentMethods, translate, shouldShowAssignedCards, shouldShowSelectedState, selectedMethodID],
+ [filteredPaymentMethods, translate, shouldShowSelectedState, selectedMethodID],
);
return (
diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.js b/src/pages/workspace/WorkspaceInviteMessagePage.js
index 65e60fda119d..5e7efadd3778 100644
--- a/src/pages/workspace/WorkspaceInviteMessagePage.js
+++ b/src/pages/workspace/WorkspaceInviteMessagePage.js
@@ -82,7 +82,7 @@ class WorkspaceInviteMessagePage extends React.Component {
componentDidMount() {
if (_.isEmpty(this.props.invitedEmailsToAccountIDsDraft)) {
- Navigation.goBack(ROUTES.WORKSPACE_INITIAL.getRoute(this.props.route.params.policyID), true);
+ Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(this.props.route.params.policyID), true);
return;
}
this.focusWelcomeMessageInput();
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index fb394721ff3c..e6cf8315d714 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -80,6 +80,29 @@ function WorkspaceMembersPage(props) {
const prevAccountIDs = usePrevious(accountIDs);
const textInputRef = useRef(null);
const isOfflineAndNoMemberDataAvailable = _.isEmpty(props.policyMembers) && props.network.isOffline;
+ const prevPersonalDetails = usePrevious(props.personalDetails);
+
+ /**
+ * Get filtered personalDetails list with current policyMembers
+ * @param {Object} policyMembers
+ * @param {Object} personalDetails
+ * @returns {Object}
+ */
+ const filterPersonalDetails = (policyMembers, personalDetails) =>
+ _.reduce(
+ _.keys(policyMembers),
+ (result, key) => {
+ if (personalDetails[key]) {
+ return {
+ ...result,
+ [key]: personalDetails[key],
+ };
+ }
+ return result;
+ },
+ {},
+ );
+
/**
* Get members for the current workspace
*/
@@ -116,7 +139,17 @@ function WorkspaceMembersPage(props) {
if (removeMembersConfirmModalVisible && !_.isEqual(accountIDs, prevAccountIDs)) {
setRemoveMembersConfirmModalVisible(false);
}
- setSelectedEmployees((prevSelected) => _.intersection(prevSelected, _.values(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails))));
+ setSelectedEmployees((prevSelected) => {
+ // Filter all personal details in order to use the elements needed for the current workspace
+ const currentPersonalDetails = filterPersonalDetails(props.policyMembers, props.personalDetails);
+ // We need to filter the previous selected employees by the new personal details, since unknown/new user id's change when transitioning from offline to online
+ const prevSelectedElements = _.map(prevSelected, (id) => {
+ const prevItem = lodashGet(prevPersonalDetails, id);
+ const res = _.find(_.values(currentPersonalDetails), (item) => lodashGet(prevItem, 'login') === lodashGet(item, 'login'));
+ return lodashGet(res, 'accountID', id);
+ });
+ return _.intersection(prevSelectedElements, _.values(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)));
+ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.policyMembers]);
diff --git a/src/stories/Composer.stories.js b/src/stories/Composer.stories.js
index 50039d7d537a..2eedd6b97c38 100644
--- a/src/stories/Composer.stories.js
+++ b/src/stories/Composer.stories.js
@@ -1,6 +1,7 @@
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
-import React, {useState} from 'react';
+import React, {useMemo, useState} from 'react';
import {View, Image} from 'react-native';
+import {NavigationContext} from '@react-navigation/core';
import Composer from '../components/Composer';
import RenderHTML from '../components/RenderHTML';
import Text from '../components/Text';
@@ -8,9 +9,6 @@ import styles from '../styles/styles';
import themeColors from '../styles/themes/default';
import * as StyleUtils from '../styles/StyleUtils';
import CONST from '../CONST';
-import withNavigationFallback from '../components/withNavigationFallback';
-
-const ComposerWithNavigation = withNavigationFallback(Composer);
/**
* We use the Component Story Format for writing stories. Follow the docs here:
@@ -19,7 +17,7 @@ const ComposerWithNavigation = withNavigationFallback(Composer);
*/
const story = {
title: 'Components/Composer',
- component: ComposerWithNavigation,
+ component: Composer,
};
const parser = new ExpensiMark();
@@ -28,19 +26,22 @@ function Default(args) {
const [pastedFile, setPastedFile] = useState(null);
const [comment, setComment] = useState(args.defaultValue);
const renderedHTML = parser.replace(comment);
+ const navigationContextValue = useMemo(() => ({addListener: () => () => {}, removeListener: () => () => {}}), []);
return (
-
+
+
+
eReceiptAmountLarge: {
...headlineFont,
fontSize: variables.fontSizeEReceiptLarge,
- lineHeight: variables.lineHeightXXLarge,
- wordBreak: 'break-word',
textAlign: 'center',
},
eReceiptCurrency: {
...headlineFont,
fontSize: variables.fontSizeXXLarge,
- lineHeight: variables.lineHeightXXLarge,
- wordBreak: 'break-all',
},
eReceiptMerchant: {
@@ -3406,7 +3402,6 @@ const styles = (theme: ThemeDefault) =>
},
eReceiptContainer: {
- flex: 1,
width: 335,
minHeight: 540,
borderRadius: 20,
@@ -3674,6 +3669,7 @@ const styles = (theme: ThemeDefault) =>
paddingRight: 4,
marginBottom: 32,
alignSelf: 'flex-start',
+ ...userSelect.userSelectNone,
},
emojiPickerButtonDropdownIcon: {
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index e7efcf4052d4..ea0af11d1b7a 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -167,6 +167,7 @@ export default {
eReceiptWordmarkWidth: 86,
eReceiptBGHeight: 540,
eReceiptBGHWidth: 335,
+ eReceiptTextContainerWidth: 263,
reportPreviewMaxWidth: 335,
reportActionImagesSingleImageHeight: 147,
reportActionImagesDoubleImageHeight: 138,
diff --git a/src/types/onyx/AccountData.ts b/src/types/onyx/AccountData.ts
new file mode 100644
index 000000000000..79484e7886af
--- /dev/null
+++ b/src/types/onyx/AccountData.ts
@@ -0,0 +1,55 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type AdditionalData = {
+ isP2PDebitCard?: boolean;
+ beneficialOwners?: string[];
+ currency?: string;
+ bankName?: string;
+ fieldsType?: string;
+ country?: string;
+};
+
+type AccountData = {
+ /** The masked bank account number */
+ accountNumber?: string;
+
+ /** The name of the institution (bank of america, etc */
+ addressName?: string;
+
+ /** Can we use this account to pay other people? */
+ allowDebit?: boolean;
+
+ /** Can we use this account to receive money from other people? */
+ defaultCredit?: boolean;
+
+ /** Is a saving account */
+ isSavings?: boolean;
+
+ /** Return whether or not this bank account has been risk checked */
+ riskChecked?: boolean;
+
+ /** Account routing number */
+ routingNumber?: string;
+
+ /** The status of the bank account */
+ state?: string;
+
+ /** All user emails that have access to this bank account */
+ sharees?: string[];
+
+ processor?: string;
+
+ /** The bankAccountID in the bankAccounts db */
+ bankAccountID?: number;
+
+ /** All data related to the bank account */
+ additionalData?: AdditionalData;
+
+ /** The bank account type */
+ type?: string;
+
+ /** Any error message to show */
+ errors?: OnyxCommon.Errors;
+};
+
+export default AccountData;
diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts
index 61c7e44f68d7..58d9654f0c6f 100644
--- a/src/types/onyx/BankAccount.ts
+++ b/src/types/onyx/BankAccount.ts
@@ -1,54 +1,6 @@
-import {ValueOf} from 'type-fest';
import CONST from '../../CONST';
-
-type AdditionalData = {
- isP2PDebitCard?: boolean;
- beneficialOwners?: string[];
- currency?: string;
- bankName?: string;
- fieldsType?: string;
- country?: string;
-};
-
-type AccountData = {
- /** The masked bank account number */
- accountNumber?: string;
-
- /** The name of the institution (bank of america, etc */
- addressName?: string;
-
- /** Can we use this account to pay other people? */
- allowDebit?: boolean;
-
- /** Can we use this account to receive money from other people? */
- defaultCredit?: boolean;
-
- /** Is a saving account */
- isSavings?: boolean;
-
- /** Return whether or not this bank account has been risk checked */
- riskChecked?: boolean;
-
- /** Account routing number */
- routingNumber?: string;
-
- /** The status of the bank account */
- state?: string;
-
- /** All user emails that have access to this bank account */
- sharees?: string[];
-
- processor?: string;
-
- /** The bankAccountID in the bankAccounts db */
- bankAccountID?: number;
-
- /** All data related to the bank account */
- additionalData?: AdditionalData;
-
- /** The bank account type */
- type?: string;
-};
+import AccountData from './AccountData';
+import * as OnyxCommon from './OnyxCommon';
type BankAccount = {
/** The bank account type */
@@ -71,8 +23,11 @@ type BankAccount = {
/** All data related to the bank account */
accountData?: AccountData;
- /** Action that is waiting to happen on the bank account */
- pendingAction?: ValueOf;
+ /** Any additional error message to show */
+ errors?: OnyxCommon.Errors;
+
+ /** Indicates the type of change made to the bank account that hasn't been synced with the server yet */
+ pendingAction?: OnyxCommon.PendingAction;
};
export default BankAccount;
diff --git a/src/types/onyx/Credentials.ts b/src/types/onyx/Credentials.ts
index f6a9ce669ad0..6bc36079f363 100644
--- a/src/types/onyx/Credentials.ts
+++ b/src/types/onyx/Credentials.ts
@@ -7,9 +7,12 @@ type Credentials = {
/** The validate code */
validateCode?: string;
- autoGeneratedLogin?: string;
- autoGeneratedPassword?: string;
+ autoGeneratedLogin: string;
+ autoGeneratedPassword: string;
accountID?: number;
+
+ partnerUserID: string;
+ partnerUserSecret: string;
};
export default Credentials;
diff --git a/src/types/onyx/Fund.ts b/src/types/onyx/Fund.ts
index 2da0edf78045..e27cc0e20e0e 100644
--- a/src/types/onyx/Fund.ts
+++ b/src/types/onyx/Fund.ts
@@ -1,4 +1,5 @@
import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
type AdditionalData = {
isBillingCard?: boolean;
@@ -30,6 +31,9 @@ type Fund = {
key?: string;
methodID?: number;
title?: string;
+ isDefault?: boolean;
+ errors?: OnyxCommon.Errors;
+ pendingAction?: OnyxCommon.PendingAction;
};
export default Fund;
diff --git a/src/types/onyx/PaymentMethod.ts b/src/types/onyx/PaymentMethod.ts
new file mode 100644
index 000000000000..773e6ff1197c
--- /dev/null
+++ b/src/types/onyx/PaymentMethod.ts
@@ -0,0 +1,11 @@
+import {SvgProps} from 'react-native-svg';
+import BankAccount from './BankAccount';
+import Fund from './Fund';
+
+type PaymentMethod = (BankAccount | Fund) & {
+ description: string;
+ icon: React.FC;
+ iconSize?: number;
+};
+
+export default PaymentMethod;
diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts
index a3032401b346..836138ca99ba 100644
--- a/src/types/onyx/Request.ts
+++ b/src/types/onyx/Request.ts
@@ -1,4 +1,5 @@
import {OnyxUpdate} from 'react-native-onyx';
+import Response from './Response';
type OnyxData = {
successData?: OnyxUpdate[];
@@ -8,9 +9,15 @@ type OnyxData = {
type RequestData = {
command: string;
+ commandName?: string;
data?: Record;
type?: string;
shouldUseSecure?: boolean;
+ successData?: OnyxUpdate[];
+ failureData?: OnyxUpdate[];
+
+ resolve?: (value: Response) => void;
+ reject?: (value?: unknown) => void;
};
type Request = RequestData & OnyxData;
diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts
index 255ac6d9bae4..3d834d0bcb2b 100644
--- a/src/types/onyx/Response.ts
+++ b/src/types/onyx/Response.ts
@@ -6,6 +6,9 @@ type Response = {
jsonCode?: number | string;
onyxData?: OnyxUpdate[];
requestID?: string;
+ shouldPauseQueue?: boolean;
+ authToken?: string;
+ encryptedAuthToken?: string;
message?: string;
};
diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts
index 75cb4f4818ad..62930e3b2c27 100644
--- a/src/types/onyx/Session.ts
+++ b/src/types/onyx/Session.ts
@@ -1,3 +1,5 @@
+import * as OnyxCommon from './OnyxCommon';
+
type Session = {
/** The user's email for the current session */
email?: string;
@@ -5,6 +7,8 @@ type Session = {
/** Currently logged in user authToken */
authToken?: string;
+ supportAuthToken?: string;
+
/** Currently logged in user encrypted authToken */
encryptedAuthToken?: string;
@@ -12,6 +16,8 @@ type Session = {
accountID?: number;
autoAuthState?: string;
+ /** Server side errors keyed by microtime */
+ errors?: OnyxCommon.Errors;
};
export default Session;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 292addbb142e..21be3c49497e 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -3,10 +3,22 @@ import * as OnyxCommon from './OnyxCommon';
import CONST from '../../CONST';
import RecentWaypoint from './RecentWaypoint';
-type WaypointCollection = Record;
+type Waypoint = {
+ /** The full address of the waypoint */
+ address?: string;
+
+ /** The lattitude of the waypoint */
+ lat?: number;
+
+ /** The longitude of the waypoint */
+ lng?: number;
+};
+
+type WaypointCollection = Record;
type Comment = {
comment?: string;
waypoints?: WaypointCollection;
+ isLoading?: boolean;
type?: string;
customUnit?: Record;
source?: string;
@@ -77,4 +89,4 @@ type Transaction = {
};
export default Transaction;
-export type {WaypointCollection, Comment, Receipt};
+export type {WaypointCollection, Comment, Receipt, Waypoint};
diff --git a/src/types/onyx/WalletTransfer.ts b/src/types/onyx/WalletTransfer.ts
index 3dd28729ba96..18b223a0b1ef 100644
--- a/src/types/onyx/WalletTransfer.ts
+++ b/src/types/onyx/WalletTransfer.ts
@@ -1,5 +1,7 @@
+import {ValueOf} from 'type-fest';
import CONST from '../../CONST';
import * as OnyxCommon from './OnyxCommon';
+import PaymentMethod from './PaymentMethod';
type WalletTransfer = {
/** Selected accountID for transfer */
@@ -9,7 +11,7 @@ type WalletTransfer = {
selectedAccountType?: string;
/** Type to filter the payment Method list */
- filterPaymentMethodType?: typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.BANK_ACCOUNT;
+ filterPaymentMethodType?: FilterMethodPaymentType;
/** Whether the success screen is shown to user. */
shouldShowSuccess?: boolean;
@@ -19,6 +21,12 @@ type WalletTransfer = {
/** Whether or not data is loading */
loading?: boolean;
+
+ paymentMethodType?: ValueOf>;
};
+type FilterMethodPaymentType = typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.BANK_ACCOUNT | null;
+
export default WalletTransfer;
+
+export type {FilterMethodPaymentType};
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index cb064869427e..4603c4579343 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -46,6 +46,7 @@ import RecentWaypoint from './RecentWaypoint';
import RecentlyUsedCategories from './RecentlyUsedCategories';
import RecentlyUsedTags from './RecentlyUsedTags';
import PolicyTag from './PolicyTag';
+import AccountData from './AccountData';
export type {
Account,
@@ -99,4 +100,5 @@ export type {
RecentlyUsedCategories,
RecentlyUsedTags,
PolicyTag,
+ AccountData,
};
diff --git a/src/types/utils/ChildrenProps.ts b/src/types/utils/ChildrenProps.ts
new file mode 100644
index 000000000000..896f6ff62006
--- /dev/null
+++ b/src/types/utils/ChildrenProps.ts
@@ -0,0 +1,8 @@
+import type {ReactNode} from 'react';
+
+type ChildrenProps = {
+ /** Rendered child component */
+ children: ReactNode;
+};
+
+export default ChildrenProps;
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 63fd7a0dd78b..f530e5892e94 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -13,6 +13,7 @@ import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager';
import waitForNetworkPromises from '../utils/waitForNetworkPromises';
import * as ReportUtils from '../../src/libs/ReportUtils';
import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils';
+import * as PolicyActions from '../../src/libs/actions/Policy';
import * as PersonalDetailsUtils from '../../src/libs/PersonalDetailsUtils';
import * as User from '../../src/libs/actions/User';
import PusherHelper from '../utils/PusherHelper';
@@ -2158,4 +2159,206 @@ describe('actions/IOU', () => {
expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID));
});
});
+
+ describe('submitReport', () => {
+ it('correctly submits a report', () => {
+ const amount = 10000;
+ const comment = '💸💸💸💸';
+ const merchant = 'NASDAQ';
+ let expenseReport = {};
+ let chatReport = {};
+ return waitForBatchedUpdates()
+ .then(() => {
+ PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace");
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ chatReport = _.find(allReports, (report) => report.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ IOU.requestMoney(
+ chatReport,
+ amount,
+ CONST.CURRENCY.USD,
+ '',
+ merchant,
+ RORY_EMAIL,
+ RORY_ACCOUNT_ID,
+ {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID},
+ comment,
+ );
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+ Onyx.merge(`report_${expenseReport.reportID}`, {
+ statusNum: 0,
+ stateNum: 0,
+ });
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Verify report is a draft
+ expect(expenseReport.stateNum).toBe(0);
+ expect(expenseReport.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ IOU.submitReport(expenseReport);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Report was submitted correctly
+ expect(expenseReport.stateNum).toBe(1);
+ expect(expenseReport.statusNum).toBe(1);
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ it('correctly implements error handling', () => {
+ const amount = 10000;
+ const comment = '💸💸💸💸';
+ const merchant = 'NASDAQ';
+ let expenseReport = {};
+ let chatReport = {};
+ return waitForBatchedUpdates()
+ .then(() => {
+ PolicyActions.createWorkspace(CARLOS_EMAIL, true, "Carlos's Workspace");
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ chatReport = _.find(allReports, (report) => report.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT);
+
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ IOU.requestMoney(
+ chatReport,
+ amount,
+ CONST.CURRENCY.USD,
+ '',
+ merchant,
+ RORY_EMAIL,
+ RORY_ACCOUNT_ID,
+ {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID},
+ comment,
+ );
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+ Onyx.merge(`report_${expenseReport.reportID}`, {
+ statusNum: 0,
+ stateNum: 0,
+ });
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Verify report is a draft
+ expect(expenseReport.stateNum).toBe(0);
+ expect(expenseReport.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ )
+ .then(() => {
+ fetch.fail();
+ IOU.submitReport(expenseReport);
+ return waitForBatchedUpdates();
+ })
+ .then(
+ () =>
+ new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ waitForCollectionCallback: true,
+ callback: (allReports) => {
+ Onyx.disconnect(connectionID);
+ expenseReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.EXPENSE);
+
+ // Report was submitted with some fail
+ expect(expenseReport.stateNum).toBe(0);
+ expect(expenseReport.statusNum).toBe(0);
+ resolve();
+ },
+ });
+ }),
+ );
+ });
+ });
});
diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js
index 512a86a25e19..235dff45f631 100644
--- a/tests/unit/CalendarPickerTest.js
+++ b/tests/unit/CalendarPickerTest.js
@@ -1,17 +1,10 @@
import {render, fireEvent, within} from '@testing-library/react-native';
-import {format, eachMonthOfInterval, subYears, addYears} from 'date-fns';
+import {subYears, addYears} from 'date-fns';
import DateUtils from '../../src/libs/DateUtils';
import CalendarPicker from '../../src/components/NewDatePicker/CalendarPicker';
import CONST from '../../src/CONST';
-DateUtils.setLocale(CONST.LOCALES.EN);
-const fullYear = new Date().getFullYear();
-const monthsArray = eachMonthOfInterval({
- start: new Date(fullYear, 0, 1), // January 1st of the current year
- end: new Date(fullYear, 11, 31), // December 31st of the current year
-});
-// eslint-disable-next-line rulesdir/prefer-underscore-method
-const monthNames = monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
+const monthNames = DateUtils.getMonthNames(CONST.LOCALES.EN);
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({navigate: jest.fn()}),