diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml
index e6da6fff1446..43f3c64554bc 100644
--- a/.github/workflows/cherryPick.yml
+++ b/.github/workflows/cherryPick.yml
@@ -41,6 +41,7 @@ jobs:
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- name: Set up git for OSBotify
+ id: setupGitForOSBotify
uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -119,7 +120,7 @@ jobs:
**Important:** There may be conflicts that GitHub is not able to detect, so please _carefully_ review this pull request before approving."
gh pr edit --add-assignee "${{ github.actor }},${{ steps.getCPMergeCommit.outputs.MERGE_ACTOR }}"
env:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
- name: "Announces a CP failure in the #announce Slack room"
uses: 8398a7/action-slack@v3
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index 4fe6249edacc..f8b68786aaab 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -34,13 +34,13 @@ jobs:
echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT"
fi
env:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
- name: Reopen and comment on issue (not a team member)
if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }}
uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT: |
Sorry, only members of @Expensify/Mobile-Deployers can close deploy checklists.
@@ -51,14 +51,14 @@ jobs:
id: checkDeployBlockers
uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Reopen and comment on issue (has blockers)
if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }}
uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main
with:
- GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
COMMENT: |
This issue either has unchecked items or has not yet been marked with the `:shipit:` emoji of approval.
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
index fe58f3c0a6d8..6717c1736f65 100644
--- a/.storybook/webpack.config.js
+++ b/.storybook/webpack.config.js
@@ -24,8 +24,7 @@ const custom = require('../config/webpack/webpack.common')({
module.exports = ({config}) => {
config.resolve.alias = {
'react-native-config': 'react-web-config',
- 'react-native$': '@expensify/react-native-web',
- 'react-native-web': '@expensify/react-native-web',
+ 'react-native$': 'react-native-web',
'@react-native-community/netinfo': path.resolve(__dirname, '../__mocks__/@react-native-community/netinfo.js'),
'@react-navigation/native': path.resolve(__dirname, '../__mocks__/@react-navigation/native'),
diff --git a/android/app/build.gradle b/android/app/build.gradle
index e43909433367..50de8145e67c 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 1001039504
- versionName "1.3.95-4"
+ versionCode 1001039603
+ versionName "1.3.96-3"
}
flavorDimensions "default"
diff --git a/assets/css/fonts.css b/assets/css/fonts.css
index 7834a0ebb861..078cec114c31 100644
--- a/assets/css/fonts.css
+++ b/assets/css/fonts.css
@@ -54,6 +54,11 @@
src: url('/fonts/ExpensifyNewKansas-MediumItalic.woff2') format('woff2'), url('/fonts/ExpensifyNewKansas-MediumItalic.woff') format('woff');
}
+@font-face {
+ font-family: Windows Segoe UI Emoji;
+ src: url('/fonts/seguiemj.ttf');
+}
+
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
diff --git a/assets/fonts/web/seguiemj.ttf b/assets/fonts/web/seguiemj.ttf
new file mode 100644
index 000000000000..3a455801aa0c
Binary files /dev/null and b/assets/fonts/web/seguiemj.ttf differ
diff --git a/assets/images/bell.svg b/assets/images/bell.svg
index 6ba600dc695b..5a6b411185a9 100644
--- a/assets/images/bell.svg
+++ b/assets/images/bell.svg
@@ -1,6 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/assets/images/home-background--android.svg b/assets/images/home-background--android.svg
index 507aecf04836..2b72b6ccabe9 100644
--- a/assets/images/home-background--android.svg
+++ b/assets/images/home-background--android.svg
@@ -1,6555 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/babel.config.js b/babel.config.js
index 7de6926c850d..64a433936eb7 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -17,16 +17,8 @@ const defaultPlugins = [
];
const webpack = {
- env: {
- production: {
- presets: defaultPresets,
- plugins: [...defaultPlugins, 'transform-remove-console'],
- },
- development: {
- presets: defaultPresets,
- plugins: defaultPlugins,
- },
- },
+ presets: defaultPresets,
+ plugins: defaultPlugins,
};
const metro = {
@@ -78,6 +70,11 @@ const metro = {
},
],
],
+ env: {
+ production: {
+ plugins: ['transform-remove-console', {exclude: ['error', 'warn']}],
+ },
+ },
};
/*
@@ -102,11 +99,19 @@ if (process.env.CAPTURE_METRICS === 'true') {
]);
}
-module.exports = ({caller}) => {
+module.exports = (api) => {
+ console.debug('babel.config.js');
+ console.debug(' - api.version:', api.version);
+ console.debug(' - api.env:', api.env());
+ console.debug(' - process.env.NODE_ENV:', process.env.NODE_ENV);
+ console.debug(' - process.env.BABEL_ENV:', process.env.BABEL_ENV);
+
// For `react-native` (iOS/Android) caller will be "metro"
// For `webpack` (Web) caller will be "@babel-loader"
// For jest, it will be babel-jest
// For `storybook` there won't be any config at all so we must give default argument of an empty object
- const runningIn = caller((args = {}) => args.name);
+ const runningIn = api.caller((args = {}) => args.name);
+ console.debug(' - running in: ', runningIn);
+
return ['metro', 'babel-jest'].includes(runningIn) ? metro : webpack;
};
diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js
index d12f602260e1..b66b4f67a3b6 100644
--- a/config/webpack/webpack.common.js
+++ b/config/webpack/webpack.common.js
@@ -13,7 +13,7 @@ const includeModules = [
'react-native-animatable',
'react-native-reanimated',
'react-native-picker-select',
- '@expensify/react-native-web',
+ 'react-native-web',
'react-native-webview',
'@react-native-picker',
'react-native-modal',
@@ -185,8 +185,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({
resolve: {
alias: {
'react-native-config': 'react-web-config',
- 'react-native$': '@expensify/react-native-web',
- 'react-native-web': '@expensify/react-native-web',
+ 'react-native$': 'react-native-web',
// Module alias for web & desktop
// https://webpack.js.org/configuration/resolve/#resolvealias
diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md
index 24e0d1878237..6e02cae677bb 100644
--- a/contributingGuides/CONTRIBUTING.md
+++ b/contributingGuides/CONTRIBUTING.md
@@ -148,7 +148,7 @@ Additionally if you want to discuss an idea with the open source community witho
- If you have made a change to your pull request and are ready for another review, leave a comment that says "Updated" on the pull request itself.
- Please keep the conversation in GitHub, and do not ping individual reviewers in Slack or Upwork to get their attention.
- Pull Request reviews can sometimes take a few days. If your pull request has not been addressed after four days please let us know via the #expensify-open-source Slack channel.
-- On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR. Depending on the hold length, our team will decide if a bonus will be applied to the job.
+- On occasion, our engineers will need to focus on a feature release and choose to place a hold on the review of your PR.
#### Important note about JavaScript Style
- Read our official [JavaScript and React style guide](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md). Please refer to our Style Guide before asking for a review.
diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md
index 1fb67483daca..ffec5f20254c 100644
--- a/contributingGuides/FORMS.md
+++ b/contributingGuides/FORMS.md
@@ -53,17 +53,27 @@ The phone number can be formatted in different ways.
### Native Keyboards
-We should always set people up for success on native platforms by enabling the best keyboard for the type of input we’re asking them to provide. See [keyboardType](https://reactnative.dev/docs/0.64/textinput#keyboardtype) in the React Native documentation.
+We should always set people up for success on native platforms by enabling the best keyboard for the type of input we’re asking them to provide. See [inputMode](https://reactnative.dev/docs/textinput#inputmode) in the React Native documentation.
-We have a couple of keyboard types [defined](https://github.com/Expensify/App/blob/572caa9e7cf32a2d64fe0e93d171bb05a1dfb217/src/CONST.js#L357-L360) and should be used like so:
+We have a list of input modes [defined](https://github.com/Expensify/App/blob/9418b870515102631ea2156b5ea253ee05a98ff1/src/CONST.js#L765-L774) and should be used like so:
```jsx
```
+We also have [keyboardType](https://github.com/Expensify/App/blob/9418b870515102631ea2156b5ea253ee05a98ff1/src/CONST.js#L760-L763) and should be used for specific use cases when there is no `inputMode` equivalent of the value exist. and should be used like so:
+
+```jsx
+
+```
+
+
### Autofill Behavior
Forms should autofill information whenever possible i.e. they should work with browsers and password managers auto complete features.
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md
new file mode 100644
index 000000000000..71edcdeba00d
--- /dev/null
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Cards.md
@@ -0,0 +1,5 @@
+---
+title: Personal Cards
+description: Connect your credit card directly to Expensify to easily track your personal finances.
+---
+## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md
deleted file mode 100644
index f89729b69586..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Personal-Credit-Cards.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Personal Credit Cards
-description: Personal Credit Cards
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md
index 6debce6240ff..fc1e83701caf 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md
@@ -1,5 +1,101 @@
---
-title: CSV Import
-description: CSV Import
+title: Import and assign company cards from CSV file
+description: uploading a CSV file containing your company card transactions
---
-## Resource Coming Soon!
+
+# Overview
+Expensify offers a convenient CSV import feature for managing company card expenses when direct connections or commercial card feeds aren't available. This feature allows you to upload a CSV file containing your company card transactions and assign them to cardholders within your Expensify domain.
+This feature is available on Group Workspaces and requires Domain Admin access.
+
+# How to import company cards via CSV
+1. Download a CSV of transactions from your bank by logging into their website and finding the relevant statement.
+2. Format the CSV for upload using [this template](https://s3-us-west-1.amazonaws.com/concierge-responses-expensify-com/uploads%2F1594908368712-Best+Example+CSV+for+Domains.csv) as a guide.
+- At a minimum, your file must include the following columns:
+ - **Card Number** - each number in this column should display at least the last four digits, and you can obscure up to 12 characters
+(e.g., 543212XXXXXX12334).
+ - **Posted Date** - use the YYYY-MM-DD format in this column (and any other date column in your spreadsheet).
+ - **Merchant** - the name of the individual or business that provided goods or services for the transaction. This is a free-text field.
+ - **Posted Amount** - use the number format in this column, and indicate negative amounts with parentheses (e.g., (335.98) for -$335.98).
+ - **Posted Currency** - use currency codes (e.g., USD, GBP, EUR) to indicate the currency of the posted transactions.
+- You can also add mapping for Categories and Tags, but those parameters are optional.
+3. Log into Expensify on your web browser.
+4. Head to Settings > Domains > Domain Name > Company Cards
+5. Click Manage/Import CSV
+6. Create a Company Card Layout Name for your spreadsheet
+7. Click Upload CSV
+8. Review the mapping of your spreadsheet to ensure that the Card Number, Date, Merchant, Amount, and Currency match your data.
+9. Double-check the Output Preview for any errors and, if needed, refer to the common error solutions listed in the FAQ below.
+10. Once the mapping is correct, click Submit Spreadsheet to complete the import.
+11. After submitting the spreadsheet, click I'll wait a minute. Then, wait about 1-2 minutes for the import to process. The domain page will refresh once the upload is complete.
+
+# How to assign new cards
+If you're assigning cards via CSV upload for the first time:
+1. Head to **Settings > Domains > Domain Name > Company Cards**
+2. Find the new CSV feed in the drop-down list underneath **Imported Cards**
+3. Click **Assign New Cards**
+4. Under **Assign a Card**, enter the relevant info
+5. Click **Assign**
+From there, transactions will be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval.
+
+# How to upload new expenses for existing assigned cards
+There's no need to create a new upload layout for subsequent CSV uploads. Instead, add new expenses to the existing CSV:
+1. Head to **Settings > Domains > Domain Name > Company Cards**
+2. Click **Manage/Import CSV**
+3. Select the saved layout from the drop-down list
+4. Click **Upload CSV**
+5. After uploading the more recent CSV, click **Update All Cards** to retrieve the new expenses for the assigned cards.
+
+# Deep dive
+If the CSV upload isn't formatted correctly, it will cause issues when you try to import or assign cards. Let's go over some common issues and how to fix them.
+
+## Error: "Attribute value mapping is missing"
+If you encounter an error that says "Attribute-value mapping is missing," the spreadsheet likely lacks critical details like Card Number, Date, Merchant, Amount, or Currency. To resolve:
+1. Click the **X** at the top of the page to close the mapping window
+2. Confirm what's missing from the spreadsheet
+3. Add a new column to your spreadsheet and add the missing detail
+4. Upload the revised spreadsheet by clicking **Manage Spreadsheet**
+5. Enter a **Company Card Layout Name** for the contents of your spreadsheet
+6. Click **Upload CSV**
+
+## Error: "We've detected an error while processing your spreadsheet feed"
+This error usually occurs when there's an upload issue.
+To troubleshoot this:
+1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV**
+2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created.
+3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported.
+4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again.
+
+## Error: "An unexpected error occurred, and we could not retrieve the list of cards"
+This error occurs when there's an issue uploading the spreadsheet or the upload fails.
+To troubleshoot this:
+1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV**
+2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created.
+3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported.
+4. If the layout isn't listed, create a new **Company Card Layout Name** and upload the spreadsheet again.
+
+
+## I added a new parameter to an existing spreadsheet, but the data isn't showing in Expensify after the upload completes. What's going on?
+If you added a new card to an existing spreadsheet and imported it via a saved layout, but it isn't showing up for assignment, this suggests that the modification may have caused an issue.
+The next step in troubleshooting this issue is to compare the number of rows on the revised spreadsheet to the Output Preview to ensure the row count matches the revised spreadsheet.
+To check this:
+1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV**
+2. Select your saved layout in the dropdown list
+3. Click **Upload CSV** and select the revised spreadsheet
+4. Compare the Output Preview row count to your revised spreadsheet to ensure they match
+
+
+If they don't match, you'll need to revise the spreadsheet by following the CSV formatting guidelines in step 2 of "How to import company cards via CSV" above.
+Once you do that, save the revised spreadsheet with a new layout name.
+Then, try to upload the revised spreadsheet again:
+
+1. Click **Upload CSV**
+2. Upload the revised file
+3. Check the row count again on the Output Preview to confirm it matches the spreadsheet
+4. Click **Submit Spreadsheet**
+
+# FAQ
+## Why can't I see my CSV transactions immediately after uploading them?
+Don't worry! You'll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!**
+
+## I'm trying to import a credit. Why isn't it uploading?
+Negative expenses shouldn't include a minus sign. Instead, they should just be wrapped in parentheses. For example, to indicate "-335.98," you'll want to make sure it's formatted as "(335.98)."
diff --git a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md
index ae367d25891e..7f3d83af1e6e 100644
--- a/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md
+++ b/docs/articles/expensify-classic/expense-and-report-features/Attendee-Tracking.md
@@ -11,12 +11,14 @@ Every expense has an Attendees field and will list the expense creator’s name
## How to Add Additional Attendees to an Expense
* Go to the attendees field
* Search for the names of the attendees
- * The default list will be of internal attendees belonging to your workspace and domain.
+ * The default list will be of internal attendees belonging to your workspace and domain
* External attendees are not part of your workspace or domain, so you will need to enter their name or email
* Select the attendees you would like to add
* Save the expense
-* Once added, the list of attendees for each expense will be visible on the expense line.
-* An amount per employee expense will also be displayed on the report for easy viewing
+* Once added, the list of attendees for each expense will be visible on the expense line
+* An amount per employee expense will also be displayed on the report for easy viewing
+
+![image of an expense with attendee tracking]({{site.url}}/assets/images/attendee-tracking.png){:width="100%"}
# FAQ
diff --git a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md
index f30dde9efc3d..42a8a914e5bc 100644
--- a/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md
+++ b/docs/articles/expensify-classic/expense-and-report-features/The-Expenses-Page.md
@@ -1,5 +1,73 @@
---
title: The Expenses Page
-description: The Expenses Page
+description: Details on Expenses Page filters
---
-## Resource Coming Soon!
+# Overview
+
+The Expenses page allows you to see all of your personal expenses. If you are an admin, you can view all submitter’s expenses on the Expensify page. The Expenses page can be filtered in several ways to give you spending visibility, find expenses to submit and export to a spreadsheet (CSV).
+
+## Expense filters
+Here are the available filters you can use on the Expenses Page:
+
+- **Date Range:** Find expenses within a specific time frame.
+- **Merchant Name:** Search for expenses from a particular merchant. (Partial search terms also work if you need clarification on the exact name match.)
+- **Workspace:** Locate specific Group/Individual Workspace expenses.
+- **Categories:** Group expenses by category or identify those without a category.
+- **Tags:** Filter expenses with specific tags.
+- **Submitters:** Narrow expenses by submitter (employee or vendor).
+- **Personal Expenses:** Find all expenses yet to be included in a report. A Workspace admin can see these expenses once they are on a Processing, Approved, or Reimbursed report.
+- **Open:** Display expenses on reports that still need to be submitted (not submitted).
+- **Processing, Approved, Reimbursed:** See expenses on reports at various stages – processing, approved, or reimbursed.
+- **Closed:** View expenses on closed reports (not submitted for approval).
+
+Here's how to make the most of these filters:
+
+1. Log into your web account
+2. Go to the **Expenses** page
+3. At the top of the page, click on **Show Filters**
+4. Adjust the filters to match your specific needs
+
+Note, you might notice that not all expense filters are always visible. They adapt based on the data you're currently filtering and persist from the last time you logged in. For instance, you won't see the deleted filter if there are no **Deleted** expenses to filter out.
+
+If you are not seeing what you expected, you may have too many filters applied. Click **Reset** at the top to clear your filters.
+
+
+# How to add an expense to a report from the Expenses Page
+The submitter (and their copilot) can add expenses to a report from the Expenses page.
+
+Note, when expenses aren’t on a report, they are **personal expenses**. So you’ll want to make sure you haven’t filtered out **personal expenses** expenses, or you won’t be able to see them.
+
+1. Find the expense you want to add. (Hint: Use the filters to sort expenses by the desired date range if it is not a recent expense.)
+2. Then, select the expense you want to add to a report. You can click Select All to select multiple expenses.
+3. Click **Add to Report** in the upper right corner, and choose either an existing report or create a new one.
+
+# How to code expenses from the Expenses Page
+To code expenses from the Expenses page, do the following:
+
+1. Look for the **Tag**, **Category**, and **Description** columns on the **Expenses** page.
+2. Click on the relevant field for a specific expense and add or update the **Category**, **Tag**, or **Description**.
+
+Note, you can also open up individual expenses by clicking on them to see a detailed look, but coding the expenses from the Expense list is even faster and more convenient!
+
+# How to export expenses to a CSV file or spreadsheet
+If you want to export multiple expenses, run through the below steps:
+Select the expenses you want to export by checking the box to the left of each expense.
+Then, click **Export To** in the upper right corner of the page, and choose our default CSV format or create your own custom CSV template.
+
+
+# FAQ
+
+## Can I use the filters and analytics features on the mobile app?
+The various features on the Expenses Page are only available while logged into your web account.
+
+## As a Workspace admin, what submitter expenses can you see?
+A Workspace admin can see Processing, Approved, and Reimbursed expenses as long as they were submitted on the workspace that you are an admin.
+
+If employees submit expense reports on a workspace where you are not an admin, you will not have visibility into those expenses. Additionally, if an expense is left unreported, a workspace admin will not be able to see that expense until it’s been added to a report.
+
+A Workspace admin can edit the tags and categories on an expense, but if they want to edit the amount, date, or merchant name, the expense will need to be in a Processing state or rejected back to the submitter for changes.
+We have more about company card expense reconciliation in this support article.
+
+## Can I edit multiple expenses at once?
+Yes! Select the expenses you want to edit and click **Edit Multiple**.
+
diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
index 5c9761b7ff1d..4c216faffc18 100644
--- a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
+++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
@@ -5,40 +5,7 @@ description: Get the most out of your Expensify Card with exclusive perks!
# Overview
-The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. The Expensify Card’s primary perks include:
-- Access to our premiere Expensify Lounge (with more locations coming soon)
-- Swipe to Win, where every swipe has a chance to win fun personalized gifts for you and your closest friends and family members
-- And unbeatable cash back incentive with each swipe
-Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners.
-
-# Expensify Card Perks
-
-## Access to the Expensify Lounge
-Our [world-class lounge](https://use.expensify.com/lounge) is now open for Expensify members and guests to enjoy!
-
-We invite you to visit our sleek San Francisco lounge, where sweeping city views provide the perfect backdrop for a morning coffee to start your day.
-
-Enjoy complimentary cocktails and snacks in a vibrant atmosphere with blazing-fast WiFi. Whether you want a place to focus on work, socialize with other members, or simply kick back and relax – our lounge is ready and waiting to welcome you.
-
-You can sign up for free [here](https://use.expensify.com) if you’re not an Expensify member. If you have any questions, reach out to concierge@expensify.com and [check this out](https://use.expensify.com/lounge) for more info.
-
-## Swipe to Win
-Swipe to Win is a new [Expensify Card](https://use.expensify.com/company-credit-card) perk that gives cardholders the chance to send a gift to a friend, family member, or essential worker on the frontlines!
-
-Winners can choose to _Send a Smile_ or _Send a Laugh_. To start, we’re offering one gift per option:
-
-- **Send A Smile:** Champagne by Expensify
-- **Send a Laugh:** Jenga Set
-
-**How to Participate**
-It’s easy! Once you have an Expensify Card, you just need to start using it. With each swipe, you're automatically entered to win and have a 1 in 250 chance of getting a prize!
-
-**How will I know if I’ve won?**
-Winners will be notified immediately via the Expensify app, and receive additional instructions on how to choose and send their desired gift.
-
-If you don't have Expensify notifications turned on yet, here are some helpful guides:
-- [Apple Notification Preferences](https://support.apple.com/en-us/HT201925)
-- [Android Notification Preferences](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fsupport.google.com%2Fandroid%2Fanswer%2F9079661%3Fhl%3Den)
+The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners.
# Partner Specific Perks
@@ -222,26 +189,3 @@ Stripe Atlas helps removes obstacles typically associated with starting a busine
**How to redeem:** Sign up with your Expensify Card.
-# FAQ
-
-## Where is the Expensify Lounge?
-The Expensify Lounge is located on the 16th floor of 88 Kearny Street in San Francisco, California, 94108. This is currently our only lounge location, but keep an eye out for more work lounges popping up soon!
-
-## When is the Expensify Lounge open?
-The lounge is open 8 a.m. to 6 p.m. from Monday through Friday, except for national holidays. Capacity is limited, and we are admitting loungers on a first-come, first-served basis, so make sure to get there early!
-
-## Who can use the lounge workplace?
-Customers with an Expensify subscription can use Expensify’s lounge workplace, and any partner who has completed [ExpensifyApproved! University!](https://university.expensify.com/users/sign_in?next=%2Fdashboard)
-
-
-
-
-# FAQ
-This section covers the useful but not as vital information, it should capture commonly queried elements which do not organically form part of the About or How-to sections.
-
-- What's idiosyncratic or potentially confusing about this feature?
-- Is there anything unique about how this feature relates to billing/activity?
-- If this feature is released, are there any common confusions that can't be solved by improvements to the product itself?
-- Similarly, if this feature hasn't been released, can you predict and pre-empt any potential confusion?
-- Is there any general troubleshooting for this feature?
- - Note: troubleshooting should generally go in the FAQ, but if there is extensive troubleshooting, such as with integrations, that will be housed in a separate page, stored with and linked from the main page for that feature.
diff --git a/docs/articles/expensify-classic/getting-started/Best-Practices.md b/docs/articles/expensify-classic/getting-started/Best-Practices.md
deleted file mode 100644
index b02ea9d68fe6..000000000000
--- a/docs/articles/expensify-classic/getting-started/Best-Practices.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Best Practices
-description: Best Practices
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/getting-started/Policy-Admins.md b/docs/articles/expensify-classic/getting-started/Policy-Admins.md
deleted file mode 100644
index 484350f101a5..000000000000
--- a/docs/articles/expensify-classic/getting-started/Policy-Admins.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Policy Admins
-description: Policy Admins
----
-## 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 d933e66cc2d1..3ad3110bf09b 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
@@ -6,7 +6,7 @@ redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-B
# 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
+- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups) if you are more concerned with top-line revenue growth
# 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.
@@ -22,23 +22,23 @@ 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
-There are three policy types, but for your small business needs we recommend the *Control Plan* for the following reasons:
+## Step 2: Create a Control Workspace
+There are three workspace 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
- The ease of use and mobile-first design of the Control plan can increase employee adoption and participation, leading to better expense tracking and management.
- The plan integrates with a variety of tools, including accounting software and payroll systems, providing a seamless and integrated experience
- Accounting integrations include QuickBooks Online, Xero, NetSuite, and Sage Intacct, with indirect support from Microsoft Dynamics and any other accounting solution you work with
-We recommend creating one single policy for your US entity. This allows you to centrally manage all employees in one “group” while enforcing compliance controls and syncing with your accounting package accordingly.
+We recommend creating one single workspace for your US entity. This allows you to centrally manage all employees in one “group” while enforcing compliance controls and syncing with your accounting package accordingly.
-To create your Control Policy:
+To create your Control Workspace:
-1. Go to *Settings > Policies*
-2. Select *Group* and click the button that says *New Policy*
+1. Go to *Settings > Workspace*
+2. Select *Group* and click the button that says *New Workspace*
3. Click *Select* under Control
-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.
+The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your workspace's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s workspace 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
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:
@@ -49,17 +49,17 @@ As a small to medium-sized business, it's important to maintain proper spend man
You do this by synchronizing Expensify and your accounting package as follows:
-1. Click *Settings > Policies*
+1. Click *Settings > Workspace*
2. Navigate to the *Connections* tab
3. Select your accounting system
4. Follow the prompts to connect your accounting package
Check out the links below for more information on how to connect to your accounting solution:
-- *[QuickBooks Online](https://community.expensify.com/discussion/4833/how-to-connect-your-policy-to-quickbooks-online)*
-- *[Xero](https://community.expensify.com/discussion/5282/how-to-connect-your-policy-to-xero)*
-- *[NetSuite](https://community.expensify.com/discussion/5212/how-to-connect-your-policy-to-netsuite-token-based-authentication)*
-- *[Sage Intacct](https://community.expensify.com/discussion/4777/how-to-connect-to-sage-intacct-user-based-permissions-expense-reports)*
-- *[Other Accounting System](https://community.expensify.com/discussion/5271/how-to-set-up-an-indirect-accounting-integration)
+- *[QuickBooks Online](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online#gsc.tab=0)*
+- *[Xero](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Xero#gsc.tab=0)*
+- *[NetSuite](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/NetSuite#gsc.tab=0)*
+- *[Sage Intacct](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct#gsc.tab=0)*
+- *[Other Accounting System](https://help.expensify.com/articles/expensify-classic/integrations/accounting-integrations/Indirect-Accounting-Integrations#gsc.tab=0)
*“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.”*
@@ -82,15 +82,15 @@ Head over to the *Categories* tab to set compliance controls on your newly impor
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*
-In the tags tab in your policy settings, you’ll notice the option to enable the “Required” field. This makes it so any time an employee doesn’t assign a tag to an expense, we’ll flag a violation on it and notify both the employee and the approver.
+In the tags tab in your workspace settings, you’ll notice the option to enable the “Required” field. This makes it so any time an employee doesn’t assign a tag to an expense, we’ll flag a violation on it and notify both the employee and the approver.
- *Note:* In general, we take prior selection into account, so anytime you select a tag in Expensify, we’ll pre-populate that same field for any subsequent expense. It’s completely interchangeable, and there for convenience.
*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.
+Separately, if your workspace 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
-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:
+In the Expenses tab in your group Control workspace, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your workspace. We recommend the following confiuration:
*Max Expense Age: 90 days (or leave it blank)*
This will enable Expensify to catch employee reimbursement requests that are far too outdated for reimbursement, and present them as a violations. If you’d prefer a different time window, you can edit it accordingly
@@ -106,17 +106,17 @@ 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
-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:
+For an efficient company, we recommend setting up [Scheduled Submit](https://help.expensify.com/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit#gsc.tab=0) on a *Daily* frequency:
-- Click *Settings > Policies*
-- From here, select your group collect policy
-- Within your policy settings, select the *Reports* tab
+- Click *Settings > Workspace*
+- From here, select your group collect workspace
+- Within your workspace settings, select the *Reports* tab
- You’ll notice *Scheduled Submit* is located directly under *Report Basics*
- Choose *Daily*
-Between Expensify's SmartScan technology, automatic categorization, and [DoubleCheck](https://community.expensify.com/discussion/5738/deep-dive-how-does-concierge-receipt-audit-work) features, your employees shouldn't need to do anything more than swipe their Expensify Card or take a photo of their receipt.
+Between Expensify's SmartScan technology, automatic categorization, and [DoubleCheck](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports) features, your employees shouldn't need to do anything more than swipe their Expensify Card or take a photo of their receipt.
-Expenses with violations will stay behind for the employee to fix, while expenses that are “in-policy” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval.
+Expenses with violations will stay behind for the employee to fix, while expenses that are “in-workspace” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval.
![Scheduled submit](https://help.expensify.com/assets/images/playbook-scheduled-submit.png){:width="100%"}
@@ -147,10 +147,10 @@ You only need to do this once: you are fully set up for not only reimbursing exp
## 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!
+We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows#gsc.tab=0). But if *Advanced Approval* is your jam, keep reading!
*Import your employees in bulk via CSV*
-Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://community.expensify.com/discussion/5735/deep-dive-the-ins-and-outs-of-advanced-approval)*
+Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows#gsc.tab=0)*
![Bulk import your employees](https://help.expensify.com/assets/images/playbook-impoort-employees.png){:width="100%"}
@@ -162,8 +162,8 @@ In this case we recommend setting *Manually approve all expenses over: $0*
## 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*
+1. Navigate to *Settings > Workspace > Group > [Workspace Name] > Reimbursement*
+2. Set your *Manual Reimbursement threshold to $20,000*
## 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.
@@ -191,7 +191,7 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily)
Expensify provides a corporate card with the following features:
- Up to 2% cash back (up to 4% in your first 3 months!)
-- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) to control what each individual cardholder can spend
+- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Card-Settings) to control what each individual cardholder can spend
- A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues)
- Receipt compliance - informing notifications (eg. add a receipt!) for users *as soon as the card is swiped*
- A 50% discount on the price of all Expensify plans
@@ -202,8 +202,8 @@ The Expensify Card is recommended as the most efficient way to manage your compa
Here’s how to enable it:
-1. There are *two ways* you can [apply for the Expensify Card](https://community.expensify.com/discussion/4874/how-to-apply-for-the-expensify-card)
- - *Via your Inbox*
+1. There are *two ways* you can [apply for the Expensify Card](https://help.expensify.com/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company)
+ - *Via your tasks on the Home page*
- *Via Domain Settings* - Go to Settings > Domain > Company Cards > Enable Expensify Card
2. Assign the cards to your employees
3. Set *SmartLimits*:
@@ -212,14 +212,14 @@ Here’s how to enable it:
Once the Expensify Cards have been assigned, each employee will be prompted to enter their mailing address so they can receive their physical card. In the meantime, a virtual card will be ready to use immediately.
-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.
+If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://help.expensify.com/articles/expensify-classic/expensify-card/Auto-Reconciliation). 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
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:
- Flexible payment options: Expensify allows you to pay your bills via ACH, credit card, or check, so you can choose the option that works best for you (US businesses only).
-- Free, No Fees: The bill pay and invoicing features come included with every policy and workspace, so you won't need to pay any additional fees.
+- Free, No Fees: The bill pay and invoicing features come included with every workspace and workspace, so you won't need to pay any additional fees.
- Integration with your business bank account: With your business bank account verified, you can easily link your finances to receive payment from customers when invoices are paid.
Let’s first chat through how Bill Pay works
@@ -244,7 +244,7 @@ Reports, invoices, and bills are largely the same, in theory, just with differen
2. Add all of the expenses/transactions tied to the Invoice
3. Enter the recipient’s email address, a memo if needed, and a due date for when it needs to get paid, and click *Send*
-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.
+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 workspace 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
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.
@@ -266,7 +266,7 @@ Our pricing model is unique in the sense that you are in full control of your bi
To set your subscription, head to:
-1. Settings > Policies
+1. Settings > Workspace
2. Select *Group*
3. Scroll down to *Subscription*
4. Select *Annual Subscription*
@@ -281,4 +281,4 @@ Now that we’ve gone through all of the steps for setting up your account, let
4. Click *Accept Terms*
# 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.
+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 Workspace, and we’ll automatically assign a dedicated Setup Specialist to you.
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md
index e9077fc40a50..ecdea4699ee0 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md
@@ -64,6 +64,8 @@ Before completing the steps below, you will need Workday Report Writer access to
- Note: _if there is field data you want to import that is not listed above, or you have any special requests, let your Expensify Account Manager know and we will work with you to accommodate the request._
4. Rename the columns so they match Expensify's API key names (The full list of names are found here):
- employeeID
+ - customField1
+ - customField2
- firstName
- lastName
- employeeEmail
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md
similarity index 61%
rename from docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md
rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md
index 3ee1c8656b4b..f2978434959b 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Add-Members-to-your-Workspace.md
@@ -1,5 +1,5 @@
---
-title: Coming Soon
+title: Add Members to your Workspace
description: Coming Soon
---
## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md
index 3ee1c8656b4b..a1916465fca8 100644
--- a/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md
+++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md
@@ -1,5 +1,47 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Reimbursement
+description: Enable reimbursement and reimburse expense reports
---
-## Resource Coming Soon!
+
+
+# Overview
+Reimbursement in Expensify is quick, easy, and completely free. Let Expensify do the tedious work for you by taking advantage of features to automate employee reimbursement.
+
+# How to Enable Reimbursement
+There are several options for reimbursing employees in Expensify. The options available will depend on which country your business bank account is domiciled in.
+
+## Direct Reimbursement
+
+Direct reimbursement is available to companies who have a verified US bank account and are reimbursing employees within the US. To use direct reimbursement, you must have a US business bank account verified in Expensify.
+
+A Workspace admin can enable direct reimbursement via **Settings > Workspaces > Workspace Name > Reimbursement > Direct**.
+
+**Additional features under Reimbursement > Direct:**
+ - Select a **default reimburser** for the Workspace from the dropdown menu. The default reimburser is the person who will receive notifications to reimburse reports in Expensify. You’ll be able to choose among all Workspace Admins who have access to the business bank account.
+ - Set a **default withdrawal account** for the Workspace. This will set a default bank account that report reimbursements are withdrawn from.
+ - Set a **manual reimbursement threshold** to automate reimbursement. Reports whose total falls under the manual reimbursement threshhold will be reimbursed automatocally upon final approval; reports whose total falls above the threshhold will need to be reimbursed manually by the default reimburser.
+
+Expensify also offers direct global reimbursement to some companies with verified bank accounts in USD, GBP, EUR and AUD who are reimbursing employees internationally. For more information about Global Reimbursement, see LINK
+
+## Indirect Reimbursement
+
+Indirect reimbursement is available to all companies in Expensify and no bank account is required. Indirect reimbursement indicates that the report will be reimbursed outside of Expensify.
+
+A Workspace admin can enanble indirect reimbursement via **Settings > Workspaces > Workspace Name > Reimbursement > Indirect**.
+
+**Additional features under Reimbursement > Indirect:**
+If you reimburse through a seperate system or through payroll, Expensify can collect and export employee bank account details for you. Just reach out to your Account Manager or concierge@expensify.com for us to add the Reimbursement Details Export format to the account.
+
+# FAQ
+
+## How do I export employee bank account details once the Reimbursement Details Export format is added to my account?
+
+Employee bank account details can be exported from the Reports page by selecting the relevant Approved reports and then clicking **Export to > Reimbursement Details Export**.
+
+## Is it possible to change the name of a verified business bank account in Expensify?
+
+Bank account names can be updated via **Settings > Accounts > Payments** and clicking the pencil icon next to the bank account name.
+
+## What is the benefit of setting a default reimburser?
+
+The main benefit of being defined as the "reimburser" in the Workspace settings is that this user will receive notifications on their Home page alerting them when reports need to be reimbursed.
diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png
new file mode 100644
index 000000000000..2e11b7eb1f49
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Mobile.png differ
diff --git a/docs/assets/images/ExpensifyHelp_RemovingMembers.png b/docs/assets/images/ExpensifyHelp_RemovingMembers.png
new file mode 100644
index 000000000000..1e0157476fbf
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_RemovingMembers.png differ
diff --git a/docs/assets/images/attendee-tracking.png b/docs/assets/images/attendee-tracking.png
new file mode 100644
index 000000000000..66ab22b6efe7
Binary files /dev/null and b/docs/assets/images/attendee-tracking.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 4d019ccacaa1..853bed5517f8 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.95
+ 1.3.96CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.95.4
+ 1.3.96.3ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 64aaf1899c16..2f5543ca303e 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.95
+ 1.3.96CFBundleSignature????CFBundleVersion
- 1.3.95.4
+ 1.3.96.3
diff --git a/metro.config.js b/metro.config.js
index 62ca2a25c6b2..dd391c86c34c 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -6,13 +6,15 @@ require('dotenv').config();
const defaultConfig = getDefaultConfig(__dirname);
-const isUsingMockAPI = process.env.E2E_TESTING === 'true';
+const isE2ETesting = process.env.E2E_TESTING === 'true';
-if (isUsingMockAPI) {
+if (isE2ETesting) {
// eslint-disable-next-line no-console
console.log('⚠️⚠️⚠️⚠️ Using mock API ⚠️⚠️⚠️⚠️');
}
+const e2eSourceExts = ['e2e.js', 'e2e.ts'];
+
/**
* Metro configuration
* https://facebook.github.io/metro/docs/configuration
@@ -22,10 +24,11 @@ if (isUsingMockAPI) {
const config = {
resolver: {
assetExts: _.filter(defaultAssetExts, (ext) => ext !== 'svg'),
- sourceExts: [...defaultSourceExts, 'jsx', 'svg'],
+ // When we run the e2e tests we want files that have the extension e2e.js to be resolved as source files
+ sourceExts: [...(isE2ETesting ? e2eSourceExts : []), ...defaultSourceExts, 'jsx', 'svg'],
resolveRequest: (context, moduleName, platform) => {
const resolution = context.resolveRequest(context, moduleName, platform);
- if (isUsingMockAPI && moduleName.includes('/API')) {
+ if (isE2ETesting && moduleName.includes('/API')) {
const originalPath = resolution.filePath;
const mockPath = originalPath.replace('src/libs/API.ts', 'src/libs/E2E/API.mock.js').replace('/src/libs/API.js/', 'src/libs/E2E/API.mock.js');
// eslint-disable-next-line no-console
diff --git a/package-lock.json b/package-lock.json
index a3cc282129da..32f874bfc3a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,16 @@
{
"name": "new.expensify",
- "version": "1.3.95-4",
+ "version": "1.3.96-3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.95-4",
+ "version": "1.3.96-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-web": "0.18.15",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -96,7 +95,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.100",
+ "react-native-onyx": "1.0.111",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
@@ -114,6 +113,7 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^3.6.0",
"react-native-vision-camera": "^2.16.2",
+ "react-native-web": "^0.19.9",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-webview": "^11.17.2",
"react-pdf": "^6.2.2",
@@ -3684,25 +3684,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@expensify/react-native-web": {
- "version": "0.18.15",
- "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.15.tgz",
- "integrity": "sha512-xE3WdGKY4SRLfIrimUlgP78ZsDaWy3g+KIO8mpxTm9zCXeX/sgEYs6QvhFghgEhhp7Y1bLH9LWTKiZy9LZM8EA==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.18.6",
- "create-react-class": "^15.7.0",
- "fbjs": "^3.0.4",
- "inline-style-prefixer": "^6.0.1",
- "normalize-css-color": "^1.0.2",
- "postcss-value-parser": "^4.2.0",
- "styleq": "^0.1.2"
- },
- "peerDependencies": {
- "react": "^17.0.2 || ^18.0.0",
- "react-dom": "^17.0.2 || ^18.0.0"
- }
- },
"node_modules/@expo/config-plugins": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-4.1.5.tgz",
@@ -26262,16 +26243,6 @@
"sha.js": "^2.4.8"
}
},
- "node_modules/create-react-class": {
- "version": "15.7.0",
- "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.7.0.tgz",
- "integrity": "sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.3.1",
- "object-assign": "^4.1.1"
- }
- },
"node_modules/cross-fetch": {
"version": "3.1.5",
"license": "MIT",
@@ -41728,12 +41699,6 @@
"url": "https://github.com/sponsors/antelle"
}
},
- "node_modules/normalize-css-color": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/normalize-css-color/-/normalize-css-color-1.0.2.tgz",
- "integrity": "sha512-jPJ/V7Cp1UytdidsPqviKEElFQJs22hUUgK5BOPHTwOonNCk7/2qOxhhqzEajmFrWJowADFfOFh1V+aWkRfy+w==",
- "license": "BSD-3-Clause"
- },
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -44802,17 +44767,17 @@
}
},
"node_modules/react-native-onyx": {
- "version": "1.0.100",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz",
- "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==",
+ "version": "1.0.111",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.111.tgz",
+ "integrity": "sha512-6drd5Grhkyq4oyt2+Bu6t7JYK5tqaARc0YP7taEHK9jLbhjdC4E9MPLJR2FVXiORkQCPOoyy1Gqmb4AUVIsvxg==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
"underscore": "^1.13.1"
},
"engines": {
- "node": "16.15.1",
- "npm": "8.11.0"
+ "node": ">=16.15.1 <=18.17.1",
+ "npm": ">=8.11.0 <=9.6.7"
},
"peerDependencies": {
"idb-keyval": "^6.2.1",
@@ -45106,7 +45071,6 @@
"version": "0.19.9",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.9.tgz",
"integrity": "sha512-m69arZbS6FV+BNSKE6R/NQwUX+CzxCkYM7AJlSLlS8dz3BDzlaxG8Bzqtzv/r3r1YFowhnZLBXVKIwovKDw49g==",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-color": "^2.1.0",
@@ -45133,8 +45097,7 @@
"node_modules/react-native-web/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
- "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
- "peer": true
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/react-native-webview": {
"version": "11.23.0",
@@ -55659,20 +55622,6 @@
}
}
},
- "@expensify/react-native-web": {
- "version": "0.18.15",
- "resolved": "https://registry.npmjs.org/@expensify/react-native-web/-/react-native-web-0.18.15.tgz",
- "integrity": "sha512-xE3WdGKY4SRLfIrimUlgP78ZsDaWy3g+KIO8mpxTm9zCXeX/sgEYs6QvhFghgEhhp7Y1bLH9LWTKiZy9LZM8EA==",
- "requires": {
- "@babel/runtime": "^7.18.6",
- "create-react-class": "^15.7.0",
- "fbjs": "^3.0.4",
- "inline-style-prefixer": "^6.0.1",
- "normalize-css-color": "^1.0.2",
- "postcss-value-parser": "^4.2.0",
- "styleq": "^0.1.2"
- }
- },
"@expo/config-plugins": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-4.1.5.tgz",
@@ -72107,15 +72056,6 @@
"sha.js": "^2.4.8"
}
},
- "create-react-class": {
- "version": "15.7.0",
- "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.7.0.tgz",
- "integrity": "sha512-QZv4sFWG9S5RUvkTYWbflxeZX+JG7Cz0Tn33rQBJ+WFQTqTfUTjMjiv9tnfXazjsO5r0KhPs+AqCjyrQX6h2ng==",
- "requires": {
- "loose-envify": "^1.3.1",
- "object-assign": "^4.1.1"
- }
- },
"cross-fetch": {
"version": "3.1.5",
"requires": {
@@ -83165,11 +83105,6 @@
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="
},
- "normalize-css-color": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/normalize-css-color/-/normalize-css-color-1.0.2.tgz",
- "integrity": "sha512-jPJ/V7Cp1UytdidsPqviKEElFQJs22hUUgK5BOPHTwOonNCk7/2qOxhhqzEajmFrWJowADFfOFh1V+aWkRfy+w=="
- },
"normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -85416,9 +85351,9 @@
}
},
"react-native-onyx": {
- "version": "1.0.100",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.100.tgz",
- "integrity": "sha512-m4bOF/uOtYpfL83fqoWhw7TYV4oKGXt0sfGoel/fhaT1HzXKheXc//ibt/G3VrTCf5nmRv7bXgsXkWjUYLH3UQ==",
+ "version": "1.0.111",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.111.tgz",
+ "integrity": "sha512-6drd5Grhkyq4oyt2+Bu6t7JYK5tqaARc0YP7taEHK9jLbhjdC4E9MPLJR2FVXiORkQCPOoyy1Gqmb4AUVIsvxg==",
"requires": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -85603,7 +85538,6 @@
"version": "0.19.9",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.9.tgz",
"integrity": "sha512-m69arZbS6FV+BNSKE6R/NQwUX+CzxCkYM7AJlSLlS8dz3BDzlaxG8Bzqtzv/r3r1YFowhnZLBXVKIwovKDw49g==",
- "peer": true,
"requires": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-color": "^2.1.0",
@@ -85618,8 +85552,7 @@
"memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
- "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
- "peer": true
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
}
}
},
diff --git a/package.json b/package.json
index e0c9b83e3c41..ab38d2bb65b0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.95-4",
+ "version": "1.3.96-3",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -49,8 +49,9 @@
"analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production",
"symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map",
"symbolicate:ios": "npx metro-symbolicate main.jsbundle.map",
- "test:e2e:main": "node tests/e2e/testRunner.js --development --branch main --skipCheckout",
- "test:e2e:delta": "node tests/e2e/testRunner.js --development --branch main --label delta --skipCheckout",
+ "test:e2e:dev": "node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps",
+ "test:e2e:main": "node tests/e2e/testRunner.js --development --skipCheckout",
+ "test:e2e:delta": "node tests/e2e/testRunner.js --development --label delta --skipCheckout --skipInstallDeps",
"test:e2e:compare": "node tests/e2e/merge.js",
"gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh",
"workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh",
@@ -59,7 +60,6 @@
},
"dependencies": {
"@dotlottie/react-player": "^1.6.3",
- "@expensify/react-native-web": "0.18.15",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -144,7 +144,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.100",
+ "react-native-onyx": "1.0.111",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
@@ -162,6 +162,7 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^3.6.0",
"react-native-vision-camera": "^2.16.2",
+ "react-native-web": "^0.19.9",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-webview": "^11.17.2",
"react-pdf": "^6.2.2",
diff --git a/patches/eslint-plugin-react-native-a11y+3.3.0.patch b/patches/eslint-plugin-react-native-a11y+3.3.0.patch
new file mode 100644
index 000000000000..fe5998118afa
--- /dev/null
+++ b/patches/eslint-plugin-react-native-a11y+3.3.0.patch
@@ -0,0 +1,59 @@
+diff --git a/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js b/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js
+index 9ecf8b1..fef94dd 100644
+--- a/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js
++++ b/node_modules/eslint-plugin-react-native-a11y/__tests__/src/rules/has-valid-accessibility-descriptors-test.js
+@@ -20,7 +20,7 @@ const ruleTester = new RuleTester();
+
+ const expectedError = {
+ message:
+- 'Missing a11y props. Expected one of: accessibilityRole OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction',
++ 'Missing a11y props. Expected one of: accessibilityRole OR role OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction',
+ type: 'JSXOpeningElement',
+ };
+
+@@ -29,6 +29,11 @@ ruleTester.run('has-valid-accessibility-descriptors', rule, {
+ {
+ code: ';',
+ },
++ {
++ code: `
++ Back
++ `,
++ },
+ {
+ code: `
+ Back
+diff --git a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js
+index 99deb91..555ebd9 100644
+--- a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js
++++ b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-descriptors.js
+@@ -16,7 +16,7 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
+ // ----------------------------------------------------------------------------
+ // Rule Definition
+ // ----------------------------------------------------------------------------
+-var errorMessage = 'Missing a11y props. Expected one of: accessibilityRole OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction';
++var errorMessage = 'Missing a11y props. Expected one of: accessibilityRole OR role OR BOTH accessibilityLabel + accessibilityHint OR BOTH accessibilityActions + onAccessibilityAction';
+ var schema = (0, _schemas.generateObjSchema)();
+
+ var hasSpreadProps = function hasSpreadProps(attributes) {
+@@ -35,7 +35,7 @@ module.exports = {
+ return {
+ JSXOpeningElement: function JSXOpeningElement(node) {
+ if ((0, _isTouchable.default)(node, context) || (0, _jsxAstUtils.elementType)(node) === 'TextInput') {
+- if (!(0, _jsxAstUtils.hasAnyProp)(node.attributes, ['accessibilityRole', 'accessibilityLabel', 'accessibilityActions', 'accessible']) && !hasSpreadProps(node.attributes)) {
++ if (!(0, _jsxAstUtils.hasAnyProp)(node.attributes, ['role', 'accessibilityRole', 'accessibilityLabel', 'accessibilityActions', 'accessible']) && !hasSpreadProps(node.attributes)) {
+ context.report({
+ node,
+ message: errorMessage,
+diff --git a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js
+index fe74702..fa6bdaa 100644
+--- a/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js
++++ b/node_modules/eslint-plugin-react-native-a11y/lib/rules/has-valid-accessibility-role.js
+@@ -13,5 +13,5 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
+ // Rule Definition
+ // ----------------------------------------------------------------------------
+ var errorMessage = 'accessibilityRole must be one of defined values';
+-var validValues = ['togglebutton', 'adjustable', 'alert', 'button', 'checkbox', 'combobox', 'header', 'image', 'imagebutton', 'keyboardkey', 'link', 'menu', 'menubar', 'menuitem', 'none', 'progressbar', 'radio', 'radiogroup', 'scrollbar', 'search', 'spinbutton', 'summary', 'switch', 'tab', 'tabbar', 'tablist', 'text', 'timer', 'list', 'toolbar'];
++var validValues = ['img', 'img button', 'img link', 'togglebutton', 'adjustable', 'alert', 'button', 'checkbox', 'combobox', 'header', 'image', 'imagebutton', 'keyboardkey', 'link', 'menu', 'menubar', 'menuitem', 'none', 'progressbar', 'radio', 'radiogroup', 'scrollbar', 'search', 'spinbutton', 'summary', 'switch', 'tab', 'tabbar', 'tablist', 'text', 'timer', 'list', 'toolbar'];
+ module.exports = (0, _validProp.default)('accessibilityRole', validValues, errorMessage);
+\ No newline at end of file
diff --git a/patches/react-native-modal+13.0.1.patch b/patches/react-native-modal+13.0.1.patch
index 049a7a09d16a..5bfb2cc5f0b0 100644
--- a/patches/react-native-modal+13.0.1.patch
+++ b/patches/react-native-modal+13.0.1.patch
@@ -11,7 +11,7 @@ index b63bcfc..bd6419e 100644
buildPanResponder: () => void;
getAccDistancePerDirection: (gestureState: PanResponderGestureState) => number;
diff --git a/node_modules/react-native-modal/dist/modal.js b/node_modules/react-native-modal/dist/modal.js
-index 80f4e75..a88a2ca 100644
+index 80f4e75..3ba8b8c 100644
--- a/node_modules/react-native-modal/dist/modal.js
+++ b/node_modules/react-native-modal/dist/modal.js
@@ -75,6 +75,13 @@ export class ReactNativeModal extends React.Component {
@@ -28,23 +28,27 @@ index 80f4e75..a88a2ca 100644
this.shouldPropagateSwipe = (evt, gestureState) => {
return typeof this.props.propagateSwipe === 'function'
? this.props.propagateSwipe(evt, gestureState)
-@@ -454,9 +461,15 @@ export class ReactNativeModal extends React.Component {
+@@ -453,10 +460,18 @@ export class ReactNativeModal extends React.Component {
+ if (this.state.isVisible) {
this.open();
}
- BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress);
+ if (Platform.OS === 'web') {
+ document?.body?.addEventListener?.('keyup', this.handleEscape, true);
++ return;
+ }
+ BackHandler.addEventListener('hardwareBackPress', this.onBackButtonPress);
}
componentWillUnmount() {
- BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress);
+- BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress);
+ if (Platform.OS === 'web') {
+ document?.body?.removeEventListener?.('keyup', this.handleEscape, true);
++ } else {
++ BackHandler.removeEventListener('hardwareBackPress', this.onBackButtonPress);
+ }
if (this.didUpdateDimensionsEmitter) {
this.didUpdateDimensionsEmitter.remove();
}
-@@ -525,7 +538,7 @@ export class ReactNativeModal extends React.Component {
+@@ -525,7 +540,7 @@ export class ReactNativeModal extends React.Component {
}
return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps),
this.makeBackdrop(),
diff --git a/patches/react-native-web+0.19.9+001+initial.patch b/patches/react-native-web+0.19.9+001+initial.patch
new file mode 100644
index 000000000000..d88ef83d4bcd
--- /dev/null
+++ b/patches/react-native-web+0.19.9+001+initial.patch
@@ -0,0 +1,286 @@
+diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+index c879838..288316c 100644
+--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+@@ -117,6 +117,14 @@ function findLastWhere(arr, predicate) {
+ *
+ */
+ class VirtualizedList extends StateSafePureComponent {
++ pushOrUnshift(input, item) {
++ if (this.props.inverted) {
++ input.unshift(item);
++ } else {
++ input.push(item);
++ }
++ }
++
+ // scrollToEnd may be janky without getItemLayout prop
+ scrollToEnd(params) {
+ var animated = params ? params.animated : true;
+@@ -350,6 +358,7 @@ class VirtualizedList extends StateSafePureComponent {
+ };
+ this._defaultRenderScrollComponent = props => {
+ var onRefresh = props.onRefresh;
++ var inversionStyle = this.props.inverted ? this.props.horizontal ? styles.rowReverse : styles.columnReverse : null;
+ if (this._isNestedWithSameOrientation()) {
+ // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors
+ return /*#__PURE__*/React.createElement(View, props);
+@@ -367,13 +376,16 @@ class VirtualizedList extends StateSafePureComponent {
+ refreshing: props.refreshing,
+ onRefresh: onRefresh,
+ progressViewOffset: props.progressViewOffset
+- }) : props.refreshControl
++ }) : props.refreshControl,
++ contentContainerStyle: [inversionStyle, this.props.contentContainerStyle]
+ }))
+ );
+ } else {
+ // $FlowFixMe[prop-missing] Invalid prop usage
+ // $FlowFixMe[incompatible-use]
+- return /*#__PURE__*/React.createElement(ScrollView, props);
++ return /*#__PURE__*/React.createElement(ScrollView, _extends({}, props, {
++ contentContainerStyle: [inversionStyle, this.props.contentContainerStyle]
++ }));
+ }
+ };
+ this._onCellLayout = (e, cellKey, index) => {
+@@ -683,7 +695,7 @@ class VirtualizedList extends StateSafePureComponent {
+ onViewableItemsChanged = _this$props3.onViewableItemsChanged,
+ viewabilityConfig = _this$props3.viewabilityConfig;
+ if (onViewableItemsChanged) {
+- this._viewabilityTuples.push({
++ this.pushOrUnshift(this._viewabilityTuples, {
+ viewabilityHelper: new ViewabilityHelper(viewabilityConfig),
+ onViewableItemsChanged: onViewableItemsChanged
+ });
+@@ -937,10 +949,10 @@ class VirtualizedList extends StateSafePureComponent {
+ var key = _this._keyExtractor(item, ii, _this.props);
+ _this._indicesToKeys.set(ii, key);
+ if (stickyIndicesFromProps.has(ii + stickyOffset)) {
+- stickyHeaderIndices.push(cells.length);
++ _this.pushOrUnshift(stickyHeaderIndices, cells.length);
+ }
+ var shouldListenForLayout = getItemLayout == null || debug || _this._fillRateHelper.enabled();
+- cells.push( /*#__PURE__*/React.createElement(CellRenderer, _extends({
++ _this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(CellRenderer, _extends({
+ CellRendererComponent: CellRendererComponent,
+ ItemSeparatorComponent: ii < end ? ItemSeparatorComponent : undefined,
+ ListItemComponent: ListItemComponent,
+@@ -1012,14 +1024,14 @@ class VirtualizedList extends StateSafePureComponent {
+ // 1. Add cell for ListHeaderComponent
+ if (ListHeaderComponent) {
+ if (stickyIndicesFromProps.has(0)) {
+- stickyHeaderIndices.push(0);
++ this.pushOrUnshift(stickyHeaderIndices, 0);
+ }
+ var _element = /*#__PURE__*/React.isValidElement(ListHeaderComponent) ? ListHeaderComponent :
+ /*#__PURE__*/
+ // $FlowFixMe[not-a-component]
+ // $FlowFixMe[incompatible-type-arg]
+ React.createElement(ListHeaderComponent, null);
+- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
+ cellKey: this._getCellKey() + '-header',
+ key: "$header"
+ }, /*#__PURE__*/React.createElement(View, {
+@@ -1038,7 +1050,7 @@ class VirtualizedList extends StateSafePureComponent {
+ // $FlowFixMe[not-a-component]
+ // $FlowFixMe[incompatible-type-arg]
+ React.createElement(ListEmptyComponent, null);
+- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
+ cellKey: this._getCellKey() + '-empty',
+ key: "$empty"
+ }, /*#__PURE__*/React.cloneElement(_element2, {
+@@ -1077,7 +1089,7 @@ class VirtualizedList extends StateSafePureComponent {
+ var firstMetrics = this.__getFrameMetricsApprox(section.first, this.props);
+ var lastMetrics = this.__getFrameMetricsApprox(last, this.props);
+ var spacerSize = lastMetrics.offset + lastMetrics.length - firstMetrics.offset;
+- cells.push( /*#__PURE__*/React.createElement(View, {
++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(View, {
+ key: "$spacer-" + section.first,
+ style: {
+ [spacerKey]: spacerSize
+@@ -1100,7 +1112,7 @@ class VirtualizedList extends StateSafePureComponent {
+ // $FlowFixMe[not-a-component]
+ // $FlowFixMe[incompatible-type-arg]
+ React.createElement(ListFooterComponent, null);
+- cells.push( /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
+ cellKey: this._getFooterCellKey(),
+ key: "$footer"
+ }, /*#__PURE__*/React.createElement(View, {
+@@ -1266,7 +1278,7 @@ class VirtualizedList extends StateSafePureComponent {
+ * suppresses an error found when Flow v0.68 was deployed. To see the
+ * error delete this comment and run Flow. */
+ if (frame.inLayout) {
+- framesInLayout.push(frame);
++ this.pushOrUnshift(framesInLayout, frame);
+ }
+ }
+ var windowTop = this.__getFrameMetricsApprox(this.state.cellsAroundViewport.first, this.props).offset;
+@@ -1452,6 +1464,12 @@ var styles = StyleSheet.create({
+ left: 0,
+ borderColor: 'red',
+ borderWidth: 2
++ },
++ rowReverse: {
++ flexDirection: 'row-reverse'
++ },
++ columnReverse: {
++ flexDirection: 'column-reverse'
+ }
+ });
+ export default VirtualizedList;
+\ No newline at end of file
+diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
+index c7d68bb..46b3fc9 100644
+--- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
++++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
+@@ -167,6 +167,14 @@ function findLastWhere(
+ class VirtualizedList extends StateSafePureComponent {
+ static contextType: typeof VirtualizedListContext = VirtualizedListContext;
+
++ pushOrUnshift(input: Array, item: Item) {
++ if (this.props.inverted) {
++ input.unshift(item)
++ } else {
++ input.push(item)
++ }
++ }
++
+ // scrollToEnd may be janky without getItemLayout prop
+ scrollToEnd(params?: ?{animated?: ?boolean, ...}) {
+ const animated = params ? params.animated : true;
+@@ -438,7 +446,7 @@ class VirtualizedList extends StateSafePureComponent {
+ } else {
+ const {onViewableItemsChanged, viewabilityConfig} = this.props;
+ if (onViewableItemsChanged) {
+- this._viewabilityTuples.push({
++ this.pushOrUnshift(this._viewabilityTuples, {
+ viewabilityHelper: new ViewabilityHelper(viewabilityConfig),
+ onViewableItemsChanged: onViewableItemsChanged,
+ });
+@@ -814,13 +822,13 @@ class VirtualizedList extends StateSafePureComponent {
+
+ this._indicesToKeys.set(ii, key);
+ if (stickyIndicesFromProps.has(ii + stickyOffset)) {
+- stickyHeaderIndices.push(cells.length);
++ this.pushOrUnshift(stickyHeaderIndices, (cells.length));
+ }
+
+ const shouldListenForLayout =
+ getItemLayout == null || debug || this._fillRateHelper.enabled();
+
+- cells.push(
++ this.pushOrUnshift(cells,
+ {
+ // 1. Add cell for ListHeaderComponent
+ if (ListHeaderComponent) {
+ if (stickyIndicesFromProps.has(0)) {
+- stickyHeaderIndices.push(0);
++ this.pushOrUnshift(stickyHeaderIndices, 0);
+ }
+ const element = React.isValidElement(ListHeaderComponent) ? (
+ ListHeaderComponent
+@@ -932,7 +940,7 @@ class VirtualizedList extends StateSafePureComponent {
+ // $FlowFixMe[incompatible-type-arg]
+
+ );
+- cells.push(
++ this.pushOrUnshift(cells,
+
+@@ -963,7 +971,7 @@ class VirtualizedList extends StateSafePureComponent {
+ // $FlowFixMe[incompatible-type-arg]
+
+ )): any);
+- cells.push(
++ this.pushOrUnshift(cells,
+
+@@ -1017,7 +1025,7 @@ class VirtualizedList extends StateSafePureComponent {
+ const lastMetrics = this.__getFrameMetricsApprox(last, this.props);
+ const spacerSize =
+ lastMetrics.offset + lastMetrics.length - firstMetrics.offset;
+- cells.push(
++ this.pushOrUnshift(cells,
+ {
+ // $FlowFixMe[incompatible-type-arg]
+
+ );
+- cells.push(
++ this.pushOrUnshift(cells,
+
+@@ -1246,6 +1254,12 @@ class VirtualizedList extends StateSafePureComponent {
+ * LTI update could not be added via codemod */
+ _defaultRenderScrollComponent = props => {
+ const onRefresh = props.onRefresh;
++ const inversionStyle = this.props.inverted
++ ? this.props.horizontal
++ ? styles.rowReverse
++ : styles.columnReverse
++ : null;
++
+ if (this._isNestedWithSameOrientation()) {
+ // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors
+ return ;
+@@ -1273,12 +1287,24 @@ class VirtualizedList extends StateSafePureComponent {
+ props.refreshControl
+ )
+ }
++ contentContainerStyle={[
++ inversionStyle,
++ this.props.contentContainerStyle,
++ ]}
+ />
+ );
+ } else {
+ // $FlowFixMe[prop-missing] Invalid prop usage
+ // $FlowFixMe[incompatible-use]
+- return ;
++ return (
++
++ );
+ }
+ };
+
+@@ -1432,7 +1458,7 @@ class VirtualizedList extends StateSafePureComponent {
+ * suppresses an error found when Flow v0.68 was deployed. To see the
+ * error delete this comment and run Flow. */
+ if (frame.inLayout) {
+- framesInLayout.push(frame);
++ this.pushOrUnshift(framesInLayout, frame);
+ }
+ }
+ const windowTop = this.__getFrameMetricsApprox(
+@@ -2044,6 +2070,12 @@ const styles = StyleSheet.create({
+ borderColor: 'red',
+ borderWidth: 2,
+ },
++ rowReverse: {
++ flexDirection: 'row-reverse',
++ },
++ columnReverse: {
++ flexDirection: 'column-reverse',
++ },
+ });
+
+ export default VirtualizedList;
+\ No newline at end of file
diff --git a/patches/react-native-web+0.19.9+002+fix-mvcp.patch b/patches/react-native-web+0.19.9+002+fix-mvcp.patch
new file mode 100644
index 000000000000..afd681bba3b0
--- /dev/null
+++ b/patches/react-native-web+0.19.9+002+fix-mvcp.patch
@@ -0,0 +1,687 @@
+diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+index a6fe142..faeb323 100644
+--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+@@ -293,7 +293,7 @@ class VirtualizedList extends StateSafePureComponent {
+ // $FlowFixMe[missing-local-annot]
+
+ constructor(_props) {
+- var _this$props$updateCel;
++ var _this$props$updateCel, _this$props$maintainV, _this$props$maintainV2;
+ super(_props);
+ this._getScrollMetrics = () => {
+ return this._scrollMetrics;
+@@ -532,6 +532,11 @@ class VirtualizedList extends StateSafePureComponent {
+ visibleLength,
+ zoomScale
+ };
++ if (this.state.pendingScrollUpdateCount > 0) {
++ this.setState(state => ({
++ pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1
++ }));
++ }
+ this._updateViewableItems(this.props, this.state.cellsAroundViewport);
+ if (!this.props) {
+ return;
+@@ -581,7 +586,7 @@ class VirtualizedList extends StateSafePureComponent {
+ this._updateCellsToRender = () => {
+ this._updateViewableItems(this.props, this.state.cellsAroundViewport);
+ this.setState((state, props) => {
+- var cellsAroundViewport = this._adjustCellsAroundViewport(props, state.cellsAroundViewport);
++ var cellsAroundViewport = this._adjustCellsAroundViewport(props, state.cellsAroundViewport, state.pendingScrollUpdateCount);
+ var renderMask = VirtualizedList._createRenderMask(props, cellsAroundViewport, this._getNonViewportRenderRegions(props));
+ if (cellsAroundViewport.first === state.cellsAroundViewport.first && cellsAroundViewport.last === state.cellsAroundViewport.last && renderMask.equals(state.renderMask)) {
+ return null;
+@@ -601,7 +606,7 @@ class VirtualizedList extends StateSafePureComponent {
+ return {
+ index,
+ item,
+- key: this._keyExtractor(item, index, props),
++ key: VirtualizedList._keyExtractor(item, index, props),
+ isViewable
+ };
+ };
+@@ -633,12 +638,10 @@ class VirtualizedList extends StateSafePureComponent {
+ };
+ this._getFrameMetrics = (index, props) => {
+ var data = props.data,
+- getItem = props.getItem,
+ getItemCount = props.getItemCount,
+ getItemLayout = props.getItemLayout;
+ invariant(index >= 0 && index < getItemCount(data), 'Tried to get frame for out of range index ' + index);
+- var item = getItem(data, index);
+- var frame = this._frames[this._keyExtractor(item, index, props)];
++ var frame = this._frames[VirtualizedList._getItemKey(props, index)];
+ if (!frame || frame.index !== index) {
+ if (getItemLayout) {
+ /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment
+@@ -662,7 +665,7 @@ class VirtualizedList extends StateSafePureComponent {
+
+ // The last cell we rendered may be at a new index. Bail if we don't know
+ // where it is.
+- if (focusedCellIndex >= itemCount || this._keyExtractor(props.getItem(props.data, focusedCellIndex), focusedCellIndex, props) !== this._lastFocusedCellKey) {
++ if (focusedCellIndex >= itemCount || VirtualizedList._getItemKey(props, focusedCellIndex) !== this._lastFocusedCellKey) {
+ return [];
+ }
+ var first = focusedCellIndex;
+@@ -702,9 +705,15 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ }
+ var initialRenderRegion = VirtualizedList._initialRenderRegion(_props);
++ var minIndexForVisible = (_this$props$maintainV = (_this$props$maintainV2 = this.props.maintainVisibleContentPosition) == null ? void 0 : _this$props$maintainV2.minIndexForVisible) !== null && _this$props$maintainV !== void 0 ? _this$props$maintainV : 0;
+ this.state = {
+ cellsAroundViewport: initialRenderRegion,
+- renderMask: VirtualizedList._createRenderMask(_props, initialRenderRegion)
++ renderMask: VirtualizedList._createRenderMask(_props, initialRenderRegion),
++ firstVisibleItemKey: this.props.getItemCount(this.props.data) > minIndexForVisible ? VirtualizedList._getItemKey(this.props, minIndexForVisible) : null,
++ // When we have a non-zero initialScrollIndex, we will receive a
++ // scroll event later so this will prevent the window from updating
++ // until we get a valid offset.
++ pendingScrollUpdateCount: this.props.initialScrollIndex != null && this.props.initialScrollIndex > 0 ? 1 : 0
+ };
+
+ // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller.
+@@ -715,7 +724,7 @@ class VirtualizedList extends StateSafePureComponent {
+ var clientLength = this.props.horizontal ? ev.target.clientWidth : ev.target.clientHeight;
+ var isEventTargetScrollable = scrollLength > clientLength;
+ var delta = this.props.horizontal ? ev.deltaX || ev.wheelDeltaX : ev.deltaY || ev.wheelDeltaY;
+- var leftoverDelta = delta;
++ var leftoverDelta = delta * 0.5;
+ if (isEventTargetScrollable) {
+ leftoverDelta = delta < 0 ? Math.min(delta + scrollOffset, 0) : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0);
+ }
+@@ -760,6 +769,26 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ }
+ }
++ static _findItemIndexWithKey(props, key, hint) {
++ var itemCount = props.getItemCount(props.data);
++ if (hint != null && hint >= 0 && hint < itemCount) {
++ var curKey = VirtualizedList._getItemKey(props, hint);
++ if (curKey === key) {
++ return hint;
++ }
++ }
++ for (var ii = 0; ii < itemCount; ii++) {
++ var _curKey = VirtualizedList._getItemKey(props, ii);
++ if (_curKey === key) {
++ return ii;
++ }
++ }
++ return null;
++ }
++ static _getItemKey(props, index) {
++ var item = props.getItem(props.data, index);
++ return VirtualizedList._keyExtractor(item, index, props);
++ }
+ static _createRenderMask(props, cellsAroundViewport, additionalRegions) {
+ var itemCount = props.getItemCount(props.data);
+ invariant(cellsAroundViewport.first >= 0 && cellsAroundViewport.last >= cellsAroundViewport.first - 1 && cellsAroundViewport.last < itemCount, "Invalid cells around viewport \"[" + cellsAroundViewport.first + ", " + cellsAroundViewport.last + "]\" was passed to VirtualizedList._createRenderMask");
+@@ -808,7 +837,7 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ }
+ }
+- _adjustCellsAroundViewport(props, cellsAroundViewport) {
++ _adjustCellsAroundViewport(props, cellsAroundViewport, pendingScrollUpdateCount) {
+ var data = props.data,
+ getItemCount = props.getItemCount;
+ var onEndReachedThreshold = onEndReachedThresholdOrDefault(props.onEndReachedThreshold);
+@@ -831,17 +860,9 @@ class VirtualizedList extends StateSafePureComponent {
+ last: Math.min(cellsAroundViewport.last + renderAhead, getItemCount(data) - 1)
+ };
+ } else {
+- // If we have a non-zero initialScrollIndex and run this before we've scrolled,
+- // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex.
+- // So let's wait until we've scrolled the view to the right place. And until then,
+- // we will trust the initialScrollIndex suggestion.
+-
+- // Thus, we want to recalculate the windowed render limits if any of the following hold:
+- // - initialScrollIndex is undefined or is 0
+- // - initialScrollIndex > 0 AND scrolling is complete
+- // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case
+- // where the list is shorter than the visible area)
+- if (props.initialScrollIndex && !this._scrollMetrics.offset && Math.abs(distanceFromEnd) >= Number.EPSILON) {
++ // If we have a pending scroll update, we should not adjust the render window as it
++ // might override the correct window.
++ if (pendingScrollUpdateCount > 0) {
+ return cellsAroundViewport.last >= getItemCount(data) ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) : cellsAroundViewport;
+ }
+ newCellsAroundViewport = computeWindowedRenderLimits(props, maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), windowSizeOrDefault(props.windowSize), cellsAroundViewport, this.__getFrameMetricsApprox, this._scrollMetrics);
+@@ -914,16 +935,36 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ }
+ static getDerivedStateFromProps(newProps, prevState) {
++ var _newProps$maintainVis, _newProps$maintainVis2;
+ // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make
+ // sure we're rendering a reasonable range here.
+ var itemCount = newProps.getItemCount(newProps.data);
+ if (itemCount === prevState.renderMask.numCells()) {
+ return prevState;
+ }
+- var constrainedCells = VirtualizedList._constrainToItemCount(prevState.cellsAroundViewport, newProps);
++ var maintainVisibleContentPositionAdjustment = null;
++ var prevFirstVisibleItemKey = prevState.firstVisibleItemKey;
++ var minIndexForVisible = (_newProps$maintainVis = (_newProps$maintainVis2 = newProps.maintainVisibleContentPosition) == null ? void 0 : _newProps$maintainVis2.minIndexForVisible) !== null && _newProps$maintainVis !== void 0 ? _newProps$maintainVis : 0;
++ var newFirstVisibleItemKey = newProps.getItemCount(newProps.data) > minIndexForVisible ? VirtualizedList._getItemKey(newProps, minIndexForVisible) : null;
++ if (newProps.maintainVisibleContentPosition != null && prevFirstVisibleItemKey != null && newFirstVisibleItemKey != null) {
++ if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) {
++ // Fast path if items were added at the start of the list.
++ var hint = itemCount - prevState.renderMask.numCells() + minIndexForVisible;
++ var firstVisibleItemIndex = VirtualizedList._findItemIndexWithKey(newProps, prevFirstVisibleItemKey, hint);
++ maintainVisibleContentPositionAdjustment = firstVisibleItemIndex != null ? firstVisibleItemIndex - minIndexForVisible : null;
++ } else {
++ maintainVisibleContentPositionAdjustment = null;
++ }
++ }
++ var constrainedCells = VirtualizedList._constrainToItemCount(maintainVisibleContentPositionAdjustment != null ? {
++ first: prevState.cellsAroundViewport.first + maintainVisibleContentPositionAdjustment,
++ last: prevState.cellsAroundViewport.last + maintainVisibleContentPositionAdjustment
++ } : prevState.cellsAroundViewport, newProps);
+ return {
+ cellsAroundViewport: constrainedCells,
+- renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells)
++ renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
++ firstVisibleItemKey: newFirstVisibleItemKey,
++ pendingScrollUpdateCount: maintainVisibleContentPositionAdjustment != null ? prevState.pendingScrollUpdateCount + 1 : prevState.pendingScrollUpdateCount
+ };
+ }
+ _pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, first, last, inversionStyle) {
+@@ -946,7 +987,7 @@ class VirtualizedList extends StateSafePureComponent {
+ last = Math.min(end, last);
+ var _loop = function _loop() {
+ var item = getItem(data, ii);
+- var key = _this._keyExtractor(item, ii, _this.props);
++ var key = VirtualizedList._keyExtractor(item, ii, _this.props);
+ _this._indicesToKeys.set(ii, key);
+ if (stickyIndicesFromProps.has(ii + stickyOffset)) {
+ _this.pushOrUnshift(stickyHeaderIndices, cells.length);
+@@ -981,20 +1022,23 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ static _constrainToItemCount(cells, props) {
+ var itemCount = props.getItemCount(props.data);
+- var last = Math.min(itemCount - 1, cells.last);
++ var lastPossibleCellIndex = itemCount - 1;
++
++ // Constraining `last` may significantly shrink the window. Adjust `first`
++ // to expand the window if the new `last` results in a new window smaller
++ // than the number of cells rendered per batch.
+ var maxToRenderPerBatch = maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch);
++ var maxFirst = Math.max(0, lastPossibleCellIndex - maxToRenderPerBatch);
+ return {
+- first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first),
+- last
++ first: clamp(0, cells.first, maxFirst),
++ last: Math.min(lastPossibleCellIndex, cells.last)
+ };
+ }
+ _isNestedWithSameOrientation() {
+ var nestedContext = this.context;
+ return !!(nestedContext && !!nestedContext.horizontal === horizontalOrDefault(this.props.horizontal));
+ }
+- _keyExtractor(item, index, props
+- // $FlowFixMe[missing-local-annot]
+- ) {
++ static _keyExtractor(item, index, props) {
+ if (props.keyExtractor != null) {
+ return props.keyExtractor(item, index);
+ }
+@@ -1034,7 +1078,12 @@ class VirtualizedList extends StateSafePureComponent {
+ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, {
+ cellKey: this._getCellKey() + '-header',
+ key: "$header"
+- }, /*#__PURE__*/React.createElement(View, {
++ }, /*#__PURE__*/React.createElement(View
++ // We expect that header component will be a single native view so make it
++ // not collapsable to avoid this view being flattened and make this assumption
++ // no longer true.
++ , {
++ collapsable: false,
+ onLayout: this._onLayoutHeader,
+ style: [inversionStyle, this.props.ListHeaderComponentStyle]
+ },
+@@ -1136,7 +1185,11 @@ class VirtualizedList extends StateSafePureComponent {
+ // TODO: Android support
+ invertStickyHeaders: this.props.invertStickyHeaders !== undefined ? this.props.invertStickyHeaders : this.props.inverted,
+ stickyHeaderIndices,
+- style: inversionStyle ? [inversionStyle, this.props.style] : this.props.style
++ style: inversionStyle ? [inversionStyle, this.props.style] : this.props.style,
++ maintainVisibleContentPosition: this.props.maintainVisibleContentPosition != null ? _objectSpread(_objectSpread({}, this.props.maintainVisibleContentPosition), {}, {
++ // Adjust index to account for ListHeaderComponent.
++ minIndexForVisible: this.props.maintainVisibleContentPosition.minIndexForVisible + (this.props.ListHeaderComponent ? 1 : 0)
++ }) : undefined
+ });
+ this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1;
+ var innerRet = /*#__PURE__*/React.createElement(VirtualizedListContextProvider, {
+@@ -1319,8 +1372,12 @@ class VirtualizedList extends StateSafePureComponent {
+ onStartReached = _this$props8.onStartReached,
+ onStartReachedThreshold = _this$props8.onStartReachedThreshold,
+ onEndReached = _this$props8.onEndReached,
+- onEndReachedThreshold = _this$props8.onEndReachedThreshold,
+- initialScrollIndex = _this$props8.initialScrollIndex;
++ onEndReachedThreshold = _this$props8.onEndReachedThreshold;
++ // If we have any pending scroll updates it means that the scroll metrics
++ // are out of date and we should not call any of the edge reached callbacks.
++ if (this.state.pendingScrollUpdateCount > 0) {
++ return;
++ }
+ var _this$_scrollMetrics2 = this._scrollMetrics,
+ contentLength = _this$_scrollMetrics2.contentLength,
+ visibleLength = _this$_scrollMetrics2.visibleLength,
+@@ -1360,16 +1417,10 @@ class VirtualizedList extends StateSafePureComponent {
+ // and call onStartReached only once for a given content length,
+ // and only if onEndReached is not being executed
+ else if (onStartReached != null && this.state.cellsAroundViewport.first === 0 && isWithinStartThreshold && this._scrollMetrics.contentLength !== this._sentStartForContentLength) {
+- // On initial mount when using initialScrollIndex the offset will be 0 initially
+- // and will trigger an unexpected onStartReached. To avoid this we can use
+- // timestamp to differentiate between the initial scroll metrics and when we actually
+- // received the first scroll event.
+- if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) {
+- this._sentStartForContentLength = this._scrollMetrics.contentLength;
+- onStartReached({
+- distanceFromStart
+- });
+- }
++ this._sentStartForContentLength = this._scrollMetrics.contentLength;
++ onStartReached({
++ distanceFromStart
++ });
+ }
+
+ // If the user scrolls away from the start or end and back again,
+@@ -1424,6 +1475,11 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ }
+ _updateViewableItems(props, cellsAroundViewport) {
++ // If we have any pending scroll updates it means that the scroll metrics
++ // are out of date and we should not call any of the visibility callbacks.
++ if (this.state.pendingScrollUpdateCount > 0) {
++ return;
++ }
+ this._viewabilityTuples.forEach(tuple => {
+ tuple.viewabilityHelper.onUpdate(props, this._scrollMetrics.offset, this._scrollMetrics.visibleLength, this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, cellsAroundViewport);
+ });
+diff --git a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
+index d896fb1..f303b31 100644
+--- a/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
++++ b/node_modules/react-native-web/src/vendor/react-native/VirtualizedList/index.js
+@@ -75,6 +75,10 @@ type ViewabilityHelperCallbackTuple = {
+ type State = {
+ renderMask: CellRenderMask,
+ cellsAroundViewport: {first: number, last: number},
++ // Used to track items added at the start of the list for maintainVisibleContentPosition.
++ firstVisibleItemKey: ?string,
++ // When > 0 the scroll position available in JS is considered stale and should not be used.
++ pendingScrollUpdateCount: number,
+ };
+
+ /**
+@@ -455,9 +459,24 @@ class VirtualizedList extends StateSafePureComponent {
+
+ const initialRenderRegion = VirtualizedList._initialRenderRegion(props);
+
++ const minIndexForVisible =
++ this.props.maintainVisibleContentPosition?.minIndexForVisible ?? 0;
++
+ this.state = {
+ cellsAroundViewport: initialRenderRegion,
+ renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion),
++ firstVisibleItemKey:
++ this.props.getItemCount(this.props.data) > minIndexForVisible
++ ? VirtualizedList._getItemKey(this.props, minIndexForVisible)
++ : null,
++ // When we have a non-zero initialScrollIndex, we will receive a
++ // scroll event later so this will prevent the window from updating
++ // until we get a valid offset.
++ pendingScrollUpdateCount:
++ this.props.initialScrollIndex != null &&
++ this.props.initialScrollIndex > 0
++ ? 1
++ : 0,
+ };
+
+ // REACT-NATIVE-WEB patch to preserve during future RN merges: Support inverted wheel scroller.
+@@ -470,7 +489,7 @@ class VirtualizedList extends StateSafePureComponent {
+ const delta = this.props.horizontal
+ ? ev.deltaX || ev.wheelDeltaX
+ : ev.deltaY || ev.wheelDeltaY;
+- let leftoverDelta = delta;
++ let leftoverDelta = delta * 5;
+ if (isEventTargetScrollable) {
+ leftoverDelta = delta < 0
+ ? Math.min(delta + scrollOffset, 0)
+@@ -542,6 +561,40 @@ class VirtualizedList extends StateSafePureComponent {
+ }
+ }
+
++ static _findItemIndexWithKey(
++ props: Props,
++ key: string,
++ hint: ?number,
++ ): ?number {
++ const itemCount = props.getItemCount(props.data);
++ if (hint != null && hint >= 0 && hint < itemCount) {
++ const curKey = VirtualizedList._getItemKey(props, hint);
++ if (curKey === key) {
++ return hint;
++ }
++ }
++ for (let ii = 0; ii < itemCount; ii++) {
++ const curKey = VirtualizedList._getItemKey(props, ii);
++ if (curKey === key) {
++ return ii;
++ }
++ }
++ return null;
++ }
++
++ static _getItemKey(
++ props: {
++ data: Props['data'],
++ getItem: Props['getItem'],
++ keyExtractor: Props['keyExtractor'],
++ ...
++ },
++ index: number,
++ ): string {
++ const item = props.getItem(props.data, index);
++ return VirtualizedList._keyExtractor(item, index, props);
++ }
++
+ static _createRenderMask(
+ props: Props,
+ cellsAroundViewport: {first: number, last: number},
+@@ -625,6 +678,7 @@ class VirtualizedList extends StateSafePureComponent {
+ _adjustCellsAroundViewport(
+ props: Props,
+ cellsAroundViewport: {first: number, last: number},
++ pendingScrollUpdateCount: number,
+ ): {first: number, last: number} {
+ const {data, getItemCount} = props;
+ const onEndReachedThreshold = onEndReachedThresholdOrDefault(
+@@ -656,21 +710,9 @@ class VirtualizedList extends StateSafePureComponent {
+ ),
+ };
+ } else {
+- // If we have a non-zero initialScrollIndex and run this before we've scrolled,
+- // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex.
+- // So let's wait until we've scrolled the view to the right place. And until then,
+- // we will trust the initialScrollIndex suggestion.
+-
+- // Thus, we want to recalculate the windowed render limits if any of the following hold:
+- // - initialScrollIndex is undefined or is 0
+- // - initialScrollIndex > 0 AND scrolling is complete
+- // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case
+- // where the list is shorter than the visible area)
+- if (
+- props.initialScrollIndex &&
+- !this._scrollMetrics.offset &&
+- Math.abs(distanceFromEnd) >= Number.EPSILON
+- ) {
++ // If we have a pending scroll update, we should not adjust the render window as it
++ // might override the correct window.
++ if (pendingScrollUpdateCount > 0) {
+ return cellsAroundViewport.last >= getItemCount(data)
+ ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props)
+ : cellsAroundViewport;
+@@ -779,14 +821,59 @@ class VirtualizedList extends StateSafePureComponent {
+ return prevState;
+ }
+
++ let maintainVisibleContentPositionAdjustment: ?number = null;
++ const prevFirstVisibleItemKey = prevState.firstVisibleItemKey;
++ const minIndexForVisible =
++ newProps.maintainVisibleContentPosition?.minIndexForVisible ?? 0;
++ const newFirstVisibleItemKey =
++ newProps.getItemCount(newProps.data) > minIndexForVisible
++ ? VirtualizedList._getItemKey(newProps, minIndexForVisible)
++ : null;
++ if (
++ newProps.maintainVisibleContentPosition != null &&
++ prevFirstVisibleItemKey != null &&
++ newFirstVisibleItemKey != null
++ ) {
++ if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) {
++ // Fast path if items were added at the start of the list.
++ const hint =
++ itemCount - prevState.renderMask.numCells() + minIndexForVisible;
++ const firstVisibleItemIndex = VirtualizedList._findItemIndexWithKey(
++ newProps,
++ prevFirstVisibleItemKey,
++ hint,
++ );
++ maintainVisibleContentPositionAdjustment =
++ firstVisibleItemIndex != null
++ ? firstVisibleItemIndex - minIndexForVisible
++ : null;
++ } else {
++ maintainVisibleContentPositionAdjustment = null;
++ }
++ }
++
+ const constrainedCells = VirtualizedList._constrainToItemCount(
+- prevState.cellsAroundViewport,
++ maintainVisibleContentPositionAdjustment != null
++ ? {
++ first:
++ prevState.cellsAroundViewport.first +
++ maintainVisibleContentPositionAdjustment,
++ last:
++ prevState.cellsAroundViewport.last +
++ maintainVisibleContentPositionAdjustment,
++ }
++ : prevState.cellsAroundViewport,
+ newProps,
+ );
+
+ return {
+ cellsAroundViewport: constrainedCells,
+ renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
++ firstVisibleItemKey: newFirstVisibleItemKey,
++ pendingScrollUpdateCount:
++ maintainVisibleContentPositionAdjustment != null
++ ? prevState.pendingScrollUpdateCount + 1
++ : prevState.pendingScrollUpdateCount,
+ };
+ }
+
+@@ -818,11 +905,11 @@ class VirtualizedList extends StateSafePureComponent {
+
+ for (let ii = first; ii <= last; ii++) {
+ const item = getItem(data, ii);
+- const key = this._keyExtractor(item, ii, this.props);
++ const key = VirtualizedList._keyExtractor(item, ii, this.props);
+
+ this._indicesToKeys.set(ii, key);
+ if (stickyIndicesFromProps.has(ii + stickyOffset)) {
+- this.pushOrUnshift(stickyHeaderIndices, (cells.length));
++ this.pushOrUnshift(stickyHeaderIndices, cells.length);
+ }
+
+ const shouldListenForLayout =
+@@ -861,15 +948,19 @@ class VirtualizedList extends StateSafePureComponent {
+ props: Props,
+ ): {first: number, last: number} {
+ const itemCount = props.getItemCount(props.data);
+- const last = Math.min(itemCount - 1, cells.last);
++ const lastPossibleCellIndex = itemCount - 1;
+
++ // Constraining `last` may significantly shrink the window. Adjust `first`
++ // to expand the window if the new `last` results in a new window smaller
++ // than the number of cells rendered per batch.
+ const maxToRenderPerBatch = maxToRenderPerBatchOrDefault(
+ props.maxToRenderPerBatch,
+ );
++ const maxFirst = Math.max(0, lastPossibleCellIndex - maxToRenderPerBatch);
+
+ return {
+- first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first),
+- last,
++ first: clamp(0, cells.first, maxFirst),
++ last: Math.min(lastPossibleCellIndex, cells.last),
+ };
+ }
+
+@@ -891,15 +982,14 @@ class VirtualizedList extends StateSafePureComponent {
+ _getSpacerKey = (isVertical: boolean): string =>
+ isVertical ? 'height' : 'width';
+
+- _keyExtractor(
++ static _keyExtractor(
+ item: Item,
+ index: number,
+ props: {
+ keyExtractor?: ?(item: Item, index: number) => string,
+ ...
+ },
+- // $FlowFixMe[missing-local-annot]
+- ) {
++ ): string {
+ if (props.keyExtractor != null) {
+ return props.keyExtractor(item, index);
+ }
+@@ -945,6 +1035,10 @@ class VirtualizedList extends StateSafePureComponent {
+ cellKey={this._getCellKey() + '-header'}
+ key="$header">
+ {
+ style: inversionStyle
+ ? [inversionStyle, this.props.style]
+ : this.props.style,
++ maintainVisibleContentPosition:
++ this.props.maintainVisibleContentPosition != null
++ ? {
++ ...this.props.maintainVisibleContentPosition,
++ // Adjust index to account for ListHeaderComponent.
++ minIndexForVisible:
++ this.props.maintainVisibleContentPosition.minIndexForVisible +
++ (this.props.ListHeaderComponent ? 1 : 0),
++ }
++ : undefined,
+ };
+
+ this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1;
+@@ -1255,11 +1359,10 @@ class VirtualizedList extends StateSafePureComponent {
+ _defaultRenderScrollComponent = props => {
+ const onRefresh = props.onRefresh;
+ const inversionStyle = this.props.inverted
+- ? this.props.horizontal
+- ? styles.rowReverse
+- : styles.columnReverse
+- : null;
+-
++ ? this.props.horizontal
++ ? styles.rowReverse
++ : styles.columnReverse
++ : null;
+ if (this._isNestedWithSameOrientation()) {
+ // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors
+ return ;
+@@ -1542,8 +1645,12 @@ class VirtualizedList extends StateSafePureComponent {
+ onStartReachedThreshold,
+ onEndReached,
+ onEndReachedThreshold,
+- initialScrollIndex,
+ } = this.props;
++ // If we have any pending scroll updates it means that the scroll metrics
++ // are out of date and we should not call any of the edge reached callbacks.
++ if (this.state.pendingScrollUpdateCount > 0) {
++ return;
++ }
+ const {contentLength, visibleLength, offset} = this._scrollMetrics;
+ let distanceFromStart = offset;
+ let distanceFromEnd = contentLength - visibleLength - offset;
+@@ -1595,14 +1702,8 @@ class VirtualizedList extends StateSafePureComponent {
+ isWithinStartThreshold &&
+ this._scrollMetrics.contentLength !== this._sentStartForContentLength
+ ) {
+- // On initial mount when using initialScrollIndex the offset will be 0 initially
+- // and will trigger an unexpected onStartReached. To avoid this we can use
+- // timestamp to differentiate between the initial scroll metrics and when we actually
+- // received the first scroll event.
+- if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) {
+- this._sentStartForContentLength = this._scrollMetrics.contentLength;
+- onStartReached({distanceFromStart});
+- }
++ this._sentStartForContentLength = this._scrollMetrics.contentLength;
++ onStartReached({distanceFromStart});
+ }
+
+ // If the user scrolls away from the start or end and back again,
+@@ -1729,6 +1830,11 @@ class VirtualizedList extends StateSafePureComponent {
+ visibleLength,
+ zoomScale,
+ };
++ if (this.state.pendingScrollUpdateCount > 0) {
++ this.setState(state => ({
++ pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1,
++ }));
++ }
+ this._updateViewableItems(this.props, this.state.cellsAroundViewport);
+ if (!this.props) {
+ return;
+@@ -1844,6 +1950,7 @@ class VirtualizedList extends StateSafePureComponent {
+ const cellsAroundViewport = this._adjustCellsAroundViewport(
+ props,
+ state.cellsAroundViewport,
++ state.pendingScrollUpdateCount,
+ );
+ const renderMask = VirtualizedList._createRenderMask(
+ props,
+@@ -1874,7 +1981,7 @@ class VirtualizedList extends StateSafePureComponent {
+ return {
+ index,
+ item,
+- key: this._keyExtractor(item, index, props),
++ key: VirtualizedList._keyExtractor(item, index, props),
+ isViewable,
+ };
+ };
+@@ -1935,13 +2042,12 @@ class VirtualizedList extends StateSafePureComponent {
+ inLayout?: boolean,
+ ...
+ } => {
+- const {data, getItem, getItemCount, getItemLayout} = props;
++ const {data, getItemCount, getItemLayout} = props;
+ invariant(
+ index >= 0 && index < getItemCount(data),
+ 'Tried to get frame for out of range index ' + index,
+ );
+- const item = getItem(data, index);
+- const frame = this._frames[this._keyExtractor(item, index, props)];
++ const frame = this._frames[VirtualizedList._getItemKey(props, index)];
+ if (!frame || frame.index !== index) {
+ if (getItemLayout) {
+ /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment
+@@ -1976,11 +2082,8 @@ class VirtualizedList extends StateSafePureComponent {
+ // where it is.
+ if (
+ focusedCellIndex >= itemCount ||
+- this._keyExtractor(
+- props.getItem(props.data, focusedCellIndex),
+- focusedCellIndex,
+- props,
+- ) !== this._lastFocusedCellKey
++ VirtualizedList._getItemKey(props, focusedCellIndex) !==
++ this._lastFocusedCellKey
+ ) {
+ return [];
+ }
+@@ -2021,6 +2124,11 @@ class VirtualizedList extends StateSafePureComponent {
+ props: FrameMetricProps,
+ cellsAroundViewport: {first: number, last: number},
+ ) {
++ // If we have any pending scroll updates it means that the scroll metrics
++ // are out of date and we should not call any of the visibility callbacks.
++ if (this.state.pendingScrollUpdateCount > 0) {
++ return;
++ }
+ this._viewabilityTuples.forEach(tuple => {
+ tuple.viewabilityHelper.onUpdate(
+ props,
diff --git a/src/CONST.ts b/src/CONST.ts
index 29bb0b83aaee..a840699f1c46 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -50,6 +50,9 @@ const CONST = {
// An arbitrary size, but the same minimum as in the PHP layer
MIN_SIZE: 240,
+
+ // Allowed extensions for receipts
+ ALLOWED_RECEIPT_EXTENSIONS: ['jpg', 'jpeg', 'gif', 'png', 'pdf', 'htm', 'html', 'text', 'rtf', 'doc', 'tif', 'tiff', 'msword', 'zip', 'xml', 'message'],
},
AUTO_AUTH_STATE: {
@@ -866,14 +869,19 @@ const CONST = {
RECOVERY_CODE_LENGTH: 8,
KEYBOARD_TYPE: {
- PHONE_PAD: 'phone-pad',
- NUMBER_PAD: 'number-pad',
- DECIMAL_PAD: 'decimal-pad',
VISIBLE_PASSWORD: 'visible-password',
- EMAIL_ADDRESS: 'email-address',
ASCII_CAPABLE: 'ascii-capable',
+ },
+
+ INPUT_MODE: {
+ NONE: 'none',
+ TEXT: 'text',
+ DECIMAL: 'decimal',
+ NUMERIC: 'numeric',
+ TEL: 'tel',
+ SEARCH: 'search',
+ EMAIL: 'email',
URL: 'url',
- DEFAULT: 'default',
},
YOUR_LOCATION_TEXT: 'Your Location',
@@ -1312,6 +1320,7 @@ const CONST = {
TAX_ID: /^\d{9}$/,
NON_NUMERIC: /\D/g,
+ ANY_SPACE: /\s/g,
// Extract attachment's source from the data's html string
ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g,
@@ -2694,13 +2703,13 @@ const CONST = {
BUTTON: 'button',
LINK: 'link',
MENUITEM: 'menuitem',
- TEXT: 'text',
+ TEXT: 'presentation',
RADIO: 'radio',
- IMAGEBUTTON: 'imagebutton',
+ IMAGEBUTTON: 'img button',
CHECKBOX: 'checkbox',
SWITCH: 'switch',
- ADJUSTABLE: 'adjustable',
- IMAGE: 'image',
+ ADJUSTABLE: 'slider',
+ IMAGE: 'img',
},
TRANSLATION_KEYS: {
ATTACHMENT: 'common.attachment',
diff --git a/src/Expensify.js b/src/Expensify.js
index b7e3f0f60567..1b692f86a197 100644
--- a/src/Expensify.js
+++ b/src/Expensify.js
@@ -90,6 +90,8 @@ const defaultProps = {
isCheckingPublicRoom: true,
};
+const SplashScreenHiddenContext = React.createContext({});
+
function Expensify(props) {
const appStateChangeListener = useRef(null);
const [isNavigationReady, setIsNavigationReady] = useState(false);
@@ -105,6 +107,14 @@ function Expensify(props) {
}, [props.isCheckingPublicRoom]);
const isAuthenticated = useMemo(() => Boolean(lodashGet(props.session, 'authToken', null)), [props.session]);
+
+ const contextValue = useMemo(
+ () => ({
+ isSplashHidden,
+ }),
+ [isSplashHidden],
+ );
+
const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded) && hasAttemptedToOpenPublicRoom;
const shouldHideSplash = shouldInit && !isSplashHidden;
@@ -216,10 +226,12 @@ function Expensify(props) {
{hasAttemptedToOpenPublicRoom && (
-
+
+
+
)}
{shouldHideSplash && }
@@ -251,3 +263,5 @@ export default compose(
},
}),
)(Expensify);
+
+export {SplashScreenHiddenContext};
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 11c2318672d8..8de77ff678a5 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -420,7 +420,7 @@ type OnyxValues = {
[ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.LEGAL_NAME_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.DateOfBirthForm;
[ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index bcc4685368cb..864e8934ad88 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2,14 +2,11 @@ import {ValueOf} from 'type-fest';
import CONST from './CONST';
/**
- * This is a file containing constants for all of the routes we want to be able to go to
+ * This is a file containing constants for all the routes we want to be able to go to
*/
/**
- * This is a file containing constants for all of the routes we want to be able to go to
- * Returns the URL with an encoded URI component for the backTo param which can be added to the end of URLs
- * @param backTo
- * @returns
+ * Builds a URL with an encoded URI component for the `backTo` param which can be added to the end of URLs
*/
function getUrlWithBackToParam(url: string, backTo?: string): string {
const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : '';
@@ -111,7 +108,10 @@ export default {
route: 'settings/profile/personal-details/address/country',
getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo),
},
- SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods',
+ SETTINGS_CONTACT_METHODS: {
+ route: 'settings/profile/contact-methods',
+ getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods', backTo),
+ },
SETTINGS_CONTACT_METHOD_DETAILS: {
route: 'settings/profile/contact-methods/:contactMethod/details',
getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`,
diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js
index 5899e68bedb3..43fd5e6a1b98 100644
--- a/src/components/AmountTextInput.js
+++ b/src/components/AmountTextInput.js
@@ -50,11 +50,11 @@ function AmountTextInput(props) {
ref={props.forwardedRef}
value={props.formattedAmount}
placeholder={props.placeholder}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ inputMode={CONST.INPUT_MODE.NUMERIC}
blurOnSubmit={false}
selection={props.selection}
onSelectionChange={props.onSelectionChange}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
onKeyPress={props.onKeyPress}
/>
);
diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js
index fd6c3d358a33..1e2d18bc4691 100644
--- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js
+++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.js
@@ -58,7 +58,7 @@ function BaseAnchorForAttachmentsOnly(props) {
onPressOut={props.onPressOut}
onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))}
accessibilityLabel={fileName}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
(linkRef = el)}
style={StyleSheet.flatten([style, defaultTextStyle])}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.LINK}
+ role={CONST.ACCESSIBILITY_ROLE.LINK}
hrefAttrs={{
rel,
target: isEmail || !linkProps.href ? '_self' : target,
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
index 9bef889e61a1..f11bbcc9b187 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
@@ -82,5 +82,6 @@ function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward
CarouselButtons.propTypes = propTypes;
CarouselButtons.defaultProps = defaultProps;
+CarouselButtons.displayName = 'CarouselButtons';
export default CarouselButtons;
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
index 2d271aa6d4c4..38f70057be61 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
@@ -61,8 +61,7 @@ function CarouselItem({item, isFocused, onPress}) {
onPress={() => setIsHidden(!isHidden)}
>
{isHidden ? translate('moderation.revealMessage') : translate('moderation.hideMessage')}
@@ -81,7 +80,7 @@ function CarouselItem({item, isFocused, onPress}) {
{children}
@@ -116,5 +115,6 @@ function CarouselItem({item, isFocused, onPress}) {
CarouselItem.propTypes = propTypes;
CarouselItem.defaultProps = defaultProps;
+CarouselItem.displayName = 'CarouselItem';
export default CarouselItem;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
index 2ded34829a08..7a083d71b591 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js
@@ -181,7 +181,9 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI
>
);
}
+
AttachmentCarouselPage.propTypes = pagePropTypes;
AttachmentCarouselPage.defaultProps = defaultProps;
+AttachmentCarouselPage.displayName = 'AttachmentCarouselPage';
export default AttachmentCarouselPage;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
index 5bf8b79dae77..0839462d4f23 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js
@@ -574,5 +574,6 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc
}
ImageTransformer.propTypes = imageTransformerPropTypes;
ImageTransformer.defaultProps = imageTransformerDefaultProps;
+ImageTransformer.displayName = 'ImageTransformer';
export default ImageTransformer;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
index 10f2ae94340a..3a27d80c5509 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageWrapper.js
@@ -1,4 +1,3 @@
-/* eslint-disable es/no-optional-chaining */
import PropTypes from 'prop-types';
import React from 'react';
import {StyleSheet} from 'react-native';
@@ -19,6 +18,8 @@ function ImageWrapper({children}) {
);
}
+
ImageWrapper.propTypes = imageWrapperPropTypes;
+ImageWrapper.displayName = 'ImageWrapper';
export default ImageWrapper;
diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.js b/src/components/Attachments/AttachmentCarousel/Pager/index.js
index e4659caf24f0..59fd7596f0ad 100644
--- a/src/components/Attachments/AttachmentCarousel/Pager/index.js
+++ b/src/components/Attachments/AttachmentCarousel/Pager/index.js
@@ -1,4 +1,3 @@
-/* eslint-disable es/no-optional-chaining */
import PropTypes from 'prop-types';
import React, {useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
@@ -168,8 +167,10 @@ function AttachmentCarouselPager({
);
}
+
AttachmentCarouselPager.propTypes = pagerPropTypes;
AttachmentCarouselPager.defaultProps = pagerDefaultProps;
+AttachmentCarouselPager.displayName = 'AttachmentCarouselPager';
const AttachmentCarouselPagerWithRef = React.forwardRef((props, ref) => (
);
}
+
AttachmentCarousel.propTypes = propTypes;
AttachmentCarousel.defaultProps = defaultProps;
+AttachmentCarousel.displayName = 'AttachmentCarousel';
export default compose(
withOnyx({
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index 7088a5c7057c..b86c9b1c786e 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -169,6 +169,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose,
}
AttachmentCarousel.propTypes = propTypes;
AttachmentCarousel.defaultProps = defaultProps;
+AttachmentCarousel.displayName = 'AttachmentCarousel';
export default compose(
withOnyx({
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
index 23049915a8d9..d88eb81506ca 100755
--- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.js
@@ -27,7 +27,7 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, o
onPress={onPress}
disabled={loadComplete}
style={[styles.flex1, styles.flexRow, styles.alignSelfStretch]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={file.name || translate('attachmentView.unknownFilename')}
>
{children}
@@ -39,5 +39,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, loadComplete, o
AttachmentViewImage.propTypes = propTypes;
AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
+AttachmentViewImage.displayName = 'AttachmentViewImage';
export default compose(memo, withLocalize)(AttachmentViewImage);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js
index faf2f21c133d..8b29d8d5ba3d 100755
--- a/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewImage/index.native.js
@@ -35,7 +35,7 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUs
onPress={onPress}
disabled={loadComplete}
style={[styles.flex1, styles.flexRow, styles.alignSelfStretch]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={file.name || translate('attachmentView.unknownFilename')}
>
{children}
@@ -47,5 +47,6 @@ function AttachmentViewImage({source, file, isAuthTokenRequired, isFocused, isUs
AttachmentViewImage.propTypes = propTypes;
AttachmentViewImage.defaultProps = attachmentViewImageDefaultProps;
+AttachmentViewImage.displayName = 'AttachmentViewImage';
export default compose(memo, withLocalize)(AttachmentViewImage);
diff --git a/src/components/Avatar.js b/src/components/Avatar.js
index 546387031643..5e414486cc70 100644
--- a/src/components/Avatar.js
+++ b/src/components/Avatar.js
@@ -88,10 +88,7 @@ function Avatar(props) {
const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(props.name) : props.fallbackIcon || Expensicons.FallbackAvatar;
return (
-
+
{_.isFunction(props.source) || (imageError && _.isFunction(fallbackAvatar)) ? (
);
}
+
Avatar.defaultProps = defaultProps;
Avatar.propTypes = propTypes;
+Avatar.displayName = 'Avatar';
+
export default Avatar;
diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js
index c8bd7f6f7bc8..9b2b92aa9cee 100644
--- a/src/components/AvatarCropModal/AvatarCropModal.js
+++ b/src/components/AvatarCropModal/AvatarCropModal.js
@@ -409,7 +409,7 @@ function AvatarCropModal(props) {
onLayout={initializeSliderContainer}
onPressIn={(e) => runOnUI(sliderOnPress)(e.nativeEvent.locationX)}
accessibilityLabel="slider"
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.ADJUSTABLE}
+ role={CONST.ACCESSIBILITY_ROLE.ADJUSTABLE}
>
showActorDetails(props.report, props.shouldEnableDetailPageNavigation)}
accessibilityLabel={title}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{shouldShowSubscriptAvatar ? (
ReportUtils.navigateToDetailsPage(props.report)}
style={[styles.flexRow, styles.alignItemsCenter, styles.flex1]}
accessibilityLabel={title}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{headerView}
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js
index 871d967e23dc..87bd382e806b 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.js
@@ -264,7 +264,7 @@ class AvatarWithImagePicker extends React.Component {
this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')}
disabled={this.state.isAvatarCropModalOpen}
ref={this.anchorRef}
diff --git a/src/components/Badge.js b/src/components/Badge.js
index 0a6b72201655..49b330ae37b2 100644
--- a/src/components/Badge.js
+++ b/src/components/Badge.js
@@ -59,8 +59,9 @@ function Badge(props) {
diff --git a/src/components/BigNumberPad.js b/src/components/BigNumberPad.js
index ecbde3a5afe6..451b8fc3e0bf 100644
--- a/src/components/BigNumberPad.js
+++ b/src/components/BigNumberPad.js
@@ -16,14 +16,14 @@ const propTypes = {
longPressHandlerStateChanged: PropTypes.func,
/** Used to locate this view from native classes. */
- nativeID: PropTypes.string,
+ id: PropTypes.string,
...withLocalizePropTypes,
};
const defaultProps = {
longPressHandlerStateChanged: () => {},
- nativeID: 'numPadView',
+ id: 'numPadView',
};
const padNumbers = [
@@ -59,7 +59,7 @@ function BigNumberPad(props) {
return (
{_.map(padNumbers, (row, rowIndex) => (
{renderContent()}
diff --git a/src/components/Checkbox.js b/src/components/Checkbox.js
index 51b9212133a4..5734ad2fed26 100644
--- a/src/components/Checkbox.js
+++ b/src/components/Checkbox.js
@@ -93,8 +93,8 @@ function Checkbox(props) {
ref={props.forwardedRef}
style={[StyleUtils.getCheckboxPressableStyle(props.containerBorderRadius + 2), props.style]} // to align outline on focus, border-radius of pressable should be 2px more than Checkbox
onKeyDown={handleSpaceKey}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{checked: props.isChecked}}
+ role={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
+ ariaChecked={props.isChecked}
accessibilityLabel={props.accessibilityLabel}
pressDimmingValue={1}
>
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
index 4bffadecb733..86dba1d2a932 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -108,7 +108,7 @@ function CheckboxWithLabel(props) {
accessibilityLabel={props.accessibilityLabel || props.label}
/>
{this.props.title}
diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js
index aca2a9d06f7a..08d3e45e671f 100644
--- a/src/components/Composer/index.android.js
+++ b/src/components/Composer/index.android.js
@@ -4,6 +4,7 @@ import {StyleSheet} from 'react-native';
import _ from 'underscore';
import RNTextInput from '@components/RNTextInput';
import * as ComposerUtils from '@libs/ComposerUtils';
+import styles from '@styles/styles';
import themeColors from '@styles/themes/default';
const propTypes = {
@@ -103,7 +104,7 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC
return maxLines;
}, [isComposerFullSize, maxLines]);
- const styles = useMemo(() => {
+ const composerStyles = useMemo(() => {
StyleSheet.flatten(props.style);
}, [props.style]);
@@ -114,16 +115,15 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC
ref={setTextInputRef}
onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e)}
rejectResponderTermination={false}
- textAlignVertical="center"
// Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line,
// when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670)
// @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines)
// TODO: remove this comment once upstream PR is merged and available in a future release
maxNumberOfLines={maxNumberOfLines}
- style={styles}
+ style={[composerStyles, styles.verticalAlignMiddle]}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
- editable={!isDisabled}
+ readOnly={isDisabled}
/>
);
}
diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js
index e5dab3756594..a1b8c1a4ffe6 100644
--- a/src/components/Composer/index.ios.js
+++ b/src/components/Composer/index.ios.js
@@ -4,6 +4,7 @@ import {StyleSheet} from 'react-native';
import _ from 'underscore';
import RNTextInput from '@components/RNTextInput';
import * as ComposerUtils from '@libs/ComposerUtils';
+import styles from '@styles/styles';
import themeColors from '@styles/themes/default';
const propTypes = {
@@ -103,7 +104,7 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC
return maxLines;
}, [isComposerFullSize, maxLines]);
- const styles = useMemo(() => {
+ const composerStyles = useMemo(() => {
StyleSheet.flatten(props.style);
}, [props.style]);
@@ -118,13 +119,12 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC
ref={setTextInputRef}
onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e)}
rejectResponderTermination={false}
- textAlignVertical="center"
smartInsertDelete={false}
maxNumberOfLines={maxNumberOfLines}
- style={styles}
+ style={[composerStyles, styles.verticalAlignMiddle]}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...propsToPass}
- editable={!isDisabled}
+ readOnly={isDisabled}
/>
);
}
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js
index f8045eb87f9f..d5d905f7d639 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.js
@@ -467,7 +467,7 @@ function Composer({
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
onSelectionChange={addCursorPositionToSelectionChange}
- numberOfLines={numberOfLines}
+ rows={numberOfLines}
disabled={isDisabled}
onKeyPress={handleKeyPress}
onFocus={(e) => {
@@ -490,6 +490,7 @@ function Composer({
Composer.propTypes = propTypes;
Composer.defaultProps = defaultProps;
+Composer.displayName = 'Composer';
const ComposerWithRef = React.forwardRef((props, ref) => (
{`${props.translate('common.youAppearToBeOffline')} ${props.translate('common.thisFeatureRequiresInternet')}`}
diff --git a/src/components/CountrySelector.js b/src/components/CountrySelector.js
index 93a90dcf6be9..c2426c5b7b0b 100644
--- a/src/components/CountrySelector.js
+++ b/src/components/CountrySelector.js
@@ -53,7 +53,7 @@ function CountrySelector({errorText, value: countryCode, onInputChange, forwarde
descriptionTextStyle={countryTitleDescStyle}
description={translate('common.country')}
onPress={() => {
- const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, '');
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode, activeRoute));
}}
/>
diff --git a/src/components/CurrencySymbolButton.js b/src/components/CurrencySymbolButton.js
index 695cb2bc10c8..ca7816a9f117 100644
--- a/src/components/CurrencySymbolButton.js
+++ b/src/components/CurrencySymbolButton.js
@@ -22,7 +22,7 @@ function CurrencySymbolButton({onCurrencyButtonPress, currencySymbol}) {
{currencySymbol}
diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js
index 26424f2d8283..02d11806b8af 100644
--- a/src/components/DatePicker/datepickerPropTypes.js
+++ b/src/components/DatePicker/datepickerPropTypes.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import {defaultProps as defaultFieldPropTypes, propTypes as fieldPropTypes} from '@components/TextInput/baseTextInputPropTypes';
+import {defaultProps as defaultFieldPropTypes, propTypes as fieldPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes';
import CONST from '@src/CONST';
const propTypes = {
diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js
index d92869162d49..93a9f521ef2c 100644
--- a/src/components/DatePicker/index.android.js
+++ b/src/components/DatePicker/index.android.js
@@ -1,5 +1,5 @@
import RNDatePicker from '@react-native-community/datetimepicker';
-import {format} from 'date-fns';
+import {format, parseISO} from 'date-fns';
import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react';
import {Keyboard} from 'react-native';
import TextInput from '@components/TextInput';
@@ -39,14 +39,14 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain
);
const date = value || defaultValue;
- const dateAsText = date ? format(new Date(date), CONST.DATE.FNS_FORMAT_STRING) : '';
+ const dateAsText = date ? format(parseISO(date), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
{isPickerVisible && (
@@ -85,14 +85,14 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca
forceActiveLabel
label={label}
accessibilityLabel={label}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
value={dateAsText}
placeholder={placeholder}
errorText={errorText}
containerStyles={containerStyles}
textInputContainerStyles={[isPickerVisible && styles.borderColorFocus]}
onPress={showPicker}
- editable={false}
+ readOnly
disabled={disabled}
onBlur={onBlur}
ref={inputRef}
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index 3bed9ca55321..16d2eb2668a3 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,4 +1,4 @@
-import {format, isValid} from 'date-fns';
+import {format, isValid, parseISO} from 'date-fns';
import React, {useEffect, useRef} from 'react';
import _ from 'underscore';
import TextInput from '@components/TextInput';
@@ -29,7 +29,7 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
return;
}
- const date = new Date(text);
+ const date = parseISO(text);
if (isValid(date)) {
onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING));
}
@@ -61,7 +61,7 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
onFocus={showDatepicker}
label={label}
accessibilityLabel={label}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
onInputChange={setDate}
value={value}
placeholder={placeholder}
diff --git a/src/components/EmojiPicker/CategoryShortcutButton.js b/src/components/EmojiPicker/CategoryShortcutButton.js
index 132da7c540c2..3b12afce0d08 100644
--- a/src/components/EmojiPicker/CategoryShortcutButton.js
+++ b/src/components/EmojiPicker/CategoryShortcutButton.js
@@ -38,7 +38,7 @@ function CategoryShortcutButton(props) {
onHoverOut={() => setIsHighlighted(false)}
style={({pressed}) => [StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)), styles.categoryShortcutButton, isHighlighted && styles.emojiItemHighlighted]}
accessibilityLabel={`emojiPicker.headers.${props.code}`}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{
outerStyle={StyleUtils.getOuterModalStyle(windowHeight, props.viewportOffsetTop)}
innerContainerStyle={styles.popoverInnerContainer}
avoidKeyboard
+ shouldUseTargetLocation
>
{({hovered, pressed}) => (
diff --git a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
index 8a5a66444fda..63a6c33a437f 100644
--- a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
+++ b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
@@ -38,6 +38,7 @@ function EmojiPickerButtonDropdown(props) {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
shiftVertical: 4,
+ shouldUseTargetLocation: true,
});
};
@@ -48,9 +49,9 @@ function EmojiPickerButtonDropdown(props) {
style={styles.emojiPickerButtonDropdown}
disabled={props.isDisabled}
onPress={onPress}
- nativeID="emojiDropdownButton"
+ id="emojiDropdownButton"
accessibilityLabel="statusEmoji"
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{({hovered, pressed}) => (
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 7dc53e958849..0ee12579733d 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -471,15 +471,18 @@ function EmojiPickerMenu(props) {
const overflowLimit = Math.floor(height / CONST.EMOJI_PICKER_ITEM_HEIGHT) * 8;
return (
{translate('common.noResultsFound')}}
+ ListEmptyComponent={() => {translate('common.noResultsFound')}}
/>
0}
/>
diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
index 90f7f966172f..451e2e939a09 100644
--- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
@@ -100,7 +100,7 @@ class EmojiPickerMenuItem extends PureComponent {
styles.emojiItem,
]}
accessibilityLabel={this.props.emoji}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{this.props.emoji}
diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.js
index 099adf620af7..6ebaa3391992 100644
--- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.js
+++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.native.js
@@ -77,7 +77,7 @@ class EmojiPickerMenuItem extends PureComponent {
styles.emojiItem,
]}
accessibilityLabel={this.props.emoji}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{this.props.emoji}
diff --git a/src/components/EmojiPicker/EmojiSkinToneList.js b/src/components/EmojiPicker/EmojiSkinToneList.js
index edb8bf49e77f..29c39c335b14 100644
--- a/src/components/EmojiPicker/EmojiSkinToneList.js
+++ b/src/components/EmojiPicker/EmojiSkinToneList.js
@@ -48,7 +48,7 @@ function EmojiSkinToneList(props) {
onPress={toggleIsSkinToneListVisible}
style={[styles.flexRow, styles.alignSelfCenter, styles.justifyContentStart, styles.alignItemsCenter]}
accessibilityLabel={props.translate('emojiPicker.skinTonePickerLabel')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{currentSkinTone.code}
diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js
new file mode 100644
index 000000000000..69c6b6767dae
--- /dev/null
+++ b/src/components/FlatList/MVCPFlatList.js
@@ -0,0 +1,205 @@
+/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */
+import PropTypes from 'prop-types';
+import React from 'react';
+import {FlatList} from 'react-native';
+
+function mergeRefs(...args) {
+ return function forwardRef(node) {
+ args.forEach((ref) => {
+ if (ref == null) {
+ return;
+ }
+ if (typeof ref === 'function') {
+ ref(node);
+ return;
+ }
+ if (typeof ref === 'object') {
+ // eslint-disable-next-line no-param-reassign
+ ref.current = node;
+ return;
+ }
+ console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`);
+ });
+ };
+}
+
+function useMergeRefs(...args) {
+ return React.useMemo(
+ () => mergeRefs(...args),
+ // eslint-disable-next-line
+ [...args],
+ );
+}
+
+const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, inverted, onScroll, ...props}, forwardedRef) => {
+ const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {};
+ const scrollRef = React.useRef(null);
+ const prevFirstVisibleOffsetRef = React.useRef(null);
+ const firstVisibleViewRef = React.useRef(null);
+ const mutationObserverRef = React.useRef(null);
+ const lastScrollOffsetRef = React.useRef(0);
+
+ const getScrollOffset = React.useCallback(() => {
+ if (scrollRef.current == null) {
+ return 0;
+ }
+ return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop;
+ }, [horizontal]);
+
+ const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []);
+
+ const scrollToOffset = React.useCallback(
+ (offset, animated) => {
+ const behavior = animated ? 'smooth' : 'instant';
+ scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior});
+ },
+ [horizontal],
+ );
+
+ const prepareForMaintainVisibleContentPosition = React.useCallback(() => {
+ if (mvcpMinIndexForVisible == null) {
+ return;
+ }
+
+ const contentView = getContentView();
+ if (contentView == null) {
+ return;
+ }
+
+ const scrollOffset = getScrollOffset();
+
+ const contentViewLength = contentView.childNodes.length;
+ for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) {
+ const subview = contentView.childNodes[inverted ? contentViewLength - i - 1 : i];
+ const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop;
+ if (subviewOffset > scrollOffset || i === contentViewLength - 1) {
+ prevFirstVisibleOffsetRef.current = subviewOffset;
+ firstVisibleViewRef.current = subview;
+ break;
+ }
+ }
+ }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal, inverted]);
+
+ const adjustForMaintainVisibleContentPosition = React.useCallback(() => {
+ if (mvcpMinIndexForVisible == null) {
+ return;
+ }
+
+ const firstVisibleView = firstVisibleViewRef.current;
+ const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current;
+ if (firstVisibleView == null || prevFirstVisibleOffset == null) {
+ return;
+ }
+
+ const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop;
+ const delta = firstVisibleViewOffset - prevFirstVisibleOffset;
+ if (Math.abs(delta) > 0.5) {
+ const scrollOffset = getScrollOffset();
+ prevFirstVisibleOffsetRef.current = firstVisibleViewOffset;
+ scrollToOffset(scrollOffset + delta, false);
+ if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) {
+ scrollToOffset(0, true);
+ }
+ }
+ }, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]);
+
+ const setupMutationObserver = React.useCallback(() => {
+ const contentView = getContentView();
+ if (contentView == null) {
+ return;
+ }
+
+ mutationObserverRef.current?.disconnect();
+
+ const mutationObserver = new MutationObserver(() => {
+ // Chrome adjusts scroll position when elements are added at the top of the
+ // view. We want to have the same behavior as react-native / Safari so we
+ // reset the scroll position to the last value we got from an event.
+ const lastScrollOffset = lastScrollOffsetRef.current;
+ const scrollOffset = getScrollOffset();
+ if (lastScrollOffset !== scrollOffset) {
+ scrollToOffset(lastScrollOffset, false);
+ }
+
+ // This needs to execute after scroll events are dispatched, but
+ // in the same tick to avoid flickering. rAF provides the right timing.
+ requestAnimationFrame(() => {
+ adjustForMaintainVisibleContentPosition();
+ });
+ });
+ mutationObserver.observe(contentView, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+
+ mutationObserverRef.current = mutationObserver;
+ }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]);
+
+ React.useEffect(() => {
+ prepareForMaintainVisibleContentPosition();
+ setupMutationObserver();
+ }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]);
+
+ const setMergedRef = useMergeRefs(scrollRef, forwardedRef);
+
+ const onRef = React.useCallback(
+ (newRef) => {
+ // Make sure to only call refs and re-attach listeners if the node changed.
+ if (newRef == null || newRef === scrollRef.current) {
+ return;
+ }
+
+ setMergedRef(newRef);
+ prepareForMaintainVisibleContentPosition();
+ setupMutationObserver();
+ },
+ [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver],
+ );
+
+ React.useEffect(() => {
+ const mutationObserver = mutationObserverRef.current;
+ return () => {
+ mutationObserver?.disconnect();
+ };
+ }, []);
+
+ const onScrollInternal = React.useCallback(
+ (ev) => {
+ lastScrollOffsetRef.current = getScrollOffset();
+
+ prepareForMaintainVisibleContentPosition();
+
+ onScroll?.(ev);
+ },
+ [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll],
+ );
+
+ return (
+
+ );
+});
+
+MVCPFlatList.displayName = 'MVCPFlatList';
+MVCPFlatList.propTypes = {
+ maintainVisibleContentPosition: PropTypes.shape({
+ minIndexForVisible: PropTypes.number.isRequired,
+ autoscrollToTopThreshold: PropTypes.number,
+ }),
+ horizontal: PropTypes.bool,
+};
+
+MVCPFlatList.defaultProps = {
+ maintainVisibleContentPosition: null,
+ horizontal: false,
+};
+
+export default MVCPFlatList;
diff --git a/src/components/FlatList/index.web.js b/src/components/FlatList/index.web.js
new file mode 100644
index 000000000000..7299776db9bc
--- /dev/null
+++ b/src/components/FlatList/index.web.js
@@ -0,0 +1,3 @@
+import MVCPFlatList from './MVCPFlatList';
+
+export default MVCPFlatList;
diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js
index 22f88cc53f59..d8a5a0256e62 100644
--- a/src/components/FloatingActionButton.js
+++ b/src/components/FloatingActionButton.js
@@ -90,7 +90,7 @@ class FloatingActionButton extends PureComponent {
}
}}
accessibilityLabel={this.props.accessibilityLabel}
- accessibilityRole={this.props.accessibilityRole}
+ role={this.props.role}
pressDimmingValue={1}
onPress={(e) => {
// Drop focus to avoid blue focus ring.
diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js
index 55abcc1fc923..f82199d0f587 100644
--- a/src/components/Form/FormWrapper.js
+++ b/src/components/Form/FormWrapper.js
@@ -56,6 +56,9 @@ const propTypes = {
/** Container styles */
style: stylePropTypes,
+ /** Submit button styles */
+ submitButtonStyles: stylePropTypes,
+
/** Custom content to display in the footer after submit button */
footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
@@ -74,10 +77,25 @@ const defaultProps = {
scrollContextEnabled: false,
footerContent: null,
style: [],
+ submitButtonStyles: [],
};
function FormWrapper(props) {
- const {onSubmit, children, formState, errors, inputRefs, submitButtonText, footerContent, isSubmitButtonVisible, style, enabledWhenOffline, isSubmitActionDangerous, formID} = props;
+ const {
+ onSubmit,
+ children,
+ formState,
+ errors,
+ inputRefs,
+ submitButtonText,
+ footerContent,
+ isSubmitButtonVisible,
+ style,
+ submitButtonStyles,
+ enabledWhenOffline,
+ isSubmitActionDangerous,
+ formID,
+ } = props;
const formRef = useRef(null);
const formContentRef = useRef(null);
const errorMessage = useMemo(() => {
@@ -129,7 +147,7 @@ function FormWrapper(props) {
focusInput.focus();
}
}}
- containerStyles={[styles.mh0, styles.mt5, styles.flex1]}
+ containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...submitButtonStyles]}
enabledWhenOffline={enabledWhenOffline}
isSubmitActionDangerous={isSubmitActionDangerous}
disablePressOnEnter
@@ -151,6 +169,7 @@ function FormWrapper(props) {
isSubmitButtonVisible,
onSubmit,
style,
+ submitButtonStyles,
submitButtonText,
],
);
diff --git a/src/components/FormElement.js b/src/components/FormElement.js
index cc9423a6147f..d929ddb5f2e4 100644
--- a/src/components/FormElement.js
+++ b/src/components/FormElement.js
@@ -4,7 +4,7 @@ import * as ComponentUtils from '@libs/ComponentUtils';
const FormElement = forwardRef((props, ref) => (
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
index f6d37f661252..8461f714373b 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
@@ -76,7 +76,7 @@ function ImageRenderer(props) {
ReportUtils.isArchivedRoom(report),
)
}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
>
{
onPressIn={props.onPressIn}
onPressOut={props.onPressOut}
onLongPress={(event) => showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
accessibilityLabel={props.translate('accessibilityHints.prestyledText')}
>
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js
index 67e8790560dc..6a8f630d1e78 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.js
@@ -76,7 +76,7 @@ function HeaderWithBackButton({
onBackButtonPress();
}}
style={[styles.touchableButtonImage]}
- accessibilityRole="button"
+ role="button"
accessibilityLabel={translate('common.back')}
>
Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID))))}
style={[styles.touchableButtonImage]}
- accessibilityRole="button"
+ role="button"
accessibilityLabel={translate('getAssistancePage.questionMarkButtonTooltip')}
>
void;
+
+type ImageWithSizeCalculationProps = {
/** Url for image to display */
- url: PropTypes.string.isRequired,
+ url: string;
/** Any additional styles to apply */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.any,
+ style?: StyleProp;
/** Callback fired when the image has been measured. */
- onMeasure: PropTypes.func,
+ onMeasure: OnMeasure;
/** Whether the image requires an authToken */
- isAuthTokenRequired: PropTypes.bool,
-};
-
-const defaultProps = {
- style: {},
- onMeasure: () => {},
- isAuthTokenRequired: false,
+ isAuthTokenRequired: boolean;
};
/**
@@ -33,23 +29,19 @@ const defaultProps = {
* Image size must be provided by parent via width and height props. Useful for
* performing some calculation on a network image after fetching dimensions so
* it can be appropriately resized.
- *
- * @param {Object} props
- * @returns {React.Component}
- *
*/
-function ImageWithSizeCalculation(props) {
- const isLoadedRef = useRef(null);
+function ImageWithSizeCalculation({url, style, onMeasure, isAuthTokenRequired}: ImageWithSizeCalculationProps) {
+ const isLoadedRef = useRef(null);
const [isImageCached, setIsImageCached] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const onError = () => {
- Log.hmmm('Unable to fetch image to calculate size', {url: props.url});
+ Log.hmmm('Unable to fetch image to calculate size', {url});
};
- const imageLoadedSuccessfully = (event) => {
+ const imageLoadedSuccessfully = (event: OnLoadEvent) => {
isLoadedRef.current = true;
- props.onMeasure({
+ onMeasure({
width: event.nativeEvent.width,
height: event.nativeEvent.height,
});
@@ -57,10 +49,10 @@ function ImageWithSizeCalculation(props) {
/** Delay the loader to detect whether the image is being loaded from the cache or the internet. */
useEffect(() => {
- if (isLoadedRef.current || !isLoading) {
+ if (isLoadedRef.current ?? !isLoading) {
return;
}
- const timeout = _.delay(() => {
+ const timeout = delay(() => {
if (!isLoading || isLoadedRef.current) {
return;
}
@@ -70,14 +62,14 @@ function ImageWithSizeCalculation(props) {
}, [isLoading]);
return (
-
+ {
- if (isLoadedRef.current || isLoading) {
+ if (isLoadedRef.current ?? isLoading) {
return;
}
setIsLoading(true);
@@ -94,7 +86,5 @@ function ImageWithSizeCalculation(props) {
);
}
-ImageWithSizeCalculation.propTypes = propTypes;
-ImageWithSizeCalculation.defaultProps = defaultProps;
ImageWithSizeCalculation.displayName = 'ImageWithSizeCalculation';
export default React.memo(ImageWithSizeCalculation);
diff --git a/src/components/InlineErrorText.js b/src/components/InlineErrorText.js
deleted file mode 100644
index 80438eea8b5f..000000000000
--- a/src/components/InlineErrorText.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import _ from 'underscore';
-import styles from '@styles/styles';
-import Text from './Text';
-
-const propTypes = {
- /** Text to display */
- children: PropTypes.string.isRequired,
-
- /** Styling for inline error text */
- // eslint-disable-next-line react/forbid-prop-types
- styles: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- styles: [],
-};
-
-function InlineErrorText(props) {
- if (_.isEmpty(props.children)) {
- return null;
- }
-
- return {props.children};
-}
-
-InlineErrorText.propTypes = propTypes;
-InlineErrorText.defaultProps = defaultProps;
-InlineErrorText.displayName = 'InlineErrorText';
-export default InlineErrorText;
diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js
index 14b781759904..a17e676c3e30 100644
--- a/src/components/InvertedFlatList/index.js
+++ b/src/components/InvertedFlatList/index.js
@@ -1,8 +1,7 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useEffect, useRef} from 'react';
-import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native';
+import {DeviceEventEmitter, FlatList} from 'react-native';
import _ from 'underscore';
-import styles from '@styles/styles';
import CONST from '@src/CONST';
import BaseInvertedFlatList from './BaseInvertedFlatList';
@@ -116,7 +115,7 @@ function InvertedFlatList(props) {
{...props}
ref={listRef}
shouldMeasureItems
- contentContainerStyle={StyleSheet.compose(contentContainerStyle, styles.justifyContentEnd)}
+ contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
// We need to keep batch size to one to workaround a bug in react-native-web.
// This can be removed once https://github.com/Expensify/App/pull/24482 is merged.
@@ -130,6 +129,7 @@ InvertedFlatList.defaultProps = {
contentContainerStyle: {},
onScroll: () => {},
};
+InvertedFlatList.displayName = 'InvertedFlatList';
const InvertedFlatListWithRef = forwardRef((props, ref) => (
(
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
+ contentContainerStyle={styles.justifyContentEnd}
CellRendererComponent={CellRendererComponent}
/**
* To achieve absolute positioning and handle overflows for list items, the property must be disabled
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index a06b3314e5a9..0c5383054d04 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,11 +1,20 @@
+import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, {useCallback} from 'react';
import {FlatList, View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import participantPropTypes from '@components/participantPropTypes';
+import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID';
+import compose from '@libs/compose';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
+import reportPropTypes from '@pages/reportPropTypes';
import styles from '@styles/styles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
-import OptionRowLHNDataWithFocus from './OptionRowLHNDataWithFocus';
+import ONYXKEYS from '@src/ONYXKEYS';
+import OptionRowLHNData from './OptionRowLHNData';
const propTypes = {
/** Wrapper style for the section list */
@@ -27,14 +36,72 @@ const propTypes = {
/** Whether to allow option focus or not */
shouldDisableFocusOptions: PropTypes.bool,
+
+ /** The policy which the user has access to and which the report could be tied to */
+ policy: PropTypes.shape({
+ /** The ID of the policy */
+ id: PropTypes.string,
+ /** Name of the policy */
+ name: PropTypes.string,
+ /** Avatar of the policy */
+ avatar: PropTypes.string,
+ }),
+
+ /** All reports shared with the user */
+ reports: PropTypes.objectOf(reportPropTypes),
+
+ /** Array of report actions for this report */
+ reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
+
+ /** Indicates which locale the user currently has selected */
+ preferredLocale: PropTypes.string,
+
+ /** List of users' personal details */
+ personalDetails: PropTypes.objectOf(participantPropTypes),
+
+ /** The transaction from the parent report action */
+ transactions: PropTypes.objectOf(
+ PropTypes.shape({
+ /** The ID of the transaction */
+ transactionID: PropTypes.string,
+ }),
+ ),
+ /** List of draft comments */
+ draftComments: PropTypes.objectOf(PropTypes.string),
+ ...withCurrentReportIDPropTypes,
};
const defaultProps = {
style: styles.flex1,
shouldDisableFocusOptions: false,
+ reportActions: {},
+ reports: {},
+ policy: {},
+ preferredLocale: CONST.LOCALES.DEFAULT,
+ personalDetails: {},
+ transactions: {},
+ draftComments: {},
+ ...withCurrentReportIDDefaultProps,
};
-function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions}) {
+const keyExtractor = (item) => item;
+
+function LHNOptionsList({
+ style,
+ contentContainerStyles,
+ data,
+ onSelectRow,
+ optionMode,
+ shouldDisableFocusOptions,
+ reports,
+ reportActions,
+ policy,
+ preferredLocale,
+ personalDetails,
+ transactions,
+ draftComments,
+ currentReportID,
+}) {
/**
* This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization
* so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large
@@ -45,14 +112,17 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
*
* @returns {Object}
*/
- const getItemLayout = (itemData, index) => {
- const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight;
- return {
- length: optionHeight,
- offset: index * optionHeight,
- index,
- };
- };
+ const getItemLayout = useCallback(
+ (itemData, index) => {
+ const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight;
+ return {
+ length: optionHeight,
+ offset: index * optionHeight,
+ index,
+ };
+ },
+ [optionMode],
+ );
/**
* Function which renders a row in the list
@@ -62,13 +132,38 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
*
* @return {Component}
*/
- const renderItem = ({item}) => (
-
+ const renderItem = useCallback(
+ ({item: reportID}) => {
+ const itemFullReport = reports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] || {};
+ const itemReportActions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`];
+ const itemParentReportActions = reportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${itemFullReport.parentReportID}`];
+ const itemPolicy = policy[`${ONYXKEYS.COLLECTION.POLICY}${itemFullReport.policyID}`];
+ const itemTransaction = `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(
+ itemParentReportActions,
+ [itemFullReport.parentReportActionID, 'originalMessage', 'IOUTransactionID'],
+ '',
+ )}`;
+ const itemComment = draftComments[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] || '';
+ const participantsPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(itemFullReport.participantAccountIDs, personalDetails);
+ return (
+
+ );
+ },
+ [currentReportID, draftComments, onSelectRow, optionMode, personalDetails, policy, preferredLocale, reportActions, reports, shouldDisableFocusOptions, transactions],
);
return (
@@ -80,11 +175,11 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
showsVerticalScrollIndicator={false}
data={data}
testID="lhn-options-list"
- keyExtractor={(item) => item}
+ keyExtractor={keyExtractor}
stickySectionHeadersEnabled={false}
renderItem={renderItem}
getItemLayout={getItemLayout}
- initialNumToRender={5}
+ initialNumToRender={20}
maxToRenderPerBatch={5}
windowSize={5}
/>
@@ -94,5 +189,31 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
LHNOptionsList.propTypes = propTypes;
LHNOptionsList.defaultProps = defaultProps;
+LHNOptionsList.displayName = 'LHNOptionsList';
-export default LHNOptionsList;
+export default compose(
+ withCurrentReportID,
+ withOnyx({
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ reportActions: {
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ },
+ policy: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ preferredLocale: {
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ },
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ transactions: {
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ },
+ draftComments: {
+ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
+ },
+ }),
+)(LHNOptionsList);
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index 1a5bcf5ffb60..685b8763781d 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -205,7 +205,7 @@ function OptionRowLHN(props) {
props.isFocused ? styles.sidebarLinkActive : null,
(hovered || isContextMenuActive) && !props.isFocused ? props.hoverStyle : null,
]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={translate('accessibilityHints.navigatesToChat')}
needsOffscreenAlphaCompositing={props.optionItem.icons.length >= 2}
>
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js
index cb64a135b264..ca579d175cac 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.js
+++ b/src/components/LHNOptionsList/OptionRowLHNData.js
@@ -1,19 +1,14 @@
import {deepEqual} from 'fast-equals';
-import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useEffect, useMemo, useRef} from 'react';
-import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import participantPropTypes from '@components/participantPropTypes';
-import compose from '@libs/compose';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import SidebarUtils from '@libs/SidebarUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
-import * as UserUtils from '@libs/UserUtils';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import OptionRowLHN, {defaultProps as baseDefaultProps, propTypes as basePropTypes} from './OptionRowLHN';
const propTypes = {
@@ -44,10 +39,8 @@ const propTypes = {
parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
/** The transaction from the parent report action */
- transaction: PropTypes.shape({
- /** The ID of the transaction */
- transactionID: PropTypes.string,
- }),
+ transactionID: PropTypes.string,
+
...basePropTypes,
};
@@ -57,7 +50,7 @@ const defaultProps = {
fullReport: {},
policy: {},
parentReportActions: {},
- transaction: {},
+ transactionID: undefined,
preferredLocale: CONST.LOCALES.DEFAULT,
...baseDefaultProps,
};
@@ -78,11 +71,10 @@ function OptionRowLHNData({
policy,
receiptTransactions,
parentReportActions,
- transaction,
+ transactionID,
...propsToForward
}) {
const reportID = propsToForward.reportID;
-
const parentReportAction = parentReportActions[fullReport.parentReportActionID];
const optionItemRef = useRef();
@@ -105,7 +97,7 @@ function OptionRowLHNData({
// Listen parentReportAction to update title of thread report when parentReportAction changed
// Listen to transaction to update title of transaction report when transaction changed
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction, transaction]);
+ }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction, transactionID]);
useEffect(() => {
if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) {
@@ -129,30 +121,6 @@ OptionRowLHNData.propTypes = propTypes;
OptionRowLHNData.defaultProps = defaultProps;
OptionRowLHNData.displayName = 'OptionRowLHNData';
-/**
- * @param {Object} [personalDetails]
- * @returns {Object|undefined}
- */
-const personalDetailsSelector = (personalDetails) =>
- _.reduce(
- personalDetails,
- (finalPersonalDetails, personalData, accountID) => {
- // It's OK to do param-reassignment in _.reduce() because we absolutely know the starting state of finalPersonalDetails
- // eslint-disable-next-line no-param-reassign
- finalPersonalDetails[accountID] = {
- accountID: Number(accountID),
- login: personalData.login,
- displayName: personalData.displayName,
- firstName: personalData.firstName,
- status: personalData.status,
- avatar: UserUtils.getAvatar(personalData.avatar, personalData.accountID),
- fallbackIcon: personalData.fallbackIcon,
- };
- return finalPersonalDetails;
- },
- {},
- );
-
/**
* This component is rendered in a list.
* On scroll we want to avoid that a item re-renders
@@ -160,53 +128,4 @@ const personalDetailsSelector = (personalDetails) =>
* Thats also why the React.memo is used on the outer component here, as we just
* use it to prevent re-renders from parent re-renders.
*/
-export default React.memo(
- compose(
- withOnyx({
- comment: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
- },
- fullReport: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- initialValue: {},
- },
- reportActions: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- canEvict: false,
- initialValue: {},
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- selector: personalDetailsSelector,
- initialValue: {},
- },
- preferredLocale: {
- key: ONYXKEYS.NVP_PREFERRED_LOCALE,
- },
- }),
- // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
- withOnyx({
- parentReportActions: {
- key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`,
- canEvict: false,
- initialValue: {},
- },
- policy: {
- key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`,
- initialValue: {},
- },
- // Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions.
- // In some scenarios, a transaction might be created after reportActions have been modified.
- // This can lead to situations where `lastTransaction` doesn't update and retains the previous value.
- // However, performance overhead of this is minimized by using memos inside the component.
- receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION, initialValue: {}},
- }),
- // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
- withOnyx({
- transaction: {
- key: ({fullReport, parentReportActions}) =>
- `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportActions, [fullReport.parentReportActionID, 'originalMessage', 'IOUTransactionID'], '')}`,
- },
- }),
- )(OptionRowLHNData),
-);
+export default React.memo(OptionRowLHNData);
diff --git a/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js b/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js
deleted file mode 100644
index 67e90bcbb0e0..000000000000
--- a/src/components/LHNOptionsList/OptionRowLHNDataWithFocus.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID';
-import OptionRowLHNData from './OptionRowLHNData';
-
-const propTypes = {
- ...withCurrentReportIDPropTypes,
- shouldDisableFocusOptions: PropTypes.bool,
-};
-
-const defaultProps = {
- ...withCurrentReportIDDefaultProps,
- shouldDisableFocusOptions: false,
-};
-
-/**
- * Wrapper component for OptionRowLHNData that calculates isFocused prop based on currentReportID.
- * This is extracted from OptionRowLHNData to prevent unnecessary re-renders when currentReportID changes.
- * @returns {React.Component} OptionRowLHNData component with isFocused prop
- */
-function OptionRowLHNDataWithFocus({currentReportID, shouldDisableFocusOptions, ...props}) {
- // We only want to pass a boolean to the memoized component,
- // instead of a changing number (so we prevent unnecessary re-renders).
- const isFocused = !shouldDisableFocusOptions && currentReportID === props.reportID;
-
- return (
-
- );
-}
-
-OptionRowLHNDataWithFocus.defaultProps = defaultProps;
-OptionRowLHNDataWithFocus.propTypes = propTypes;
-OptionRowLHNDataWithFocus.displayName = 'OptionRowLHNDataWithFocus';
-
-export default withCurrentReportID(OptionRowLHNDataWithFocus);
diff --git a/src/components/LinearGradient/index.js b/src/components/LinearGradient/index.js
deleted file mode 100644
index 8270681641d0..000000000000
--- a/src/components/LinearGradient/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import LinearGradient from 'react-native-web-linear-gradient';
-
-export default LinearGradient;
diff --git a/src/components/LinearGradient/index.native.js b/src/components/LinearGradient/index.native.js
deleted file mode 100644
index c8d5af2646b2..000000000000
--- a/src/components/LinearGradient/index.native.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import LinearGradient from 'react-native-linear-gradient';
-
-export default LinearGradient;
diff --git a/src/components/LinearGradient/index.native.ts b/src/components/LinearGradient/index.native.ts
new file mode 100644
index 000000000000..46bed24ebc10
--- /dev/null
+++ b/src/components/LinearGradient/index.native.ts
@@ -0,0 +1,6 @@
+import LinearGradientNative from 'react-native-linear-gradient';
+import LinearGradient from './types';
+
+const LinearGradientImplementation: LinearGradient = LinearGradientNative;
+
+export default LinearGradientImplementation;
diff --git a/src/components/LinearGradient/index.ts b/src/components/LinearGradient/index.ts
new file mode 100644
index 000000000000..7246ccf2fb69
--- /dev/null
+++ b/src/components/LinearGradient/index.ts
@@ -0,0 +1,6 @@
+import LinearGradientWeb from 'react-native-web-linear-gradient';
+import LinearGradient from './types';
+
+const LinearGradientImplementation: LinearGradient = LinearGradientWeb;
+
+export default LinearGradientImplementation;
diff --git a/src/components/LinearGradient/types.ts b/src/components/LinearGradient/types.ts
new file mode 100644
index 000000000000..cf6661eaecaa
--- /dev/null
+++ b/src/components/LinearGradient/types.ts
@@ -0,0 +1,5 @@
+import LinearGradientNative from 'react-native-linear-gradient';
+
+type LinearGradient = typeof LinearGradientNative;
+
+export default LinearGradient;
diff --git a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js
index b5114acaa36b..5880d3475650 100644
--- a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js
+++ b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js
@@ -62,7 +62,7 @@ function BaseLocationErrorMessage({onClose, onAllowLocationLinkPress, locationEr
onPress={onClose}
onMouseDown={(e) => e.preventDefault()}
style={[styles.touchableButtonImage]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={translate('common.close')}
>
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index 585b7005ab1e..978b101a6cce 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -319,7 +319,6 @@ function MagicCodeInput(props) {
value={input}
hideFocusedState
autoComplete={index === 0 ? props.autoComplete : 'off'}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
onChangeText={(value) => {
// Do not run when the event comes from an input that is
// not currently being responsible for the input, this is
@@ -337,7 +336,7 @@ function MagicCodeInput(props) {
selectionColor="transparent"
textInputContainerStyles={[styles.borderNone]}
inputStyle={[styles.inputTransparent]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
/>
@@ -355,6 +354,7 @@ function MagicCodeInput(props) {
MagicCodeInput.propTypes = propTypes;
MagicCodeInput.defaultProps = defaultProps;
+MagicCodeInput.displayName = 'MagicCodeInput';
const MagicCodeInputWithRef = forwardRef((props, ref) => (
{
]}
disabled={props.disabled}
ref={ref}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.MENUITEM}
+ role={CONST.ACCESSIBILITY_ROLE.MENUITEM}
accessibilityLabel={props.title ? props.title.toString() : ''}
>
{({pressed}) => (
diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js
index 6ed3b16c676d..bf1fdc8ee7de 100644
--- a/src/components/Modal/BaseModal.js
+++ b/src/components/Modal/BaseModal.js
@@ -183,6 +183,7 @@ function BaseModal({
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
+ onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()}
onDismiss={handleDismissModal}
onSwipeComplete={onClose}
swipeDirection={swipeDirection}
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index da1e03bd28e8..462a8b41acd9 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -436,7 +436,7 @@ function MoneyRequestConfirmationList(props) {
*/
const navigateToReportOrUserDetail = (option) => {
if (option.accountID) {
- const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, '');
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
} else if (option.reportID) {
@@ -738,6 +738,7 @@ function MoneyRequestConfirmationList(props) {
MoneyRequestConfirmationList.propTypes = propTypes;
MoneyRequestConfirmationList.defaultProps = defaultProps;
+MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
export default compose(
withCurrentUserPersonalDetails,
diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js
index 85b6f7995693..209540189b69 100644
--- a/src/components/MultipleAvatars.js
+++ b/src/components/MultipleAvatars.js
@@ -227,8 +227,7 @@ function MultipleAvatars(props) {
]}
>
{`+${avatars.length - props.maxAvatarsInRow}`}
@@ -293,8 +292,7 @@ function MultipleAvatars(props) {
{`+${props.icons.length - 1}`}
diff --git a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
index d784f439dfee..f040a99450f1 100644
--- a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
+++ b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
@@ -76,7 +76,7 @@ function YearPickerModal(props) {
textInputValue={searchText}
textInputMaxLength={4}
onChangeText={(text) => setSearchText(text.replace(CONST.REGEX.NON_NUMERIC, '').trim())}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ inputMode={CONST.INPUT_MODE.NUMERIC}
headerMessage={headerMessage}
sections={sections}
onSelectRow={(option) => props.onYearChange(option.value)}
diff --git a/src/components/NewDatePicker/CalendarPicker/index.js b/src/components/NewDatePicker/CalendarPicker/index.js
index 4b17766feb17..58ab42a9b56a 100644
--- a/src/components/NewDatePicker/CalendarPicker/index.js
+++ b/src/components/NewDatePicker/CalendarPicker/index.js
@@ -213,7 +213,7 @@ class CalendarPicker extends React.PureComponent {
onPress={() => this.onDayPressed(day)}
style={styles.calendarDayRoot}
accessibilityLabel={day ? day.toString() : undefined}
- focusable={Boolean(day)}
+ tabIndex={day ? 0 : -1}
accessible={Boolean(day)}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
diff --git a/src/components/NewDatePicker/index.js b/src/components/NewDatePicker/index.js
index fa4bab7d00ec..351e5034cfb4 100644
--- a/src/components/NewDatePicker/index.js
+++ b/src/components/NewDatePicker/index.js
@@ -6,7 +6,7 @@ import {View} from 'react-native';
import InputWrapper from '@components/Form/InputWrapper';
import * as Expensicons from '@components/Icon/Expensicons';
import TextInput from '@components/TextInput';
-import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/baseTextInputPropTypes';
+import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import styles from '@styles/styles';
import CONST from '@src/CONST';
@@ -75,7 +75,7 @@ function NewDatePicker({containerStyles, defaultValue, disabled, errorText, inpu
icon={Expensicons.Calendar}
label={label}
accessibilityLabel={label}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
value={value || selectedDate || ''}
placeholder={placeholder || translate('common.dateFormat')}
errorText={errorText}
@@ -83,7 +83,7 @@ function NewDatePicker({containerStyles, defaultValue, disabled, errorText, inpu
textInputContainerStyles={[styles.borderColorFocus]}
inputStyle={[styles.pointerEventsNone]}
disabled={disabled}
- editable={false}
+ readOnly
/>
@@ -100,5 +100,6 @@ function NewDatePicker({containerStyles, defaultValue, disabled, errorText, inpu
NewDatePicker.propTypes = propTypes;
NewDatePicker.defaultProps = datePickerDefaultProps;
+NewDatePicker.displayName = 'NewDatePicker';
export default withLocalize(NewDatePicker);
diff --git a/src/components/OpacityView.js b/src/components/OpacityView.js
deleted file mode 100644
index ebd261916e65..000000000000
--- a/src/components/OpacityView.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
-import shouldRenderOffscreen from '@libs/shouldRenderOffscreen';
-import * as StyleUtils from '@styles/StyleUtils';
-import variables from '@styles/variables';
-
-const propTypes = {
- /**
- * Should we dim the view
- */
- shouldDim: PropTypes.bool.isRequired,
-
- /**
- * Content to render
- */
- children: PropTypes.node.isRequired,
-
- /**
- * Array of style objects
- * @default []
- */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
-
- /**
- * The value to use for the opacity when the view is dimmed
- * @default 0.5
- */
- dimmingValue: PropTypes.number,
-
- /** Whether the view needs to be rendered offscreen (for Android only) */
- needsOffscreenAlphaCompositing: PropTypes.bool,
-};
-
-const defaultProps = {
- style: [],
- dimmingValue: variables.hoverDimValue,
- needsOffscreenAlphaCompositing: false,
-};
-
-function OpacityView(props) {
- const opacity = useSharedValue(1);
- const opacityStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- }));
-
- React.useEffect(() => {
- if (props.shouldDim) {
- opacity.value = withTiming(props.dimmingValue, {duration: 50});
- } else {
- opacity.value = withTiming(1, {duration: 50});
- }
- }, [props.shouldDim, props.dimmingValue, opacity]);
-
- return (
-
- {props.children}
-
- );
-}
-
-OpacityView.displayName = 'OpacityView';
-OpacityView.propTypes = propTypes;
-OpacityView.defaultProps = defaultProps;
-export default OpacityView;
diff --git a/src/components/OpacityView.tsx b/src/components/OpacityView.tsx
new file mode 100644
index 000000000000..6f82658bcac1
--- /dev/null
+++ b/src/components/OpacityView.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import {StyleProp, ViewStyle} from 'react-native';
+import Animated, {AnimatedStyle, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import shouldRenderOffscreen from '@libs/shouldRenderOffscreen';
+import variables from '@styles/variables';
+
+type OpacityViewProps = {
+ /** Should we dim the view */
+ shouldDim: boolean;
+
+ /** Content to render */
+ children: React.ReactNode;
+
+ /**
+ * Array of style objects
+ * @default []
+ */
+ style?: StyleProp>;
+
+ /**
+ * The value to use for the opacity when the view is dimmed
+ * @default variables.hoverDimValue
+ */
+ dimmingValue?: number;
+
+ /** Whether the view needs to be rendered offscreen (for Android only) */
+ needsOffscreenAlphaCompositing?: boolean;
+};
+
+function OpacityView({shouldDim, children, style = [], dimmingValue = variables.hoverDimValue, needsOffscreenAlphaCompositing = false}: OpacityViewProps) {
+ const opacity = useSharedValue(1);
+ const opacityStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ }));
+
+ React.useEffect(() => {
+ if (shouldDim) {
+ opacity.value = withTiming(dimmingValue, {duration: 50});
+ } else {
+ opacity.value = withTiming(1, {duration: 50});
+ }
+ }, [shouldDim, dimmingValue, opacity]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+OpacityView.displayName = 'OpacityView';
+export default OpacityView;
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index 2ce3bda63896..1a60cc0280b6 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -188,7 +188,7 @@ function OptionRow(props) {
props.isSelected && props.highlightSelected && styles.optionRowSelected,
]}
accessibilityLabel={props.option.text}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
hoverDimmingValue={1}
hoverStyle={props.hoverStyle}
needsOffscreenAlphaCompositing={lodashGet(props.option, 'icons.length', 0) >= 2}
@@ -263,7 +263,7 @@ function OptionRow(props) {
props.onSelectedStatePressed(props.option)}
disabled={isDisabled}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
+ role={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
accessibilityLabel={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
>
@@ -303,6 +303,7 @@ function OptionRow(props) {
OptionRow.propTypes = propTypes;
OptionRow.defaultProps = defaultProps;
+OptionRow.displayName = 'OptionRow';
export default React.memo(
withLocalize(OptionRow),
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index fb312125efc0..8c480c27f20f 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -378,7 +378,7 @@ class BaseOptionsSelector extends Component {
value={this.props.value}
label={this.props.textInputLabel}
accessibilityLabel={this.props.textInputLabel}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
onChangeText={this.updateSearchValue}
errorText={this.state.errorMessage}
onSubmitEditing={this.selectFocusedOption}
diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js
index 37c15b6e3ae2..94aab8fac5f6 100644
--- a/src/components/OptionsSelector/optionsSelectorPropTypes.js
+++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js
@@ -40,8 +40,8 @@ const propTypes = {
/** Label to display for the text input */
textInputLabel: PropTypes.string,
- /** Optional keyboard type for the input */
- keyboardType: PropTypes.string,
+ /** Optional input mode precedence over keyboardType */
+ inputMode: PropTypes.string,
/** Optional placeholder text for the selector */
placeholderText: PropTypes.string,
@@ -144,7 +144,7 @@ const defaultProps = {
onSelectRow: undefined,
textInputLabel: '',
placeholderText: '',
- keyboardType: 'default',
+ inputMode: CONST.INPUT_MODE.TEXT,
selectedOptions: [],
headerMessage: '',
canSelectMultipleOptions: false,
diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js
index 2105bc13cb00..e495057dec46 100644
--- a/src/components/PDFView/PDFPasswordForm.js
+++ b/src/components/PDFView/PDFPasswordForm.js
@@ -122,7 +122,7 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat
ref={textInputRef}
label={translate('common.password')}
accessibilityLabel={translate('common.password')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
/**
* This is a workaround to bypass Safari's autofill odd behaviour.
* This tricks the browser not to fill the username somewhere else and still fill the password correctly.
@@ -131,7 +131,7 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat
autoCorrect={false}
textContentType="password"
onChangeText={updatePassword}
- returnKeyType="go"
+ enterKeyHint="done"
onSubmitEditing={submitPassword}
errorText={errorText}
onFocus={() => onPasswordFieldFocused(true)}
diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js
index af153d69d166..fd01176d9fb2 100644
--- a/src/components/PDFView/index.js
+++ b/src/components/PDFView/index.js
@@ -274,7 +274,7 @@ class PDFView extends Component {
return (
{this.renderPDFView()}
diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js
index 7c6514c1e035..c8636b2dc50f 100644
--- a/src/components/PDFView/index.native.js
+++ b/src/components/PDFView/index.native.js
@@ -180,7 +180,7 @@ class PDFView extends Component {
{this.renderPDFView()}
diff --git a/src/components/ParentNavigationSubtitle.js b/src/components/ParentNavigationSubtitle.js
index b29794e62856..5b4825392719 100644
--- a/src/components/ParentNavigationSubtitle.js
+++ b/src/components/ParentNavigationSubtitle.js
@@ -41,7 +41,7 @@ function ParentNavigationSubtitle(props) {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.parentReportID));
}}
accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.LINK}
+ role={CONST.ACCESSIBILITY_ROLE.LINK}
style={[...props.pressableStyles]}
>
- {props.label && (
-
- {props.label}
-
- )}
+ {props.label && {props.label}}
Report.togglePinnedState(props.report.reportID, props.report.isPinned))}
style={[styles.touchableButtonImage]}
- accessibilityState={{checked: props.report.isPinned}}
+ ariaChecked={props.report.isPinned}
accessibilityLabel={props.report.isPinned ? props.translate('common.unPin') : props.translate('common.pin')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
({
onOpen: () => {},
popover: {},
close: () => {},
isOpen: false,
});
-function PopoverContextProvider(props) {
+function PopoverContextProvider(props: PopoverContextProps) {
const contextValue = React.useMemo(
() => ({
onOpen: () => {},
@@ -28,8 +22,6 @@ function PopoverContextProvider(props) {
return {props.children};
}
-PopoverContextProvider.defaultProps = defaultProps;
-PopoverContextProvider.propTypes = propTypes;
PopoverContextProvider.displayName = 'PopoverContextProvider';
export default PopoverContextProvider;
diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.tsx
similarity index 66%
rename from src/components/PopoverProvider/index.js
rename to src/components/PopoverProvider/index.tsx
index 3e245faceeef..06345ebdbc1c 100644
--- a/src/components/PopoverProvider/index.js
+++ b/src/components/PopoverProvider/index.tsx
@@ -1,24 +1,18 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import {AnchorRef, PopoverContextProps, PopoverContextValue} from './types';
-const propTypes = {
- children: PropTypes.node.isRequired,
-};
-
-const defaultProps = {};
-
-const PopoverContext = React.createContext({
+const PopoverContext = React.createContext({
onOpen: () => {},
popover: {},
close: () => {},
isOpen: false,
});
-function PopoverContextProvider(props) {
+function PopoverContextProvider(props: PopoverContextProps) {
const [isOpen, setIsOpen] = React.useState(false);
- const activePopoverRef = React.useRef(null);
+ const activePopoverRef = React.useRef(null);
- const closePopover = React.useCallback((anchorRef) => {
+ const closePopover = React.useCallback((anchorRef?: React.RefObject) => {
if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) {
return;
}
@@ -32,17 +26,12 @@ function PopoverContextProvider(props) {
}, []);
React.useEffect(() => {
- const listener = (e) => {
- if (
- !activePopoverRef.current ||
- !activePopoverRef.current.ref ||
- !activePopoverRef.current.ref.current ||
- activePopoverRef.current.ref.current.contains(e.target) ||
- (activePopoverRef.current.anchorRef && activePopoverRef.current.anchorRef.current && activePopoverRef.current.anchorRef.current.contains(e.target))
- ) {
+ const listener = (e: Event) => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (activePopoverRef.current?.ref?.current?.contains(e.target as Node) || activePopoverRef.current?.anchorRef?.current?.contains(e.target as Node)) {
return;
}
- const ref = activePopoverRef.current.anchorRef;
+ const ref = activePopoverRef.current?.anchorRef;
closePopover(ref);
};
document.addEventListener('click', listener, true);
@@ -52,8 +41,8 @@ function PopoverContextProvider(props) {
}, [closePopover]);
React.useEffect(() => {
- const listener = (e) => {
- if (!activePopoverRef.current || !activePopoverRef.current.ref || !activePopoverRef.current.ref.current || activePopoverRef.current.ref.current.contains(e.target)) {
+ const listener = (e: Event) => {
+ if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) {
return;
}
closePopover();
@@ -65,7 +54,7 @@ function PopoverContextProvider(props) {
}, [closePopover]);
React.useEffect(() => {
- const listener = (e) => {
+ const listener = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
return;
}
@@ -91,8 +80,8 @@ function PopoverContextProvider(props) {
}, [closePopover]);
React.useEffect(() => {
- const listener = (e) => {
- if (activePopoverRef.current && activePopoverRef.current.ref && activePopoverRef.current.ref.current && activePopoverRef.current.ref.current.contains(e.target)) {
+ const listener = (e: Event) => {
+ if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) {
return;
}
@@ -105,12 +94,12 @@ function PopoverContextProvider(props) {
}, [closePopover]);
const onOpen = React.useCallback(
- (popoverParams) => {
- if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams.ref) {
+ (popoverParams: AnchorRef) => {
+ if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) {
closePopover(activePopoverRef.current.anchorRef);
}
activePopoverRef.current = popoverParams;
- if (popoverParams && popoverParams.onOpenCallback) {
+ if (popoverParams?.onOpenCallback) {
popoverParams.onOpenCallback();
}
setIsOpen(true);
@@ -131,8 +120,6 @@ function PopoverContextProvider(props) {
return {props.children};
}
-PopoverContextProvider.defaultProps = defaultProps;
-PopoverContextProvider.propTypes = propTypes;
PopoverContextProvider.displayName = 'PopoverContextProvider';
export default PopoverContextProvider;
diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts
new file mode 100644
index 000000000000..ffd0087cd5ff
--- /dev/null
+++ b/src/components/PopoverProvider/types.ts
@@ -0,0 +1,20 @@
+type PopoverContextProps = {
+ children: React.ReactNode;
+};
+
+type PopoverContextValue = {
+ onOpen?: (popoverParams: AnchorRef) => void;
+ popover?: AnchorRef | Record | null;
+ close: (anchorRef?: React.RefObject) => void;
+ isOpen: boolean;
+};
+
+type AnchorRef = {
+ ref: React.RefObject;
+ close: (anchorRef?: React.RefObject) => void;
+ anchorRef: React.RefObject;
+ onOpenCallback?: () => void;
+ onCloseCallback?: () => void;
+};
+
+export type {PopoverContextProps, PopoverContextValue, AnchorRef};
diff --git a/src/components/PopoverWithMeasuredContent.js b/src/components/PopoverWithMeasuredContent.js
index 04eacfa88ec8..ba116a628c52 100644
--- a/src/components/PopoverWithMeasuredContent.js
+++ b/src/components/PopoverWithMeasuredContent.js
@@ -3,6 +3,7 @@ import React, {useMemo, useState} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import getClickedTargetLocation from '@libs/getClickedTargetLocation';
import {computeHorizontalShift, computeVerticalShift} from '@styles/getPopoverWithMeasuredContentStyles';
import styles from '@styles/styles';
import CONST from '@src/CONST';
@@ -36,6 +37,9 @@ const propTypes = {
height: PropTypes.number,
width: PropTypes.number,
}),
+
+ /** Whether we should use the target location from anchor element directly */
+ shouldUseTargetLocation: PropTypes.bool,
};
const defaultProps = {
@@ -51,6 +55,7 @@ const defaultProps = {
width: 0,
},
withoutOverlay: false,
+ shouldUseTargetLocation: false,
};
/**
@@ -90,6 +95,9 @@ function PopoverWithMeasuredContent(props) {
setIsContentMeasured(true);
};
+ const {x: horizontal, y: vertical} = props.anchorRef.current ? getClickedTargetLocation(props.anchorRef.current) : {};
+ const clickedTargetLocation = props.anchorRef.current && props.shouldUseTargetLocation ? {horizontal, vertical} : props.anchorPosition;
+
const adjustedAnchorPosition = useMemo(() => {
let horizontalConstraint;
switch (props.anchorAlignment.horizontal) {
@@ -103,13 +111,18 @@ function PopoverWithMeasuredContent(props) {
break;
case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT:
default:
- horizontalConstraint = {left: props.anchorPosition.horizontal};
+ horizontalConstraint = {left: clickedTargetLocation.horizontal};
}
let verticalConstraint;
+ const anchorLocationVertical = clickedTargetLocation.vertical;
+
switch (props.anchorAlignment.vertical) {
case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM:
- verticalConstraint = {top: props.anchorPosition.vertical - popoverHeight};
+ if (!anchorLocationVertical) {
+ break;
+ }
+ verticalConstraint = {top: anchorLocationVertical - popoverHeight};
break;
case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.CENTER:
verticalConstraint = {
@@ -125,7 +138,7 @@ function PopoverWithMeasuredContent(props) {
...horizontalConstraint,
...verticalConstraint,
};
- }, [props.anchorPosition, props.anchorAlignment, popoverWidth, popoverHeight]);
+ }, [props.anchorPosition, props.anchorAlignment, clickedTargetLocation.vertical, clickedTargetLocation.horizontal, popoverWidth, popoverHeight]);
const horizontalShift = computeHorizontalShift(adjustedAnchorPosition.left, popoverWidth, windowWidth);
const verticalShift = computeVerticalShift(adjustedAnchorPosition.top, popoverHeight, windowHeight);
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
similarity index 65%
rename from src/components/Pressable/GenericPressable/BaseGenericPressable.js
rename to src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
index a3ce55003cdd..1576fe18da54 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
@@ -1,7 +1,6 @@
-import React, {forwardRef, useCallback, useEffect, useMemo} from 'react';
+import React, {ForwardedRef, forwardRef, useCallback, useEffect, useMemo} from 'react';
// eslint-disable-next-line no-restricted-imports
-import {Pressable} from 'react-native';
-import _ from 'underscore';
+import {GestureResponderEvent, Pressable, View, ViewStyle} from 'react-native';
import useSingleExecution from '@hooks/useSingleExecution';
import Accessibility from '@libs/Accessibility';
import HapticFeedback from '@libs/HapticFeedback';
@@ -9,15 +8,12 @@ import KeyboardShortcut from '@libs/KeyboardShortcut';
import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
import CONST from '@src/CONST';
-import genericPressablePropTypes from './PropTypes';
+import PressableProps from './types';
/**
* Returns the cursor style based on the state of Pressable
- * @param {Boolean} isDisabled
- * @param {Boolean} isText
- * @returns {Object}
*/
-const getCursorStyle = (isDisabled, isText) => {
+function getCursorStyle(isDisabled: boolean, isText: boolean): Pick {
if (isDisabled) {
return styles.cursorDisabled;
}
@@ -27,28 +23,34 @@ const getCursorStyle = (isDisabled, isText) => {
}
return styles.cursorPointer;
-};
+}
-const GenericPressable = forwardRef((props, ref) => {
- const {
+function GenericPressable(
+ {
children,
- onPress,
+ onPress = () => {},
onLongPress,
- onKeyPress,
onKeyDown,
disabled,
style,
- shouldUseHapticsOnLongPress,
- shouldUseHapticsOnPress,
+ disabledStyle = {},
+ hoverStyle = {},
+ focusStyle = {},
+ pressStyle = {},
+ screenReaderActiveStyle = {},
+ shouldUseHapticsOnLongPress = false,
+ shouldUseHapticsOnPress = false,
nextFocusRef,
keyboardShortcut,
- shouldUseAutoHitSlop,
- enableInScreenReaderStates,
+ shouldUseAutoHitSlop = false,
+ enableInScreenReaderStates = CONST.SCREEN_READER_STATES.ALL,
onPressIn,
onPressOut,
+ accessible = true,
...rest
- } = props;
-
+ }: PressableProps,
+ ref: ForwardedRef,
+) {
const {isExecuting, singleExecution} = useSingleExecution();
const isScreenReaderActive = Accessibility.useScreenReaderStatus();
const [hitSlop, onLayout] = Accessibility.useAutoHitSlop();
@@ -63,13 +65,14 @@ const GenericPressable = forwardRef((props, ref) => {
shouldBeDisabledByScreenReader = isScreenReaderActive;
}
- return props.disabled || shouldBeDisabledByScreenReader || isExecuting;
- }, [isScreenReaderActive, enableInScreenReaderStates, props.disabled, isExecuting]);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ return disabled || shouldBeDisabledByScreenReader || isExecuting;
+ }, [isScreenReaderActive, enableInScreenReaderStates, disabled, isExecuting]);
const shouldUseDisabledCursor = useMemo(() => isDisabled && !isExecuting, [isDisabled, isExecuting]);
const onLongPressHandler = useCallback(
- (event) => {
+ (event: GestureResponderEvent) => {
if (isDisabled) {
return;
}
@@ -79,8 +82,8 @@ const GenericPressable = forwardRef((props, ref) => {
if (shouldUseHapticsOnLongPress) {
HapticFeedback.longPress();
}
- if (ref && ref.current) {
- ref.current.blur();
+ if (ref && 'current' in ref) {
+ ref.current?.blur();
}
onLongPress(event);
@@ -90,7 +93,7 @@ const GenericPressable = forwardRef((props, ref) => {
);
const onPressHandler = useCallback(
- (event) => {
+ (event?: GestureResponderEvent | KeyboardEvent) => {
if (isDisabled) {
return;
}
@@ -100,8 +103,8 @@ const GenericPressable = forwardRef((props, ref) => {
if (shouldUseHapticsOnPress) {
HapticFeedback.press();
}
- if (ref && ref.current) {
- ref.current.blur();
+ if (ref && 'current' in ref) {
+ ref.current?.blur();
}
onPress(event);
@@ -110,16 +113,6 @@ const GenericPressable = forwardRef((props, ref) => {
[shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled],
);
- const onKeyPressHandler = useCallback(
- (event) => {
- if (event.key !== 'Enter') {
- return;
- }
- onPressHandler(event);
- },
- [onPressHandler],
- );
-
useEffect(() => {
if (!keyboardShortcut) {
return () => {};
@@ -135,39 +128,37 @@ const GenericPressable = forwardRef((props, ref) => {
ref={ref}
onPress={!isDisabled ? singleExecution(onPressHandler) : undefined}
onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined}
- onKeyPress={!isDisabled ? onKeyPressHandler : undefined}
onKeyDown={!isDisabled ? onKeyDown : undefined}
onPressIn={!isDisabled ? onPressIn : undefined}
onPressOut={!isDisabled ? onPressOut : undefined}
style={(state) => [
- getCursorStyle(shouldUseDisabledCursor, [props.accessibilityRole, props.role].includes('text')),
- StyleUtils.parseStyleFromFunction(props.style, state),
- isScreenReaderActive && StyleUtils.parseStyleFromFunction(props.screenReaderActiveStyle, state),
- state.focused && StyleUtils.parseStyleFromFunction(props.focusStyle, state),
- state.hovered && StyleUtils.parseStyleFromFunction(props.hoverStyle, state),
- state.pressed && StyleUtils.parseStyleFromFunction(props.pressStyle, state),
- isDisabled && [...StyleUtils.parseStyleFromFunction(props.disabledStyle, state), styles.noSelect],
+ getCursorStyle(shouldUseDisabledCursor, [rest.accessibilityRole, rest.role].includes('text')),
+ StyleUtils.parseStyleFromFunction(style, state),
+ isScreenReaderActive && StyleUtils.parseStyleFromFunction(screenReaderActiveStyle, state),
+ state.focused && StyleUtils.parseStyleFromFunction(focusStyle, state),
+ state.hovered && StyleUtils.parseStyleFromFunction(hoverStyle, state),
+ state.pressed && StyleUtils.parseStyleFromFunction(pressStyle, state),
+ isDisabled && [StyleUtils.parseStyleFromFunction(disabledStyle, state), styles.noSelect],
]}
// accessibility props
accessibilityState={{
disabled: isDisabled,
- ...props.accessibilityState,
+ ...rest.accessibilityState,
}}
aria-disabled={isDisabled}
- aria-keyshortcuts={keyboardShortcut && `${keyboardShortcut.modifiers}+${keyboardShortcut.shortcutKey}`}
+ aria-keyshortcuts={keyboardShortcut && `${keyboardShortcut.modifiers.join('')}+${keyboardShortcut.shortcutKey}`}
// ios-only form of inputs
- onMagicTap={!isDisabled && onPressHandler}
- onAccessibilityTap={!isDisabled && onPressHandler}
+ onMagicTap={!isDisabled ? onPressHandler : undefined}
+ onAccessibilityTap={!isDisabled ? onPressHandler : undefined}
+ accessible={accessible}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
>
- {(state) => (_.isFunction(props.children) ? props.children({...state, isScreenReaderActive, isDisabled}) : props.children)}
+ {(state) => (typeof children === 'function' ? children({...state, isScreenReaderActive, isDisabled}) : children)}
);
-});
+}
GenericPressable.displayName = 'GenericPressable';
-GenericPressable.propTypes = genericPressablePropTypes.pressablePropTypes;
-GenericPressable.defaultProps = genericPressablePropTypes.defaultProps;
-export default GenericPressable;
+export default forwardRef(GenericPressable);
diff --git a/src/components/Pressable/GenericPressable/PropTypes.js b/src/components/Pressable/GenericPressable/PropTypes.js
deleted file mode 100644
index 870c63301239..000000000000
--- a/src/components/Pressable/GenericPressable/PropTypes.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import PropTypes from 'prop-types';
-import stylePropType from '@styles/stylePropTypes';
-import CONST from '@src/CONST';
-
-const stylePropTypeWithFunction = PropTypes.oneOfType([stylePropType, PropTypes.func]);
-
-/**
- * Custom test for required props
- * + accessibilityLabel is required when accessible is true
- * @param {Object} props
- * @returns {Error} Error if prop is required
- */
-function requiredPropsCheck(props) {
- if (props.accessible !== true || (props.accessibilityLabel !== undefined && typeof props.accessibilityLabel === 'string')) {
- return;
- }
- return new Error(`Provide a valid string for accessibilityLabel prop when accessible is true`);
-}
-
-const pressablePropTypes = {
- /**
- * onPress callback
- */
- onPress: PropTypes.func,
-
- /**
- * Specifies keyboard shortcut to trigger onPressHandler
- * @example {shortcutKey: 'a', modifiers: ['ctrl', 'shift'], descriptionKey: 'keyboardShortcut.description'}
- */
- keyboardShortcut: PropTypes.shape({
- descriptionKey: PropTypes.string.isRequired,
- shortcutKey: PropTypes.string.isRequired,
- modifiers: PropTypes.arrayOf(PropTypes.string),
- }),
-
- /**
- * Specifies if haptic feedback should be used on press
- * @default false
- */
- shouldUseHapticsOnPress: PropTypes.bool,
-
- /**
- * Specifies if haptic feedback should be used on long press
- * @default false
- */
- shouldUseHapticsOnLongPress: PropTypes.bool,
-
- /**
- * style for when the component is disabled. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
- * @default {}
- * @example {backgroundColor: 'red'}
- * @example state => ({backgroundColor: state.isDisabled ? 'red' : 'blue'})
- */
- disabledStyle: stylePropTypeWithFunction,
-
- /**
- * style for when the component is hovered. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
- * @default {}
- * @example {backgroundColor: 'red'}
- * @example state => ({backgroundColor: state.hovered ? 'red' : 'blue'})
- */
- hoverStyle: stylePropTypeWithFunction,
-
- /**
- * style for when the component is focused. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
- * @default {}
- * @example {backgroundColor: 'red'}
- * @example state => ({backgroundColor: state.focused ? 'red' : 'blue'})
- */
- focusStyle: stylePropTypeWithFunction,
-
- /**
- * style for when the component is pressed. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
- * @default {}
- * @example {backgroundColor: 'red'}
- * @example state => ({backgroundColor: state.pressed ? 'red' : 'blue'})
- */
- pressStyle: stylePropTypeWithFunction,
-
- /**
- * style for when the component is active and the screen reader is on.
- * Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
- * @default {}
- * @example {backgroundColor: 'red'}
- * @example state => ({backgroundColor: state.isScreenReaderActive ? 'red' : 'blue'})
- */
- screenReaderActiveStyle: stylePropTypeWithFunction,
-
- /**
- * Specifies if the component should be accessible when the screen reader is on
- * @default 'all'
- * @example 'all' - the component is accessible regardless of screen reader state
- * @example 'active' - the component is accessible only when the screen reader is on
- * @example 'disabled' - the component is not accessible when the screen reader is on
- */
- enableInScreenReaderStates: PropTypes.oneOf([CONST.SCREEN_READER_STATES.ALL, CONST.SCREEN_READER_STATES.ACTIVE, CONST.SCREEN_READER_STATES.DISABLED]),
-
- /**
- * Specifies which component should be focused after interacting with this component
- */
- nextFocusRef: PropTypes.func,
-
- /**
- * Specifies the accessibility label for the component
- * @example 'Search'
- * @example 'Close'
- */
- accessibilityLabel: requiredPropsCheck,
-
- /**
- * Specifies the accessibility hint for the component
- * @example 'Double tap to open'
- */
- accessibilityHint: PropTypes.string,
-
- /**
- * Specifies if the component should calculate its hitSlop automatically
- * @default true
- */
- shouldUseAutoHitSlop: PropTypes.bool,
-};
-
-const defaultProps = {
- onPress: () => {},
- keyboardShortcut: undefined,
- shouldUseHapticsOnPress: false,
- shouldUseHapticsOnLongPress: false,
- disabledStyle: {},
- hoverStyle: {},
- focusStyle: {},
- pressStyle: {},
- screenReaderActiveStyle: {},
- enableInScreenReaderStates: CONST.SCREEN_READER_STATES.ALL,
- nextFocusRef: undefined,
- shouldUseAutoHitSlop: false,
- accessible: true,
-};
-
-export default {
- pressablePropTypes,
- defaultProps,
-};
diff --git a/src/components/Pressable/GenericPressable/index.js b/src/components/Pressable/GenericPressable/index.js
deleted file mode 100644
index 8247d0c35670..000000000000
--- a/src/components/Pressable/GenericPressable/index.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React, {forwardRef} from 'react';
-import GenericPressable from './BaseGenericPressable';
-import GenericPressablePropTypes from './PropTypes';
-
-const WebGenericPressable = forwardRef((props, ref) => (
-
-));
-
-WebGenericPressable.propTypes = GenericPressablePropTypes.pressablePropTypes;
-WebGenericPressable.defaultProps = GenericPressablePropTypes.defaultProps;
-WebGenericPressable.displayName = 'WebGenericPressable';
-
-export default WebGenericPressable;
diff --git a/src/components/Pressable/GenericPressable/index.native.js b/src/components/Pressable/GenericPressable/index.native.js
deleted file mode 100644
index 14a2c2bcbf82..000000000000
--- a/src/components/Pressable/GenericPressable/index.native.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React, {forwardRef} from 'react';
-import GenericPressable from './BaseGenericPressable';
-import GenericPressablePropTypes from './PropTypes';
-
-const NativeGenericPressable = forwardRef((props, ref) => (
-
-));
-
-NativeGenericPressable.propTypes = GenericPressablePropTypes.pressablePropTypes;
-NativeGenericPressable.defaultProps = GenericPressablePropTypes.defaultProps;
-NativeGenericPressable.displayName = 'WebGenericPressable';
-
-export default NativeGenericPressable;
diff --git a/src/components/Pressable/GenericPressable/index.native.tsx b/src/components/Pressable/GenericPressable/index.native.tsx
new file mode 100644
index 000000000000..5bed0f488063
--- /dev/null
+++ b/src/components/Pressable/GenericPressable/index.native.tsx
@@ -0,0 +1,21 @@
+import React, {ForwardedRef, forwardRef} from 'react';
+import {View} from 'react-native';
+import GenericPressable from './BaseGenericPressable';
+import PressableProps from './types';
+
+function NativeGenericPressable(props: PressableProps, ref: ForwardedRef) {
+ return (
+
+ );
+}
+
+NativeGenericPressable.displayName = 'NativeGenericPressable';
+
+export default forwardRef(NativeGenericPressable);
diff --git a/src/components/Pressable/GenericPressable/index.tsx b/src/components/Pressable/GenericPressable/index.tsx
new file mode 100644
index 000000000000..c8e9560062e0
--- /dev/null
+++ b/src/components/Pressable/GenericPressable/index.tsx
@@ -0,0 +1,30 @@
+import React, {ForwardedRef, forwardRef} from 'react';
+import {Role, View} from 'react-native';
+import GenericPressable from './BaseGenericPressable';
+import PressableProps from './types';
+
+function WebGenericPressable(props: PressableProps, ref: ForwardedRef) {
+ return (
+
+ );
+}
+
+WebGenericPressable.displayName = 'WebGenericPressable';
+
+export default forwardRef(WebGenericPressable);
diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts
new file mode 100644
index 000000000000..35616cb600a3
--- /dev/null
+++ b/src/components/Pressable/GenericPressable/types.ts
@@ -0,0 +1,147 @@
+import {ElementRef, RefObject} from 'react';
+import {GestureResponderEvent, HostComponent, PressableStateCallbackType, PressableProps as RNPressableProps, StyleProp, ViewStyle} from 'react-native';
+import {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+
+type StylePropWithFunction = StyleProp | ((state: PressableStateCallbackType) => StyleProp);
+
+type Shortcut = {
+ displayName: string;
+ shortcutKey: string;
+ descriptionKey: string;
+ modifiers: string[];
+};
+
+type RequiredAccessibilityLabel =
+ | {
+ /**
+ * When true, indicates that the view is an accessibility element.
+ * By default, all the touchable elements are accessible.
+ */
+ accessible?: true | undefined;
+
+ /**
+ * Specifies the accessibility label for the component
+ * @example 'Search'
+ * @example 'Close'
+ */
+ accessibilityLabel: string;
+ }
+ | {
+ /**
+ * When false, indicates that the view is not an accessibility element.
+ */
+ accessible: false;
+
+ /**
+ * Specifies the accessibility label for the component
+ * @example 'Search'
+ * @example 'Close'
+ */
+ accessibilityLabel?: string;
+ };
+
+type PressableProps = RNPressableProps &
+ RequiredAccessibilityLabel & {
+ /**
+ * onPress callback
+ */
+ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+
+ /**
+ * Specifies keyboard shortcut to trigger onPressHandler
+ * @example {shortcutKey: 'a', modifiers: ['ctrl', 'shift'], descriptionKey: 'keyboardShortcut.description'}
+ */
+ keyboardShortcut?: Shortcut;
+
+ /**
+ * Specifies if haptic feedback should be used on press
+ * @default false
+ */
+ shouldUseHapticsOnPress?: boolean;
+
+ /**
+ * Specifies if haptic feedback should be used on long press
+ * @default false
+ */
+ shouldUseHapticsOnLongPress?: boolean;
+
+ /**
+ * style for when the component is disabled. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
+ * @default {}
+ * @example {backgroundColor: 'red'}
+ * @example state => ({backgroundColor: state.isDisabled ? 'red' : 'blue'})
+ */
+ disabledStyle?: StylePropWithFunction;
+
+ /**
+ * style for when the component is hovered. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
+ * @default {}
+ * @example {backgroundColor: 'red'}
+ * @example state => ({backgroundColor: state.hovered ? 'red' : 'blue'})
+ */
+ hoverStyle?: StylePropWithFunction;
+
+ /**
+ * style for when the component is focused. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
+ * @default {}
+ * @example {backgroundColor: 'red'}
+ * @example state => ({backgroundColor: state.focused ? 'red' : 'blue'})
+ */
+ focusStyle?: StylePropWithFunction;
+
+ /**
+ * style for when the component is pressed. Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
+ * @default {}
+ * @example {backgroundColor: 'red'}
+ * @example state => ({backgroundColor: state.pressed ? 'red' : 'blue'})
+ */
+ pressStyle?: StylePropWithFunction;
+
+ /**
+ * style for when the component is active and the screen reader is on.
+ * Can be a function that receives the component's state (active, disabled, hover, focus, pressed, isScreenReaderActive)
+ * @default {}
+ * @example {backgroundColor: 'red'}
+ * @example state => ({backgroundColor: state.isScreenReaderActive ? 'red' : 'blue'})
+ */
+ screenReaderActiveStyle?: StylePropWithFunction;
+
+ /**
+ * Specifies if the component should be accessible when the screen reader is on
+ * @default 'all'
+ * @example 'all' - the component is accessible regardless of screen reader state
+ * @example 'active' - the component is accessible only when the screen reader is on
+ * @example 'disabled' - the component is not accessible when the screen reader is on
+ */
+ enableInScreenReaderStates?: ValueOf;
+
+ /**
+ * Specifies which component should be focused after interacting with this component
+ */
+ nextFocusRef?: ElementRef> & RefObject;
+
+ /**
+ * Specifies the accessibility label for the component
+ * @example 'Search'
+ * @example 'Close'
+ */
+ accessibilityLabel?: string;
+
+ /**
+ * Specifies the accessibility hint for the component
+ * @example 'Double tap to open'
+ */
+ accessibilityHint?: string;
+
+ /**
+ * Specifies if the component should calculate its hitSlop automatically
+ * @default true
+ */
+ shouldUseAutoHitSlop?: boolean;
+
+ /** Turns off drag area for the component */
+ noDragArea?: boolean;
+ };
+
+export default PressableProps;
diff --git a/src/components/Pressable/PressableWithDelayToggle.js b/src/components/Pressable/PressableWithDelayToggle.js
deleted file mode 100644
index 7113afff8bdc..000000000000
--- a/src/components/Pressable/PressableWithDelayToggle.js
+++ /dev/null
@@ -1,154 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
-import refPropTypes from '@components/refPropTypes';
-import Text from '@components/Text';
-import Tooltip from '@components/Tooltip';
-import useThrottledButtonState from '@hooks/useThrottledButtonState';
-import getButtonState from '@libs/getButtonState';
-import styles from '@styles/styles';
-import * as StyleUtils from '@styles/StyleUtils';
-import variables from '@styles/variables';
-import PressableWithoutFeedback from './PressableWithoutFeedback';
-
-const propTypes = {
- /** Ref passed to the component by React.forwardRef (do not pass from parent) */
- innerRef: refPropTypes,
-
- /** The text to display */
- text: PropTypes.string,
-
- /** The text to display once the pressable is pressed */
- textChecked: PropTypes.string,
-
- /** The tooltip text to display */
- tooltipText: PropTypes.string,
-
- /** The tooltip text to display once the pressable is pressed */
- tooltipTextChecked: PropTypes.string,
-
- /** Styles to apply to the container */
- // eslint-disable-next-line react/forbid-prop-types
- styles: PropTypes.arrayOf(PropTypes.object),
-
- /** Styles to apply to the text */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Styles to apply to the icon */
- // eslint-disable-next-line react/forbid-prop-types
- iconStyles: PropTypes.arrayOf(PropTypes.object),
-
- /** Callback to be called on onPress */
- onPress: PropTypes.func.isRequired,
-
- /** The icon to display */
- icon: PropTypes.func,
-
- /** The icon to display once the pressable is pressed */
- iconChecked: PropTypes.func,
-
- /**
- * Should be set to `true` if this component is being rendered inline in
- * another `Text`. This is due to limitations in RN regarding the
- * vertical text alignment of non-Text elements
- */
- inline: PropTypes.bool,
-};
-
-const defaultProps = {
- text: '',
- textChecked: '',
- tooltipText: '',
- tooltipTextChecked: '',
- styles: [],
- textStyles: [],
- iconStyles: [],
- icon: null,
- inline: true,
- iconChecked: Expensicons.Checkmark,
- innerRef: () => {},
-};
-
-function PressableWithDelayToggle(props) {
- const [isActive, temporarilyDisableInteractions] = useThrottledButtonState();
-
- const updatePressState = () => {
- if (!isActive) {
- return;
- }
- temporarilyDisableInteractions();
- props.onPress();
- };
-
- // Due to limitations in RN regarding the vertical text alignment of non-Text elements,
- // for elements that are supposed to be inline, we need to use a Text element instead
- // of a Pressable
- const PressableView = props.inline ? Text : PressableWithoutFeedback;
- const tooltipText = !isActive ? props.tooltipTextChecked : props.tooltipText;
- const labelText = (
-
- {!isActive && props.textChecked ? props.textChecked : props.text}
-
-
- );
-
- return (
-
- <>
- {props.inline && labelText}
-
-
- {({hovered, pressed}) => (
- <>
- {!props.inline && labelText}
- {props.icon && (
-
- )}
- >
- )}
-
-
- >
-
- );
-}
-
-PressableWithDelayToggle.propTypes = propTypes;
-PressableWithDelayToggle.defaultProps = defaultProps;
-
-const PressableWithDelayToggleWithRef = React.forwardRef((props, ref) => (
-
-));
-
-PressableWithDelayToggleWithRef.displayName = 'PressableWithDelayToggleWithRef';
-
-export default PressableWithDelayToggleWithRef;
diff --git a/src/components/Pressable/PressableWithDelayToggle.tsx b/src/components/Pressable/PressableWithDelayToggle.tsx
new file mode 100644
index 000000000000..c402710d71bd
--- /dev/null
+++ b/src/components/Pressable/PressableWithDelayToggle.tsx
@@ -0,0 +1,140 @@
+/* eslint-disable react-native-a11y/has-valid-accessibility-descriptors */
+import React, {ForwardedRef, forwardRef} from 'react';
+import {Text as RNText, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import {SvgProps} from 'react-native-svg';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import Text from '@components/Text';
+import Tooltip from '@components/Tooltip';
+import useThrottledButtonState from '@hooks/useThrottledButtonState';
+import getButtonState from '@libs/getButtonState';
+import styles from '@styles/styles';
+import * as StyleUtils from '@styles/StyleUtils';
+import variables from '@styles/variables';
+import PressableProps from './GenericPressable/types';
+import PressableWithoutFeedback from './PressableWithoutFeedback';
+
+type PressableWithDelayToggleProps = PressableProps & {
+ /** The text to display */
+ text: string;
+
+ /** The text to display once the pressable is pressed */
+ textChecked: string;
+
+ /** The tooltip text to display */
+ tooltipText: string;
+
+ /** The tooltip text to display once the pressable is pressed */
+ tooltipTextChecked: string;
+
+ /** Styles to apply to the container */
+ styles?: StyleProp;
+
+ // /** Styles to apply to the text */
+ textStyles?: StyleProp;
+
+ /** Styles to apply to the icon */
+ iconStyles?: StyleProp;
+
+ /** The icon to display */
+ icon?: React.FC;
+
+ /** The icon to display once the pressable is pressed */
+ iconChecked?: React.FC;
+
+ /**
+ * Should be set to `true` if this component is being rendered inline in
+ * another `Text`. This is due to limitations in RN regarding the
+ * vertical text alignment of non-Text elements
+ */
+ inline?: boolean;
+};
+
+function PressableWithDelayToggle(
+ {
+ iconChecked = Expensicons.Checkmark,
+ inline = true,
+ onPress,
+ text,
+ textChecked,
+ tooltipText,
+ tooltipTextChecked,
+ styles: pressableStyle,
+ textStyles,
+ iconStyles,
+ icon,
+ }: PressableWithDelayToggleProps,
+ ref: ForwardedRef,
+) {
+ const [isActive, temporarilyDisableInteractions] = useThrottledButtonState();
+
+ const updatePressState = () => {
+ if (!isActive) {
+ return;
+ }
+ temporarilyDisableInteractions();
+ onPress();
+ };
+
+ // Due to limitations in RN regarding the vertical text alignment of non-Text elements,
+ // for elements that are supposed to be inline, we need to use a Text element instead
+ // of a Pressable
+ const PressableView = inline ? Text : PressableWithoutFeedback;
+ const tooltipTexts = !isActive ? tooltipTextChecked : tooltipText;
+ const labelText = (
+
+ {!isActive && textChecked ? textChecked : text}
+
+
+ );
+
+ return (
+
+ <>
+ {inline && labelText}
+
+
+ {({hovered, pressed}) => (
+ <>
+ {!inline && labelText}
+ {icon && (
+
+ )}
+ >
+ )}
+
+
+ >
+
+ );
+}
+
+PressableWithDelayToggle.displayName = 'PressableWithDelayToggle';
+
+export default forwardRef(PressableWithDelayToggle);
diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js
deleted file mode 100644
index ad29204bb018..000000000000
--- a/src/components/Pressable/PressableWithFeedback.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import propTypes from 'prop-types';
-import React, {forwardRef, useState} from 'react';
-import _ from 'underscore';
-import OpacityView from '@components/OpacityView';
-import variables from '@styles/variables';
-import GenericPressable from './GenericPressable';
-import GenericPressablePropTypes from './GenericPressable/PropTypes';
-
-const omittedProps = ['wrapperStyle', 'needsOffscreenAlphaCompositing'];
-
-const PressableWithFeedbackPropTypes = {
- ...GenericPressablePropTypes.pressablePropTypes,
- /**
- * Determines what opacity value should be applied to the underlaying view when Pressable is pressed.
- * To disable dimming, pass 1 as pressDimmingValue
- * @default variables.pressDimValue
- */
- pressDimmingValue: propTypes.number,
- /**
- * Determines what opacity value should be applied to the underlaying view when pressable is hovered.
- * To disable dimming, pass 1 as hoverDimmingValue
- * @default variables.hoverDimValue
- */
- hoverDimmingValue: propTypes.number,
- /**
- * Used to locate this view from native classes.
- */
- nativeID: propTypes.string,
-
- /** Whether the view needs to be rendered offscreen (for Android only) */
- needsOffscreenAlphaCompositing: propTypes.bool,
-};
-
-const PressableWithFeedbackDefaultProps = {
- ...GenericPressablePropTypes.defaultProps,
- pressDimmingValue: variables.pressDimValue,
- hoverDimmingValue: variables.hoverDimValue,
- nativeID: '',
- wrapperStyle: [],
- needsOffscreenAlphaCompositing: false,
-};
-
-const PressableWithFeedback = forwardRef((props, ref) => {
- const propsWithoutWrapperProps = _.omit(props, omittedProps);
- const [isPressed, setIsPressed] = useState(false);
- const [isHovered, setIsHovered] = useState(false);
-
- return (
-
- {
- setIsHovered(true);
- if (props.onHoverIn) {
- props.onHoverIn();
- }
- }}
- onHoverOut={() => {
- setIsHovered(false);
- if (props.onHoverOut) {
- props.onHoverOut();
- }
- }}
- onPressIn={() => {
- setIsPressed(true);
- if (props.onPressIn) {
- props.onPressIn();
- }
- }}
- onPressOut={() => {
- setIsPressed(false);
- if (props.onPressOut) {
- props.onPressOut();
- }
- }}
- >
- {(state) => (_.isFunction(props.children) ? props.children(state) : props.children)}
-
-
- );
-});
-
-PressableWithFeedback.displayName = 'PressableWithFeedback';
-PressableWithFeedback.propTypes = PressableWithFeedbackPropTypes;
-PressableWithFeedback.defaultProps = PressableWithFeedbackDefaultProps;
-
-export default PressableWithFeedback;
diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx
new file mode 100644
index 000000000000..5d7f7c110ea7
--- /dev/null
+++ b/src/components/Pressable/PressableWithFeedback.tsx
@@ -0,0 +1,90 @@
+import React, {ForwardedRef, forwardRef, useState} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import {AnimatedStyle} from 'react-native-reanimated';
+import OpacityView from '@components/OpacityView';
+import variables from '@styles/variables';
+import GenericPressable from './GenericPressable';
+import PressableProps from './GenericPressable/types';
+
+type PressableWithFeedbackProps = PressableProps & {
+ /** Style for the wrapper view */
+ wrapperStyle?: StyleProp>;
+
+ /**
+ * Determines what opacity value should be applied to the underlaying view when Pressable is pressed.
+ * To disable dimming, pass 1 as pressDimmingValue
+ * @default variables.pressDimValue
+ */
+ pressDimmingValue?: number;
+
+ /**
+ * Determines what opacity value should be applied to the underlaying view when pressable is hovered.
+ * To disable dimming, pass 1 as hoverDimmingValue
+ * @default variables.hoverDimValue
+ */
+ hoverDimmingValue?: number;
+
+ /** Whether the view needs to be rendered offscreen (for Android only) */
+ needsOffscreenAlphaCompositing?: boolean;
+};
+
+function PressableWithFeedback(
+ {
+ children,
+ wrapperStyle = [],
+ needsOffscreenAlphaCompositing = false,
+ pressDimmingValue = variables.pressDimValue,
+ hoverDimmingValue = variables.hoverDimValue,
+ ...rest
+ }: PressableWithFeedbackProps,
+ ref: ForwardedRef,
+) {
+ const [isPressed, setIsPressed] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+
+ return (
+
+ {
+ setIsHovered(true);
+ if (rest.onHoverIn) {
+ rest.onHoverIn(event);
+ }
+ }}
+ onHoverOut={(event) => {
+ setIsHovered(false);
+ if (rest.onHoverOut) {
+ rest.onHoverOut(event);
+ }
+ }}
+ onPressIn={(event) => {
+ setIsPressed(true);
+ if (rest.onPressIn) {
+ rest.onPressIn(event);
+ }
+ }}
+ onPressOut={(event) => {
+ setIsPressed(false);
+ if (rest.onPressOut) {
+ rest.onPressOut(event);
+ }
+ }}
+ >
+ {(state) => (typeof children === 'function' ? children(state) : children)}
+
+
+ );
+}
+
+PressableWithFeedback.displayName = 'PressableWithFeedback';
+
+export default forwardRef(PressableWithFeedback);
diff --git a/src/components/Pressable/PressableWithoutFeedback.js b/src/components/Pressable/PressableWithoutFeedback.js
deleted file mode 100644
index 92e704550dec..000000000000
--- a/src/components/Pressable/PressableWithoutFeedback.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react';
-import _ from 'underscore';
-import GenericPressable from './GenericPressable';
-import GenericPressableProps from './GenericPressable/PropTypes';
-
-const omittedProps = ['pressStyle', 'hoverStyle', 'focusStyle', 'activeStyle', 'disabledStyle', 'screenReaderActiveStyle', 'shouldUseHapticsOnPress', 'shouldUseHapticsOnLongPress'];
-
-const PressableWithoutFeedback = React.forwardRef((props, ref) => {
- const propsWithoutStyling = _.omit(props, omittedProps);
- return (
-
- );
-});
-
-PressableWithoutFeedback.displayName = 'PressableWithoutFeedback';
-PressableWithoutFeedback.propTypes = _.omit(GenericPressableProps.pressablePropTypes, omittedProps);
-PressableWithoutFeedback.defaultProps = _.omit(GenericPressableProps.defaultProps, omittedProps);
-
-export default PressableWithoutFeedback;
diff --git a/src/components/Pressable/PressableWithoutFeedback.tsx b/src/components/Pressable/PressableWithoutFeedback.tsx
new file mode 100644
index 000000000000..c3b780e63cfd
--- /dev/null
+++ b/src/components/Pressable/PressableWithoutFeedback.tsx
@@ -0,0 +1,21 @@
+import React, {ForwardedRef} from 'react';
+import {View} from 'react-native';
+import GenericPressable from './GenericPressable';
+import PressableProps from './GenericPressable/types';
+
+function PressableWithoutFeedback(
+ {pressStyle, hoverStyle, focusStyle, disabledStyle, screenReaderActiveStyle, shouldUseHapticsOnPress, shouldUseHapticsOnLongPress, ...rest}: PressableProps,
+ ref: ForwardedRef,
+) {
+ return (
+
+ );
+}
+
+PressableWithoutFeedback.displayName = 'PressableWithoutFeedback';
+
+export default React.forwardRef(PressableWithoutFeedback);
diff --git a/src/components/Pressable/PressableWithoutFocus.js b/src/components/Pressable/PressableWithoutFocus.js
deleted file mode 100644
index 641e695b1013..000000000000
--- a/src/components/Pressable/PressableWithoutFocus.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import _ from 'underscore';
-import StylePropType from '@styles/stylePropTypes';
-import GenericPressable from './GenericPressable';
-import genericPressablePropTypes from './GenericPressable/PropTypes';
-
-const propTypes = {
- /** Element that should be clickable */
- children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
-
- /** Callback for onPress event */
- onPress: PropTypes.func.isRequired,
-
- /** Callback for onLongPress event */
- onLongPress: PropTypes.func,
-
- /** Styles that should be passed to touchable container */
- style: StylePropType,
-
- /** Proptypes of pressable component used for implementation */
- ...genericPressablePropTypes.pressablePropTypes,
-};
-
-const defaultProps = {
- style: [],
- onLongPress: undefined,
-};
-
-/**
- * This component prevents the tapped element from capturing focus.
- * We need to blur this element when clicked as it opens modal that implements focus-trapping.
- * When the modal is closed it focuses back to the last active element.
- * Therefore it shifts the element to bring it back to focus.
- * https://github.com/Expensify/App/issues/6806
- */
-class PressableWithoutFocus extends React.Component {
- constructor(props) {
- super(props);
- this.pressAndBlur = this.pressAndBlur.bind(this);
- }
-
- pressAndBlur() {
- this.pressableRef.blur();
- this.props.onPress();
- }
-
- render() {
- const restProps = _.omit(this.props, ['children', 'onPress', 'onLongPress', 'style']);
- return (
- (this.pressableRef = el)}
- style={this.props.style}
- // eslint-disable-next-line react/jsx-props-no-spreading
- {...restProps}
- >
- {this.props.children}
-
- );
- }
-}
-
-PressableWithoutFocus.propTypes = propTypes;
-PressableWithoutFocus.defaultProps = defaultProps;
-
-export default PressableWithoutFocus;
diff --git a/src/components/Pressable/PressableWithoutFocus.tsx b/src/components/Pressable/PressableWithoutFocus.tsx
new file mode 100644
index 000000000000..32cb1708baf0
--- /dev/null
+++ b/src/components/Pressable/PressableWithoutFocus.tsx
@@ -0,0 +1,36 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import GenericPressable from './GenericPressable';
+import PressableProps from './GenericPressable/types';
+
+/**
+ * This component prevents the tapped element from capturing focus.
+ * We need to blur this element when clicked as it opens modal that implements focus-trapping.
+ * When the modal is closed it focuses back to the last active element.
+ * Therefore it shifts the element to bring it back to focus.
+ * https://github.com/Expensify/App/issues/6806
+ */
+function PressableWithoutFocus({children, onPress, onLongPress, ...rest}: PressableProps) {
+ const ref = useRef(null);
+
+ const pressAndBlur = () => {
+ ref?.current?.blur();
+ onPress();
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+PressableWithoutFocus.displayName = 'PressableWithoutFocus';
+
+export default PressableWithoutFocus;
diff --git a/src/components/Pressable/index.js b/src/components/Pressable/index.ts
similarity index 100%
rename from src/components/Pressable/index.js
rename to src/components/Pressable/index.ts
diff --git a/src/components/RadioButton.js b/src/components/RadioButton.js
index 9d8e739d723c..bb32f4a2c37b 100644
--- a/src/components/RadioButton.js
+++ b/src/components/RadioButton.js
@@ -37,7 +37,7 @@ function RadioButton(props) {
hoverDimmingValue={1}
pressDimmingValue={1}
accessibilityLabel={props.accessibilityLabel}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.RADIO}
+ role={CONST.ACCESSIBILITY_ROLE.RADIO}
>
props.onPress()}
style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]}
diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js
index 4e12ace9cc6c..653236f35831 100644
--- a/src/components/Reactions/AddReactionBubble.js
+++ b/src/components/Reactions/AddReactionBubble.js
@@ -98,7 +98,7 @@ function AddReactionBubble(props) {
e.preventDefault();
}}
accessibilityLabel={props.translate('emojiReactions.addReactionTooltip')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
// disable dimming
pressDimmingValue={1}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js
index a61923e49860..822b15711c50 100644
--- a/src/components/Reactions/EmojiReactionBubble.js
+++ b/src/components/Reactions/EmojiReactionBubble.js
@@ -82,7 +82,7 @@ function EmojiReactionBubble(props) {
// Prevent text input blur when emoji reaction is left clicked
e.preventDefault();
}}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={props.emojiCodes.join('')}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js
index ff4b94443940..d5f9f7ed06b8 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.js
+++ b/src/components/ReportActionItem/ReportActionItemImage.js
@@ -84,7 +84,7 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal, transactio
const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report.reportID, imageSource);
Navigation.navigate(route);
}}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
>
{receiptImageComponent}
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 00a4526b382f..e735210178ef 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -192,7 +192,7 @@ function ReportPreview(props) {
onPressOut={() => ControlSelection.unblock()}
onLongPress={(event) => showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive)}
style={[styles.flexRow, styles.justifyContentBetween, styles.reportPreviewBox]}
- accessibilityRole="button"
+ role="button"
accessibilityLabel={props.translate('iou.viewDetails')}
>
diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js
index af5b1e25f2a9..b31de0d22f4c 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -105,7 +105,7 @@ function TaskPreview(props) {
onPressOut={() => ControlSelection.unblock()}
onLongPress={(event) => showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive)}
style={[styles.flexRow, styles.justifyContentBetween]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={props.translate('task.task')}
>
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index 9aa85392dde7..b12d6ae32128 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -58,8 +58,8 @@ function TaskView(props) {
Task.clearEditTaskErrors(props.report.reportID)}
+ errors={lodashGet(props, 'report.errorFields.editTask') || lodashGet(props, 'report.errorFields.createTask')}
+ onClose={() => Task.clearTaskErrors(props.report.reportID)}
errorRowStyles={styles.ph5}
>
diff --git a/src/components/ReportHeaderSkeletonView.js b/src/components/ReportHeaderSkeletonView.js
index 6d2a8e343e3b..1b183fa9604c 100644
--- a/src/components/ReportHeaderSkeletonView.js
+++ b/src/components/ReportHeaderSkeletonView.js
@@ -32,7 +32,7 @@ function ReportHeaderSkeletonView(props) {
{}}
style={[styles.LHNToggle]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={props.translate('common.back')}
>
diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.js
index 6f191f82ba35..13d07bb0278a 100644
--- a/src/components/RoomHeaderAvatars.js
+++ b/src/components/RoomHeaderAvatars.js
@@ -39,7 +39,7 @@ function RoomHeaderAvatars(props) {
+
{_.map(iconsToDisplay, (icon, index) => (
gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
@@ -84,7 +88,7 @@ function ScreenWrapper({
// described here https://reactnavigation.org/docs/preventing-going-back/#limitations
const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose
? navigation.addListener('beforeRemove', () => {
- if (!isKeyboardShown) {
+ if (!isKeyboardShownRef.current) {
return;
}
Keyboard.dismiss();
@@ -118,7 +122,7 @@ function ScreenWrapper({
return (
diff --git a/src/components/SelectCircle.js b/src/components/SelectCircle.js
deleted file mode 100644
index ce451e148030..000000000000
--- a/src/components/SelectCircle.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import styles from '@styles/styles';
-import themeColors from '@styles/themes/default';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-
-const propTypes = {
- /** Should we show the checkmark inside the circle */
- isChecked: PropTypes.bool,
-
- /** Additional styles to pass to SelectCircle */
- // eslint-disable-next-line react/forbid-prop-types
- styles: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- isChecked: false,
- styles: [],
-};
-
-function SelectCircle(props) {
- return (
-
- {props.isChecked && (
-
- )}
-
- );
-}
-
-SelectCircle.propTypes = propTypes;
-SelectCircle.defaultProps = defaultProps;
-SelectCircle.displayName = 'SelectCircle';
-
-export default SelectCircle;
diff --git a/src/components/SelectCircle.tsx b/src/components/SelectCircle.tsx
new file mode 100644
index 000000000000..cf8ee6af975d
--- /dev/null
+++ b/src/components/SelectCircle.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import globalStyles from '@styles/styles';
+import themeColors from '@styles/themes/default';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+
+type SelectCircleProps = {
+ /** Should we show the checkmark inside the circle */
+ isChecked: boolean;
+
+ /** Additional styles to pass to SelectCircle */
+ styles?: StyleProp;
+};
+
+function SelectCircle({isChecked = false, styles}: SelectCircleProps) {
+ return (
+
+ {isChecked && (
+
+ )}
+
+ );
+}
+
+SelectCircle.displayName = 'SelectCircle';
+
+export default SelectCircle;
diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.js
index 3dd4417367b2..5f9fced94cb2 100644
--- a/src/components/SelectionList/BaseListItem.js
+++ b/src/components/SelectionList/BaseListItem.js
@@ -40,7 +40,7 @@ function BaseListItem({
onPress={() => onSelectRow(item)}
disabled={isDisabled}
accessibilityLabel={item.text}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
hoverDimmingValue={1}
hoverStyle={styles.hoveredComponentBG}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
index 2a7947733a9e..e3ba0dbd7c2f 100644
--- a/src/components/SelectionList/BaseSelectionList.js
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -40,7 +40,7 @@ function BaseSelectionList({
textInputPlaceholder = '',
textInputValue = '',
textInputMaxLength,
- keyboardType = CONST.KEYBOARD_TYPE.DEFAULT,
+ inputMode = CONST.INPUT_MODE.TEXT,
onChangeText,
initiallyFocusedOptionKey = '',
onScroll,
@@ -389,12 +389,12 @@ function BaseSelectionList({
}}
label={textInputLabel}
accessibilityLabel={textInputLabel}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
value={textInputValue}
placeholder={textInputPlaceholder}
maxLength={textInputMaxLength}
onChangeText={onChangeText}
- keyboardType={keyboardType}
+ inputMode={inputMode}
selectTextOnFocus
spellCheck={false}
onSubmitEditing={selectFocusedOption}
@@ -417,7 +417,7 @@ function BaseSelectionList({
style={[styles.peopleRow, styles.userSelectNone, styles.ph5, styles.pb3]}
onPress={selectAllRow}
accessibilityLabel={translate('workspace.people.selectAll')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role="button"
accessibilityState={{checked: flattenedSections.allSelected}}
disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
index c3bae89eaba2..5b95f7dd0cbf 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -138,8 +138,8 @@ const propTypes = {
/** Callback to fire when the text input changes */
onChangeText: PropTypes.func,
- /** Keyboard type for the text input */
- keyboardType: PropTypes.string,
+ /** Input mode for the text input */
+ inputMode: PropTypes.string,
/** Item `keyForList` to focus initially */
initiallyFocusedOptionKey: PropTypes.string,
diff --git a/src/components/SignInButtons/GoogleSignIn/index.website.js b/src/components/SignInButtons/GoogleSignIn/index.website.js
index 7f3a3019c318..d65af124bfe8 100644
--- a/src/components/SignInButtons/GoogleSignIn/index.website.js
+++ b/src/components/SignInButtons/GoogleSignIn/index.website.js
@@ -5,6 +5,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import styles from '@styles/styles';
import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
+import CONST from '@src/CONST';
const propTypes = {
/** Whether we're rendering in the Desktop Flow, if so show a different button. */
@@ -74,7 +75,7 @@ function GoogleSignIn({translate, isDesktopFlow}) {
@@ -82,7 +83,7 @@ function GoogleSignIn({translate, isDesktopFlow}) {
diff --git a/src/components/SignInButtons/IconButton.js b/src/components/SignInButtons/IconButton.js
index 0d18779ea9ba..ce932b875542 100644
--- a/src/components/SignInButtons/IconButton.js
+++ b/src/components/SignInButtons/IconButton.js
@@ -37,7 +37,7 @@ function IconButton({onPress, translate, provider}) {
onSelectOption(option)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityState={{checked: selectedOptionKey === option.key}}
aria-checked={selectedOptionKey === option.key}
accessibilityLabel={option.label}
diff --git a/src/components/Switch.js b/src/components/Switch.js
index 755cd60f2597..c5adbbef61da 100644
--- a/src/components/Switch.js
+++ b/src/components/Switch.js
@@ -38,9 +38,8 @@ function Switch(props) {
style={[styles.switchTrack, !props.isOn && styles.switchInactive]}
onPress={() => props.onToggle(!props.isOn)}
onLongPress={() => props.onToggle(!props.isOn)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.SWITCH}
- accessibilityState={{checked: props.isOn}}
- aria-checked={props.isOn}
+ role={CONST.ACCESSIBILITY_ROLE.SWITCH}
+ ariaChecked={props.isOn}
accessibilityLabel={props.accessibilityLabel}
// disable hover dim for switch
hoverDimmingValue={1}
diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js
similarity index 100%
rename from src/components/TextInput/baseTextInputPropTypes.js
rename to src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js
diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput/index.js
similarity index 92%
rename from src/components/TextInput/BaseTextInput.js
rename to src/components/TextInput/BaseTextInput/index.js
index c9b1944b5b81..e643c6ff6b4f 100644
--- a/src/components/TextInput/BaseTextInput.js
+++ b/src/components/TextInput/BaseTextInput/index.js
@@ -10,9 +10,10 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed
import RNTextInput from '@components/RNTextInput';
import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder';
import Text from '@components/Text';
+import * as styleConst from '@components/TextInput/styleConst';
+import TextInputLabel from '@components/TextInput/TextInputLabel';
import withLocalize from '@components/withLocalize';
import * as Browser from '@libs/Browser';
-import getSecureEntryKeyboardType from '@libs/getSecureEntryKeyboardType';
import isInputAutoFilled from '@libs/isInputAutoFilled';
import useNativeDriver from '@libs/useNativeDriver';
import styles from '@styles/styles';
@@ -21,8 +22,6 @@ import themeColors from '@styles/themes/default';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import * as baseTextInputPropTypes from './baseTextInputPropTypes';
-import * as styleConst from './styleConst';
-import TextInputLabel from './TextInputLabel';
function BaseTextInput(props) {
const initialValue = props.value || props.defaultValue || '';
@@ -214,7 +213,7 @@ function BaseTextInput(props) {
// eslint-disable-next-line react/forbid-foreign-prop-types
const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes));
const hasLabel = Boolean(props.label.length);
- const isEditable = _.isUndefined(props.editable) ? !props.disabled : props.editable;
+ const isReadOnly = _.isUndefined(props.readOnly) ? props.disabled : props.readOnly;
const inputHelpText = props.errorText || props.hint;
const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null;
const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight;
@@ -231,7 +230,7 @@ function BaseTextInput(props) {
/* To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome,
make sure to include the `lineHeight`.
Reference: https://github.com/Expensify/App/issues/26735
-
+
For other platforms, explicitly remove `lineHeight` from single-line inputs
to prevent long text from disappearing once it exceeds the input space.
See https://github.com/Expensify/App/issues/13802 */
@@ -256,7 +255,7 @@ function BaseTextInput(props) {
>
{/* Adding this background to the label only for multiline text input,
to prevent text overlapping with label when scrolling */}
- {isMultiline && (
-
- )}
+ {isMultiline && }
>
) : null}
-
+
{Boolean(props.prefixCharacter) && (
{props.prefixCharacter}
@@ -346,6 +336,7 @@ function BaseTextInput(props) {
props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight),
// Add disabled color theme when field is not editable.
props.disabled && styles.textInputDisabled,
+ styles.pointerEventsAuto,
]}
multiline={isMultiline}
maxLength={props.maxLength}
@@ -355,10 +346,10 @@ function BaseTextInput(props) {
secureTextEntry={passwordHidden}
onPressOut={props.onPress}
showSoftInputOnFocus={!props.disableKeyboard}
- keyboardType={getSecureEntryKeyboardType(props.keyboardType, props.secureTextEntry, passwordHidden)}
+ inputMode={props.inputMode}
value={props.value}
selection={props.selection}
- editable={isEditable}
+ readOnly={isReadOnly}
defaultValue={props.defaultValue}
// FormSubmit Enter key handler does not have access to direct props.
// `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback.
@@ -385,7 +376,7 @@ function BaseTextInput(props) {
)}
{!props.secureTextEntry && Boolean(props.icon) && (
-
+
{/*
- Text input component doesn't support auto grow by default.
- We're using a hidden text input to achieve that.
- This text view is used to calculate width or height of the input value given textStyle in this component.
- This Text component is intentionally positioned out of the screen.
- */}
+ Text input component doesn't support auto grow by default.
+ We're using a hidden text input to achieve that.
+ This text view is used to calculate width or height of the input value given textStyle in this component.
+ This Text component is intentionally positioned out of the screen.
+ */}
{(props.autoGrow || props.autoGrowHeight) && (
// Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value
// https://github.com/Expensify/App/issues/8158
diff --git a/src/components/TextInput/BaseTextInput/index.native.js b/src/components/TextInput/BaseTextInput/index.native.js
new file mode 100644
index 000000000000..5c3e19a2d94c
--- /dev/null
+++ b/src/components/TextInput/BaseTextInput/index.native.js
@@ -0,0 +1,401 @@
+import Str from 'expensify-common/lib/str';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native';
+import _ from 'underscore';
+import Checkbox from '@components/Checkbox';
+import FormHelpMessage from '@components/FormHelpMessage';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+import RNTextInput from '@components/RNTextInput';
+import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder';
+import Text from '@components/Text';
+import * as styleConst from '@components/TextInput/styleConst';
+import TextInputLabel from '@components/TextInput/TextInputLabel';
+import withLocalize from '@components/withLocalize';
+import getSecureEntryKeyboardType from '@libs/getSecureEntryKeyboardType';
+import isInputAutoFilled from '@libs/isInputAutoFilled';
+import useNativeDriver from '@libs/useNativeDriver';
+import styles from '@styles/styles';
+import * as StyleUtils from '@styles/StyleUtils';
+import themeColors from '@styles/themes/default';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import * as baseTextInputPropTypes from './baseTextInputPropTypes';
+
+function BaseTextInput(props) {
+ const initialValue = props.value || props.defaultValue || '';
+ const initialActiveLabel = props.forceActiveLabel || initialValue.length > 0 || Boolean(props.prefixCharacter);
+
+ const [isFocused, setIsFocused] = useState(false);
+ const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry);
+ const [textInputWidth, setTextInputWidth] = useState(0);
+ const [textInputHeight, setTextInputHeight] = useState(0);
+ const [height, setHeight] = useState(variables.componentSizeLarge);
+ const [width, setWidth] = useState();
+ const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current;
+ const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current;
+
+ const input = useRef(null);
+ const isLabelActive = useRef(initialActiveLabel);
+
+ // AutoFocus which only works on mount:
+ useEffect(() => {
+ // We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514
+ if (!props.autoFocus || !input.current) {
+ return;
+ }
+
+ if (props.shouldDelayFocus) {
+ const focusTimeout = setTimeout(() => input.current.focus(), CONST.ANIMATED_TRANSITION);
+ return () => clearTimeout(focusTimeout);
+ }
+ input.current.focus();
+ // We only want this to run on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const animateLabel = useCallback(
+ (translateY, scale) => {
+ Animated.parallel([
+ Animated.spring(labelTranslateY, {
+ toValue: translateY,
+ duration: styleConst.LABEL_ANIMATION_DURATION,
+ useNativeDriver,
+ }),
+ Animated.spring(labelScale, {
+ toValue: scale,
+ duration: styleConst.LABEL_ANIMATION_DURATION,
+ useNativeDriver,
+ }),
+ ]).start();
+ },
+ [labelScale, labelTranslateY],
+ );
+
+ const activateLabel = useCallback(() => {
+ const value = props.value || '';
+
+ if (value.length < 0 || isLabelActive.current) {
+ return;
+ }
+
+ animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE);
+ isLabelActive.current = true;
+ }, [animateLabel, props.value]);
+
+ const deactivateLabel = useCallback(() => {
+ const value = props.value || '';
+
+ if (props.forceActiveLabel || value.length !== 0 || props.prefixCharacter) {
+ return;
+ }
+
+ animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE);
+ isLabelActive.current = false;
+ }, [animateLabel, props.forceActiveLabel, props.prefixCharacter, props.value]);
+
+ const onFocus = (event) => {
+ if (props.onFocus) {
+ props.onFocus(event);
+ }
+ setIsFocused(true);
+ };
+
+ const onBlur = (event) => {
+ if (props.onBlur) {
+ props.onBlur(event);
+ }
+ setIsFocused(false);
+ };
+
+ const onPress = (event) => {
+ if (props.disabled) {
+ return;
+ }
+
+ if (props.onPress) {
+ props.onPress(event);
+ }
+
+ if (!event.isDefaultPrevented()) {
+ input.current.focus();
+ }
+ };
+
+ const onLayout = useCallback(
+ (event) => {
+ if (!props.autoGrowHeight && props.multiline) {
+ return;
+ }
+
+ const layout = event.nativeEvent.layout;
+
+ setWidth((prevWidth) => (props.autoGrowHeight ? layout.width : prevWidth));
+ setHeight((prevHeight) => (!props.multiline ? layout.height : prevHeight));
+ },
+ [props.autoGrowHeight, props.multiline],
+ );
+
+ // The ref is needed when the component is uncontrolled and we don't have a value prop
+ const hasValueRef = useRef(initialValue.length > 0);
+ const inputValue = props.value || '';
+ const hasValue = inputValue.length > 0 || hasValueRef.current;
+
+ // Activate or deactivate the label when either focus changes, or for controlled
+ // components when the value prop changes:
+ useEffect(() => {
+ if (
+ hasValue ||
+ isFocused ||
+ // If the text has been supplied by Chrome autofill, the value state is not synced with the value
+ // as Chrome doesn't trigger a change event. When there is autofill text, keep the label activated.
+ isInputAutoFilled(input.current)
+ ) {
+ activateLabel();
+ } else {
+ deactivateLabel();
+ }
+ }, [activateLabel, deactivateLabel, hasValue, isFocused]);
+
+ // When the value prop gets cleared externally, we need to keep the ref in sync:
+ useEffect(() => {
+ // Return early when component uncontrolled, or we still have a value
+ if (props.value === undefined || !_.isEmpty(props.value)) {
+ return;
+ }
+ hasValueRef.current = false;
+ }, [props.value]);
+
+ /**
+ * Set Value & activateLabel
+ *
+ * @param {String} value
+ * @memberof BaseTextInput
+ */
+ const setValue = (value) => {
+ if (props.onInputChange) {
+ props.onInputChange(value);
+ }
+
+ Str.result(props.onChangeText, value);
+
+ if (value && value.length > 0) {
+ hasValueRef.current = true;
+ // When the componment is uncontrolled, we need to manually activate the label:
+ if (props.value === undefined) {
+ activateLabel();
+ }
+ } else {
+ hasValueRef.current = false;
+ }
+ };
+
+ const togglePasswordVisibility = useCallback(() => {
+ setPasswordHidden((prevPasswordHidden) => !prevPasswordHidden);
+ }, []);
+
+ // When adding a new prefix character, adjust this method to add expected character width.
+ // This is because character width isn't known before it's rendered to the screen, and once it's rendered,
+ // it's too late to calculate it's width because the change in padding would cause a visible jump.
+ // Some characters are wider than the others when rendered, e.g. '@' vs '#'. Chosen font-family and font-size
+ // also have an impact on the width of the character, but as long as there's only one font-family and one font-size,
+ // this method will produce reliable results.
+ const getCharacterPadding = (prefix) => {
+ switch (prefix) {
+ case CONST.POLICY.ROOM_PREFIX:
+ return 10;
+ default:
+ throw new Error(`Prefix ${prefix} has no padding assigned.`);
+ }
+ };
+
+ // eslint-disable-next-line react/forbid-foreign-prop-types
+ const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes));
+ const hasLabel = Boolean(props.label.length);
+ const isReadOnly = _.isUndefined(props.readOnly) ? props.disabled : props.readOnly;
+ const inputHelpText = props.errorText || props.hint;
+ const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null;
+ const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight;
+ const textInputContainerStyles = StyleSheet.flatten([
+ styles.textInputContainer,
+ ...props.textInputContainerStyles,
+ props.autoGrow && StyleUtils.getWidthStyle(textInputWidth),
+ !props.hideFocusedState && isFocused && styles.borderColorFocus,
+ (props.hasError || props.errorText) && styles.borderColorDanger,
+ props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight},
+ ]);
+ const isMultiline = props.multiline || props.autoGrowHeight;
+
+ return (
+ <>
+
+
+
+ {hasLabel ? (
+ <>
+ {/* Adding this background to the label only for multiline text input,
+ to prevent text overlapping with label when scrolling */}
+ {isMultiline && }
+
+ >
+ ) : null}
+
+ {Boolean(props.prefixCharacter) && (
+
+
+ {props.prefixCharacter}
+
+
+ )}
+ {
+ if (typeof props.innerRef === 'function') {
+ props.innerRef(ref);
+ } else if (props.innerRef && _.has(props.innerRef, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ props.innerRef.current = ref;
+ }
+ input.current = ref;
+ }}
+ // eslint-disable-next-line
+ {...inputProps}
+ autoCorrect={props.secureTextEntry ? false : props.autoCorrect}
+ placeholder={placeholder}
+ placeholderTextColor={themeColors.placeholderText}
+ underlineColorAndroid="transparent"
+ style={[
+ styles.flex1,
+ styles.w100,
+ props.inputStyle,
+ (!hasLabel || isMultiline) && styles.pv0,
+ props.prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(props.prefixCharacter) + styles.pl1.paddingLeft),
+ props.secureTextEntry && styles.secureInput,
+
+ !isMultiline && {height, lineHeight: undefined},
+
+ // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
+ props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight),
+ // Add disabled color theme when field is not editable.
+ props.disabled && styles.textInputDisabled,
+ styles.pointerEventsAuto,
+ ]}
+ multiline={isMultiline}
+ maxLength={props.maxLength}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onChangeText={setValue}
+ secureTextEntry={passwordHidden}
+ onPressOut={props.onPress}
+ showSoftInputOnFocus={!props.disableKeyboard}
+ keyboardType={getSecureEntryKeyboardType(props.keyboardType, props.secureTextEntry, passwordHidden)}
+ inputMode={!props.disableKeyboard ? props.inputMode : CONST.INPUT_MODE.NONE}
+ value={props.value}
+ selection={props.selection}
+ readOnly={isReadOnly}
+ defaultValue={props.defaultValue}
+ // FormSubmit Enter key handler does not have access to direct props.
+ // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback.
+ dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}}
+ />
+ {props.isLoading && (
+
+ )}
+ {Boolean(props.secureTextEntry) && (
+ e.preventDefault()}
+ accessibilityLabel={props.translate('common.visible')}
+ >
+
+
+ )}
+ {!props.secureTextEntry && Boolean(props.icon) && (
+
+
+
+ )}
+
+
+
+ {!_.isEmpty(inputHelpText) && (
+
+ )}
+
+ {/*
+ Text input component doesn't support auto grow by default.
+ We're using a hidden text input to achieve that.
+ This text view is used to calculate width or height of the input value given textStyle in this component.
+ This Text component is intentionally positioned out of the screen.
+ */}
+ {(props.autoGrow || props.autoGrowHeight) && (
+ // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value
+ // https://github.com/Expensify/App/issues/8158
+ // https://github.com/Expensify/App/issues/26628
+ {
+ setTextInputWidth(e.nativeEvent.layout.width);
+ setTextInputHeight(e.nativeEvent.layout.height);
+ }}
+ >
+ {/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
+ {props.value ? `${props.value}${props.value.endsWith('\n') ? '\u200B' : ''}` : props.placeholder}
+
+ )}
+ >
+ );
+}
+
+BaseTextInput.displayName = 'BaseTextInput';
+BaseTextInput.propTypes = baseTextInputPropTypes.propTypes;
+BaseTextInput.defaultProps = baseTextInputPropTypes.defaultProps;
+
+export default withLocalize(BaseTextInput);
diff --git a/src/components/TextInput/TextInputLabel/index.js b/src/components/TextInput/TextInputLabel/index.js
index f777eff039bd..b49635b91d96 100644
--- a/src/components/TextInput/TextInputLabel/index.js
+++ b/src/components/TextInput/TextInputLabel/index.js
@@ -18,9 +18,8 @@ function TextInputLabel({for: inputId, label, labelTranslateY, labelScale}) {
return (
{label}
diff --git a/src/components/TextInput/index.js b/src/components/TextInput/index.js
index 5f6164d3bc9a..044399ec6e11 100644
--- a/src/components/TextInput/index.js
+++ b/src/components/TextInput/index.js
@@ -5,7 +5,7 @@ import DomUtils from '@libs/DomUtils';
import Visibility from '@libs/Visibility';
import styles from '@styles/styles';
import BaseTextInput from './BaseTextInput';
-import * as baseTextInputPropTypes from './baseTextInputPropTypes';
+import * as baseTextInputPropTypes from './BaseTextInput/baseTextInputPropTypes';
import * as styleConst from './styleConst';
function TextInput(props) {
diff --git a/src/components/TextInput/index.native.js b/src/components/TextInput/index.native.js
index d28824a9977b..a4d0c76337ab 100644
--- a/src/components/TextInput/index.native.js
+++ b/src/components/TextInput/index.native.js
@@ -2,7 +2,7 @@ import React, {forwardRef, useEffect} from 'react';
import {AppState, Keyboard} from 'react-native';
import styles from '@styles/styles';
import BaseTextInput from './BaseTextInput';
-import * as baseTextInputPropTypes from './baseTextInputPropTypes';
+import * as baseTextInputPropTypes from './BaseTextInput/baseTextInputPropTypes';
const TextInput = forwardRef((props, ref) => {
useEffect(() => {
diff --git a/src/components/TextLink.js b/src/components/TextLink.js
index 79f3d43a7743..9292ac51e78f 100644
--- a/src/components/TextLink.js
+++ b/src/components/TextLink.js
@@ -66,7 +66,7 @@ function TextLink(props) {
return (
- {props.text}
-
- );
-}
-
-TextPill.propTypes = propTypes;
-TextPill.defaultProps = defaultProps;
-TextPill.displayName = 'TextPill';
-
-export default TextPill;
diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js
index 973aa7e5e189..a1c75a81a92f 100644
--- a/src/components/ThreeDotsMenu/index.js
+++ b/src/components/ThreeDotsMenu/index.js
@@ -110,7 +110,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me
}}
ref={buttonRef}
style={[styles.touchableButtonImage, ...iconStyles]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={translate(iconTooltip)}
>
diff --git a/src/components/ValidateCode/JustSignedInModal.js b/src/components/ValidateCode/JustSignedInModal.js
index dd6085646120..143a967f1458 100644
--- a/src/components/ValidateCode/JustSignedInModal.js
+++ b/src/components/ValidateCode/JustSignedInModal.js
@@ -50,4 +50,6 @@ function JustSignedInModal(props) {
}
JustSignedInModal.propTypes = propTypes;
+JustSignedInModal.displayName = 'JustSignedInModal';
+
export default withLocalize(JustSignedInModal);
diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js
index 173467d16b14..6faccef6c31f 100644
--- a/src/components/ValidateCode/ValidateCodeModal.js
+++ b/src/components/ValidateCode/ValidateCodeModal.js
@@ -81,6 +81,8 @@ function ValidateCodeModal(props) {
ValidateCodeModal.propTypes = propTypes;
ValidateCodeModal.defaultProps = defaultProps;
+ValidateCodeModal.displayName = 'ValidateCodeModal';
+
export default compose(
withLocalize,
withOnyx({
diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
index ceb10de0f909..589866eecc67 100755
--- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
+++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
@@ -101,7 +101,7 @@ function BaseVideoChatButtonAndMenu(props) {
})}
style={styles.touchableButtonImage}
accessibilityLabel={props.translate('videoChatButtonAndMenu.tooltip')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []);
-
- return context ? (
-
- ) : (
-
-
-
- );
- }
- WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`;
- WithNavigationFallback.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithNavigationFallback.defaultProps = {
- forwardedRef: undefined,
- };
-
- const WithNavigationFallbackWithRef = forwardRef((props, ref) => (
-
- ));
-
- WithNavigationFallbackWithRef.displayName = `WithNavigationFallbackWithRef`;
-
- return WithNavigationFallbackWithRef;
-}
diff --git a/src/components/withNavigationFallback.tsx b/src/components/withNavigationFallback.tsx
new file mode 100644
index 000000000000..aa58b12d4b01
--- /dev/null
+++ b/src/components/withNavigationFallback.tsx
@@ -0,0 +1,49 @@
+import {NavigationContext} from '@react-navigation/core';
+import {NavigationProp} from '@react-navigation/native';
+import {ParamListBase} from '@react-navigation/routers';
+import React, {ComponentType, ForwardedRef, forwardRef, ReactElement, RefAttributes, useContext, useMemo} from 'react';
+
+type AddListenerCallback = () => void;
+
+type RemoveListenerCallback = () => void;
+
+type NavigationContextValue = {
+ isFocused: () => boolean;
+ addListener: () => AddListenerCallback;
+ removeListener: () => RemoveListenerCallback;
+};
+
+export default function (WrappedComponent: ComponentType>): (props: TProps & RefAttributes) => ReactElement | null {
+ function WithNavigationFallback(props: TProps, ref: ForwardedRef) {
+ const context = useContext(NavigationContext);
+
+ const navigationContextValue: NavigationContextValue = useMemo(
+ () => ({
+ isFocused: () => true,
+ addListener: () => () => {},
+ removeListener: () => () => {},
+ }),
+ [],
+ );
+
+ return context ? (
+
+ ) : (
+ }>
+
+
+ );
+ }
+
+ WithNavigationFallback.displayName = 'WithNavigationFocusWithFallback';
+
+ return forwardRef(WithNavigationFallback);
+}
diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js
deleted file mode 100644
index 04c6ab8e8481..000000000000
--- a/src/components/withToggleVisibilityView.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-import styles from '@styles/styles';
-import refPropTypes from './refPropTypes';
-
-const toggleVisibilityViewPropTypes = {
- /** Whether the content is visible. */
- isVisible: PropTypes.bool,
-};
-
-export default function (WrappedComponent) {
- function WithToggleVisibilityView(props) {
- return (
-
-
-
- );
- }
-
- WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`;
- WithToggleVisibilityView.propTypes = {
- forwardedRef: refPropTypes,
-
- /** Whether the content is visible. */
- isVisible: PropTypes.bool,
- };
- WithToggleVisibilityView.defaultProps = {
- forwardedRef: undefined,
- isVisible: false,
- };
-
- const WithToggleVisibilityViewWithRef = React.forwardRef((props, ref) => (
-
- ));
-
- WithToggleVisibilityViewWithRef.displayName = `WithToggleVisibilityViewWithRef`;
-
- return WithToggleVisibilityViewWithRef;
-}
-
-export {toggleVisibilityViewPropTypes};
diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx
new file mode 100644
index 000000000000..5e0204f6e06f
--- /dev/null
+++ b/src/components/withToggleVisibilityView.tsx
@@ -0,0 +1,30 @@
+import React, {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react';
+import {View} from 'react-native';
+import {SetOptional} from 'type-fest';
+import getComponentDisplayName from '@libs/getComponentDisplayName';
+import styles from '@styles/styles';
+
+type ToggleVisibilityViewProps = {
+ /** Whether the content is visible. */
+ isVisible: boolean;
+};
+
+export default function withToggleVisibilityView(
+ WrappedComponent: ComponentType>,
+): (props: TProps & RefAttributes) => ReactElement | null {
+ function WithToggleVisibilityView({isVisible = false, ...rest}: SetOptional, ref: ForwardedRef) {
+ return (
+
+
+
+ );
+ }
+
+ WithToggleVisibilityView.displayName = `WithToggleVisibilityViewWithRef(${getComponentDisplayName(WrappedComponent)})`;
+ return React.forwardRef(WithToggleVisibilityView);
+}
diff --git a/src/hooks/useAutoFocusInput.js b/src/hooks/useAutoFocusInput.js
index 275fed67f52d..d4d43c8bf144 100644
--- a/src/hooks/useAutoFocusInput.js
+++ b/src/hooks/useAutoFocusInput.js
@@ -1,20 +1,24 @@
import {useFocusEffect} from '@react-navigation/native';
-import {useCallback, useEffect, useRef, useState} from 'react';
+import {useCallback, useContext, useEffect, useRef, useState} from 'react';
import CONST from '@src/CONST';
+import * as Expensify from '@src/Expensify';
export default function useAutoFocusInput() {
const [isInputInitialized, setIsInputInitialized] = useState(false);
const [isScreenTransitionEnded, setIsScreenTransitionEnded] = useState(false);
+ const {isSplashHidden} = useContext(Expensify.SplashScreenHiddenContext);
+
const inputRef = useRef(null);
const focusTimeoutRef = useRef(null);
useEffect(() => {
- if (!isScreenTransitionEnded || !isInputInitialized || !inputRef.current) {
+ if (!isScreenTransitionEnded || !isInputInitialized || !inputRef.current || !isSplashHidden) {
return;
}
inputRef.current.focus();
- }, [isScreenTransitionEnded, isInputInitialized]);
+ setIsScreenTransitionEnded(false);
+ }, [isScreenTransitionEnded, isInputInitialized, isSplashHidden]);
useFocusEffect(
useCallback(() => {
diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js
index 874f9d72b276..62a919925a53 100644
--- a/src/hooks/useDebounce.js
+++ b/src/hooks/useDebounce.js
@@ -1,5 +1,5 @@
import lodashDebounce from 'lodash/debounce';
-import {useEffect, useRef} from 'react';
+import {useCallback, useEffect, useRef} from 'react';
/**
* Create and return a debounced function.
@@ -27,11 +27,13 @@ export default function useDebounce(func, wait, options) {
return debouncedFn.cancel;
}, [func, wait, leading, maxWait, trailing]);
- return (...args) => {
+ const debounceCallback = useCallback((...args) => {
const debouncedFn = debouncedFnRef.current;
if (debouncedFn) {
debouncedFn(...args);
}
- };
+ }, []);
+
+ return debounceCallback;
}
diff --git a/src/hooks/useInitialWindowDimensions/index.js b/src/hooks/useInitialWindowDimensions/index.js
index 487b4e498228..5878c8b3371f 100644
--- a/src/hooks/useInitialWindowDimensions/index.js
+++ b/src/hooks/useInitialWindowDimensions/index.js
@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import {useEffect, useState} from 'react';
import {Dimensions} from 'react-native';
-import {initialWindowMetrics} from 'react-native-safe-area-context';
/**
* A convenience hook that provides initial size (width and height).
@@ -50,10 +49,8 @@ export default function () {
};
}, []);
- const bottomInset = initialWindowMetrics && initialWindowMetrics.insets && initialWindowMetrics.insets.bottom ? initialWindowMetrics.insets.bottom : 0;
-
return {
initialWidth: dimensions.initialWidth,
- initialHeight: dimensions.initialHeight - bottomInset,
+ initialHeight: dimensions.initialHeight,
};
}
diff --git a/src/hooks/useSingleExecution.js b/src/hooks/useSingleExecution/index.native.ts
similarity index 83%
rename from src/hooks/useSingleExecution.js
rename to src/hooks/useSingleExecution/index.native.ts
index a2b4ccb4cd53..16a98152def1 100644
--- a/src/hooks/useSingleExecution.js
+++ b/src/hooks/useSingleExecution/index.native.ts
@@ -1,20 +1,20 @@
import {useCallback, useRef, useState} from 'react';
import {InteractionManager} from 'react-native';
+type Action = (...params: T) => void | Promise;
+
/**
* With any action passed in, it will only allow 1 such action to occur at a time.
- *
- * @returns {Object}
*/
export default function useSingleExecution() {
const [isExecuting, setIsExecuting] = useState(false);
- const isExecutingRef = useRef();
+ const isExecutingRef = useRef();
isExecutingRef.current = isExecuting;
const singleExecution = useCallback(
- (action) =>
- (...params) => {
+ (action: Action) =>
+ (...params: T) => {
if (isExecutingRef.current) {
return;
}
diff --git a/src/hooks/useSingleExecution/index.ts b/src/hooks/useSingleExecution/index.ts
new file mode 100644
index 000000000000..c37087d27c5f
--- /dev/null
+++ b/src/hooks/useSingleExecution/index.ts
@@ -0,0 +1,20 @@
+import {useCallback} from 'react';
+
+type Action = (...params: T) => void | Promise;
+
+/**
+ * This hook was specifically written for native issue
+ * more information: https://github.com/Expensify/App/pull/24614 https://github.com/Expensify/App/pull/24173
+ * on web we don't need this mechanism so we just call the action directly.
+ */
+export default function useSingleExecution() {
+ const singleExecution = useCallback(
+ (action: Action) =>
+ (...params: T) => {
+ action(...params);
+ },
+ [],
+ );
+
+ return {isExecuting: false, singleExecution};
+}
diff --git a/src/languages/en.ts b/src/languages/en.ts
index c186a1fffedf..38efe0ef92f6 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -811,7 +811,6 @@ export default {
title: 'Private notes',
personalNoteMessage: 'Keep notes about this chat here. You are the only person who can add, edit or view these notes.',
sharedNoteMessage: 'Keep notes about this chat here. Expensify employees and other users on the team.expensify.com domain can view these notes.',
- notesUnavailable: 'No notes found for the user',
composerLabel: 'Notes',
myNote: 'My note',
},
@@ -1638,6 +1637,7 @@ export default {
markAsComplete: 'Mark as complete',
markAsIncomplete: 'Mark as incomplete',
assigneeError: 'There was an error assigning this task, please try another assignee.',
+ genericCreateTaskFailureMessage: 'Unexpected error create task, please try again later.',
},
statementPage: {
generatingPDF: "We're generating your PDF right now. Please come back later!",
diff --git a/src/languages/es.ts b/src/languages/es.ts
index a0a30bcf4141..2bdb71ae82f7 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -806,7 +806,6 @@ export default {
title: 'Notas privadas',
personalNoteMessage: 'Guarda notas sobre este chat aquí. Usted es la única persona que puede añadir, editar o ver estas notas.',
sharedNoteMessage: 'Guarda notas sobre este chat aquí. Los empleados de Expensify y otros usuarios del dominio team.expensify.com pueden ver estas notas.',
- notesUnavailable: 'No se han encontrado notas para el usuario',
composerLabel: 'Notas',
myNote: 'Mi nota',
},
@@ -1661,6 +1660,7 @@ export default {
markAsComplete: 'Marcar como completada',
markAsIncomplete: 'Marcar como incompleta',
assigneeError: 'Hubo un error al asignar esta tarea, inténtalo con otro usuario.',
+ genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea, por favor, inténtalo más tarde.',
},
statementPage: {
generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!',
diff --git a/src/languages/translations.ts b/src/languages/translations.ts
index d228394589b2..4d89f1f529de 100644
--- a/src/languages/translations.ts
+++ b/src/languages/translations.ts
@@ -46,5 +46,5 @@ export default {
en: flattenObject(en),
es: flattenObject(es),
// eslint-disable-next-line @typescript-eslint/naming-convention
- 'es-ES': esES,
+ 'es-ES': flattenObject(esES),
};
diff --git a/src/languages/types.ts b/src/languages/types.ts
index d2a387a329d0..5f6669315041 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -246,6 +246,7 @@ export type {
EnglishTranslation,
TranslationFlatObject,
AddressLineParams,
+ TranslationPaths,
CharacterLimitParams,
MaxParticipantsReachedParams,
ZipCodeExampleFormatParams,
diff --git a/src/libs/Accessibility/index.ts b/src/libs/Accessibility/index.ts
index 5eceda8edcb1..aa167b1239b2 100644
--- a/src/libs/Accessibility/index.ts
+++ b/src/libs/Accessibility/index.ts
@@ -42,7 +42,7 @@ const useAutoHitSlop = () => {
},
[frameSize],
);
- return [getHitSlopForSize(frameSize), onLayout];
+ return [getHitSlopForSize(frameSize), onLayout] as const;
};
export default {
diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js
deleted file mode 100644
index fe79e38585c4..000000000000
--- a/src/libs/Clipboard/index.native.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Clipboard from '@react-native-clipboard/clipboard';
-
-/**
- * Sets a string on the Clipboard object via @react-native-clipboard/clipboard
- *
- * @param {String} text
- */
-const setString = (text) => {
- Clipboard.setString(text);
-};
-
-export default {
- setString,
-
- // We don't want to set HTML on native platforms so noop them.
- canSetHtml: () => false,
- setHtml: () => {},
-};
diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts
new file mode 100644
index 000000000000..f78c5e4ab230
--- /dev/null
+++ b/src/libs/Clipboard/index.native.ts
@@ -0,0 +1,19 @@
+import Clipboard from '@react-native-clipboard/clipboard';
+import {CanSetHtml, SetHtml, SetString} from './types';
+
+/**
+ * Sets a string on the Clipboard object via @react-native-clipboard/clipboard
+ */
+const setString: SetString = (text) => {
+ Clipboard.setString(text);
+};
+
+// We don't want to set HTML on native platforms so noop them.
+const canSetHtml: CanSetHtml = () => false;
+const setHtml: SetHtml = () => {};
+
+export default {
+ setString,
+ canSetHtml,
+ setHtml,
+};
diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.ts
similarity index 68%
rename from src/libs/Clipboard/index.js
rename to src/libs/Clipboard/index.ts
index 3fb2091c5cb1..b703b0b4d7f5 100644
--- a/src/libs/Clipboard/index.js
+++ b/src/libs/Clipboard/index.ts
@@ -1,16 +1,34 @@
import Clipboard from '@react-native-clipboard/clipboard';
-import lodashGet from 'lodash/get';
import * as Browser from '@libs/Browser';
import CONST from '@src/CONST';
+import {CanSetHtml, SetHtml, SetString} from './types';
-const canSetHtml = () => lodashGet(navigator, 'clipboard.write');
+type ComposerSelection = {
+ start: number;
+ end: number;
+ direction: 'forward' | 'backward' | 'none';
+};
+
+type AnchorSelection = {
+ anchorOffset: number;
+ focusOffset: number;
+ anchorNode: Node;
+ focusNode: Node;
+};
+
+type NullableObject = {[K in keyof T]: T[K] | null};
+
+type OriginalSelection = ComposerSelection | NullableObject;
+
+const canSetHtml: CanSetHtml =
+ () =>
+ (...args: ClipboardItems) =>
+ navigator?.clipboard?.write([...args]);
/**
* Deprecated method to write the content as HTML to clipboard.
- * @param {String} html HTML representation
- * @param {String} text Plain text representation
*/
-function setHTMLSync(html, text) {
+function setHTMLSync(html: string, text: string) {
const node = document.createElement('span');
node.textContent = html;
node.style.all = 'unset';
@@ -21,16 +39,21 @@ function setHTMLSync(html, text) {
node.addEventListener('copy', (e) => {
e.stopPropagation();
e.preventDefault();
- e.clipboardData.clearData();
- e.clipboardData.setData('text/html', html);
- e.clipboardData.setData('text/plain', text);
+ e.clipboardData?.clearData();
+ e.clipboardData?.setData('text/html', html);
+ e.clipboardData?.setData('text/plain', text);
});
document.body.appendChild(node);
- const selection = window.getSelection();
- const firstAnchorChild = selection.anchorNode && selection.anchorNode.firstChild;
+ const selection = window?.getSelection();
+
+ if (selection === null) {
+ return;
+ }
+
+ const firstAnchorChild = selection.anchorNode?.firstChild;
const isComposer = firstAnchorChild instanceof HTMLTextAreaElement;
- let originalSelection = null;
+ let originalSelection: OriginalSelection | null = null;
if (isComposer) {
originalSelection = {
start: firstAnchorChild.selectionStart,
@@ -60,12 +83,14 @@ function setHTMLSync(html, text) {
selection.removeAllRanges();
- if (isComposer) {
+ const anchorSelection = originalSelection as AnchorSelection;
+
+ if (isComposer && 'start' in originalSelection) {
firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction);
- } else if (originalSelection.anchorNode && originalSelection.focusNode) {
+ } else if (anchorSelection.anchorNode && anchorSelection.focusNode) {
// When copying to the clipboard here, the original values of anchorNode and focusNode will be null since there will be no user selection.
// We are adding a check to prevent null values from being passed to setBaseAndExtent, in accordance with the standards of the Selection API as outlined here: https://w3c.github.io/selection-api/#dom-selection-setbaseandextent.
- selection.setBaseAndExtent(originalSelection.anchorNode, originalSelection.anchorOffset, originalSelection.focusNode, originalSelection.focusOffset);
+ selection.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset);
}
document.body.removeChild(node);
@@ -73,10 +98,8 @@ function setHTMLSync(html, text) {
/**
* Writes the content as HTML if the web client supports it.
- * @param {String} html HTML representation
- * @param {String} text Plain text representation
*/
-const setHtml = (html, text) => {
+const setHtml: SetHtml = (html: string, text: string) => {
if (!html || !text) {
return;
}
@@ -93,8 +116,8 @@ const setHtml = (html, text) => {
setHTMLSync(html, text);
} else {
navigator.clipboard.write([
- // eslint-disable-next-line no-undef
new ClipboardItem({
+ /* eslint-disable @typescript-eslint/naming-convention */
'text/html': new Blob([html], {type: 'text/html'}),
'text/plain': new Blob([text], {type: 'text/plain'}),
}),
@@ -104,10 +127,8 @@ const setHtml = (html, text) => {
/**
* Sets a string on the Clipboard object via react-native-web
- *
- * @param {String} text
*/
-const setString = (text) => {
+const setString: SetString = (text) => {
Clipboard.setString(text);
};
diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts
new file mode 100644
index 000000000000..1d899144a2ba
--- /dev/null
+++ b/src/libs/Clipboard/types.ts
@@ -0,0 +1,5 @@
+type SetString = (text: string) => void;
+type SetHtml = (html: string, text: string) => void;
+type CanSetHtml = (() => (...args: ClipboardItems) => Promise) | (() => boolean);
+
+export type {SetString, CanSetHtml, SetHtml};
diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts
index 5a7da7ca08cf..58e1efa7aa65 100644
--- a/src/libs/ComposerUtils/index.ts
+++ b/src/libs/ComposerUtils/index.ts
@@ -32,7 +32,11 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo
*/
function getCommonSuffixLength(str1: string, str2: string): number {
let i = 0;
- while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
+ if (str1.length === 0 || str2.length === 0) {
+ return 0;
+ }
+ const minLen = Math.min(str1.length, str2.length);
+ while (i < minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
i++;
}
return i;
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 965d85134968..b956b5adcc51 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -135,7 +135,13 @@ function isYesterday(date: Date, timeZone: string): boolean {
* Jan 20 at 5:30 PM within the past year
* Jan 20, 2019 at 5:30 PM anything over 1 year ago
*/
-function datetimeToCalendarTime(locale: string, datetime: string, includeTimeZone = false, currentSelectedTimezone = timezone.selected, isLowercase = false): string {
+function datetimeToCalendarTime(
+ locale: 'en' | 'es' | 'es-ES' | 'es_ES',
+ datetime: string,
+ includeTimeZone = false,
+ currentSelectedTimezone = timezone.selected,
+ isLowercase = false,
+): string {
const date = getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone);
const tz = includeTimeZone ? ' [UTC]Z' : '';
let todayAt = Localize.translate(locale, 'common.todayAt');
diff --git a/src/libs/E2E/API.mock.js b/src/libs/E2E/API.mock.js
index 2c7da3f420a3..cecad375b22e 100644
--- a/src/libs/E2E/API.mock.js
+++ b/src/libs/E2E/API.mock.js
@@ -7,6 +7,7 @@ import mockAuthenticatePusher from './apiMocks/authenticatePusher';
import mockBeginSignin from './apiMocks/beginSignin';
import mockOpenApp from './apiMocks/openApp';
import mockOpenReport from './apiMocks/openReport';
+import mockReadNewestAction from './apiMocks/readNewestAction';
import mockSigninUser from './apiMocks/signinUser';
/**
@@ -20,17 +21,23 @@ const mocks = {
OpenApp: mockOpenApp,
ReconnectApp: mockOpenApp,
OpenReport: mockOpenReport,
+ ReconnectToReport: mockOpenReport,
AuthenticatePusher: mockAuthenticatePusher,
+ ReadNewestAction: mockReadNewestAction,
};
function mockCall(command, apiCommandParameters, tag) {
const mockResponse = mocks[command] && mocks[command](apiCommandParameters);
- if (!mockResponse || !_.isArray(mockResponse.onyxData)) {
- Log.warn(`[${tag}] for command ${command} is not mocked yet!`);
+ if (!mockResponse) {
+ Log.warn(`[${tag}] for command ${command} is not mocked yet! ⚠️`);
return;
}
- return Onyx.update(mockResponse.onyxData);
+ if (_.isArray(mockResponse.onyxData)) {
+ return Onyx.update(mockResponse.onyxData);
+ }
+
+ return Promise.resolve(mockResponse);
}
/**
diff --git a/src/libs/E2E/actions/waitForKeyboard.js b/src/libs/E2E/actions/waitForKeyboard.js
new file mode 100644
index 000000000000..4bc0f492e3a3
--- /dev/null
+++ b/src/libs/E2E/actions/waitForKeyboard.js
@@ -0,0 +1,15 @@
+import {Keyboard} from 'react-native';
+
+export default function waitForKeyboard() {
+ return new Promise((resolve) => {
+ function checkKeyboard() {
+ if (Keyboard.isVisible()) {
+ resolve();
+ } else {
+ console.debug(`[E2E] Waiting for keyboard to appear…`);
+ setTimeout(checkKeyboard, 1000);
+ }
+ }
+ checkKeyboard();
+ });
+}
diff --git a/src/libs/E2E/apiMocks/openReport.js b/src/libs/E2E/apiMocks/openReport.js
index 936f9d77ef06..b20b3df35bad 100644
--- a/src/libs/E2E/apiMocks/openReport.js
+++ b/src/libs/E2E/apiMocks/openReport.js
@@ -6,91 +6,1969 @@ export default () => ({
value: {
reportID: '98345625',
reportName: 'Chat Report',
+ type: 'chat',
chatType: '',
+ ownerEmail: '__fake__',
ownerAccountID: 0,
+ managerEmail: '__fake__',
+ managerID: 0,
policyID: '_FAKE_',
- participantAccountIDs: [2, 1, 4, 3, 5, 16, 18, 19],
+ participantAccountIDs: [14567013],
isPinned: false,
- lastReadCreated: '1980-01-01 00:00:00.000',
- lastVisibleActionCreated: '2022-08-01 20:49:11',
- lastMessageTimestamp: 1659386951000,
- lastMessageText: 'Say hello\ud83d\ude10',
- lastActorAccountID: 10773236,
+ lastReadTime: '2023-09-14 11:50:21.768',
+ lastMentionedTime: '2023-07-27 07:37:43.100',
+ lastReadSequenceNumber: 0,
+ lastVisibleActionCreated: '2023-08-29 12:38:16.070',
+ lastVisibleActionLastModified: '2023-08-29 12:38:16.070',
+ lastMessageText: 'terry+hightraffic@margelo.io owes \u20ac12.00',
+ lastActorAccountID: 14567013,
notificationPreference: 'always',
+ welcomeMessage: '',
stateNum: 0,
statusNum: 0,
oldPolicyName: '',
visibility: null,
isOwnPolicyExpenseChat: false,
- lastMessageHtml: 'Say hello\ud83d\ude10',
+ lastMessageHtml: 'terry+hightraffic@margelo.io owes \u20ac12.00',
+ iouReportID: 206636935813547,
hasOutstandingIOU: false,
+ hasOutstandingChildRequest: false,
+ policyName: null,
+ hasParentAccess: null,
+ parentReportID: null,
+ parentReportActionID: null,
+ writeCapability: 'all',
+ description: null,
+ isDeletedParentAction: null,
+ total: 0,
+ currency: 'USD',
+ submitterPayPalMeAddress: '',
+ chatReportID: null,
+ isWaitingOnBankAccount: false,
+ },
+ },
+ {
+ onyxMethod: 'mergecollection',
+ key: 'transactions_',
+ value: {
+ transactions_5509240412000765850: {
+ amount: 1200,
+ billable: false,
+ cardID: 15467728,
+ category: '',
+ comment: {
+ comment: '',
+ },
+ created: '2023-08-29',
+ currency: 'EUR',
+ filename: '',
+ merchant: 'Request',
+ modifiedAmount: 0,
+ modifiedCreated: '',
+ modifiedCurrency: '',
+ modifiedMerchant: '',
+ originalAmount: 0,
+ originalCurrency: '',
+ parentTransactionID: '',
+ receipt: {},
+ reimbursable: true,
+ reportID: '206636935813547',
+ status: 'Pending',
+ tag: '',
+ transactionID: '5509240412000765850',
+ hasEReceipt: false,
+ },
},
},
{
onyxMethod: 'merge',
key: 'reportActions_98345625',
value: {
- 226245034: {
- reportActionID: '226245034',
- actionName: 'CREATED',
- created: '2022-08-01 20:48:58',
- timestamp: 1659386938,
- reportActionTimestamp: 0,
- avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png',
+ '885570376575240776': {
+ person: [
+ {
+ type: 'TEXT',
+ style: 'strong',
+ text: 'Hanno J. G\u00f6decke',
+ },
+ ],
+ actorAccountID: 12883048,
+ message: [
+ {
+ type: 'COMMENT',
+ html: '',
+ text: '',
+ isEdited: true,
+ whisperedTo: [],
+ isDeletedParentAction: false,
+ reactions: [],
+ },
+ ],
+ originalMessage: {
+ edits: [],
+ html: '',
+ lastModified: '2023-09-01 07:43:29.374',
+ },
+ avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg',
+ created: '2023-08-31 07:23:52.892',
+ timestamp: 1693466632,
+ reportActionTimestamp: 1693466632892,
+ automatic: false,
+ actionName: 'ADDCOMMENT',
+ shouldShow: true,
+ reportActionID: '885570376575240776',
+ previousReportActionID: '6576518341807837187',
+ lastModified: '2023-09-01 07:43:29.374',
+ whisperedToAccountIDs: [],
+ },
+ '6576518341807837187': {
+ person: [
+ {
+ type: 'TEXT',
+ style: 'strong',
+ text: 'Terry Hightraffic1337',
+ },
+ ],
+ actorAccountID: 14567013,
+ message: [
+ {
+ type: 'COMMENT',
+ html: 'terry+hightraffic@margelo.io owes \u20ac12.00',
+ text: 'terry+hightraffic@margelo.io owes \u20ac12.00',
+ isEdited: false,
+ whisperedTo: [],
+ isDeletedParentAction: false,
+ reactions: [],
+ },
+ ],
+ originalMessage: {
+ lastModified: '2023-08-29 12:38:16.070',
+ linkedReportID: '206636935813547',
+ },
+ avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg',
+ created: '2023-08-29 12:38:16.070',
+ timestamp: 1693312696,
+ reportActionTimestamp: 1693312696070,
+ automatic: false,
+ actionName: 'REPORTPREVIEW',
+ shouldShow: true,
+ reportActionID: '6576518341807837187',
+ previousReportActionID: '2658221912430757962',
+ lastModified: '2023-08-29 12:38:16.070',
+ childReportID: 206636935813547,
+ childType: 'iou',
+ childStatusNum: 1,
+ childStateNum: 1,
+ childMoneyRequestCount: 1,
+ whisperedToAccountIDs: [],
+ },
+ '2658221912430757962': {
+ person: [
+ {
+ type: 'TEXT',
+ style: 'strong',
+ text: 'Hanno J. G\u00f6decke',
+ },
+ ],
+ actorAccountID: 12883048,
+ message: [
+ {
+ type: 'COMMENT',
+ html: 'Hshshdhdhejje Cuududdke