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 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.95 + 1.3.96 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.95.4 + 1.3.96.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes 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 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.95 + 1.3.96 CFBundleSignature ???? 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

F
D
R
D
R
Jfj c
D

D
D
R
D
R', + text: 'Hshshdhdhejje\nCuududdke\n\nF\nD\nR\nD\nR\nJfj c\nD\n\nD\nD\nR\nD\nR', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [ + { + emoji: 'heart', + users: [ + { + accountID: 12883048, + skinTone: -1, + }, + ], + }, + ], + }, + ], + originalMessage: { + html: 'Hshshdhdhejje
Cuududdke

F
D
R
D
R
Jfj c
D

D
D
R
D
R', + lastModified: '2023-08-25 12:39:48.121', + reactions: [ + { + emoji: 'heart', + users: [ + { + accountID: 12883048, + skinTone: -1, + }, + ], + }, + ], + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:54:06.972', + timestamp: 1692953646, + reportActionTimestamp: 1692953646972, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2658221912430757962', + previousReportActionID: '6551789403725495383', + lastModified: '2023-08-25 12:39:48.121', + childReportID: 1411015346900020, + childType: 'chat', + childOldestFourAccountIDs: '12883048', + childCommenterCount: 1, + childLastVisibleActionCreated: '2023-08-29 06:08:59.247', + childVisibleActionCount: 1, + whisperedToAccountIDs: [], + }, + '6551789403725495383': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'Typing with the composer is now also reasonably fast again', + text: 'Typing with the composer is now also reasonably fast again', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'Typing with the composer is now also reasonably fast again', + lastModified: '2023-08-25 08:53:57.490', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:53:57.490', + timestamp: 1692953637, + reportActionTimestamp: 1692953637490, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6551789403725495383', + previousReportActionID: '6184477005811241106', + lastModified: '2023-08-25 08:53:57.490', + whisperedToAccountIDs: [], + }, + '6184477005811241106': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '\ud83d\ude3a', + text: '\ud83d\ude3a', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '\ud83d\ude3a', + lastModified: '2023-08-25 08:53:41.689', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:53:41.689', + timestamp: 1692953621, + reportActionTimestamp: 1692953621689, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6184477005811241106', + previousReportActionID: '7473953427765241164', + lastModified: '2023-08-25 08:53:41.689', + whisperedToAccountIDs: [], + }, + '7473953427765241164': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'Skkkkkkrrrrrrrr', + text: 'Skkkkkkrrrrrrrr', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'Skkkkkkrrrrrrrr', + lastModified: '2023-08-25 08:53:31.900', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-25 08:53:31.900', + timestamp: 1692953611, + reportActionTimestamp: 1692953611900, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7473953427765241164', + previousReportActionID: '872421684593496491', + lastModified: '2023-08-25 08:53:31.900', + whisperedToAccountIDs: [], + }, + '872421684593496491': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', + text: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', + lastModified: '2023-08-11 13:35:03.962', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-11 13:35:03.962', + timestamp: 1691760903, + reportActionTimestamp: 1691760903962, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '872421684593496491', + previousReportActionID: '175680146540578558', + lastModified: '2023-08-11 13:35:03.962', + whisperedToAccountIDs: [], + }, + '175680146540578558': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '', + text: '[Attachment]', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '', + lastModified: '2023-08-10 06:59:21.381', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-10 06:59:21.381', + timestamp: 1691650761, + reportActionTimestamp: 1691650761381, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '175680146540578558', + previousReportActionID: '1264289784533901723', + lastModified: '2023-08-10 06:59:21.381', + whisperedToAccountIDs: [], + }, + '1264289784533901723': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: '', + text: '[Attachment]', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '', + lastModified: '2023-08-10 06:59:16.922', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-10 06:59:16.922', + timestamp: 1691650756, + reportActionTimestamp: 1691650756922, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1264289784533901723', + previousReportActionID: '4870277010164688289', + lastModified: '2023-08-10 06:59:16.922', + whisperedToAccountIDs: [], + }, + '4870277010164688289': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'send test', + text: 'send test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'send test', + lastModified: '2023-08-09 06:43:25.209', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-09 06:43:25.209', + timestamp: 1691563405, + reportActionTimestamp: 1691563405209, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4870277010164688289', + previousReportActionID: '7931783095143103530', + lastModified: '2023-08-09 06:43:25.209', + whisperedToAccountIDs: [], + }, + '7931783095143103530': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', + text: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', + lastModified: '2023-08-08 14:38:45.035', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-08 14:38:45.035', + timestamp: 1691505525, + reportActionTimestamp: 1691505525035, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7931783095143103530', + previousReportActionID: '4598496324774172433', + lastModified: '2023-08-08 14:38:45.035', + whisperedToAccountIDs: [], + }, + '4598496324774172433': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, message: [ + { + type: 'COMMENT', + html: '\ud83d\uddff', + text: '\ud83d\uddff', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '\ud83d\uddff', + lastModified: '2023-08-08 13:21:42.102', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-08 13:21:42.102', + timestamp: 1691500902, + reportActionTimestamp: 1691500902102, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4598496324774172433', + previousReportActionID: '3324110555952451144', + lastModified: '2023-08-08 13:21:42.102', + whisperedToAccountIDs: [], + }, + '3324110555952451144': { + person: [ { type: 'TEXT', style: 'strong', - text: '__fake__', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'test \ud83d\uddff', + text: 'test \ud83d\uddff', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, + ], + originalMessage: { + html: 'test \ud83d\uddff', + lastModified: '2023-08-08 13:21:32.101', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-08 13:21:32.101', + timestamp: 1691500892, + reportActionTimestamp: 1691500892101, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '3324110555952451144', + previousReportActionID: '5389364980227777980', + lastModified: '2023-08-08 13:21:32.101', + whisperedToAccountIDs: [], + }, + '5389364980227777980': { + person: [ { type: 'TEXT', - style: 'normal', - text: ' created this report', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'okay now it will work again y \ud83d\udc42', + text: 'okay now it will work again y \ud83d\udc42', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, ], + originalMessage: { + html: 'okay now it will work again y \ud83d\udc42', + lastModified: '2023-08-07 10:54:38.141', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-08-07 10:54:38.141', + timestamp: 1691405678, + reportActionTimestamp: 1691405678141, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5389364980227777980', + previousReportActionID: '4717622390560689493', + lastModified: '2023-08-07 10:54:38.141', + whisperedToAccountIDs: [], + }, + '4717622390560689493': { person: [ { type: 'TEXT', style: 'strong', - text: '__fake__', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hmmmm', + text: 'hmmmm', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, ], + originalMessage: { + html: 'hmmmm', + lastModified: '2023-07-27 18:13:45.322', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 18:13:45.322', + timestamp: 1690481625, + reportActionTimestamp: 1690481625322, automatic: false, + actionName: 'ADDCOMMENT', shouldShow: true, + reportActionID: '4717622390560689493', + previousReportActionID: '745721424446883075', + lastModified: '2023-07-27 18:13:45.322', + whisperedToAccountIDs: [], }, - 1082059149: { + '745721424446883075': { person: [ { type: 'TEXT', style: 'strong', - text: '123 Ios', + text: 'Hanno J. G\u00f6decke', }, ], - actorAccountID: 10773236, + actorAccountID: 12883048, message: [ { type: 'COMMENT', - html: 'Say hello\ud83d\ude10', - text: 'Say hello\ud83d\ude10', + html: 'test', + text: 'test', isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], }, ], originalMessage: { - html: 'Say hello\ud83d\ude10', + html: 'test', + lastModified: '2023-07-27 18:13:32.595', }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/301e37631eca9e3127d6b668822e3a53771551f6_128.jpeg', - created: '2022-08-01 20:49:11', - timestamp: 1659386951, - reportActionTimestamp: 1659386951000, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 18:13:32.595', + timestamp: 1690481612, + reportActionTimestamp: 1690481612595, automatic: false, actionName: 'ADDCOMMENT', shouldShow: true, - reportActionID: '1082059149', + reportActionID: '745721424446883075', + previousReportActionID: '3986429677777110818', + lastModified: '2023-07-27 18:13:32.595', + whisperedToAccountIDs: [], + }, + '3986429677777110818': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'I will', + text: 'I will', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'I will', + lastModified: '2023-07-27 17:03:11.250', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 17:03:11.250', + timestamp: 1690477391, + reportActionTimestamp: 1690477391250, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '3986429677777110818', + previousReportActionID: '7317910228472011573', + lastModified: '2023-07-27 17:03:11.250', + childReportID: 3338245207149134, + childType: 'chat', + whisperedToAccountIDs: [], + }, + '7317910228472011573': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'will you>', + text: 'will you>', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'will you>', + lastModified: '2023-07-27 16:46:58.988', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 16:46:58.988', + timestamp: 1690476418, + reportActionTimestamp: 1690476418988, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7317910228472011573', + previousReportActionID: '6779343397958390319', + lastModified: '2023-07-27 16:46:58.988', + whisperedToAccountIDs: [], + }, + '6779343397958390319': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'i will always send :#', + text: 'i will always send :#', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'i will always send :#', + lastModified: '2023-07-27 07:55:33.468', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:33.468', + timestamp: 1690444533, + reportActionTimestamp: 1690444533468, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6779343397958390319', + previousReportActionID: '5084145419388195535', + lastModified: '2023-07-27 07:55:33.468', + whisperedToAccountIDs: [], + }, + '5084145419388195535': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new test', + text: 'new test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new test', + lastModified: '2023-07-27 07:55:22.309', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:22.309', + timestamp: 1690444522, + reportActionTimestamp: 1690444522309, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5084145419388195535', + previousReportActionID: '6742067600980190659', + lastModified: '2023-07-27 07:55:22.309', + whisperedToAccountIDs: [], + }, + '6742067600980190659': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'okay good', + text: 'okay good', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'okay good', + lastModified: '2023-07-27 07:55:15.362', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:15.362', + timestamp: 1690444515, + reportActionTimestamp: 1690444515362, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6742067600980190659', + previousReportActionID: '7811212427986810247', + lastModified: '2023-07-27 07:55:15.362', + whisperedToAccountIDs: [], + }, + '7811212427986810247': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test 2', + text: 'test 2', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 2', + lastModified: '2023-07-27 07:55:10.629', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:55:10.629', + timestamp: 1690444510, + reportActionTimestamp: 1690444510629, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7811212427986810247', + previousReportActionID: '4544757211729131829', + lastModified: '2023-07-27 07:55:10.629', + whisperedToAccountIDs: [], + }, + '4544757211729131829': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new test', + text: 'new test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new test', + lastModified: '2023-07-27 07:53:41.960', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:41.960', + timestamp: 1690444421, + reportActionTimestamp: 1690444421960, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4544757211729131829', + previousReportActionID: '8290114634148431001', + lastModified: '2023-07-27 07:53:41.960', + whisperedToAccountIDs: [], + }, + '8290114634148431001': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'something was real', + text: 'something was real', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'something was real', + lastModified: '2023-07-27 07:53:27.836', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:27.836', + timestamp: 1690444407, + reportActionTimestamp: 1690444407836, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '8290114634148431001', + previousReportActionID: '5597494166918965742', + lastModified: '2023-07-27 07:53:27.836', + whisperedToAccountIDs: [], + }, + '5597494166918965742': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'oida', + text: 'oida', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'oida', + lastModified: '2023-07-27 07:53:20.783', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:20.783', + timestamp: 1690444400, + reportActionTimestamp: 1690444400783, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5597494166918965742', + previousReportActionID: '7445709165354739065', + lastModified: '2023-07-27 07:53:20.783', + whisperedToAccountIDs: [], + }, + '7445709165354739065': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test 12', + text: 'test 12', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 12', + lastModified: '2023-07-27 07:53:17.393', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:17.393', + timestamp: 1690444397, + reportActionTimestamp: 1690444397393, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '7445709165354739065', + previousReportActionID: '1985264407541504554', + lastModified: '2023-07-27 07:53:17.393', + whisperedToAccountIDs: [], + }, + '1985264407541504554': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new test', + text: 'new test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new test', + lastModified: '2023-07-27 07:53:07.894', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:53:07.894', + timestamp: 1690444387, + reportActionTimestamp: 1690444387894, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1985264407541504554', + previousReportActionID: '6101278009725036288', + lastModified: '2023-07-27 07:53:07.894', + whisperedToAccountIDs: [], + }, + '6101278009725036288': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'grrr', + text: 'grrr', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'grrr', + lastModified: '2023-07-27 07:52:56.421', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:56.421', + timestamp: 1690444376, + reportActionTimestamp: 1690444376421, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6101278009725036288', + previousReportActionID: '6913024396112106680', + lastModified: '2023-07-27 07:52:56.421', + whisperedToAccountIDs: [], + }, + '6913024396112106680': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'ne w test', + text: 'ne w test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'ne w test', + lastModified: '2023-07-27 07:52:53.352', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:53.352', + timestamp: 1690444373, + reportActionTimestamp: 1690444373352, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6913024396112106680', + previousReportActionID: '3663318486255461038', + lastModified: '2023-07-27 07:52:53.352', + whisperedToAccountIDs: [], + }, + '3663318486255461038': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'well', + text: 'well', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'well', + lastModified: '2023-07-27 07:52:47.044', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:47.044', + timestamp: 1690444367, + reportActionTimestamp: 1690444367044, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '3663318486255461038', + previousReportActionID: '6652909175804277965', + lastModified: '2023-07-27 07:52:47.044', + whisperedToAccountIDs: [], + }, + '6652909175804277965': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'hu', + text: 'hu', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hu', + lastModified: '2023-07-27 07:52:43.489', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:43.489', + timestamp: 1690444363, + reportActionTimestamp: 1690444363489, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6652909175804277965', + previousReportActionID: '4738491624635492834', + lastModified: '2023-07-27 07:52:43.489', + whisperedToAccountIDs: [], + }, + '4738491624635492834': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test', + text: 'test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test', + lastModified: '2023-07-27 07:52:40.145', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:52:40.145', + timestamp: 1690444360, + reportActionTimestamp: 1690444360145, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4738491624635492834', + previousReportActionID: '1621235410433805703', + lastModified: '2023-07-27 07:52:40.145', + whisperedToAccountIDs: [], + }, + '1621235410433805703': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'test 4', + text: 'test 4', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 4', + lastModified: '2023-07-27 07:48:36.809', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:48:36.809', + timestamp: 1690444116, + reportActionTimestamp: 1690444116809, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1621235410433805703', + previousReportActionID: '1024550225871474566', + lastModified: '2023-07-27 07:48:36.809', + whisperedToAccountIDs: [], + }, + '1024550225871474566': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test 3', + text: 'test 3', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test 3', + lastModified: '2023-07-27 07:48:24.183', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:48:24.183', + timestamp: 1690444104, + reportActionTimestamp: 1690444104183, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1024550225871474566', + previousReportActionID: '5598482410513625723', + lastModified: '2023-07-27 07:48:24.183', + whisperedToAccountIDs: [], + }, + '5598482410513625723': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'test2', + text: 'test2', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test2', + lastModified: '2023-07-27 07:42:25.340', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:25.340', + timestamp: 1690443745, + reportActionTimestamp: 1690443745340, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5598482410513625723', + previousReportActionID: '115121137377026405', + lastModified: '2023-07-27 07:42:25.340', + whisperedToAccountIDs: [], + }, + '115121137377026405': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'test', + text: 'test', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'test', + lastModified: '2023-07-27 07:42:22.583', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:42:22.583', + timestamp: 1690443742, + reportActionTimestamp: 1690443742583, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '115121137377026405', + previousReportActionID: '2167420855737359171', + lastModified: '2023-07-27 07:42:22.583', + whisperedToAccountIDs: [], + }, + '2167420855737359171': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'new message', + text: 'new message', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'new message', + lastModified: '2023-07-27 07:42:09.177', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:09.177', + timestamp: 1690443729, + reportActionTimestamp: 1690443729177, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2167420855737359171', + previousReportActionID: '6106926938128802897', + lastModified: '2023-07-27 07:42:09.177', + whisperedToAccountIDs: [], + }, + '6106926938128802897': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'oh', + text: 'oh', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'oh', + lastModified: '2023-07-27 07:42:03.902', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:03.902', + timestamp: 1690443723, + reportActionTimestamp: 1690443723902, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '6106926938128802897', + previousReportActionID: '4366704007455141347', + lastModified: '2023-07-27 07:42:03.902', + whisperedToAccountIDs: [], + }, + '4366704007455141347': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'hm lol', + text: 'hm lol', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hm lol', + lastModified: '2023-07-27 07:42:00.734', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:42:00.734', + timestamp: 1690443720, + reportActionTimestamp: 1690443720734, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4366704007455141347', + previousReportActionID: '2078794664797360607', + lastModified: '2023-07-27 07:42:00.734', + whisperedToAccountIDs: [], + }, + '2078794664797360607': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'hi?', + text: 'hi?', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hi?', + lastModified: '2023-07-27 07:41:49.724', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:41:49.724', + timestamp: 1690443709, + reportActionTimestamp: 1690443709724, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2078794664797360607', + previousReportActionID: '2030060194258527427', + lastModified: '2023-07-27 07:41:49.724', + whisperedToAccountIDs: [], + }, + '2030060194258527427': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'lets have a thread about it, will ya?', + text: 'lets have a thread about it, will ya?', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'lets have a thread about it, will ya?', + lastModified: '2023-07-27 07:40:49.146', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:40:49.146', + timestamp: 1690443649, + reportActionTimestamp: 1690443649146, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '2030060194258527427', + previousReportActionID: '5540483153987237906', + lastModified: '2023-07-27 07:40:49.146', + childReportID: 5860710623453234, + childType: 'chat', + childOldestFourAccountIDs: '14567013,12883048', + childCommenterCount: 2, + childLastVisibleActionCreated: '2023-07-27 07:41:03.550', + childVisibleActionCount: 2, + whisperedToAccountIDs: [], + }, + '5540483153987237906': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: '@hanno@margelo.io i mention you lasagna :)', + text: '@hanno@margelo.io i mention you lasagna :)', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '@hanno@margelo.io i mention you lasagna :)', + lastModified: '2023-07-27 07:37:43.100', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:37:43.100', + timestamp: 1690443463, + reportActionTimestamp: 1690443463100, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '5540483153987237906', + previousReportActionID: '8050559753491913991', + lastModified: '2023-07-27 07:37:43.100', + whisperedToAccountIDs: [], + }, + '8050559753491913991': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: '@terry+hightraffic@margelo.io', + text: '@terry+hightraffic@margelo.io', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: '@terry+hightraffic@margelo.io', + lastModified: '2023-07-27 07:36:41.708', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:36:41.708', + timestamp: 1690443401, + reportActionTimestamp: 1690443401708, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '8050559753491913991', + previousReportActionID: '881015235172878574', + lastModified: '2023-07-27 07:36:41.708', + whisperedToAccountIDs: [], + }, + '881015235172878574': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'yeah lets see', + text: 'yeah lets see', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'yeah lets see', + lastModified: '2023-07-27 07:25:15.997', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-27 07:25:15.997', + timestamp: 1690442715, + reportActionTimestamp: 1690442715997, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '881015235172878574', + previousReportActionID: '4800357767877651330', + lastModified: '2023-07-27 07:25:15.997', + whisperedToAccountIDs: [], + }, + '4800357767877651330': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'asdasdasd', + text: 'asdasdasd', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'asdasdasd', + lastModified: '2023-07-27 07:25:03.093', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-27 07:25:03.093', + timestamp: 1690442703, + reportActionTimestamp: 1690442703093, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '4800357767877651330', + previousReportActionID: '9012557872554910346', + lastModified: '2023-07-27 07:25:03.093', + whisperedToAccountIDs: [], + }, + '9012557872554910346': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'yeah', + text: 'yeah', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'yeah', + lastModified: '2023-07-26 19:49:40.471', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-26 19:49:40.471', + timestamp: 1690400980, + reportActionTimestamp: 1690400980471, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '9012557872554910346', + previousReportActionID: '8440677969068645500', + lastModified: '2023-07-26 19:49:40.471', + whisperedToAccountIDs: [], + }, + '8440677969068645500': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'hello motor', + text: 'hello motor', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'hello motor', + lastModified: '2023-07-26 19:49:36.262', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-26 19:49:36.262', + timestamp: 1690400976, + reportActionTimestamp: 1690400976262, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '8440677969068645500', + previousReportActionID: '306887996337608775', + lastModified: '2023-07-26 19:49:36.262', + whisperedToAccountIDs: [], + }, + '306887996337608775': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'a new messagfe', + text: 'a new messagfe', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'a new messagfe', + lastModified: '2023-07-26 19:49:29.512', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-26 19:49:29.512', + timestamp: 1690400969, + reportActionTimestamp: 1690400969512, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '306887996337608775', + previousReportActionID: '587892433077506227', + lastModified: '2023-07-26 19:49:29.512', + whisperedToAccountIDs: [], + }, + '587892433077506227': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Hanno J. G\u00f6decke', + }, + ], + actorAccountID: 12883048, + message: [ + { + type: 'COMMENT', + html: 'good', + text: 'good', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'good', + lastModified: '2023-07-26 19:49:20.473', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', + created: '2023-07-26 19:49:20.473', + timestamp: 1690400960, + reportActionTimestamp: 1690400960473, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '587892433077506227', + previousReportActionID: '1433103421804347060', + lastModified: '2023-07-26 19:49:20.473', + whisperedToAccountIDs: [], + }, + '1433103421804347060': { + person: [ + { + type: 'TEXT', + style: 'strong', + text: 'Terry Hightraffic1337', + }, + ], + actorAccountID: 14567013, + message: [ + { + type: 'COMMENT', + html: 'ah', + text: 'ah', + isEdited: false, + whisperedTo: [], + isDeletedParentAction: false, + reactions: [], + }, + ], + originalMessage: { + html: 'ah', + lastModified: '2023-07-26 19:49:12.762', + }, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + created: '2023-07-26 19:49:12.762', + timestamp: 1690400952, + reportActionTimestamp: 1690400952762, + automatic: false, + actionName: 'ADDCOMMENT', + shouldShow: true, + reportActionID: '1433103421804347060', + previousReportActionID: '8774157052628183778', + lastModified: '2023-07-26 19:49:12.762', + whisperedToAccountIDs: [], + }, + }, + }, + { + onyxMethod: 'mergecollection', + key: 'reportActionsReactions_', + value: { + reportActionsReactions_2658221912430757962: { + heart: { + createdAt: '2023-08-25 12:37:45', + users: { + 12883048: { + skinTones: { + '-1': '2023-08-25 12:37:45', + }, + }, + }, + }, + }, + }, + }, + { + onyxMethod: 'merge', + key: 'personalDetailsList', + value: { + 14567013: { + accountID: 14567013, + avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', + displayName: 'Terry Hightraffic1337', + firstName: 'Terry', + lastName: 'Hightraffic1337', + status: null, + login: 'terry+hightraffic@margelo.io', + pronouns: '', + timezone: { + automatic: true, + selected: 'Europe/Kiev', + }, + payPalMeAddress: '', + phoneNumber: '', + validated: true, }, }, }, ], jsonCode: 200, - requestID: '783ef80a3fc5969a-SJC', + requestID: '81b8b8509a7f5b54-VIE', }); diff --git a/src/libs/E2E/apiMocks/readNewestAction.js b/src/libs/E2E/apiMocks/readNewestAction.js new file mode 100644 index 000000000000..04270a8d93f4 --- /dev/null +++ b/src/libs/E2E/apiMocks/readNewestAction.js @@ -0,0 +1,13 @@ +export default () => ({ + jsonCode: 200, + requestID: '81b8c48e3bfe5a84-VIE', + onyxData: [ + { + onyxMethod: 'merge', + key: 'report_98345625', + value: { + lastReadTime: '2023-10-25 07:32:48.915', + }, + }, + ], +}); diff --git a/src/libs/E2E/client.js b/src/libs/E2E/client.js index 7e6932d9fce5..59f7d7588fd5 100644 --- a/src/libs/E2E/client.js +++ b/src/libs/E2E/client.js @@ -10,19 +10,20 @@ const SERVER_ADDRESS = `http://localhost:${Config.SERVER_PORT}`; * @param {TestResult} testResult * @returns {Promise} */ -const submitTestResults = (testResult) => - fetch(`${SERVER_ADDRESS}${Routes.testResults}`, { +const submitTestResults = (testResult) => { + console.debug(`[E2E] Submitting test result '${testResult.name}'…`); + return fetch(`${SERVER_ADDRESS}${Routes.testResults}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(testResult), }).then((res) => { - if (res.statusCode === 200) { + if (res.status === 200) { console.debug(`[E2E] Test result '${testResult.name}' submitted successfully`); return; } - const errorMsg = `Test result submission failed with status code ${res.statusCode}`; + const errorMsg = `Test result submission failed with status code ${res.status}`; res.json() .then((responseText) => { throw new Error(`${errorMsg}: ${responseText}`); @@ -31,19 +32,49 @@ const submitTestResults = (testResult) => throw new Error(errorMsg); }); }); +}; const submitTestDone = () => fetch(`${SERVER_ADDRESS}${Routes.testDone}`); +let currentActiveTestConfig = null; /** * @returns {Promise} */ const getTestConfig = () => fetch(`${SERVER_ADDRESS}${Routes.testConfig}`) .then((res) => res.json()) - .then((config) => config); + .then((config) => { + currentActiveTestConfig = config; + return config; + }); + +const getCurrentActiveTestConfig = () => currentActiveTestConfig; + +const sendNativeCommand = (payload) => + fetch(`${SERVER_ADDRESS}${Routes.testNativeCommand}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }).then((res) => { + if (res.status === 200) { + return true; + } + const errorMsg = `Sending native command failed with status code ${res.status}`; + res.json() + .then((responseText) => { + throw new Error(`${errorMsg}: ${responseText}`); + }) + .catch(() => { + throw new Error(errorMsg); + }); + }); export default { submitTestResults, submitTestDone, getTestConfig, + getCurrentActiveTestConfig, + sendNativeCommand, }; diff --git a/src/libs/E2E/reactNativeLaunchingTest.js b/src/libs/E2E/reactNativeLaunchingTest.js index 7621e462f8c5..f9ff4383f86d 100644 --- a/src/libs/E2E/reactNativeLaunchingTest.js +++ b/src/libs/E2E/reactNativeLaunchingTest.js @@ -23,6 +23,7 @@ if (!Metrics.canCapturePerformanceMetrics()) { const tests = { [E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default, [E2EConfig.TEST_NAMES.OpenSearchPage]: require('./tests/openSearchPageTest.e2e').default, + [E2EConfig.TEST_NAMES.ReportTyping]: require('./tests/reportTypingTest.e2e').default, }; // Once we receive the TII measurement we know that the app is initialized and ready to be used: diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.js b/src/libs/E2E/tests/reportTypingTest.e2e.js new file mode 100644 index 000000000000..b79166063b4f --- /dev/null +++ b/src/libs/E2E/tests/reportTypingTest.e2e.js @@ -0,0 +1,59 @@ +import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard'; +import E2EClient from '@libs/E2E/client'; +import Navigation from '@libs/Navigation/Navigation'; +import Performance from '@libs/Performance'; +import {getRerenderCount, resetRerenderCount} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; + +const test = () => { + // check for login (if already logged in the action will simply resolve) + console.debug('[E2E] Logging in for typing'); + + E2ELogin().then((neededLogin) => { + if (neededLogin) { + // we don't want to submit the first login to the results + return E2EClient.submitTestDone(); + } + + console.debug('[E2E] Logged in, getting typing metrics and submitting them…'); + + Performance.subscribeToMeasurements((entry) => { + if (entry.name !== CONST.TIMING.SIDEBAR_LOADED) { + return; + } + + console.debug(`[E2E] Sidebar loaded, navigating to a report…`); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute('98345625')); + + // Wait until keyboard is visible (so we are focused on the input): + waitForKeyboard().then(() => { + console.debug(`[E2E] Keyboard visible, typing…`); + E2EClient.sendNativeCommand(NativeCommands.makeBackspaceCommand()) + .then(() => { + resetRerenderCount(); + return Promise.resolve(); + }) + .then(() => E2EClient.sendNativeCommand(NativeCommands.makeTypeTextCommand('A'))) + .then(() => { + setTimeout(() => { + const rerenderCount = getRerenderCount(); + + E2EClient.submitTestResults({ + name: 'Composer typing rerender count', + renderCount: rerenderCount, + }).then(E2EClient.submitTestDone); + }, 3000); + }) + .catch((error) => { + console.error('[E2E] Error while test', error); + E2EClient.submitTestDone(); + }); + }); + }); + }); +}; + +export default test; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 99cd8f34b1e7..5bc8ea1d3508 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,5 +1,5 @@ import CONST from '@src/CONST'; -import {TranslationFlatObject} from '@src/languages/types'; +import {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; import {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import Response from '@src/types/onyx/Response'; import DateUtils from './DateUtils'; @@ -93,7 +93,7 @@ type ErrorsList = Record; * @param errorList - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: ErrorsList, inputID?: string, message?: string) { +function addErrorMessage(errors: ErrorsList, inputID?: string, message?: TKey) { if (!message || !inputID) { return; } diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts index cfcf5d5ef535..1b684a7ab19f 100644 --- a/src/libs/KeyboardShortcut/index.ts +++ b/src/libs/KeyboardShortcut/index.ts @@ -128,7 +128,7 @@ function getPlatformEquivalentForKeys(keys: string[]): string[] { */ function subscribe( key: string, - callback: () => void, + callback: (event?: KeyboardEvent) => void, descriptionKey: string, modifiers: string[] = ['shift'], captureOnInputs = false, diff --git a/src/libs/Localize/LocaleListener/BaseLocaleListener.js b/src/libs/Localize/LocaleListener/BaseLocaleListener.ts similarity index 72% rename from src/libs/Localize/LocaleListener/BaseLocaleListener.js rename to src/libs/Localize/LocaleListener/BaseLocaleListener.ts index 0f861b20040a..c5eba18af422 100644 --- a/src/libs/Localize/LocaleListener/BaseLocaleListener.js +++ b/src/libs/Localize/LocaleListener/BaseLocaleListener.ts @@ -1,15 +1,14 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import BaseLocale, {LocaleListenerConnect} from './types'; -let preferredLocale = CONST.LOCALES.DEFAULT; +let preferredLocale: BaseLocale = CONST.LOCALES.DEFAULT; /** * Adds event listener for changes to the locale. Callbacks are executed when the locale changes in Onyx. - * - * @param {Function} [callbackAfterChange] */ -const connect = (callbackAfterChange = () => {}) => { +const connect: LocaleListenerConnect = (callbackAfterChange = () => {}) => { Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, callback: (val) => { @@ -23,10 +22,7 @@ const connect = (callbackAfterChange = () => {}) => { }); }; -/* - * @return {String} - */ -function getPreferredLocale() { +function getPreferredLocale(): BaseLocale { return preferredLocale; } diff --git a/src/libs/Localize/LocaleListener/index.desktop.js b/src/libs/Localize/LocaleListener/index.desktop.js deleted file mode 100644 index 0c0d723122da..000000000000 --- a/src/libs/Localize/LocaleListener/index.desktop.js +++ /dev/null @@ -1,13 +0,0 @@ -import ELECTRON_EVENTS from '../../../../desktop/ELECTRON_EVENTS'; -import BaseLocaleListener from './BaseLocaleListener'; - -export default { - connect: (callbackAfterChange = () => {}) => - BaseLocaleListener.connect((val) => { - // Send the updated locale to the Electron main process - window.electron.send(ELECTRON_EVENTS.LOCALE_UPDATED, val); - - // Then execute the callback provided for the renderer process - callbackAfterChange(val); - }), -}; diff --git a/src/libs/Localize/LocaleListener/index.desktop.ts b/src/libs/Localize/LocaleListener/index.desktop.ts new file mode 100644 index 000000000000..6974d3ed4879 --- /dev/null +++ b/src/libs/Localize/LocaleListener/index.desktop.ts @@ -0,0 +1,18 @@ +import ELECTRON_EVENTS from '../../../../desktop/ELECTRON_EVENTS'; +import BaseLocaleListener from './BaseLocaleListener'; +import {LocaleListener, LocaleListenerConnect} from './types'; + +const localeListenerConnect: LocaleListenerConnect = (callbackAfterChange = () => {}) => + BaseLocaleListener.connect((val) => { + // Send the updated locale to the Electron main process + window.electron.send(ELECTRON_EVENTS.LOCALE_UPDATED, val); + + // Then execute the callback provided for the renderer process + callbackAfterChange(val); + }); + +const localeListener: LocaleListener = { + connect: localeListenerConnect, +}; + +export default localeListener; diff --git a/src/libs/Localize/LocaleListener/index.js b/src/libs/Localize/LocaleListener/index.js deleted file mode 100644 index e5f1ea03f93f..000000000000 --- a/src/libs/Localize/LocaleListener/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import BaseLocaleListener from './BaseLocaleListener'; - -export default { - connect: BaseLocaleListener.connect, -}; diff --git a/src/libs/Localize/LocaleListener/index.ts b/src/libs/Localize/LocaleListener/index.ts new file mode 100644 index 000000000000..b0dda5d5fabc --- /dev/null +++ b/src/libs/Localize/LocaleListener/index.ts @@ -0,0 +1,10 @@ +import BaseLocaleListener from './BaseLocaleListener'; +import {LocaleListener, LocaleListenerConnect} from './types'; + +const localeListenerConnect: LocaleListenerConnect = BaseLocaleListener.connect; + +const localizeListener: LocaleListener = { + connect: localeListenerConnect, +}; + +export default localizeListener; diff --git a/src/libs/Localize/LocaleListener/types.ts b/src/libs/Localize/LocaleListener/types.ts new file mode 100644 index 000000000000..4daf90af0483 --- /dev/null +++ b/src/libs/Localize/LocaleListener/types.ts @@ -0,0 +1,13 @@ +import {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; + +type BaseLocale = ValueOf; + +type LocaleListenerConnect = (callbackAfterChange?: (locale?: BaseLocale) => void) => void; + +type LocaleListener = { + connect: LocaleListenerConnect; +}; + +export type {LocaleListenerConnect, LocaleListener}; +export default BaseLocale; diff --git a/src/libs/Localize/index.js b/src/libs/Localize/index.ts similarity index 52% rename from src/libs/Localize/index.js rename to src/libs/Localize/index.ts index f2f8cfa1f8b0..fd49902af369 100644 --- a/src/libs/Localize/index.js +++ b/src/libs/Localize/index.ts @@ -1,12 +1,10 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; -import _ from 'underscore'; import Log from '@libs/Log'; import Config from '@src/CONFIG'; import CONST from '@src/CONST'; import translations from '@src/languages/translations'; +import {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import LocaleListener from './LocaleListener'; import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; @@ -15,12 +13,11 @@ import BaseLocaleListener from './LocaleListener/BaseLocaleListener'; let userEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, - waitForCollectionCallback: true, callback: (val) => { if (!val) { return; } - userEmail = val.email; + userEmail = val?.email ?? ''; }, }); @@ -29,66 +26,60 @@ LocaleListener.connect(); // Note: This has to be initialized inside a function and not at the top level of the file, because Intl is polyfilled, // and if React Native executes this code upon import, then the polyfill will not be available yet and it will barf -let CONJUNCTION_LIST_FORMATS_FOR_LOCALES; +let CONJUNCTION_LIST_FORMATS_FOR_LOCALES: Record; function init() { - CONJUNCTION_LIST_FORMATS_FOR_LOCALES = _.reduce( - CONST.LOCALES, - (memo, locale) => { - // This is not a supported locale, so we'll use ES_ES instead - if (locale === CONST.LOCALES.ES_ES_ONFIDO) { - // eslint-disable-next-line no-param-reassign - memo[locale] = new Intl.ListFormat(CONST.LOCALES.ES_ES, {style: 'long', type: 'conjunction'}); - return memo; - } - + CONJUNCTION_LIST_FORMATS_FOR_LOCALES = Object.values(CONST.LOCALES).reduce((memo: Record, locale) => { + // This is not a supported locale, so we'll use ES_ES instead + if (locale === CONST.LOCALES.ES_ES_ONFIDO) { // eslint-disable-next-line no-param-reassign - memo[locale] = new Intl.ListFormat(locale, {style: 'long', type: 'conjunction'}); + memo[locale] = new Intl.ListFormat(CONST.LOCALES.ES_ES, {style: 'long', type: 'conjunction'}); return memo; - }, - {}, - ); + } + + // eslint-disable-next-line no-param-reassign + memo[locale] = new Intl.ListFormat(locale, {style: 'long', type: 'conjunction'}); + return memo; + }, {}); } +type PhraseParameters = T extends (...args: infer A) => string ? A : never[]; +type Phrase = TranslationFlatObject[TKey] extends (...args: infer A) => unknown ? (...args: A) => string : string; + /** * Return translated string for given locale and phrase * - * @param {String} [desiredLanguage] eg 'en', 'es-ES' - * @param {String} phraseKey - * @param {Object} [phraseParameters] Parameters to supply if the phrase is a template literal. - * @returns {String} + * @param [desiredLanguage] eg 'en', 'es-ES' + * @param [phraseParameters] Parameters to supply if the phrase is a template literal. */ -function translate(desiredLanguage = CONST.LOCALES.DEFAULT, phraseKey, phraseParameters = {}) { - const languageAbbreviation = desiredLanguage.substring(0, 2); - let translatedPhrase; - +function translate(desiredLanguage: 'en' | 'es' | 'es-ES' | 'es_ES', phraseKey: TKey, ...phraseParameters: PhraseParameters>): string { // Search phrase in full locale e.g. es-ES - const desiredLanguageDictionary = translations[desiredLanguage] || {}; - translatedPhrase = desiredLanguageDictionary[phraseKey]; + const language = desiredLanguage === CONST.LOCALES.ES_ES_ONFIDO ? CONST.LOCALES.ES_ES : desiredLanguage; + let translatedPhrase = translations?.[language]?.[phraseKey] as Phrase; if (translatedPhrase) { - return Str.result(translatedPhrase, phraseParameters); + return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase; } // Phrase is not found in full locale, search it in fallback language e.g. es - const fallbackLanguageDictionary = translations[languageAbbreviation] || {}; - translatedPhrase = fallbackLanguageDictionary[phraseKey]; + const languageAbbreviation = desiredLanguage.substring(0, 2) as 'en' | 'es'; + translatedPhrase = translations?.[languageAbbreviation]?.[phraseKey] as Phrase; if (translatedPhrase) { - return Str.result(translatedPhrase, phraseParameters); + return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase; } + if (languageAbbreviation !== CONST.LOCALES.DEFAULT) { Log.alert(`${phraseKey} was not found in the ${languageAbbreviation} locale`); } // Phrase is not translated, search it in default language (en) - const defaultLanguageDictionary = translations[CONST.LOCALES.DEFAULT] || {}; - translatedPhrase = defaultLanguageDictionary[phraseKey]; + translatedPhrase = translations?.[CONST.LOCALES.DEFAULT]?.[phraseKey] as Phrase; if (translatedPhrase) { - return Str.result(translatedPhrase, phraseParameters); + return typeof translatedPhrase === 'function' ? translatedPhrase(...phraseParameters) : translatedPhrase; } // Phrase is not found in default language, on production and staging log an alert to server // on development throw an error if (Config.IS_IN_PRODUCTION || Config.IS_IN_STAGING) { - const phraseString = _.isArray(phraseKey) ? phraseKey.join('.') : phraseKey; + const phraseString: string = Array.isArray(phraseKey) ? phraseKey.join('.') : phraseKey; Log.alert(`${phraseString} was not found in the en locale`); if (userEmail.includes(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN)) { return CONST.MISSING_TRANSLATION; @@ -100,49 +91,38 @@ function translate(desiredLanguage = CONST.LOCALES.DEFAULT, phraseKey, phrasePar /** * Uses the locale in this file updated by the Onyx subscriber. - * - * @param {String|Array} phrase - * @param {Object} [variables] - * @returns {String} */ -function translateLocal(phrase, variables) { - return translate(BaseLocaleListener.getPreferredLocale(), phrase, variables); +function translateLocal(phrase: TKey, ...variables: PhraseParameters>) { + return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables); } /** * Return translated string for given error. - * - * @param {String|Array} message - * @returns {String} */ -function translateIfPhraseKey(message) { - if (_.isEmpty(message)) { +function translateIfPhraseKey(message: string | [string, Record & {isTranslated?: true}] | []): string { + if (!message || (Array.isArray(message) && message.length === 0)) { return ''; } try { // check if error message has a variable parameter - const [phrase, variables] = _.isArray(message) ? message : [message]; + const [phrase, variables] = Array.isArray(message) ? message : [message]; // This condition checks if the error is already translated. For example, if there are multiple errors per input, we handle translation in ErrorUtils.addErrorMessage due to the inability to concatenate error keys. - - if (variables && variables.isTranslated) { + if (variables?.isTranslated) { return phrase; } - return translateLocal(phrase, variables); + return translateLocal(phrase as TranslationPaths, variables as never); } catch (error) { - return message; + return Array.isArray(message) ? message[0] : message; } } /** * Format an array into a string with comma and "and" ("a dog, a cat and a chicken") - * - * @param {Array} anArray - * @return {String} */ -function arrayToString(anArray) { +function arrayToString(anArray: string[]) { if (!CONJUNCTION_LIST_FORMATS_FOR_LOCALES) { init(); } @@ -152,11 +132,9 @@ function arrayToString(anArray) { /** * Returns the user device's preferred language. - * - * @return {String} */ -function getDevicePreferredLocale() { - return lodashGet(RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES]), 'languageTag', CONST.LOCALES.DEFAULT); +function getDevicePreferredLocale(): string { + return RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES])?.languageTag ?? CONST.LOCALES.DEFAULT; } export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 34837135d22d..33ddd77ed8c8 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -155,9 +155,9 @@ function AuthScreens({isUsingMemoryOnlyKeys, lastUpdateIDAppliedToClient, sessio const shortcutsOverviewShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUTS; const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; const chatShortcutConfig = CONST.KEYBOARD_SHORTCUTS.NEW_CHAT; - const shouldGetAllData = isUsingMemoryOnlyKeys || SessionUtils.didUserLogInDuringSession(); const currentUrl = getCurrentUrl(); const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(currentUrl, session.email); + const shouldGetAllData = isUsingMemoryOnlyKeys || SessionUtils.didUserLogInDuringSession() || isLoggingInAsNewUser; // Sign out the current user if we're transitioning with a different user const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS); if (isLoggingInAsNewUser && isTransitioning) { diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js index a36f98076d22..c030b91cf930 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js @@ -27,13 +27,13 @@ function Overlay(props) { style={[styles.draggableTopBar]} onPress={props.onPress} accessibilityLabel={translate('common.close')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} />
diff --git a/src/libs/Navigation/FreezeWrapper.js b/src/libs/Navigation/FreezeWrapper.js index 592d869dc0de..16a353ebddea 100644 --- a/src/libs/Navigation/FreezeWrapper.js +++ b/src/libs/Navigation/FreezeWrapper.js @@ -3,6 +3,7 @@ import lodashFindIndex from 'lodash/findIndex'; import PropTypes from 'prop-types'; import React, {useEffect, useRef, useState} from 'react'; import {Freeze} from 'react-freeze'; +import {InteractionManager} from 'react-native'; const propTypes = { /** Prop to disable freeze */ @@ -35,7 +36,7 @@ function FreezeWrapper(props) { // we don't want to freeze the screen if it's the previous screen because the freeze placeholder // would be visible at the beginning of the back animation then if (navigation.getState().index - screenIndexRef.current > 1) { - setIsScreenBlurred(true); + InteractionManager.runAfterInteractions(() => setIsScreenBlurred(true)); } else { setIsScreenBlurred(false); } diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index ae13e2b07206..de6c4a64237b 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -96,8 +96,8 @@ function navigate(route = ROUTES.HOME, type) { /** * @param {String} fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP - * @param {Bool} shouldEnforceFallback - Enforces navigation to fallback route - * @param {Bool} shouldPopToTop - Should we navigate to LHN on back press + * @param {Boolean} shouldEnforceFallback - Enforces navigation to fallback route + * @param {Boolean} shouldPopToTop - Should we navigate to LHN on back press */ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = false) { if (!canNavigate('goBack')) { @@ -207,6 +207,14 @@ function getActiveRoute() { return ''; } +/** + * Returns the current active route without the URL params + * @returns {String} + */ +function getActiveRouteWithoutParams() { + return getActiveRoute().replace(/\?.*/, ''); +} + /** Returns the active route name from a state event from the navigationRef * @param {Object} event * @returns {String | undefined} @@ -270,6 +278,7 @@ export default { dismissModal, isActiveRoute, getActiveRoute, + getActiveRouteWithoutParams, goBack, isNavigationReady, setIsNavigationReady, diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index b2db1758f24b..c017e6c7664e 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -142,7 +142,7 @@ export default { exact: true, }, Settings_ContactMethods: { - path: ROUTES.SETTINGS_CONTACT_METHODS, + path: ROUTES.SETTINGS_CONTACT_METHODS.route, exact: true, }, Settings_ContactMethodDetails: { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 99853975f86a..54d09b75eff2 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -87,7 +87,6 @@ Onyx.connect({ const policyExpenseReports = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (report, key) => { if (!ReportUtils.isPolicyExpenseChat(report)) { return; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 45bdfb18b451..4a7a34617842 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -261,6 +261,11 @@ function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | return false; } + // Do not group if one of previous / current action is report preview and another one is not report preview + if ((isReportPreviewAction(previousAction) && !isReportPreviewAction(currentAction)) || (isReportPreviewAction(currentAction) && !isReportPreviewAction(previousAction))) { + return false; + } + return currentAction.actorAccountID === previousAction.actorAccountID; } @@ -406,12 +411,9 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = }; } - let messageText = message?.text ?? ''; - if (messageText) { - messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); - } + const messageText = message?.text ?? ''; return { - lastMessageText: messageText, + lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(), }; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 5bb8fd4ad4fc..8d24d98b19e8 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -818,15 +818,8 @@ function isOneOnOneChat(report) { * @returns {Object} */ function getReport(reportID) { - /** - * Using typical string concatenation here due to performance issues - * with template literals. - */ - if (!allReports) { - return {}; - } - - return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; + // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check + return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {}; } /** @@ -1499,15 +1492,18 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { } if (moneyRequestReport) { let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0); - let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0); + let totalSpend = lodashGet(moneyRequestReport, 'total', 0); - if (nonReimbursableSpend + reimbursableSpend !== 0) { + if (nonReimbursableSpend + totalSpend !== 0) { // There is a possibility that if the Expense report has a negative total. // This is because there are instances where you can get a credit back on your card, // or you enter a negative expense to “offset” future expenses nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend); - reimbursableSpend = isExpenseReport(moneyRequestReport) ? reimbursableSpend * -1 : Math.abs(reimbursableSpend); - const totalDisplaySpend = nonReimbursableSpend + reimbursableSpend; + totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend); + + const totalDisplaySpend = totalSpend; + const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend; + return { nonReimbursableSpend, reimbursableSpend, @@ -1530,25 +1526,14 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { * @returns {String} */ function getPolicyExpenseChatName(report, policy = undefined) { - const ownerAccountID = report.ownerAccountID; - const personalDetails = allPersonalDetails[ownerAccountID]; - const login = personalDetails ? personalDetails.login : null; - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { return getPolicyName(report, false, policy); } - let policyExpenseChatRole = 'user'; - /** - * Using typical string concatenation here due to performance issues - * with template literals. - */ - const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; - if (policyItem) { - policyExpenseChatRole = policyItem.role || 'user'; - } + const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user'; // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. @@ -3316,6 +3301,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, (report.participantAccountIDs && report.participantAccountIDs.length === 0 && !isChatThread(report) && + !isPublicRoom(report) && !isUserCreatedPolicyRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && @@ -4171,6 +4157,17 @@ function shouldUseFullTitleToDisplay(report) { return isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report); } +/** + * + * @param {String} type + * @param {String} policyID + * @returns {Object} + */ +function getRoom(type, policyID) { + const room = _.find(allReports, (report) => report && report.policyID === policyID && report.chatType === type && !isThread(report)); + return room; +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -4330,4 +4327,5 @@ export { parseReportRouteParams, getReimbursementQueuedActionMessage, getPersonalDetailsForAccountID, + getRoom, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4951432bcd03..8905616d94ce 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -38,6 +38,7 @@ Onyx.connect({ const reportActionsForDisplay = actionsArray.filter( (reportAction, actionKey) => ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) && + !ReportActionsUtils.isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); @@ -156,6 +157,18 @@ function getOrderedReportIDs( } } + // There are a few properties that need to be calculated for the report which are used when sorting reports. + reportsToDisplay.forEach((report) => { + // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. + // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add + // the reportDisplayName property to the report object directly. + // eslint-disable-next-line no-param-reassign + report.displayName = ReportUtils.getReportName(report); + + // eslint-disable-next-line no-param-reassign + report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); + }); + // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: // 1. Pinned/GBR - Always sorted by reportDisplayName // 2. Drafts - Always sorted by reportDisplayName @@ -169,17 +182,7 @@ function getOrderedReportIDs( const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; - reportsToDisplay.forEach((report) => { - // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. - // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add - // the reportDisplayName property to the report object directly. - // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); - - // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); - const isPinned = report.isPinned ?? false; if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) { pinnedAndGBRReports.push(report); @@ -409,10 +412,21 @@ function getOptionData( const reportAction = lastReportActions?.[report.reportID]; if (result.isArchivedRoom) { const archiveReason = (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED && reportAction?.originalMessage?.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; - lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { - displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails, 'displayName'), - policyName: ReportUtils.getPolicyName(report, false, policy), - }); + + switch (archiveReason) { + case CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED: + case CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY: + case CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED: { + lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { + policyName: ReportUtils.getPolicyName(report, false, policy), + displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails, 'displayName'), + }); + break; + } + default: { + lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.default`); + } + } } if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) { @@ -450,7 +464,7 @@ function getOptionData( } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { - result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } } else { if (!lastMessageText) { diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index bfa0cd911177..9af74f8313c3 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -1,4 +1,3 @@ -import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ReportUtils from '@libs/ReportUtils'; @@ -6,33 +5,11 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import updateUnread from './updateUnread/index'; -let previousUnreadCount = 0; - Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (reportsFromOnyx) => { - if (!reportsFromOnyx) { - return; - } - - /** - * We need to wait until after interactions have finished to update the unread count because otherwise - * the unread count will be updated while the interactions/animations are in progress and we don't want - * to put more work on the main thread. - * - * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions - * have finished. - * - * More info: https://reactnative.dev/docs/interactionmanager - */ - InteractionManager.runAfterInteractions(() => { - const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - const unreadReportsCount = _.size(unreadReports); - if (previousUnreadCount !== unreadReportsCount) { - previousUnreadCount = unreadReportsCount; - updateUnread(unreadReportsCount); - } - }); + const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + updateUnread(_.size(unreadReports)); }, }); diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 19ac03228753..acdbc200842b 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2103,6 +2103,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, value: { hasOutstandingIOU: false, + hasOutstandingChildRequest: false, iouReportID: null, lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}).lastMessageText, lastVisibleActionCreated: lodashGet(ReportActionsUtils.getLastVisibleAction(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}), 'created'), @@ -2449,6 +2450,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho lastReadTime: DateUtils.getDBTime(), lastVisibleActionCreated: optimisticIOUReportAction.created, hasOutstandingIOU: false, + hasOutstandingChildRequest: false, iouReportID: null, lastMessageText: optimisticIOUReportAction.message[0].text, lastMessageHtml: optimisticIOUReportAction.message[0].html, @@ -2472,6 +2474,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho lastMessageText: optimisticIOUReportAction.message[0].text, lastMessageHtml: optimisticIOUReportAction.message[0].html, hasOutstandingIOU: false, + hasOutstandingChildRequest: false, statusNum: CONST.REPORT.STATUS.REIMBURSED, }, }, diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.ts similarity index 65% rename from src/libs/actions/Link.js rename to src/libs/actions/Link.ts index 0a50bb62ddc8..d741ced6dc08 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.ts @@ -1,6 +1,4 @@ -import lodashGet from 'lodash/get'; import Onyx from 'react-native-onyx'; -import _ from 'underscore'; import * as API from '@libs/API'; import asyncOpenURL from '@libs/asyncOpenURL'; import * as Environment from '@libs/Environment/Environment'; @@ -10,29 +8,23 @@ import ONYXKEYS from '@src/ONYXKEYS'; let isNetworkOffline = false; Onyx.connect({ key: ONYXKEYS.NETWORK, - callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)), + callback: (value) => (isNetworkOffline = value?.isOffline ?? false), }); -let currentUserEmail; +let currentUserEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, - callback: (val) => (currentUserEmail = lodashGet(val, 'email', '')), + callback: (value) => (currentUserEmail = value?.email ?? ''), }); -/** - * @param {String} [url] the url path - * @param {String} [shortLivedAuthToken] - * - * @returns {Promise} - */ -function buildOldDotURL(url, shortLivedAuthToken) { +function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise { const hasHashParams = url.indexOf('#') !== -1; const hasURLParams = url.indexOf('?') !== -1; const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; - - const params = _.compact([authTokenParam, emailParam]).join('&'); + const paramsArray = [authTokenParam, emailParam]; + const params = paramsArray.filter(Boolean).join('&'); return Environment.getOldDotEnvironmentURL().then((environmentURL) => { const oldDotDomain = Url.addTrailingForwardSlash(environmentURL); @@ -43,17 +35,13 @@ function buildOldDotURL(url, shortLivedAuthToken) { } /** - * @param {String} url - * @param {Boolean} shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari. + * @param shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari. */ -function openExternalLink(url, shouldSkipCustomSafariLogic = false) { +function openExternalLink(url: string, shouldSkipCustomSafariLogic = false) { asyncOpenURL(Promise.resolve(), url, shouldSkipCustomSafariLogic); } -/** - * @param {String} url the url path - */ -function openOldDotLink(url) { +function openOldDotLink(url: string) { if (isNetworkOffline) { buildOldDotURL(url).then((oldDotURL) => openExternalLink(oldDotURL)); return; @@ -63,7 +51,7 @@ function openOldDotLink(url) { asyncOpenURL( // eslint-disable-next-line rulesdir/no-api-side-effects-method API.makeRequestWithSideEffects('OpenOldDotLink', {}, {}) - .then((response) => buildOldDotURL(url, response.shortLivedAuthToken)) + .then((response) => (response ? buildOldDotURL(url, response.shortLivedAuthToken) : buildOldDotURL(url))) .catch(() => buildOldDotURL(url)), (oldDotURL) => oldDotURL, ); diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.ts similarity index 56% rename from src/libs/actions/PersonalDetails.js rename to src/libs/actions/PersonalDetails.ts index 351943ca1f29..01f8c2f4916b 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.ts @@ -1,32 +1,38 @@ import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import Onyx, {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; +import {CustomRNImageManipulatorResult, FileWithUri} from '@libs/cropOrRotateImage/types'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {DateOfBirthForm, PersonalDetails, PrivatePersonalDetails} from '@src/types/onyx'; +import {Timezone} from '@src/types/onyx/PersonalDetails'; + +type FirstAndLastName = { + firstName: string; + lastName: string; +}; let currentUserEmail = ''; -let currentUserAccountID; +let currentUserAccountID = -1; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { - currentUserEmail = val ? val.email : ''; - currentUserAccountID = val ? val.accountID : -1; + currentUserEmail = val?.email ?? ''; + currentUserAccountID = val?.accountID ?? -1; }, }); -let allPersonalDetails; +let allPersonalDetails: OnyxEntry> = null; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, callback: (val) => (allPersonalDetails = val), }); -let privatePersonalDetails; +let privatePersonalDetails: OnyxEntry = null; Onyx.connect({ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, callback: (val) => (privatePersonalDetails = val), @@ -34,64 +40,60 @@ Onyx.connect({ /** * Returns the displayName for a user - * - * @param {String} login - * @param {Object} [personalDetail] - * @returns {String} */ -function getDisplayName(login, personalDetail) { +function getDisplayName(login: string, personalDetail: Pick | null): string { // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms and format it // so that the option looks cleaner in our UI. const userLogin = LocalePhoneNumber.formatPhoneNumber(login); - const userDetails = personalDetail || lodashGet(allPersonalDetails, login); + const userDetails = personalDetail ?? allPersonalDetails?.[login]; if (!userDetails) { return userLogin; } - const firstName = userDetails.firstName || ''; - const lastName = userDetails.lastName || ''; + const firstName = userDetails.firstName ?? ''; + const lastName = userDetails.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); + // It's possible for fullName to be empty string, so we must use "||" to fallback to userLogin. return fullName || userLogin; } /** - * @param {String} userAccountIDOrLogin - * @param {String} [defaultDisplayName] display name to use if user details don't exist in Onyx or if + * @param [defaultDisplayName] display name to use if user details don't exist in Onyx or if * found details don't include the user's displayName or login - * @returns {String} */ -function getDisplayNameForTypingIndicator(userAccountIDOrLogin, defaultDisplayName = '') { +function getDisplayNameForTypingIndicator(userAccountIDOrLogin: string, defaultDisplayName = ''): string { // Try to convert to a number, which means we have an accountID const accountID = Number(userAccountIDOrLogin); // If the user is typing on OldDot, userAccountIDOrLogin will be a string (the user's login), // so Number(string) is NaN. Search for personalDetails by login to get the display name. - if (_.isNaN(accountID)) { - const detailsByLogin = _.findWhere(allPersonalDetails, {login: userAccountIDOrLogin}) || {}; - return detailsByLogin.displayName || userAccountIDOrLogin; + if (Number.isNaN(accountID)) { + const detailsByLogin = Object.entries(allPersonalDetails ?? {}).find(([, value]) => value?.login === userAccountIDOrLogin)?.[1]; + + // It's possible for displayName to be empty string, so we must use "||" to fallback to userAccountIDOrLogin. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return detailsByLogin?.displayName || userAccountIDOrLogin; } - const detailsByAccountID = lodashGet(allPersonalDetails, accountID, {}); - return detailsByAccountID.displayName || detailsByAccountID.login || defaultDisplayName; + const detailsByAccountID = allPersonalDetails?.[accountID]; + + // It's possible for displayName to be empty string, so we must use "||" to fallback to login or defaultDisplayName. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return detailsByAccountID?.displayName || detailsByAccountID?.login || defaultDisplayName; } /** * Gets the first and last name from the user's personal details. * If the login is the same as the displayName, then they don't exist, * so we return empty strings instead. - * @param {Object} personalDetail - * @param {String} personalDetail.login - * @param {String} personalDetail.displayName - * @param {String} personalDetail.firstName - * @param {String} personalDetail.lastName - * - * @returns {Object} */ -function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstName, lastName}) { +function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstName, lastName}: PersonalDetails): FirstAndLastName { + // It's possible for firstName to be empty string, so we must use "||" to consider lastName instead. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (firstName || lastName) { - return {firstName: firstName || '', lastName: lastName || ''}; + return {firstName: firstName ?? '', lastName: lastName ?? ''}; } if (login && Str.removeSMSDomain(login) === displayName) { return {firstName: '', lastName: ''}; @@ -112,24 +114,24 @@ function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstN /** * Convert country names obtained from the backend to their respective ISO codes * This is for backward compatibility of stored data before E/App#15507 - * @param {String} countryName - * @returns {String} */ -function getCountryISO(countryName) { - if (_.isEmpty(countryName) || countryName.length === 2) { +function getCountryISO(countryName: string): string { + if (!countryName || countryName.length === 2) { return countryName; } - return _.findKey(CONST.ALL_COUNTRIES, (country) => country === countryName) || ''; + + return Object.entries(CONST.ALL_COUNTRIES).find(([, value]) => value === countryName)?.[0] ?? ''; } -/** - * @param {String} pronouns - */ -function updatePronouns(pronouns) { - API.write( - 'UpdatePronouns', - {pronouns}, - { +function updatePronouns(pronouns: string) { + if (currentUserAccountID) { + type UpdatePronounsParams = { + pronouns: string; + }; + + const parameters: UpdatePronounsParams = {pronouns}; + + API.write('UpdatePronouns', parameters, { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -141,20 +143,22 @@ function updatePronouns(pronouns) { }, }, ], - }, - ); + }); + } + Navigation.goBack(ROUTES.SETTINGS_PROFILE); } -/** - * @param {String} firstName - * @param {String} lastName - */ -function updateDisplayName(firstName, lastName) { - API.write( - 'UpdateDisplayName', - {firstName, lastName}, - { +function updateDisplayName(firstName: string, lastName: string) { + if (currentUserAccountID) { + type UpdateDisplayNameParams = { + firstName: string; + lastName: string; + }; + + const parameters: UpdateDisplayNameParams = {firstName, lastName}; + + API.write('UpdateDisplayName', parameters, { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -163,7 +167,7 @@ function updateDisplayName(firstName, lastName) { [currentUserAccountID]: { firstName, lastName, - displayName: getDisplayName(currentUserEmail, { + displayName: getDisplayName(currentUserEmail ?? '', { firstName, lastName, }), @@ -171,67 +175,73 @@ function updateDisplayName(firstName, lastName) { }, }, ], - }, - ); + }); + } + Navigation.goBack(ROUTES.SETTINGS_PROFILE); } -/** - * @param {String} legalFirstName - * @param {String} legalLastName - */ -function updateLegalName(legalFirstName, legalLastName) { - API.write( - 'UpdateLegalName', - {legalFirstName, legalLastName}, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - legalFirstName, - legalLastName, - }, +function updateLegalName(legalFirstName: string, legalLastName: string) { + type UpdateLegalNameParams = { + legalFirstName: string; + legalLastName: string; + }; + + const parameters: UpdateLegalNameParams = {legalFirstName, legalLastName}; + + API.write('UpdateLegalName', parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + legalFirstName, + legalLastName, }, - ], - }, - ); + }, + ], + }); + Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); } /** - * @param {String} dob - date of birth + * @param dob - date of birth */ -function updateDateOfBirth({dob}) { - API.write( - 'UpdateDateOfBirth', - {dob}, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - value: { - dob, - }, +function updateDateOfBirth({dob}: DateOfBirthForm) { + type UpdateDateOfBirthParams = { + dob?: string; + }; + + const parameters: UpdateDateOfBirthParams = {dob}; + + API.write('UpdateDateOfBirth', parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + dob, }, - ], - }, - ); + }, + ], + }); + Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); } -/** - * @param {String} street - * @param {String} street2 - * @param {String} city - * @param {String} state - * @param {String} zip - * @param {String} country - */ -function updateAddress(street, street2, city, state, zip, country) { - const parameters = { +function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: string) { + type UpdateHomeAddressParams = { + homeAddressStreet: string; + addressStreet2: string; + homeAddressCity: string; + addressState: string; + addressZipCode: string; + addressCountry: string; + addressStateLong?: string; + }; + + const parameters: UpdateHomeAddressParams = { homeAddressStreet: street, addressStreet2: street2, homeAddressCity: city, @@ -245,6 +255,7 @@ function updateAddress(street, street2, city, state, zip, country) { if (country !== CONST.COUNTRY.US) { parameters.addressStateLong = state; } + API.write('UpdateHomeAddress', parameters, { optimisticData: [ { @@ -262,55 +273,61 @@ function updateAddress(street, street2, city, state, zip, country) { }, ], }); + Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); } /** * Updates timezone's 'automatic' setting, and updates * selected timezone if set to automatically update. - * - * @param {Object} timezone - * @param {Boolean} timezone.automatic - * @param {String} timezone.selected */ -function updateAutomaticTimezone(timezone) { - API.write( - 'UpdateAutomaticTimezone', - { - timezone: JSON.stringify(timezone), - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: { - [currentUserAccountID]: { - timezone, - }, +function updateAutomaticTimezone(timezone: Timezone) { + if (!currentUserAccountID) { + return; + } + + type UpdateAutomaticTimezoneParams = { + timezone: string; + }; + + const parameters: UpdateAutomaticTimezoneParams = { + timezone: JSON.stringify(timezone), + }; + + API.write('UpdateAutomaticTimezone', parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [currentUserAccountID]: { + timezone, }, }, - ], - }, - ); + }, + ], + }); } /** * Updates user's 'selected' timezone, then navigates to the * initial Timezone page. - * - * @param {String} selectedTimezone */ -function updateSelectedTimezone(selectedTimezone) { - const timezone = { +function updateSelectedTimezone(selectedTimezone: string) { + const timezone: Timezone = { selected: selectedTimezone, }; - API.write( - 'UpdateSelectedTimezone', - { - timezone: JSON.stringify(timezone), - }, - { + + type UpdateSelectedTimezoneParams = { + timezone: string; + }; + + const parameters: UpdateSelectedTimezoneParams = { + timezone: JSON.stringify(timezone), + }; + + if (currentUserAccountID) { + API.write('UpdateSelectedTimezone', parameters, { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -322,8 +339,9 @@ function updateSelectedTimezone(selectedTimezone) { }, }, ], - }, - ); + }); + } + Navigation.goBack(ROUTES.SETTINGS_TIMEZONE); } @@ -331,7 +349,7 @@ function updateSelectedTimezone(selectedTimezone) { * Fetches additional personal data like legal name, date of birth, address */ function openPersonalDetailsPage() { - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, @@ -341,7 +359,7 @@ function openPersonalDetailsPage() { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, @@ -351,7 +369,7 @@ function openPersonalDetailsPage() { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, @@ -361,17 +379,20 @@ function openPersonalDetailsPage() { }, ]; - API.read('OpenPersonalDetailsPage', {}, {optimisticData, successData, failureData}); + type OpenPersonalDetailsPageParams = Record; + + const parameters: OpenPersonalDetailsPageParams = {}; + + API.read('OpenPersonalDetailsPage', parameters, {optimisticData, successData, failureData}); } /** * Fetches public profile info about a given user. * The API will only return the accountID, displayName, and avatar for the user * but the profile page will use other info (e.g. contact methods and pronouns) if they are already available in Onyx - * @param {Number} accountID */ -function openPublicProfilePage(accountID) { - const optimisticData = [ +function openPublicProfilePage(accountID: number) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -382,7 +403,8 @@ function openPublicProfilePage(accountID) { }, }, ]; - const successData = [ + + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -393,7 +415,8 @@ function openPublicProfilePage(accountID) { }, }, ]; - const failureData = [ + + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -404,16 +427,25 @@ function openPublicProfilePage(accountID) { }, }, ]; - API.read('OpenPublicProfilePage', {accountID}, {optimisticData, successData, failureData}); + + type OpenPublicProfilePageParams = { + accountID: number; + }; + + const parameters: OpenPublicProfilePageParams = {accountID}; + + API.read('OpenPublicProfilePage', parameters, {optimisticData, successData, failureData}); } /** * Updates the user's avatar image - * - * @param {File|Object} file */ -function updateAvatar(file) { - const optimisticData = [ +function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) { + if (!currentUserAccountID) { + return; + } + + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -434,7 +466,7 @@ function updateAvatar(file) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -447,14 +479,14 @@ function updateAvatar(file) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { [currentUserAccountID]: { - avatar: allPersonalDetails[currentUserAccountID].avatar, - avatarThumbnail: allPersonalDetails[currentUserAccountID].avatarThumbnail || allPersonalDetails[currentUserAccountID].avatar, + avatar: allPersonalDetails?.[currentUserAccountID]?.avatar, + avatarThumbnail: allPersonalDetails?.[currentUserAccountID]?.avatarThumbnail ?? allPersonalDetails?.[currentUserAccountID]?.avatar, pendingFields: { avatar: null, }, @@ -463,17 +495,27 @@ function updateAvatar(file) { }, ]; - API.write('UpdateUserAvatar', {file}, {optimisticData, successData, failureData}); + type UpdateUserAvatarParams = { + file: FileWithUri | CustomRNImageManipulatorResult; + }; + + const parameters: UpdateUserAvatarParams = {file}; + + API.write('UpdateUserAvatar', parameters, {optimisticData, successData, failureData}); } /** * Replaces the user's avatar image with a default avatar */ function deleteAvatar() { + if (!currentUserAccountID) { + return; + } + // We want to use the old dot avatar here as this affects both platforms. const defaultAvatar = UserUtils.getDefaultAvatarURL(currentUserAccountID); - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -485,26 +527,34 @@ function deleteAvatar() { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: { [currentUserAccountID]: { - avatar: allPersonalDetails[currentUserAccountID].avatar, - fallbackIcon: allPersonalDetails[currentUserAccountID].fallbackIcon, + avatar: allPersonalDetails?.[currentUserAccountID]?.avatar, + fallbackIcon: allPersonalDetails?.[currentUserAccountID]?.fallbackIcon, }, }, }, ]; - API.write('DeleteUserAvatar', {}, {optimisticData, failureData}); + type DeleteUserAvatarParams = Record; + + const parameters: DeleteUserAvatarParams = {}; + + API.write('DeleteUserAvatar', parameters, {optimisticData, failureData}); } /** * Clear error and pending fields for the current user's avatar */ function clearAvatarErrors() { + if (!currentUserAccountID) { + return; + } + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { [currentUserAccountID]: { errorFields: { @@ -519,28 +569,27 @@ function clearAvatarErrors() { /** * Get private personal details value - * @returns {Object} */ -function getPrivatePersonalDetails() { +function getPrivatePersonalDetails(): OnyxEntry { return privatePersonalDetails; } export { + clearAvatarErrors, + deleteAvatar, + extractFirstAndLastNameFromAvailableDetails, + getCountryISO, getDisplayName, getDisplayNameForTypingIndicator, - updateAvatar, - deleteAvatar, + getPrivatePersonalDetails, openPersonalDetailsPage, openPublicProfilePage, - extractFirstAndLastNameFromAvailableDetails, + updateAddress, + updateAutomaticTimezone, + updateAvatar, + updateDateOfBirth, updateDisplayName, updateLegalName, - updateDateOfBirth, - updateAddress, updatePronouns, - clearAvatarErrors, - updateAutomaticTimezone, updateSelectedTimezone, - getCountryISO, - getPrivatePersonalDetails, }; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index e716c17de8b2..bf064d8bf6d8 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1,6 +1,7 @@ import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import {escapeRegExp} from 'lodash'; +import filter from 'lodash/filter'; import lodashGet from 'lodash/get'; import lodashUnion from 'lodash/union'; import Onyx from 'react-native-onyx'; @@ -74,6 +75,12 @@ Onyx.connect({ callback: (val) => (allPersonalDetails = val), }); +let reimbursementAccount; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => (reimbursementAccount = val), +}); + let allRecentlyUsedCategories = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, @@ -96,6 +103,36 @@ function updateLastAccessedWorkspace(policyID) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Check if the user has any active free policies (aka workspaces) + * + * @param {Array} policies + * @returns {Boolean} + */ +function hasActiveFreePolicy(policies) { + const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); + + if (adminFreePolicies.length === 0) { + return false; + } + + if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + return false; + } + + // If there are no add or delete pending actions the only option left is an update + // pendingAction, in which case we should return true. + return true; +} + /** * Delete the workspace * @@ -104,6 +141,7 @@ function updateLastAccessedWorkspace(policyID) { * @param {String} policyName */ function deleteWorkspace(policyID, reports, policyName) { + const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -146,6 +184,18 @@ function deleteWorkspace(policyID, reports, policyName) { value: optimisticReportActions, }; }), + + ...(!hasActiveFreePolicy(filteredPolicies) + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: null, + }, + }, + ] + : []), ]; // Restore the old report stateNum and statusNum @@ -160,6 +210,13 @@ function deleteWorkspace(policyID, reports, policyName) { oldPolicyName, }, })), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: lodashGet(reimbursementAccount, 'errors', null), + }, + }, ]; // We don't need success data since the push notification will update @@ -184,33 +241,66 @@ function isAdminOfFreePolicy(policies) { } /** - * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} + * Build optimistic data for adding members to the announce room + * @param {String} policyID + * @param {Array} accountIDs + * @returns {Object} */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); +function buildAnnounceRoomMembersOnyxData(policyID, accountIDs) { + const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); + const announceRoomMembers = { + onyxOptimisticData: [], + onyxFailureData: [], + }; - if (adminFreePolicies.length === 0) { - return false; - } + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: [...announceReport.participantAccountIDs, ...accountIDs], + }, + }); - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { - return true; - } + announceRoomMembers.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: announceReport.participantAccountIDs, + }, + }); + return announceRoomMembers; +} - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { - return true; - } +/** + * Build optimistic data for removing users from the announce room + * @param {String} policyID + * @param {Array} accountIDs + * @returns {Object} + */ +function removeOptimisticAnnounceRoomMembers(policyID, accountIDs) { + const announceReport = ReportUtils.getRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, policyID); + const announceRoomMembers = { + onyxOptimisticData: [], + onyxFailureData: [], + }; - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { - return false; - } + const remainUsers = _.difference(announceReport.participantAccountIDs, accountIDs); + announceRoomMembers.onyxOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: [...remainUsers], + }, + }); - // If there are no add or delete pending actions the only option left is an update - // pendingAction, in which case we should return true. - return true; + announceRoomMembers.onyxFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceReport.reportID}`, + value: { + participantAccountIDs: announceReport.participantAccountIDs, + }, + }); + return announceRoomMembers; } /** @@ -233,6 +323,8 @@ function removeMembers(accountIDs, policyID) { ReportUtils.buildOptimisticClosedReportAction(sessionEmail, policy.name, CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY), ); + const announceRoomMembers = removeOptimisticAnnounceRoomMembers(policyID, accountIDs); + const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -254,6 +346,7 @@ function removeMembers(accountIDs, policyID) { key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, value: {[reportAction.reportActionID]: reportAction}, })), + ...announceRoomMembers.onyxOptimisticData, ]; // If the policy has primaryLoginsInvited, then it displays informative messages on the members page about which primary logins were added by secondary logins. @@ -305,6 +398,7 @@ function removeMembers(accountIDs, policyID) { key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChats[index].reportID}`, value: {[reportAction.reportActionID]: null}, })), + ...announceRoomMembers.onyxFailureData, ]; API.write( 'DeleteMembersFromWorkspace', @@ -420,6 +514,8 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) const accountIDs = _.values(invitedEmailsToAccountIDs); const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs); + const announceRoomMembers = buildAnnounceRoomMembersOnyxData(policyID, accountIDs); + // create onyx data for policy expense chats for each new member const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs); @@ -433,6 +529,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) }, ...newPersonalDetailsOnyxData.optimisticData, ...membersChats.onyxOptimisticData, + ...announceRoomMembers.onyxOptimisticData, ]; const successData = [ @@ -480,6 +577,7 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) }, ...newPersonalDetailsOnyxData.failureData, ...membersChats.onyxFailureData, + ...announceRoomMembers.onyxFailureData, ]; const params = { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 4646e0e33da1..aba77d1f5e2f 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -65,7 +65,6 @@ Onyx.connect({ const currentReportData = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (data, key) => { if (!key || !data) { return; @@ -2007,7 +2006,7 @@ function openReportFromDeepLink(url, isAuthenticated) { }); return; } - Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH); + Navigation.navigate(route, CONST.NAVIGATION.ACTION_TYPE.PUSH); }); }); } diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index b9cea498a3fa..d7ff96fc6c2e 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -4,6 +4,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ReportAction from '@src/types/onyx/ReportAction'; +import * as Report from './Report'; function clearReportActionErrors(reportID: string, reportAction: ReportAction) { const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); @@ -24,6 +25,11 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`, null); } + // Delete the failed task report too + const taskReportID = reportAction.message?.[0]?.taskReportID; + if (taskReportID) { + Report.deleteReport(taskReportID); + } return; } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 959710967881..e884a4d7a6b3 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -15,6 +15,7 @@ import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import * as Report from './Report'; let currentUserEmail; let currentUserAccountID; @@ -134,9 +135,13 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail // FOR TASK REPORT const failureData = [ { - onyxMethod: Onyx.METHOD.SET, + onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, - value: null, + value: { + errorFields: { + createTask: ErrorUtils.getMicroSecondOnyxError('task.genericCreateTaskFailureMessage'), + }, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -186,7 +191,11 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, - value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}}, + value: { + [optimisticAddCommentReport.reportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('task.genericCreateTaskFailureMessage'), + }, + }, }); clearOutTaskInfo(); @@ -879,7 +888,19 @@ function canModifyTask(taskReport, sessionAccountID) { /** * @param {String} reportID */ -function clearEditTaskErrors(reportID) { +function clearTaskErrors(reportID) { + const report = ReportUtils.getReport(reportID); + + // Delete the task preview in the parent report + if (lodashGet(report, 'pendingFields.createChat') === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, { + [report.parentReportActionID]: null, + }); + + Report.navigateToConciergeChatAndDeleteReport(reportID); + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { pendingFields: null, errorFields: null, @@ -934,7 +955,7 @@ export { cancelTask, dismissModalAndClearOutTaskInfo, getTaskAssigneeAccountID, - clearEditTaskErrors, + clearTaskErrors, canModifyTask, getTaskReportActionMessage, }; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index f7375a5583a6..3c91dc4624cd 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -238,7 +238,7 @@ function deleteContactMethod(contactMethod, loginList) { }, {optimisticData, successData, failureData}, ); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } /** @@ -328,7 +328,7 @@ function addNewContactMethodAndNavigate(contactMethod) { ]; API.write('AddNewContactMethod', {partnerUserID: contactMethod}, {optimisticData, successData, failureData}); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } /** @@ -755,7 +755,7 @@ function setContactMethodAsDefault(newDefaultContactMethod) { }, ]; API.write('SetContactMethodAsDefault', {partnerUserID: newDefaultContactMethod}, {optimisticData, successData, failureData}); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); } /** diff --git a/src/libs/cropOrRotateImage/types.ts b/src/libs/cropOrRotateImage/types.ts index 6abbdab49ea5..09f441bd9324 100644 --- a/src/libs/cropOrRotateImage/types.ts +++ b/src/libs/cropOrRotateImage/types.ts @@ -26,4 +26,4 @@ type CustomRNImageManipulatorResult = RNImageManipulatorResult & {size: number; type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise; -export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions}; +export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions, CustomRNImageManipulatorResult}; diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js index 0b86daf7141f..d1fa968b665f 100644 --- a/src/libs/fileDownload/index.js +++ b/src/libs/fileDownload/index.js @@ -1,3 +1,5 @@ +import * as ApiUtils from '@libs/ApiUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import * as Link from '@userActions/Link'; import * as FileUtils from './FileUtils'; @@ -8,7 +10,15 @@ import * as FileUtils from './FileUtils'; * @returns {Promise} */ export default function fileDownload(url, fileName) { - return new Promise((resolve) => { + const resolvedUrl = tryResolveUrlFromApiRoot(url); + if (!resolvedUrl.startsWith(ApiUtils.getApiRoot())) { + // Different origin URLs might pose a CORS issue during direct downloads. + // Opening in a new tab avoids this limitation, letting the browser handle the download. + Link.openExternalLink(url); + return Promise.resolve(); + } + + return ( fetch(url) .then((response) => response.blob()) .then((blob) => { @@ -35,12 +45,8 @@ export default function fileDownload(url, fileName) { // Clean up and remove the link URL.revokeObjectURL(link.href); link.parentNode.removeChild(link); - return resolve(); }) - .catch(() => { - // file could not be downloaded, open sourceURL in new tab - Link.openExternalLink(url); - return resolve(); - }); - }); + // file could not be downloaded, open sourceURL in new tab + .catch(() => Link.openExternalLink(url)) + ); } diff --git a/src/libs/focusComposerWithDelay.ts b/src/libs/focusComposerWithDelay.ts index 94eb168328aa..19f1050d24bd 100644 --- a/src/libs/focusComposerWithDelay.ts +++ b/src/libs/focusComposerWithDelay.ts @@ -1,4 +1,4 @@ -import {InteractionManager, TextInput} from 'react-native'; +import {TextInput} from 'react-native'; import * as EmojiPickerAction from './actions/EmojiPickerAction'; import ComposerFocusManager from './ComposerFocusManager'; @@ -14,21 +14,19 @@ function focusComposerWithDelay(textInput: TextInput | null): FocusComposerWithD return (shouldDelay = false) => { // There could be other animations running while we trigger manual focus. // This prevents focus from making those animations janky. - InteractionManager.runAfterInteractions(() => { - if (!textInput || EmojiPickerAction.isEmojiPickerVisible()) { - return; - } + if (!textInput || EmojiPickerAction.isEmojiPickerVisible()) { + return; + } - if (!shouldDelay) { - textInput.focus(); + if (!shouldDelay) { + textInput.focus(); + return; + } + ComposerFocusManager.isReadyToFocus().then(() => { + if (!textInput) { return; } - ComposerFocusManager.isReadyToFocus().then(() => { - if (!textInput) { - return; - } - textInput.focus(); - }); + textInput.focus(); }); }; } diff --git a/src/libs/getButtonState.ts b/src/libs/getButtonState.ts index 6b89e1b7d383..fe593b9f613e 100644 --- a/src/libs/getButtonState.ts +++ b/src/libs/getButtonState.ts @@ -1,12 +1,10 @@ import {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -type GetButtonState = (isActive: boolean, isPressed: boolean, isComplete: boolean, isDisabled: boolean, isInteractive: boolean) => ValueOf; - /** * Get the string representation of a button's state. */ -const getButtonState: GetButtonState = (isActive = false, isPressed = false, isComplete = false, isDisabled = false, isInteractive = true) => { +function getButtonState(isActive = false, isPressed = false, isComplete = false, isDisabled = false, isInteractive = true): ValueOf { if (!isInteractive) { return CONST.BUTTON_STATES.DEFAULT; } @@ -28,6 +26,6 @@ const getButtonState: GetButtonState = (isActive = false, isPressed = false, isC } return CONST.BUTTON_STATES.DEFAULT; -}; +} export default getButtonState; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts new file mode 100644 index 000000000000..68c750b05a5f --- /dev/null +++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.android.ts @@ -0,0 +1,3 @@ +import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; + +export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts new file mode 100644 index 000000000000..cd50938c70b9 --- /dev/null +++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts @@ -0,0 +1,5 @@ +import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; + +// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event +// Because of that - on iOS we can use `keyboardWillHide` that is not available on android +export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardWillHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.native.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.native.ts deleted file mode 100644 index dbfa0c6977b3..000000000000 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.native.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {EmitterSubscription, Keyboard} from 'react-native'; -import * as Composer from '@userActions/Composer'; -import SetShouldShowComposeInputKeyboardAware from './types'; - -let keyboardDidHideListener: EmitterSubscription | null = null; -const setShouldShowComposeInputKeyboardAware: SetShouldShowComposeInputKeyboardAware = (shouldShow) => { - if (keyboardDidHideListener) { - keyboardDidHideListener.remove(); - keyboardDidHideListener = null; - } - - if (!shouldShow) { - Composer.setShouldShowComposeInput(false); - return; - } - - // If keyboard is already hidden, we should show composer immediately because keyboardDidHide event won't be called - if (!Keyboard.isVisible()) { - Composer.setShouldShowComposeInput(true); - return; - } - - keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { - Composer.setShouldShowComposeInput(true); - keyboardDidHideListener?.remove(); - }); -}; - -export default setShouldShowComposeInputKeyboardAware; diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts new file mode 100644 index 000000000000..528b71c45ab8 --- /dev/null +++ b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts @@ -0,0 +1,34 @@ +import {EmitterSubscription, Keyboard} from 'react-native'; +import {KeyboardEventName} from 'react-native/Libraries/Components/Keyboard/Keyboard'; +import * as Composer from '@userActions/Composer'; +import SetShouldShowComposeInputKeyboardAware from './types'; + +let keyboardEventListener: EmitterSubscription | null = null; +// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event +// Because of that - on iOS we can use `keyboardWillHide` that is not available on android + +const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware = + (keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => { + if (keyboardEventListener) { + keyboardEventListener.remove(); + keyboardEventListener = null; + } + + if (!shouldShow) { + Composer.setShouldShowComposeInput(false); + return; + } + + // If keyboard is already hidden, we should show composer immediately because keyboardDidHide event won't be called + if (!Keyboard.isVisible()) { + Composer.setShouldShowComposeInput(true); + return; + } + + keyboardEventListener = Keyboard.addListener(keyboardEvent, () => { + Composer.setShouldShowComposeInput(true); + keyboardEventListener?.remove(); + }); + }; + +export default setShouldShowComposeInputKeyboardAwareBuilder; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 7f01256cc024..5dcdc41afc6d 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -126,10 +126,7 @@ function DetailsPage(props) { - + {details ? ( @@ -144,7 +141,7 @@ function DetailsPage(props) { style={[styles.noOutline]} onPress={show} accessibilityLabel={props.translate('common.details')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} > diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js index d83df3a07671..936c2b802d0e 100644 --- a/src/pages/EditRequestDescriptionPage.js +++ b/src/pages/EditRequestDescriptionPage.js @@ -66,7 +66,7 @@ function EditRequestDescriptionPage({defaultDescription, onSubmit}) { defaultValue={defaultDescription} label={translate('moneyRequestConfirmationList.whatsItFor')} accessibilityLabel={translate('moneyRequestConfirmationList.whatsItFor')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} ref={(el) => { if (!el) { return; @@ -75,8 +75,7 @@ function EditRequestDescriptionPage({defaultDescription, onSubmit}) { updateMultilineInputRange(descriptionInputRef.current); }} autoGrowHeight - containerStyles={[styles.autoGrowHeightMultilineInput]} - textAlignVertical="top" + containerStyles={[styles.autoGrowHeightMultilineInput, styles.verticalAlignTop]} submitOnEnter={!Browser.isMobile()} /> diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js index e64fd121b4ff..af9b5c9a539e 100644 --- a/src/pages/EditRequestMerchantPage.js +++ b/src/pages/EditRequestMerchantPage.js @@ -56,7 +56,7 @@ function EditRequestMerchantPage({defaultMerchant, onSubmit}) { defaultValue={defaultMerchant} label={translate('common.merchant')} accessibilityLabel={translate('common.merchant')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} ref={(e) => (merchantInputRef.current = e)} /> diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 302b7d35a1c9..c958189d68b5 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -184,7 +184,7 @@ function EditRequestPage({betas, report, route, parentReport, policyCategories, }); }} onNavigateToCurrency={() => { - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(report.reportID, defaultCurrency, activeRoute)); }} /> diff --git a/src/pages/EditSplitBillPage.js b/src/pages/EditSplitBillPage.js index 1342d9297d3e..c4e47e2d4c35 100644 --- a/src/pages/EditSplitBillPage.js +++ b/src/pages/EditSplitBillPage.js @@ -112,7 +112,7 @@ function EditSplitBillPage({route, transaction, draftTransaction}) { }); }} onNavigateToCurrency={() => { - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate(ROUTES.EDIT_SPLIT_BILL_CURRENCY.getRoute(reportID, reportActionID, defaultCurrency, activeRoute)); }} /> diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index f1813062d0d7..e58d45b5f1c4 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -190,7 +190,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP containerStyles={[styles.mt4]} label={translate(fieldNameTranslationKeys.legalFirstName)} accessibilityLabel={translate(fieldNameTranslationKeys.legalFirstName)} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={PersonalDetails.extractFirstAndLastNameFromAvailableDetails(currentUserPersonalDetails).firstName} shouldSaveDraft /> @@ -199,7 +199,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP containerStyles={[styles.mt4]} label={translate(fieldNameTranslationKeys.legalLastName)} accessibilityLabel={translate(fieldNameTranslationKeys.legalLastName)} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={PersonalDetails.extractFirstAndLastNameFromAvailableDetails(currentUserPersonalDetails).lastName} shouldSaveDraft /> @@ -217,10 +217,10 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js index c195e0237034..db5098777744 100644 --- a/src/pages/EnablePayments/OnfidoPrivacy.js +++ b/src/pages/EnablePayments/OnfidoPrivacy.js @@ -86,6 +86,7 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { OnfidoPrivacy.propTypes = propTypes; OnfidoPrivacy.defaultProps = defaultProps; +OnfidoPrivacy.displayName = 'OnfidoPrivacy'; export default compose( withLocalize, diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index f8b041951016..22af82041f7b 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -9,7 +9,7 @@ import OptionsSelector from '@components/OptionsSelector'; import ScreenWrapper from '@components/ScreenWrapper'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import useDelayedInputFocus from '@hooks/useDelayedInputFocus'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useNetwork from '@hooks/useNetwork'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; @@ -53,7 +53,6 @@ const defaultProps = { const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports}) { - const optionSelectorRef = React.createRef(null); const [searchTerm, setSearchTerm] = useState(''); const [filteredRecentReports, setFilteredRecentReports] = useState([]); const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); @@ -216,7 +215,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i setSearchTerm(text); }, []); - useDelayedInputFocus(optionSelectorRef, 600); + const {inputCallbackRef} = useAutoFocusInput(); return ( 0 ? safeAreaPaddingBottomStyle : {}]}> {isSmallScreenWidth && } diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.js index 7c8aec8d12de..f38dabee9183 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.js @@ -7,7 +7,6 @@ import React, {useCallback, useMemo, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -19,8 +18,8 @@ import withLocalize from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/UpdateMultilineInputRange'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import styles from '@styles/styles'; @@ -43,23 +42,14 @@ const propTypes = { accountID: PropTypes.string, }), }).isRequired, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Currently logged in user accountID */ - accountID: PropTypes.number, - }), }; const defaultProps = { report: {}, - session: { - accountID: null, - }, personalDetailsList: {}, }; -function PrivateNotesEditPage({route, personalDetailsList, session, report}) { +function PrivateNotesEditPage({route, personalDetailsList, report}) { const {translate} = useLocalize(); // We need to edit the note in markdown format, but display it in HTML format @@ -81,8 +71,6 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { [report.reportID], ); - const isCurrentUserNote = Number(session.accountID) === Number(route.params.accountID); - // To focus on the input field when the page loads const privateNotesInput = useRef(null); const focusTimeoutRef = useRef(null); @@ -105,8 +93,15 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { const savePrivateNote = () => { const originalNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); - const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); - Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote); + + if (privateNote.trim() !== originalNote.trim()) { + const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); + Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote); + } + + // We want to delete saved private note draft after saving the note + debouncedSavePrivateNote(''); + Keyboard.dismiss(); // Take user back to the PrivateNotesView page @@ -119,73 +114,61 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) { includeSafeAreaPaddingBottom={false} testID={PrivateNotesEditPage.displayName} > - Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} + shouldShowBackButton + onCloseButtonPress={() => Navigation.dismissModal()} + /> + - Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} - shouldShowBackButton - onCloseButtonPress={() => Navigation.dismissModal()} - /> - + {translate( + Str.extractEmailDomain(lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')) === CONST.EMAIL.GUIDES_DOMAIN + ? 'privateNotes.sharedNoteMessage' + : 'privateNotes.personalNoteMessage', + )} +
+ Report.clearPrivateNotesError(report.reportID, route.params.accountID)} + style={[styles.mb3]} > - - {translate( - Str.extractEmailDomain(lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')) === CONST.EMAIL.GUIDES_DOMAIN - ? 'privateNotes.sharedNoteMessage' - : 'privateNotes.personalNoteMessage', - )} - - { + debouncedSavePrivateNote(text); + setPrivateNote(text); + }} + ref={(el) => { + if (!el) { + return; + } + privateNotesInput.current = el; + updateMultilineInputRange(privateNotesInput.current); }} - onClose={() => Report.clearPrivateNotesError(report.reportID, route.params.accountID)} - style={[styles.mb3]} - > - { - debouncedSavePrivateNote(text); - setPrivateNote(text); - }} - ref={(el) => { - if (!el) { - return; - } - privateNotesInput.current = el; - updateMultilineInputRange(privateNotesInput.current); - }} - /> - - - + /> + + ); } @@ -196,13 +179,8 @@ PrivateNotesEditPage.defaultProps = defaultProps; export default compose( withLocalize, + withReportAndPrivateNotesOrNotFound, withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.js b/src/pages/PrivateNotes/PrivateNotesListPage.js index ec3905db349e..4d5b348c4b9f 100644 --- a/src/pages/PrivateNotes/PrivateNotesListPage.js +++ b/src/pages/PrivateNotes/PrivateNotesListPage.js @@ -1,14 +1,11 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; -import networkPropTypes from '@components/networkPropTypes'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -16,12 +13,11 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import styles from '@styles/styles'; -import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -47,8 +43,6 @@ const propTypes = { /** All of the personal details for everyone */ personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - /** Information about the network */ - network: networkPropTypes.isRequired, ...withLocalizePropTypes, }; @@ -60,17 +54,9 @@ const defaultProps = { personalDetailsList: {}, }; -function PrivateNotesListPage({report, personalDetailsList, network, session}) { +function PrivateNotesListPage({report, personalDetailsList, session}) { const {translate} = useLocalize(); - useEffect(() => { - if (network.isOffline && report.isLoadingPrivateNotes) { - return; - } - Report.getReportPrivateNote(report.reportID); - // eslint-disable-next-line react-hooks/exhaustive-deps -- do not add isLoadingPrivateNotes to dependencies - }, [report.reportID, network.isOffline]); - /** * Gets the menu item for each workspace * @@ -124,26 +110,12 @@ function PrivateNotesListPage({report, personalDetailsList, network, session}) { includeSafeAreaPaddingBottom={false} testID={PrivateNotesListPage.displayName} > - - Navigation.dismissModal()} - /> - - {report.isLoadingPrivateNotes && _.isEmpty(lodashGet(report, 'privateNotes', {})) ? ( - - ) : ( - _.map(privateNotes, (item, index) => getMenuItem(item, index)) - )} - - + Navigation.dismissModal()} + /> + {_.map(privateNotes, (item, index) => getMenuItem(item, index))} ); } @@ -154,13 +126,8 @@ PrivateNotesListPage.displayName = 'PrivateNotesListPage'; export default compose( withLocalize, + withReportAndPrivateNotesOrNotFound, withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/pages/PrivateNotes/PrivateNotesViewPage.js b/src/pages/PrivateNotes/PrivateNotesViewPage.js index bb9d96516437..2b836036448d 100644 --- a/src/pages/PrivateNotes/PrivateNotesViewPage.js +++ b/src/pages/PrivateNotes/PrivateNotesViewPage.js @@ -4,7 +4,6 @@ import React from 'react'; import {ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -13,7 +12,7 @@ import withLocalize from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import styles from '@styles/styles'; @@ -71,33 +70,28 @@ function PrivateNotesViewPage({route, personalDetailsList, session, report}) { includeSafeAreaPaddingBottom={false} testID={PrivateNotesViewPage.displayName} > - - Navigation.goBack(getFallbackRoute())} - subtitle={isCurrentUserNote ? translate('privateNotes.myNote') : `${lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')} note`} - shouldShowBackButton - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - isCurrentUserNote && Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, route.params.accountID))} - shouldShowRightIcon={isCurrentUserNote} - numberOfLinesTitle={0} - shouldRenderAsHTML - brickRoadIndicator={!_.isEmpty(lodashGet(report, ['privateNotes', route.params.accountID, 'errors'], '')) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - disabled={!isCurrentUserNote} - shouldGreyOutWhenDisabled={false} - /> - - - + Navigation.goBack(getFallbackRoute())} + subtitle={isCurrentUserNote ? translate('privateNotes.myNote') : `${lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')} note`} + shouldShowBackButton + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + isCurrentUserNote && Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, route.params.accountID))} + shouldShowRightIcon={isCurrentUserNote} + numberOfLinesTitle={0} + shouldRenderAsHTML + brickRoadIndicator={!_.isEmpty(lodashGet(report, ['privateNotes', route.params.accountID, 'errors'], '')) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + disabled={!isCurrentUserNote} + shouldGreyOutWhenDisabled={false} + /> + + ); } @@ -108,13 +102,8 @@ PrivateNotesViewPage.defaultProps = defaultProps; export default compose( withLocalize, + withReportAndPrivateNotesOrNotFound, withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID.toString()}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index f50c95cb23ed..e97975b0481b 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -153,10 +153,7 @@ function ProfilePage(props) { title={props.translate('common.profile')} onBackButtonPress={() => Navigation.goBack(navigateBackTo)} /> - + {hasMinimumDetails && ( @@ -172,7 +169,7 @@ function ProfilePage(props) { style={[styles.noOutline]} onPress={show} accessibilityLabel={props.translate('common.profile')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} > props.onFieldChange({city: value})} @@ -137,8 +138,8 @@ function AddressForm(props) { shouldSaveDraft={props.shouldSaveDraft} label={props.translate('common.zip')} accessibilityLabel={props.translate('common.zip')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + role={CONST.ACCESSIBILITY_ROLE.TEXT} + inputMode={CONST.INPUT_MODE.NUMERIC} value={props.values.zipCode} defaultValue={props.defaultValues.zipCode} onChangeText={(value) => props.onFieldChange({zipCode: value})} diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js index 13155d286a5e..1612238ed8d9 100644 --- a/src/pages/ReimbursementAccount/BankAccountManualStep.js +++ b/src/pages/ReimbursementAccount/BankAccountManualStep.js @@ -102,10 +102,10 @@ function BankAccountManualStep(props) { shouldDelayFocus={shouldDelayFocus} inputID="routingNumber" label={translate('bankAccount.routingNumber')} - accessibilityLabel={translate('bankAccount.routingNumber')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={translate('bankAccount.routingNumber')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={props.getDefaultStateForField('routingNumber', '')} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + inputMode={CONST.INPUT_MODE.NUMERIC} disabled={shouldDisableInputs} shouldSaveDraft shouldUseDefaultValue={shouldDisableInputs} @@ -114,16 +114,16 @@ function BankAccountManualStep(props) { inputID="accountNumber" containerStyles={[styles.mt4]} label={translate('bankAccount.accountNumber')} - accessibilityLabel={translate('bankAccount.accountNumber')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={translate('bankAccount.accountNumber')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={props.getDefaultStateForField('accountNumber', '')} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + inputMode={CONST.INPUT_MODE.NUMERIC} disabled={shouldDisableInputs} shouldSaveDraft shouldUseDefaultValue={shouldDisableInputs} /> ( diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js index 24cfbf5ae4c6..41f73d1ebf8e 100644 --- a/src/pages/ReimbursementAccount/CompanyStep.js +++ b/src/pages/ReimbursementAccount/CompanyStep.js @@ -163,7 +163,7 @@ function CompanyStep({reimbursementAccount, reimbursementAccountDraft, getDefaul props.onFieldChange({firstName: value})} @@ -158,8 +158,8 @@ function IdentityForm(props) { inputID={props.inputKeys.lastName} shouldSaveDraft={props.shouldSaveDraft} label={`${props.translate('common.lastName')}`} - accessibilityLabel={props.translate('common.lastName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('common.lastName')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} value={props.values.lastName} defaultValue={props.defaultValues.lastName} onChangeText={(value) => props.onFieldChange({lastName: value})} @@ -183,10 +183,10 @@ function IdentityForm(props) { inputID={props.inputKeys.ssnLast4} shouldSaveDraft={props.shouldSaveDraft} label={`${props.translate('common.ssnLast4')}`} - accessibilityLabel={props.translate('common.ssnLast4')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('common.ssnLast4')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} containerStyles={[styles.mt4]} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + inputMode={CONST.INPUT_MODE.NUMERIC} defaultValue={props.defaultValues.ssnLast4} onChangeText={(value) => props.onFieldChange({ssnLast4: value})} errorText={props.errors.ssnLast4 ? props.translate('bankAccount.error.ssnLast4') : ''} diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js index 5a0149aa3ba4..343f98644766 100644 --- a/src/pages/ReimbursementAccount/ValidationStep.js +++ b/src/pages/ReimbursementAccount/ValidationStep.js @@ -157,24 +157,24 @@ function ValidationStep({reimbursementAccount, translate, onBackButtonPress, acc shouldSaveDraft containerStyles={[styles.mb1]} placeholder="1.52" - keyboardType="decimal-pad" - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + inputMode={CONST.INPUT_MODE.DECIMAL} + role={CONST.ACCESSIBILITY_ROLE.TEXT} /> {!requiresTwoFactorAuth && ( diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index ef28102cc144..de25fdc3a081 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -215,7 +215,7 @@ function ReportDetailsPage(props) { {isPolicyAdmin ? ( { Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(props.report.policyID)); diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index c2179c53126b..1ae6942c6412 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -106,10 +106,7 @@ function ReportParticipantsPage(props) { : 'common.details', )} /> - + {Boolean(participants.length) && ( { @@ -111,7 +111,7 @@ function ReportWelcomeMessagePage(props) { value={welcomeMessage} onChangeText={handleWelcomeMessageChange} autoCapitalize="none" - textAlignVertical="top" + inputStyle={[styles.verticalAlignTop]} containerStyles={[styles.autoGrowHeightMultilineInput]} /> diff --git a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js index 5d7c1d960e3a..38065ac8ab8e 100644 --- a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js +++ b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js @@ -17,6 +17,7 @@ const defaultProps = {}; function ImTeacherUpdateEmailPage() { const {translate} = useLocalize(); + const activeRoute = Navigation.getActiveRouteWithoutParams(); return ( @@ -31,7 +32,7 @@ function ImTeacherUpdateEmailPage() { title={translate('teachersUnitePage.updateYourEmail')} subtitle={translate('teachersUnitePage.schoolMailAsDefault')} linkKey="teachersUnitePage.contactMethods" - onLinkPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} + onLinkPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(activeRoute))} iconWidth={variables.signInLogoWidthLargeScreen} iconHeight={variables.lhnLogoWidth} /> @@ -40,7 +41,7 @@ function ImTeacherUpdateEmailPage() { success accessibilityLabel={translate('teachersUnitePage.updateEmail')} text={translate('teachersUnitePage.updateEmail')} - onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS)} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(activeRoute))} /> diff --git a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js index 7b84c5bc94d1..16389d69053d 100644 --- a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js +++ b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.js @@ -106,7 +106,7 @@ function IntroSchoolPrincipalPage(props) { name="firstName" label={translate('teachersUnitePage.principalFirstName')} accessibilityLabel={translate('teachersUnitePage.principalFirstName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} autoCapitalize="words" /> @@ -118,7 +118,7 @@ function IntroSchoolPrincipalPage(props) { name="lastName" label={translate('teachersUnitePage.principalLastName')} accessibilityLabel={translate('teachersUnitePage.principalLastName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} autoCapitalize="words" /> @@ -130,8 +130,8 @@ function IntroSchoolPrincipalPage(props) { name="partnerUserID" label={translate('teachersUnitePage.principalWorkEmail')} accessibilityLabel={translate('teachersUnitePage.principalWorkEmail')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} - keyboardType={CONST.KEYBOARD_TYPE.EMAIL_ADDRESS} + role={CONST.ACCESSIBILITY_ROLE.TEXT} + inputMode={CONST.INPUT_MODE.EMAIL} autoCapitalize="none" /> diff --git a/src/pages/TeachersUnite/KnowATeacherPage.js b/src/pages/TeachersUnite/KnowATeacherPage.js index d8dcc74faac0..696a9ef8b704 100644 --- a/src/pages/TeachersUnite/KnowATeacherPage.js +++ b/src/pages/TeachersUnite/KnowATeacherPage.js @@ -5,7 +5,8 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -99,7 +100,7 @@ function KnowATeacherPage(props) { title={translate('teachersUnitePage.iKnowATeacher')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> -
{translate('teachersUnitePage.getInTouch')} - - - -
+ ); } diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 36c48bd48fd2..27ee820de02f 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -184,7 +184,7 @@ function HeaderView(props) { style={[styles.LHNToggle]} accessibilityHint={props.translate('accessibilityHints.navigateToChatsList')} accessibilityLabel={props.translate('common.back')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > {shouldShowSubscript ? ( {translate('newMessages')} diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index a31e718933ea..a8ecff7c8d82 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -208,7 +208,7 @@ function AttachmentPickerWithMenuItems({ onMouseDown={(e) => e.preventDefault()} style={styles.composerSizeButton} disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('reportActionCompose.collapse')} > @@ -227,7 +227,7 @@ function AttachmentPickerWithMenuItems({ onMouseDown={(e) => e.preventDefault()} style={styles.composerSizeButton} disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('reportActionCompose.expand')} > @@ -247,7 +247,7 @@ function AttachmentPickerWithMenuItems({ }} style={styles.composerSizeButton} disabled={isBlockedFromConcierge || disabled} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('reportActionCompose.addAction')} > diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js similarity index 88% rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index 3bbc2b03ff6f..c61633618d9f 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -25,6 +25,8 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as SuggestionUtils from '@libs/SuggestionUtils'; import updateMultilineInputRange from '@libs/UpdateMultilineInputRange'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; +import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater'; +import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions'; import containerComposeStyles from '@styles/containerComposeStyles'; import styles from '@styles/styles'; import themeColors from '@styles/themes/default'; @@ -35,8 +37,6 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, propTypes} from './composerWithSuggestionsProps'; -import SilentCommentUpdater from './SilentCommentUpdater'; -import Suggestions from './Suggestions'; const {RNTextInputReset} = NativeModules; @@ -104,6 +104,8 @@ function ComposerWithSuggestions({ forwardedRef, isNextModalWillOpenRef, editFocused, + // For testing + children, }) { const {preferredLocale} = useLocalize(); const isFocused = useIsFocused(); @@ -117,6 +119,7 @@ function ComposerWithSuggestions({ return draft; }); const commentRef = useRef(value); + const lastTextRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -196,6 +199,50 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); }, [textInputRef]); + /** + * Find the newly added characters between the previous text and the new text based on the selection. + * + * @param {string} prevText - The previous text. + * @param {string} newText - The new text. + * @returns {object} An object containing information about the newly added characters. + * @property {number} startIndex - The start index of the newly added characters in the new text. + * @property {number} endIndex - The end index of the newly added characters in the new text. + * @property {string} diff - The newly added characters. + */ + const findNewlyAddedChars = useCallback( + (prevText, newText) => { + let startIndex = -1; + let endIndex = -1; + let currentIndex = 0; + + // Find the first character mismatch with newText + while (currentIndex < newText.length && prevText.charAt(currentIndex) === newText.charAt(currentIndex) && selection.start > currentIndex) { + currentIndex++; + } + + if (currentIndex < newText.length) { + startIndex = currentIndex; + + // if text is getting pasted over find length of common suffix and subtract it from new text length + if (selection.end - selection.start > 0) { + const commonSuffixLength = ComposerUtils.getCommonSuffixLength(prevText, newText); + endIndex = newText.length - commonSuffixLength; + } else { + endIndex = currentIndex + (newText.length - prevText.length); + } + } + + return { + startIndex, + endIndex, + diff: newText.substring(startIndex, endIndex), + }; + }, + [selection.end, selection.start], + ); + + const insertWhiteSpace = (text, index) => `${text.slice(0, index)} ${text.slice(index)}`; + const debouncedSaveReportComment = useMemo( () => _.debounce((selectedReportID, newComment) => { @@ -213,7 +260,14 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { raiseIsScrollLikelyLayoutTriggered(); - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); + const isEmojiInserted = diff.length && endIndex > startIndex && EmojiUtils.containsOnlyEmojis(diff); + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis( + isEmojiInserted ? insertWhiteSpace(commentValue, endIndex) : commentValue, + preferredSkinTone, + preferredLocale, + ); + if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (!_.isEmpty(newEmojis)) { @@ -226,14 +280,8 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); - - /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { - setIsCommentEmpty(isNewCommentEmpty); - } emojisPresentBefore.current = emojis; + setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/)); setValue(newCommentConverted); if (commentValue !== newComment) { const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment); @@ -264,13 +312,14 @@ function ComposerWithSuggestions({ } }, [ - debouncedUpdateFrequentlyUsedEmojis, - preferredLocale, + raiseIsScrollLikelyLayoutTriggered, + findNewlyAddedChars, preferredSkinTone, - reportID, + preferredLocale, setIsCommentEmpty, + debouncedUpdateFrequentlyUsedEmojis, suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, + reportID, debouncedSaveReportComment, ], ); @@ -321,14 +370,8 @@ function ComposerWithSuggestions({ * @param {Boolean} shouldAddTrailSpace */ const replaceSelectionWithText = useCallback( - (text, shouldAddTrailSpace = true) => { - const updatedText = shouldAddTrailSpace ? `${text} ` : text; - const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; - updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); - setSelection((prevSelection) => ({ - start: prevSelection.start + text.length + selectionSpaceLength, - end: prevSelection.start + text.length + selectionSpaceLength, - })); + (text) => { + updateComment(ComposerUtils.insertText(commentRef.current, selection, text)); }, [selection, updateComment], ); @@ -452,7 +495,12 @@ function ComposerWithSuggestions({ } focus(); - replaceSelectionWithText(e.key, false); + // Reset cursor to last known location + setSelection((prevSelection) => ({ + start: prevSelection.start + 1, + end: prevSelection.end + 1, + })); + replaceSelectionWithText(e.key); }, [checkComposerVisibility, focus, replaceSelectionWithText], ); @@ -510,10 +558,16 @@ function ComposerWithSuggestions({ if (value.length === 0) { return; } + Report.setReportWithDraft(reportID, true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + lastTextRef.current = value; + }, [value]); + useImperativeHandle( forwardedRef, () => ({ @@ -534,12 +588,11 @@ function ComposerWithSuggestions({ autoFocus={shouldAutoFocus} multiline ref={setTextInputRef} - textAlignVertical="top" placeholder={inputPlaceholder} placeholderTextColor={themeColors.placeholderText} onChangeText={(commentValue) => updateComment(commentValue, true)} onKeyPress={triggerHotkeyActions} - style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.flex4, styles.verticalAlignTop]} maxLines={maxComposerLines} onFocus={onFocus} onBlur={onBlur} @@ -593,6 +646,9 @@ function ComposerWithSuggestions({ updateComment={updateComment} commentRef={commentRef} /> + + {/* Only used for testing so far */} + {children} ); } diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js similarity index 100% rename from src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js new file mode 100644 index 000000000000..cbbd1758c9cb --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js @@ -0,0 +1,52 @@ +import _ from 'lodash'; +import React, {useEffect} from 'react'; +import E2EClient from '@libs/E2E/client'; +import ComposerWithSuggestions from './ComposerWithSuggestions'; + +let rerenderCount = 0; +const getRerenderCount = () => rerenderCount; +const resetRerenderCount = () => { + rerenderCount = 0; +}; + +function IncrementRenderCount() { + rerenderCount += 1; + return null; +} + +const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => { + // Eventually Auto focus on e2e tests + useEffect(() => { + if (_.get(E2EClient.getCurrentActiveTestConfig(), 'reportScreen.autoFocus', false) === false) { + return; + } + + // We need to wait for the component to be mounted before focusing + setTimeout(() => { + if (!ref || !ref.current) { + return; + } + + ref.current.focus(true); + }, 1); + }, [ref]); + + return ( + + {/* Important: + this has to be a child, as this container might not + re-render while the actual ComposerWithSuggestions will. + */} + + + ); +}); + +ComposerWithSuggestionsE2e.displayName = 'ComposerWithSuggestionsE2e'; + +export default ComposerWithSuggestionsE2e; +export {getRerenderCount, resetRerenderCount}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js new file mode 100644 index 000000000000..f2aebd390ba6 --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js @@ -0,0 +1,3 @@ +import ComposerWithSuggestions from './ComposerWithSuggestions'; + +export default ComposerWithSuggestions; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index f090a942e097..c0a1151f0202 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -464,6 +464,7 @@ function ReportActionCompose({ ReportActionCompose.propTypes = propTypes; ReportActionCompose.defaultProps = defaultProps; +ReportActionCompose.displayName = 'ReportActionCompose'; export default compose( withNetwork(), diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js index 061251d13c01..41f35b0f8d3d 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -42,7 +42,7 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}) { isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, isDisabledProp ? styles.cursorDisabled : undefined, ]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('common.send')} > {({pressed}) => ( diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index baf93da6ccc4..2ea2dd334528 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -1,17 +1,15 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import * as Expensicons from '@components/Icon/Expensicons'; import MentionSuggestions from '@components/MentionSuggestions'; +import {usePersonalDetails} from '@components/OnyxProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; import * as UserUtils from '@libs/UserUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import * as SuggestionProps from './suggestionProps'; /** @@ -29,9 +27,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** A ref to this component */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), @@ -39,7 +34,6 @@ const propTypes = { }; const defaultProps = { - personalDetails: {}, forwardedRef: null, }; @@ -49,7 +43,6 @@ function SuggestionMention({ selection, setSelection, isComposerFullSize, - personalDetails, updateComment, composerHeight, forwardedRef, @@ -57,6 +50,7 @@ function SuggestionMention({ measureParentContainer, isComposerFocused, }) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const {translate} = useLocalize(); const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -316,8 +310,4 @@ const SuggestionMentionWithRef = React.forwardRef((props, ref) => ( SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})(SuggestionMentionWithRef); +export default SuggestionMentionWithRef; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 4da88fd5d352..24e48eb3e7d0 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -58,6 +58,7 @@ import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import * as ContextMenuActions from './ContextMenu/ContextMenuActions'; import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; @@ -441,8 +442,7 @@ function ReportActionItem(props) { onPress={() => updateHiddenState(!isHidden)} > {isHidden ? props.translate('moderation.revealMessage') : props.translate('moderation.hideMessage')} @@ -576,23 +576,31 @@ function ReportActionItem(props) { if (ReportUtils.isCanceledTaskReport(props.report, parentReportAction)) { content = ( <> - - ${props.translate('parentReportAction.deletedTask')}`} /> - - + + + + ${props.translate('parentReportAction.deletedTask')}`} /> + + + ); } else { content = ( - + <> + + + + + ); } } @@ -647,8 +655,8 @@ function ReportActionItem(props) { const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; return ( props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onSecondaryInteraction={showPopover} @@ -765,6 +773,7 @@ export default compose( prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && _.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) && _.isEqual(prevProps.action, nextProps.action) && + _.isEqual(prevProps.iouReport, nextProps.iouReport) && _.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) && _.isEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) && _.isEqual(prevProps.report.errorFields, nextProps.report.errorFields) && diff --git a/src/pages/home/report/ReportActionItemCreated.js b/src/pages/home/report/ReportActionItemCreated.js index 10ebb13302b2..a7772ad5e0fb 100644 --- a/src/pages/home/report/ReportActionItemCreated.js +++ b/src/pages/home/report/ReportActionItemCreated.js @@ -74,7 +74,7 @@ function ReportActionItemCreated(props) { onPress={() => ReportUtils.navigateToDetailsPage(props.report)} style={[styles.mh5, styles.mb3, styles.alignSelfStart]} accessibilityLabel={props.translate('common.details')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} disabled={shouldDisableDetailPage} > {convertToLTR(props.iouMessage || text)} {Boolean(props.fragment.isEdited) && ( <> @@ -152,8 +164,15 @@ function ReportActionItemFragment(props) { ); } - case 'TEXT': - return ( + case 'TEXT': { + return props.isApprovedOrSubmittedReportAction ? ( + + {props.isFragmentContainingDisplayName ? convertToLTR(props.fragment.text) : props.fragment.text} + + ) : ( ); + } case 'LINK': return LINK; case 'INTEGRATION_COMMENT': diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 37aaa5adf287..4c6603c052a3 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -7,6 +7,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import styles from '@styles/styles'; +import CONST from '@src/CONST'; import ReportActionItemFragment from './ReportActionItemFragment'; import reportActionPropTypes from './reportActionPropTypes'; @@ -47,23 +48,45 @@ function ReportActionItemMessage(props) { } } + const isApprovedOrSubmittedReportAction = _.contains([CONST.REPORT.ACTIONS.TYPE.APPROVED, CONST.REPORT.ACTIONS.TYPE.SUBMITTED], props.action.actionName); + + /** + * Get the ReportActionItemFragments + * @param {Boolean} shouldWrapInText determines whether the fragments are wrapped in a Text component + * @returns {Object} report action item fragments + */ + const renderReportActionItemFragments = (shouldWrapInText) => { + const reportActionItemFragments = _.map(messages, (fragment, index) => ( + + )); + + // Approving or submitting reports in oldDot results in system messages made up of multiple fragments of `TEXT` type + // which we need to wrap in `` to prevent them rendering on separate lines. + + return shouldWrapInText ? {reportActionItemFragments} : reportActionItemFragments; + }; + return ( {!props.isHidden ? ( - _.map(messages, (fragment, index) => ( - - )) + renderReportActionItemFragments(isApprovedOrSubmittedReportAction) ) : ( {props.translate('moderation.flaggedContent')} )} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index b3efb0388364..db453ca38265 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -169,6 +169,15 @@ function ReportActionItemMessageEdit(props) { [props.action.reportActionID], ); + // Scroll content of textInputRef to bottom + useEffect(() => { + if (!textInputRef.current) { + return; + } + textInputRef.current.focus(); + textInputRef.current.scrollTop = textInputRef.current.scrollHeight; + }, []); + useEffect(() => { // For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), @@ -316,7 +325,7 @@ function ReportActionItemMessageEdit(props) { // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { textInputRef.current.blur(); - ReportActionContextMenu.showDeleteModal(props.reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); + ReportActionContextMenu.showDeleteModal(props.reportID, props.action, true, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); return; } Report.editReportComment(props.reportID, props.action, trimmedNewDraft); @@ -377,7 +386,7 @@ function ReportActionItemMessageEdit(props) { focus(true)} onEmojiSelected={addEmojiToTextBox} - nativeID={emojiButtonID} + id={emojiButtonID} emojiPickerID={props.action.reportActionID} /> @@ -445,7 +454,7 @@ function ReportActionItemMessageEdit(props) { style={[styles.chatItemSubmitButton, hasExceededMaxCommentLength ? {} : styles.buttonSuccess]} onPress={publishDraft} disabled={hasExceededMaxCommentLength} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('common.saveChanges')} hoverDimmingValue={1} pressDimmingValue={0.2} diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 2b4526af98d1..955e024bd7a8 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -108,7 +108,7 @@ function ReportActionItemSingle(props) { // If this is a report preview, display names and avatars of both people involved let secondaryAvatar = {}; - const primaryDisplayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); + const primaryDisplayName = displayName; if (displayAllActors) { // The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID; @@ -219,7 +219,7 @@ function ReportActionItemSingle(props) { onPress={showActorDetails} disabled={shouldDisableDetailPage} accessibilityLabel={actorHint} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > {getAvatar()}
@@ -233,7 +233,7 @@ function ReportActionItemSingle(props) { onPress={showActorDetails} disabled={shouldDisableDetailPage} accessibilityLabel={actorHint} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > {_.map(personArray, (fragment, index) => ( { Report.navigateToAndOpenChildReport(props.childReportID); }} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={`${props.numberOfReplies} ${replyText}`} onSecondaryInteraction={props.onSecondaryInteraction} > @@ -60,16 +60,14 @@ function ReportActionItemThread(props) { /> {`${numberOfRepliesText} ${replyText}`} {timeStamp} diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 4fd2bd21c99e..11c8077745f9 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -429,6 +429,7 @@ function ReportActionsList({ keyboardShouldPersistTaps="handled" onLayout={onLayoutInner} onScroll={trackVerticalScrolling} + onScrollToIndexFailed={() => {}} extraData={extraData} /> diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 2608aaf51c9b..28ddcd94dfb2 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -2,6 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; import {withNetwork} from '@components/OnyxProvider'; @@ -9,15 +10,19 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import useInitialValue from '@hooks/useInitialValue'; +import usePrevious from '@hooks/usePrevious'; import compose from '@libs/compose'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import {isUserCreatedPolicyRoom} from '@libs/ReportUtils'; +import {didUserLogInDuringSession} from '@libs/SessionUtils'; import {ReactionListContext} from '@pages/home/ReportScreenContext'; import reportPropTypes from '@pages/reportPropTypes'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import PopoverReactionList from './ReactionList/PopoverReactionList'; import reportActionPropTypes from './reportActionPropTypes'; import ReportActionsList from './ReportActionsList'; @@ -54,6 +59,12 @@ const propTypes = { avatar: PropTypes.string, }), + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; @@ -64,6 +75,9 @@ const defaultProps = { isLoadingInitialReportActions: false, isLoadingOlderReportActions: false, isLoadingNewerReportActions: false, + session: { + authTokenType: '', + }, }; function ReportActionsView(props) { @@ -76,6 +90,8 @@ function ReportActionsView(props) { const mostRecentIOUReportActionID = useInitialValue(() => ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions)); const prevNetworkRef = useRef(props.network); + const prevAuthTokenType = usePrevious(props.session.authTokenType); + const prevIsSmallScreenWidthRef = useRef(props.isSmallScreenWidth); const isFocused = useIsFocused(); @@ -118,6 +134,18 @@ function ReportActionsView(props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.network, props.report, isReportFullyVisible]); + useEffect(() => { + const wasLoginChangedDetected = prevAuthTokenType === 'anonymousAccount' && !props.session.authTokenType; + if (wasLoginChangedDetected && didUserLogInDuringSession() && isUserCreatedPolicyRoom(props.report)) { + if (isReportFullyVisible) { + openReportIfNecessary(); + } else { + Report.reconnect(reportID); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.session, props.report, isReportFullyVisible]); + useEffect(() => { const prevIsSmallScreenWidth = prevIsSmallScreenWidthRef.current; // If the view is expanded from mobile to desktop layout @@ -261,6 +289,10 @@ function arePropsEqual(oldProps, newProps) { return false; } + if (lodashGet(oldProps.session, 'authTokenType') !== lodashGet(newProps.session, 'authTokenType')) { + return false; + } + if (oldProps.isLoadingInitialReportActions !== newProps.isLoadingInitialReportActions) { return false; } @@ -334,4 +366,14 @@ function arePropsEqual(oldProps, newProps) { const MemoizedReportActionsView = React.memo(ReportActionsView, arePropsEqual); -export default compose(Performance.withRenderTrace({id: ' rendering'}), withWindowDimensions, withLocalize, withNetwork())(MemoizedReportActionsView); +export default compose( + Performance.withRenderTrace({id: ' rendering'}), + withWindowDimensions, + withLocalize, + withNetwork(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), +)(MemoizedReportActionsView); diff --git a/src/pages/home/report/withReportAndPrivateNotesOrNotFound.js b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.js new file mode 100644 index 000000000000..3982dd5ab542 --- /dev/null +++ b/src/pages/home/report/withReportAndPrivateNotesOrNotFound.js @@ -0,0 +1,130 @@ +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import networkPropTypes from '@components/networkPropTypes'; +import {withNetwork} from '@components/OnyxProvider'; +import * as Report from '@libs/actions/Report'; +import compose from '@libs/compose'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import * as ReportUtils from '@libs/ReportUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import reportPropTypes from '@pages/reportPropTypes'; +import ONYXKEYS from '@src/ONYXKEYS'; +import withReportOrNotFound from './withReportOrNotFound'; + +const propTypes = { + /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component. + * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */ + forwardedRef: PropTypes.func, + + /** The report currently being looked at */ + report: reportPropTypes, + + /** Information about the network */ + network: networkPropTypes.isRequired, + + /** Session of currently logged in user */ + session: PropTypes.shape({ + /** accountID of currently logged in user */ + accountID: PropTypes.number, + }), + + route: PropTypes.shape({ + /** Params from the URL path */ + params: PropTypes.shape({ + /** reportID and accountID passed via route: /r/:reportID/notes/:accountID */ + reportID: PropTypes.string, + accountID: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + forwardedRef: () => {}, + report: {}, + session: { + accountID: null, + }, +}; + +export default function (WrappedComponent) { + // eslint-disable-next-line rulesdir/no-negated-variables + function WithReportAndPrivateNotesOrNotFound({forwardedRef, ...props}) { + const {route, report, network, session} = props; + const accountID = route.params.accountID; + const isPrivateNotesFetchTriggered = !_.isUndefined(report.isLoadingPrivateNotes); + + useEffect(() => { + // Do not fetch private notes if isLoadingPrivateNotes is already defined, or if network is offline. + if (isPrivateNotesFetchTriggered || network.isOffline) { + return; + } + + Report.getReportPrivateNote(report.reportID); + // eslint-disable-next-line react-hooks/exhaustive-deps -- do not add report.isLoadingPrivateNotes to dependencies + }, [report.reportID, network.isOffline, isPrivateNotesFetchTriggered]); + + const isPrivateNotesEmpty = accountID ? _.isEmpty(lodashGet(report, ['privateNotes', accountID, 'note'], '')) : _.isEmpty(report.privateNotes); + const shouldShowFullScreenLoadingIndicator = !isPrivateNotesFetchTriggered || (isPrivateNotesEmpty && report.isLoadingPrivateNotes); + + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useMemo(() => { + // Show not found view if the report is archived, or if the note is not of current user. + if (ReportUtils.isArchivedRoom(report) || (accountID && Number(session.accountID) !== Number(accountID))) { + return true; + } + + // Don't show not found view if the notes are still loading, or if the notes are non-empty. + if (shouldShowFullScreenLoadingIndicator || !isPrivateNotesEmpty) { + return false; + } + + // As notes being empty and not loading is a valid case, show not found view only in offline mode. + return network.isOffline; + }, [report, network.isOffline, accountID, session.accountID, isPrivateNotesEmpty, shouldShowFullScreenLoadingIndicator]); + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } + + return ( + + ); + } + + WithReportAndPrivateNotesOrNotFound.propTypes = propTypes; + WithReportAndPrivateNotesOrNotFound.defaultProps = defaultProps; + WithReportAndPrivateNotesOrNotFound.displayName = `withReportAndPrivateNotesOrNotFound(${getComponentDisplayName(WrappedComponent)})`; + + // eslint-disable-next-line rulesdir/no-negated-variables + const WithReportAndPrivateNotesOrNotFoundWithRef = React.forwardRef((props, ref) => ( + + )); + + WithReportAndPrivateNotesOrNotFoundWithRef.displayName = 'WithReportAndPrivateNotesOrNotFoundWithRef'; + + return compose( + withReportOrNotFound(), + withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + }), + withNetwork(), + )(WithReportAndPrivateNotesOrNotFoundWithRef); +} diff --git a/src/pages/home/report/withReportOrNotFound.tsx b/src/pages/home/report/withReportOrNotFound.tsx index 81d1376abd37..95997da71a2d 100644 --- a/src/pages/home/report/withReportOrNotFound.tsx +++ b/src/pages/home/report/withReportOrNotFound.tsx @@ -36,7 +36,7 @@ export default function ( const isReportIdInRoute = props.route.params.reportID?.length; if (shouldRequireReportID || isReportIdInRoute) { - const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); + const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); const shouldShowNotFoundPage = !Object.entries(props.report ?? {}).length || !props.report?.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas, {}); diff --git a/src/pages/home/sidebar/AvatarWithOptionalStatus.js b/src/pages/home/sidebar/AvatarWithOptionalStatus.js index b456788224fb..300a898b9e90 100644 --- a/src/pages/home/sidebar/AvatarWithOptionalStatus.js +++ b/src/pages/home/sidebar/AvatarWithOptionalStatus.js @@ -41,7 +41,7 @@ function AvatarWithOptionalStatus({emojiStatus, isCreateMenuOpen}) { diff --git a/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js b/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js index 93355fcfd530..5c28681a6cfa 100644 --- a/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js +++ b/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js @@ -35,7 +35,7 @@ const GlobalNavigationMenuItem = React.forwardRef(({icon, title, isFocused, onPr onPress={() => !isFocused && onPress()} style={styles.globalNavigationItemContainer} ref={ref} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.MENUITEM} + role={CONST.ACCESSIBILITY_ROLE.MENUITEM} accessibilityLabel={title} > {({pressed}) => ( diff --git a/src/pages/home/sidebar/PressableAvatarWithIndicator.js b/src/pages/home/sidebar/PressableAvatarWithIndicator.js index e798bece339f..58703e49dae4 100644 --- a/src/pages/home/sidebar/PressableAvatarWithIndicator.js +++ b/src/pages/home/sidebar/PressableAvatarWithIndicator.js @@ -45,7 +45,7 @@ function PressableAvatarWithIndicator({isCreateMenuOpen, currentUserPersonalDeta return ( diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 0e7d6aa38545..1f5a07194732 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -148,13 +148,13 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority >
{translate('globalNavigationOptions.chats')}} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} shouldShowEnvironmentBadge /> diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 1e5e11fd9fcb..293dc3f5cd9d 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -198,28 +198,23 @@ export default compose( chatReports: { key: ONYXKEYS.COLLECTION.REPORT, selector: chatReportSelector, - initialValue: {}, }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, - initialValue: CONST.PRIORITY_MODE.DEFAULT, }, betas: { key: ONYXKEYS.BETAS, - initialValue: [], }, allReportActions: { key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, selector: reportActionsSelector, - initialValue: {}, }, policies: { key: ONYXKEYS.COLLECTION.POLICY, selector: policySelector, - initialValue: {}, }, }), )(SidebarLinksData); diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index cca91a45e36f..57f31a8c3e9f 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -238,7 +238,7 @@ function FloatingActionButtonAndPopover(props) { /> { diff --git a/src/pages/home/sidebar/SignInButton.js b/src/pages/home/sidebar/SignInButton.js index 9e8722e2e083..afa67bdc04cd 100644 --- a/src/pages/home/sidebar/SignInButton.js +++ b/src/pages/home/sidebar/SignInButton.js @@ -14,7 +14,7 @@ function SignInButton() { return ( diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index c7b5885865df..20344a08a2c8 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -126,8 +126,12 @@ function IOUCurrencySelection(props) { }; }); - const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i'); - const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text) || searchRegex.test(currencyOption.currencyName)); + const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim().replace(CONST.REGEX.ANY_SPACE, ' ')), 'i'); + const filteredCurrencies = _.filter( + currencyOptions, + (currencyOption) => + searchRegex.test(currencyOption.text.replace(CONST.REGEX.ANY_SPACE, ' ')) || searchRegex.test(currencyOption.currencyName.replace(CONST.REGEX.ANY_SPACE, ' ')), + ); const isEmpty = searchValue.trim() && !filteredCurrencies.length; return { diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js index 425aa313a468..3b52c2ae711c 100644 --- a/src/pages/iou/MoneyRequestDescriptionPage.js +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -131,7 +131,7 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { defaultValue={iou.comment} label={translate('moneyRequestConfirmationList.whatsItFor')} accessibilityLabel={translate('moneyRequestConfirmationList.whatsItFor')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} ref={(el) => { if (!el) { return; @@ -140,8 +140,7 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { updateMultilineInputRange(inputRef.current); }} autoGrowHeight - containerStyles={[styles.autoGrowHeightMultilineInput]} - textAlignVertical="top" + containerStyles={[styles.autoGrowHeightMultilineInput, styles.verticalAlignTop]} submitOnEnter={!Browser.isMobile()} /> diff --git a/src/pages/iou/MoneyRequestMerchantPage.js b/src/pages/iou/MoneyRequestMerchantPage.js index 5c01484310ff..4a609f013d95 100644 --- a/src/pages/iou/MoneyRequestMerchantPage.js +++ b/src/pages/iou/MoneyRequestMerchantPage.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -8,6 +8,7 @@ import Form from '@components/Form'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import styles from '@styles/styles'; @@ -47,7 +48,7 @@ const defaultProps = { function MoneyRequestMerchantPage({iou, route}) { const {translate} = useLocalize(); - const inputRef = useRef(null); + const {inputCallbackRef} = useAutoFocusInput(); const iouType = lodashGet(route, 'params.iouType', ''); const reportID = lodashGet(route, 'params.reportID', ''); @@ -92,7 +93,6 @@ function MoneyRequestMerchantPage({iou, route}) { inputRef.current && inputRef.current.focus()} testID={MoneyRequestMerchantPage.displayName} > (inputRef.current = el)} + role={CONST.ACCESSIBILITY_ROLE.TEXT} + ref={inputCallbackRef} /> diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index 672b1458bf8e..d1dc3682e6c9 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useContext, useReducer, useRef, useState} from 'react'; import {ActivityIndicator, PanResponder, PixelRatio, Text, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import Hand from '@assets/images/hand.svg'; import ReceiptUpload from '@assets/images/receipt-upload.svg'; import Shutter from '@assets/images/shutter.svg'; @@ -103,7 +102,7 @@ function ReceiptSelector({route, transactionID, iou, report}) { function validateReceipt(file) { const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); - if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { + if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) { setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension'); return false; } @@ -209,7 +208,7 @@ function ReceiptSelector({route, transactionID, iou, report}) { {({openPicker}) => ( { openPicker({ onPicked: (file) => { @@ -228,7 +227,7 @@ function ReceiptSelector({route, transactionID, iou, report}) { )} { + const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); + if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) { + Alert.alert(translate('attachmentPicker.wrongFileType'), translate('attachmentPicker.notAllowedExtension')); + return false; + } + + if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + Alert.alert(translate('attachmentPicker.attachmentTooLarge'), translate('attachmentPicker.sizeExceeded')); + return false; + } + + if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + Alert.alert(translate('attachmentPicker.attachmentTooSmall'), translate('attachmentPicker.sizeNotMet')); + return false; + } + return true; + }; + const askForPermissions = () => { // There's no way we can check for the BLOCKED status without requesting the permission first // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 @@ -206,12 +225,15 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s {({openPicker}) => ( { openPicker({ onPicked: (file) => { + if (!validateReceipt(file)) { + return; + } const filePath = file.uri; IOU.setMoneyRequestReceipt(filePath, file.name); @@ -236,7 +258,7 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s )} - + {isScanning && ( } nativeIds + * @param {Array} ids */ - const onMouseDown = (event, nativeIds) => { + const onMouseDown = (event, ids) => { const relatedTargetId = lodashGet(event, 'nativeEvent.target.id'); - if (!_.contains(nativeIds, relatedTargetId)) { + if (!_.contains(ids, relatedTargetId)) { return; } event.preventDefault(); @@ -240,7 +240,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu return ( onMouseDown(event, [AMOUNT_VIEW_ID])} style={[styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} > @@ -279,11 +279,11 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu onMouseDown(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])} style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]} - nativeID={NUM_PAD_CONTAINER_VIEW_ID} + id={NUM_PAD_CONTAINER_VIEW_ID} > {canUseTouchScreen ? ( diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index ec5ab3a678bd..8302564cfcb7 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -1,15 +1,19 @@ import lodashGet from 'lodash/get'; +import lodashSize from 'lodash/size'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import transactionPropTypes from '@components/transactionPropTypes'; import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; +import compose from '@libs/compose'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; import styles from '@styles/styles'; import * as IOU from '@userActions/IOU'; @@ -36,14 +40,18 @@ const propTypes = { /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]), + + /** Transaction that stores the distance request data */ + transaction: transactionPropTypes, }; const defaultProps = { iou: iouDefaultProps, + transaction: {}, selectedTab: undefined, }; -function MoneyRequestParticipantsPage({iou, selectedTab, route}) { +function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const {translate} = useLocalize(); const prevMoneyRequestId = useRef(iou.id); const optionsSelectorRef = useRef(); @@ -54,7 +62,9 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); - + const waypoints = lodashGet(transaction, 'comment.waypoints', {}); + const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); + const isInvalidWaypoint = lodashSize(validatedWaypoints) < 2; useEffect(() => { if (isDistanceRequest) { setHeaderTitle(translate('common.distance')); @@ -85,10 +95,12 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { }, []); useEffect(() => { + const isInvalidDistanceRequest = !isDistanceRequest || isInvalidWaypoint; + // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request if (prevMoneyRequestId.current !== iou.id) { // The ID is cleared on completing a request. In that case, we will do nothing - if (iou.id && !isDistanceRequest && !isSplitRequest) { + if (iou.id && isInvalidDistanceRequest && !isSplitRequest) { navigateBack(true); } return; @@ -100,14 +112,14 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { if (shouldReset) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (isInvalidDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { navigateBack(true); } return () => { prevMoneyRequestId.current = iou.id; }; - }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack]); + }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, iouType, reportID, navigateBack, isInvalidWaypoint]); return ( `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, + }), +)(MoneyRequestParticipantsPage); diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index e531e6706f55..a045fc6399e9 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -123,7 +123,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { } // Remove query from the route and encode it. - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); Navigation.navigate(ROUTES.MONEY_REQUEST_CURRENCY.getRoute(iouType, reportID, currency, activeRoute)); }; diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index 8e9618036861..b09117719a8c 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -112,10 +112,7 @@ function AboutPage(props) { height={80} width={80} /> - + v{Environment.isInternalTestBuild() ? `${pkg.version} PR:${CONST.PULL_REQUEST_NUMBER}${getFlavor()}` : `${pkg.version}${getFlavor()}`} {props.translate('initialSettingsPage.aboutPage.description')} diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 2eb0374e7ed7..207c006a31c2 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -344,7 +344,7 @@ function InitialSettingsPage(props) { disabled={isExecuting} onPress={singleExecution(openProfileSettings)} accessibilityLabel={translate('common.profile')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} - onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} /> ); @@ -255,7 +255,7 @@ class ContactMethodDetailsPage extends Component { > Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} /> Navigation.goBack(ROUTES.SETTINGS_PROFILE)} + onBackButtonPress={() => Navigation.goBack(navigateBackTo)} /> diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js index 9e40ef65dfd6..ae301a9f3c33 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js @@ -103,7 +103,7 @@ function NewContactMethodPage(props) { > Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} /> (loginInputRef.current = el)} inputID="phoneOrEmail" autoCapitalize="none" - returnKeyType="go" + enterKeyHint="done" maxLength={CONST.LOGIN_CHARACTER_LIMIT} /> diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index 3cdbb815f66f..6fbbe770591b 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -192,7 +192,7 @@ function BaseValidateCodeForm(props) { underlayColor={themeColors.componentBG} hoverDimmingValue={1} pressDimmingValue={0.2} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={props.translate('validateCodeForm.magicCodeNotReceived')} > {props.translate('validateCodeForm.magicCodeNotReceived')} @@ -228,6 +228,7 @@ function BaseValidateCodeForm(props) { BaseValidateCodeForm.propTypes = propTypes; BaseValidateCodeForm.defaultProps = defaultProps; +BaseValidateCodeForm.displayName = 'BaseValidateCodeForm'; export default compose( withLocalize, diff --git a/src/pages/settings/Profile/CustomStatus/StatusSetPage.js b/src/pages/settings/Profile/CustomStatus/StatusSetPage.js index 1d26c0e6dec4..ffe2d06b304a 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusSetPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusSetPage.js @@ -66,7 +66,7 @@ function StatusSetPage({draftStatus, currentUserPersonalDetails}) { @@ -74,8 +74,8 @@ function StatusSetPage({draftStatus, currentUserPersonalDetails}) { InputComponent={TextInput} inputID={INPUT_IDS.STATUS_TEXT} label={translate('statusPage.message')} - accessibilityLabel={INPUT_IDS.STATUS_TEXT} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={INPUT_IDS.STATUS_TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={defaultText} maxLength={CONST.STATUS_TEXT_MAX_LENGTH} autoFocus diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 379b5f225310..a7d87e08789f 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -100,8 +100,8 @@ function DisplayNamePage(props) { inputID="firstName" name="fname" label={props.translate('common.firstName')} - accessibilityLabel={props.translate('common.firstName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('common.firstName')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={lodashGet(currentUserDetails, 'firstName', '')} maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} spellCheck={false} @@ -113,8 +113,8 @@ function DisplayNamePage(props) { inputID="lastName" name="lname" label={props.translate('common.lastName')} - accessibilityLabel={props.translate('common.lastName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('common.lastName')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={lodashGet(currentUserDetails, 'lastName', '')} maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} spellCheck={false} diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js index a6cb069780b2..b86d646794bd 100644 --- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js @@ -81,6 +81,7 @@ function AddressPage({privatePersonalDetails, route}) { const [street1, street2] = (address.street || '').split('\n'); const [state, setState] = useState(address.state); const [city, setCity] = useState(address.city); + const [zipcode, setZipcode] = useState(address.zip); useEffect(() => { if (!address) { @@ -89,6 +90,7 @@ function AddressPage({privatePersonalDetails, route}) { setState(address.state); setCurrentCountry(address.country); setCity(address.city); + setZipcode(address.zip); }, [address]); /** @@ -137,20 +139,28 @@ function AddressPage({privatePersonalDetails, route}) { }, []); const handleAddressChange = useCallback((value, key) => { - if (key !== 'country' && key !== 'state' && key !== 'city') { + if (key !== 'country' && key !== 'state' && key !== 'city' && key !== 'zipPostCode') { return; } if (key === 'country') { setCurrentCountry(value); setState(''); setCity(''); + setZipcode(''); return; } if (key === 'state') { setState(value); + setCity(''); + setZipcode(''); + return; + } + if (key === 'city') { + setCity(value); + setZipcode(''); return; } - setCity(value); + setZipcode(value); }, []); useEffect(() => { @@ -202,8 +212,8 @@ function AddressPage({privatePersonalDetails, route}) { )} diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js index eeb6d8217f9f..f4e991d05b56 100644 --- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -102,8 +102,8 @@ function LegalNamePage(props) { inputID="legalFirstName" name="lfname" label={props.translate('privatePersonalDetails.legalFirstName')} - accessibilityLabel={props.translate('privatePersonalDetails.legalFirstName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('privatePersonalDetails.legalFirstName')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={legalFirstName} maxLength={CONST.LEGAL_NAME.MAX_LENGTH + CONST.SEARCH_MAX_LENGTH} spellCheck={false} @@ -115,8 +115,8 @@ function LegalNamePage(props) { inputID="legalLastName" name="llname" label={props.translate('privatePersonalDetails.legalLastName')} - accessibilityLabel={props.translate('privatePersonalDetails.legalLastName')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('privatePersonalDetails.legalLastName')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} defaultValue={legalLastName} maxLength={CONST.LEGAL_NAME.MAX_LENGTH + CONST.SEARCH_MAX_LENGTH} spellCheck={false} diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 7ec8e05b76ff..759987bf7c1e 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -79,7 +79,7 @@ function ProfilePage(props) { { description: props.translate('contacts.contactMethod'), title: props.formatPhoneNumber(lodashGet(currentUserDetails, 'login', '')), - pageRoute: ROUTES.SETTINGS_CONTACT_METHODS, + pageRoute: ROUTES.SETTINGS_CONTACT_METHODS.route, brickRoadIndicator: contactMethodBrickRoadIndicator, }, ...(Permissions.canUseCustomStatus(props.betas) diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js index da4cd7b60e2a..fb751c06c1d8 100644 --- a/src/pages/settings/Security/CloseAccountPage.js +++ b/src/pages/settings/Security/CloseAccountPage.js @@ -106,10 +106,10 @@ function CloseAccountPage(props) { InputComponent={TextInput} inputID="reasonForLeaving" autoGrowHeight - textAlignVertical="top" label={props.translate('closeAccountPage.enterMessageHere')} - accessibilityLabel={props.translate('closeAccountPage.enterMessageHere')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('closeAccountPage.enterMessageHere')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} + inputStyle={[styles.verticalAlignTop]} containerStyles={[styles.mt5, styles.autoGrowHeightMultilineInput]} /> @@ -120,11 +120,11 @@ function CloseAccountPage(props) { inputID="phoneOrEmail" autoCapitalize="none" label={props.translate('closeAccountPage.enterDefaultContact')} - accessibilityLabel={props.translate('closeAccountPage.enterDefaultContact')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + aria-label={props.translate('closeAccountPage.enterDefaultContact')} + role={CONST.ACCESSIBILITY_ROLE.TEXT} containerStyles={[styles.mt5]} autoCorrect={false} - keyboardType={Str.isValidEmail(userEmailOrPhone) ? CONST.KEYBOARD_TYPE.EMAIL_ADDRESS : CONST.KEYBOARD_TYPE.DEFAULT} + inputMode={Str.isValidEmail(userEmailOrPhone) ? CONST.INPUT_MODE.EMAIL : CONST.INPUT_MODE.TEXT} /> (nameOnCardRef.current = ref)} spellCheck={false} /> @@ -157,10 +157,10 @@ function DebitCardPage(props) { @@ -177,9 +177,9 @@ function DebitCardPage(props) { {translate('paymentMethodList.addFirstPaymentMethod')}; + const renderListEmptyComponent = () => {translate('paymentMethodList.addFirstPaymentMethod')}; const renderListFooterComponent = useCallback( () => ( diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js index f5e526964333..d776dafc1aa6 100755 --- a/src/pages/signin/ChangeExpensifyLoginLink.js +++ b/src/pages/signin/ChangeExpensifyLoginLink.js @@ -37,7 +37,7 @@ function ChangeExpensifyLoginLink(props) { diff --git a/src/pages/signin/EmailDeliveryFailurePage.js b/src/pages/signin/EmailDeliveryFailurePage.js index a7b690a6151a..7bbfe7d52ec5 100644 --- a/src/pages/signin/EmailDeliveryFailurePage.js +++ b/src/pages/signin/EmailDeliveryFailurePage.js @@ -75,7 +75,7 @@ function EmailDeliveryFailurePage(props) { redirectToSignIn()} - accessibilityRole="button" + role="button" accessibilityLabel={translate('common.back')} // disable hover dim for switch hoverDimmingValue={1} diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 9529d7fd0d60..b239ce6d3a86 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -15,7 +15,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withNavigationFocus from '@components/withNavigationFocus'; -import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '@components/withToggleVisibilityView'; +import withToggleVisibilityView from '@components/withToggleVisibilityView'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import usePrevious from '@hooks/usePrevious'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; @@ -72,14 +72,14 @@ const propTypes = { /** Whether or not the sign in page is being rendered in the RHP modal */ isInModal: PropTypes.bool, + isVisible: PropTypes.bool.isRequired, + /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, ...windowDimensionsPropTypes, ...withLocalizePropTypes, - - ...toggleVisibilityViewPropTypes, }; const defaultProps = { @@ -221,18 +221,18 @@ function LoginForm(props) { ref={input} label={translate('loginForm.phoneOrEmail')} accessibilityLabel={translate('loginForm.phoneOrEmail')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} value={login} returnKeyType="go" autoCompleteType="username" textContentType="username" - nativeID="username" + id="username" name="username" onChangeText={onTextInput} onSubmitEditing={validateAndSubmitForm} autoCapitalize="none" autoCorrect={false} - keyboardType={CONST.KEYBOARD_TYPE.EMAIL_ADDRESS} + inputMode={CONST.INPUT_MODE.EMAIL} errorText={formErrorText} hasError={hasError} maxLength={CONST.LOGIN_CHARACTER_LIMIT} diff --git a/src/pages/signin/SAMLSignInPage/index.js b/src/pages/signin/SAMLSignInPage/index.js index 67154c8e85fe..fc82cc26d497 100644 --- a/src/pages/signin/SAMLSignInPage/index.js +++ b/src/pages/signin/SAMLSignInPage/index.js @@ -60,6 +60,7 @@ function SAMLSignInPage({credentials}) { SAMLSignInPage.propTypes = propTypes; SAMLSignInPage.defaultProps = defaultProps; +SAMLSignInPage.displayName = 'SAMLSignInPage'; export default withOnyx({ credentials: {key: ONYXKEYS.CREDENTIALS}, diff --git a/src/pages/signin/SignInPageLayout/BackgroundImage/index.js b/src/pages/signin/SignInPageLayout/BackgroundImage/index.js index 2dc95bd28215..b0022c32c565 100644 --- a/src/pages/signin/SignInPageLayout/BackgroundImage/index.js +++ b/src/pages/signin/SignInPageLayout/BackgroundImage/index.js @@ -18,13 +18,11 @@ const propTypes = { function BackgroundImage(props) { return props.isSmallScreen ? ( ) : ( diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 2d8f0e98e03c..351a32dfca48 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -341,7 +341,7 @@ function BaseValidateCodeForm(props) { hoverDimmingValue={1} pressDimmingValue={0.2} disabled={isValidateCodeFormSubmitting} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')} > {props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')} @@ -376,7 +376,7 @@ function BaseValidateCodeForm(props) { disabled={shouldDisableResendValidateCode} hoverDimmingValue={1} pressDimmingValue={0.2} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={props.translate('validateCodeForm.magicCodeNotReceived')} > diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js index 0d6f03006263..c25beba384ad 100644 --- a/src/pages/tasks/NewTaskDescriptionPage.js +++ b/src/pages/tasks/NewTaskDescriptionPage.js @@ -78,7 +78,7 @@ function NewTaskDescriptionPage(props) { inputID="taskDescription" label={props.translate('newTaskPage.descriptionOptional')} accessibilityLabel={props.translate('newTaskPage.descriptionOptional')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} ref={(el) => { inputCallbackRef(el); updateMultilineInputRange(el); @@ -86,7 +86,7 @@ function NewTaskDescriptionPage(props) { autoGrowHeight submitOnEnter={!Browser.isMobile()} containerStyles={[styles.autoGrowHeightMultilineInput]} - textAlignVertical="top" + inputStyle={[styles.verticalAlignTop]} /> diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js index 0ab3771c28f2..87e0e7e430a9 100644 --- a/src/pages/tasks/NewTaskDetailsPage.js +++ b/src/pages/tasks/NewTaskDetailsPage.js @@ -99,7 +99,7 @@ function NewTaskDetailsPage(props) { setTaskDescription(value)} /> diff --git a/src/pages/tasks/NewTaskTitlePage.js b/src/pages/tasks/NewTaskTitlePage.js index e7be9a239e5d..c522ec35bcef 100644 --- a/src/pages/tasks/NewTaskTitlePage.js +++ b/src/pages/tasks/NewTaskTitlePage.js @@ -91,7 +91,7 @@ function NewTaskTitlePage(props) { { const {recentReports, personalDetails, userToInvite, currentUserOption} = OptionsListUtils.getFilteredOptions( @@ -208,7 +209,6 @@ function TaskAssigneeSelectorModal(props) { return ( optionRef.current && optionRef.current.textInput.focus()} testID={TaskAssigneeSelectorModal.displayName} > {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( @@ -229,7 +229,7 @@ function TaskAssigneeSelectorModal(props) { textInputLabel={props.translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} autoFocus={false} - ref={optionRef} + ref={inputCallbackRef} /> diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js index 5d496fbca6c1..992e7c9b582b 100644 --- a/src/pages/tasks/TaskDescriptionPage.js +++ b/src/pages/tasks/TaskDescriptionPage.js @@ -93,7 +93,7 @@ function TaskDescriptionPage(props) { diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js index cd0d8166770e..e3f992ea9b5a 100644 --- a/src/pages/tasks/TaskShareDestinationSelectorModal.js +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js @@ -1,6 +1,6 @@ /* eslint-disable es/no-optional-chaining */ import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -8,6 +8,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OptionsSelector from '@components/OptionsSelector'; import ScreenWrapper from '@components/ScreenWrapper'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useNetwork from '@hooks/useNetwork'; import * as Report from '@libs/actions/Report'; import compose from '@libs/compose'; @@ -51,8 +52,9 @@ function TaskShareDestinationSelectorModal(props) { const [searchValue, setSearchValue] = useState(''); const [headerMessage, setHeaderMessage] = useState(''); const [filteredRecentReports, setFilteredRecentReports] = useState([]); + + const {inputCallbackRef} = useAutoFocusInput(); const {isSearchingForReports} = props; - const optionRef = useRef(); const {isOffline} = useNetwork(); const filteredReports = useMemo(() => { @@ -127,7 +129,6 @@ function TaskShareDestinationSelectorModal(props) { return ( optionRef.current && optionRef.current.textInput.focus()} testID={TaskShareDestinationSelectorModal.displayName} > {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( @@ -151,7 +152,7 @@ function TaskShareDestinationSelectorModal(props) { textInputAlert={isOffline ? `${props.translate('common.youAppearToBeOffline')} ${props.translate('search.resultsAreLimited')}` : ''} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} autoFocus={false} - ref={optionRef} + ref={inputCallbackRef} isLoadingNewOptions={isSearchingForReports} /> diff --git a/src/pages/tasks/TaskTitlePage.js b/src/pages/tasks/TaskTitlePage.js index 375a23cc3012..b4dd1c7c9507 100644 --- a/src/pages/tasks/TaskTitlePage.js +++ b/src/pages/tasks/TaskTitlePage.js @@ -89,7 +89,7 @@ function TaskTitlePage(props) { policy.outputCurrency === CONST.CURRENCY.USD - ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRoute().replace(/\?.*/, ''))))() + ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRouteWithoutParams())))() : setIsCurrencyModalOpen(true), brickRoadIndicator: !_.isEmpty(props.reimbursementAccount.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', }, @@ -236,7 +236,7 @@ function WorkspaceInitialPage(props) { style={[styles.pRelative, styles.avatarLarge]} onPress={singleExecution(waitForNavigate(() => openEditor(policy.id)))} accessibilityLabel={props.translate('workspace.common.settings')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > openEditor(policy.id)))} accessibilityLabel={props.translate('workspace.common.settings')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} > (this.welcomeMessageInputRef = el)} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} inputID="welcomeMessage" label={this.props.translate('workspace.inviteMessage.personalMessagePrompt')} accessibilityLabel={this.props.translate('workspace.inviteMessage.personalMessagePrompt')} autoCompleteType="off" autoCorrect={false} autoGrowHeight - textAlignVertical="top" + inputStyle={[styles.verticalAlignTop]} containerStyles={[styles.autoGrowHeightMultilineInput]} defaultValue={this.state.welcomeNote} value={this.state.welcomeNote} onChangeText={(text) => this.setState({welcomeNote: text})} + shouldSaveDraft /> @@ -251,6 +252,10 @@ export default compose( invitedEmailsToAccountIDsDraft: { key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, }, + savedWelcomeMessage: { + key: `${ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM}Draft`, + selector: (draft) => (draft ? draft.welcomeMessage : ''), + }, }), withNavigationFocus, )(WorkspaceInviteMessagePage); diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index afb0c55e7d4e..ee6e2d826198 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -85,49 +85,36 @@ function WorkspaceInvitePage(props) { const excludedUsers = useMemo(() => PolicyUtils.getIneligibleInvitees(props.policyMembers, props.personalDetails), [props.policyMembers, props.personalDetails]); useEffect(() => { - let emails = _.compact( - searchTerm - .trim() - .replace(/\s*,\s*/g, ',') - .split(','), - ); - - if (emails.length === 0) { - emails = ['']; - } - const newUsersToInviteDict = {}; const newPersonalDetailsDict = {}; const newSelectedOptionsDict = {}; - _.each(emails, (email) => { - const inviteOptions = OptionsListUtils.getMemberInviteOptions(props.personalDetails, props.betas, email, excludedUsers); + const inviteOptions = OptionsListUtils.getMemberInviteOptions(props.personalDetails, props.betas, searchTerm, excludedUsers); - // Update selectedOptions with the latest personalDetails and policyMembers information - const detailsMap = {}; - _.each(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail))); + // Update selectedOptions with the latest personalDetails and policyMembers information + const detailsMap = {}; + _.each(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail))); - const newSelectedOptions = []; - _.each(selectedOptions, (option) => { - newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option); - }); + const newSelectedOptions = []; + _.each(selectedOptions, (option) => { + newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option); + }); - const userToInvite = inviteOptions.userToInvite; + const userToInvite = inviteOptions.userToInvite; - // Only add the user to the invites list if it is valid - if (userToInvite) { - newUsersToInviteDict[userToInvite.accountID] = userToInvite; - } + // Only add the user to the invites list if it is valid + if (userToInvite) { + newUsersToInviteDict[userToInvite.accountID] = userToInvite; + } - // Add all personal details to the new dict - _.each(inviteOptions.personalDetails, (details) => { - newPersonalDetailsDict[details.accountID] = details; - }); + // Add all personal details to the new dict + _.each(inviteOptions.personalDetails, (details) => { + newPersonalDetailsDict[details.accountID] = details; + }); - // Add all selected options to the new dict - _.each(newSelectedOptions, (option) => { - newSelectedOptionsDict[option.accountID] = option; - }); + // Add all selected options to the new dict + _.each(newSelectedOptions, (option) => { + newSelectedOptionsDict[option.accountID] = option; }); // Strip out dictionary keys and update arrays diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 271dc45026c7..55c42d26716d 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -13,7 +13,7 @@ import Text from '@components/Text'; import TextInput from '@components/TextInput'; import ValuePicker from '@components/ValuePicker'; import withNavigationFocus from '@components/withNavigationFocus'; -import useDelayedInputFocus from '@hooks/useDelayedInputFocus'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -161,10 +161,7 @@ function WorkspaceNewRoomPage(props) { [translate], ); - const roomNameInputRef = useRef(null); - - // use a 600ms delay for delayed focus on the room name input field so that it works consistently on native iOS / Android - useDelayedInputFocus(roomNameInputRef, 600); + const {inputCallbackRef} = useAutoFocusInput(); return ( (roomNameInputRef.current = el)} + ref={inputCallbackRef} inputID="roomName" isFocused={props.isFocused} shouldDelayFocus @@ -211,7 +208,7 @@ function WorkspaceNewRoomPage(props) { inputID="welcomeMessage" label={translate('welcomeMessagePage.welcomeMessageOptional')} accessibilityLabel={translate('welcomeMessagePage.welcomeMessageOptional')} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + role={CONST.ACCESSIBILITY_ROLE.TEXT} autoGrowHeight maxLength={CONST.MAX_COMMENT_LENGTH} autoCapitalize="none" diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index 9d1000179291..b78e593e8c8a 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -6,7 +6,8 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Avatar from '@components/Avatar'; import AvatarWithImagePicker from '@components/AvatarWithImagePicker'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -98,11 +99,10 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_SETTINGS} > {(hasVBA) => ( -
- - + -
+ )} ); diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index 572dd9d1152f..f4f9e0c818c8 100755 --- a/src/pages/workspace/WorkspacesListPage.js +++ b/src/pages/workspace/WorkspacesListPage.js @@ -211,6 +211,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, u WorkspacesListPage.propTypes = propTypes; WorkspacesListPage.defaultProps = defaultProps; +WorkspacesListPage.displayName = 'WorkspacesListPage'; export default compose( withPolicyAndFullscreenLoading, diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js index 14338424e11d..6d455401fe65 100644 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js @@ -176,16 +176,16 @@ class WorkspaceRateAndUnitPage extends React.Component { } > this.setState({rate: value})} diff --git a/src/setup/platformSetup/index.desktop.js b/src/setup/platformSetup/index.desktop.js index ab485b1855f1..fab7dc3f5b93 100644 --- a/src/setup/platformSetup/index.desktop.js +++ b/src/setup/platformSetup/index.desktop.js @@ -9,6 +9,7 @@ import ELECTRON_EVENTS from '../../../desktop/ELECTRON_EVENTS'; export default function () { AppRegistry.runApplication(Config.APP_NAME, { rootTag: document.getElementById('root'), + mode: 'legacy', }); // Send local notification when update is downloaded diff --git a/src/setup/platformSetup/index.website.js b/src/setup/platformSetup/index.website.js index d26268cd94bf..bdc64e769e09 100644 --- a/src/setup/platformSetup/index.website.js +++ b/src/setup/platformSetup/index.website.js @@ -56,6 +56,7 @@ const webUpdater = () => ({ export default function () { AppRegistry.runApplication(Config.APP_NAME, { rootTag: document.getElementById('root'), + mode: 'legacy', }); // When app loads, get current version (production only) diff --git a/src/stories/Composer.stories.js b/src/stories/Composer.stories.js index fc817ef2c86d..2db1011d1b3a 100644 --- a/src/stories/Composer.stories.js +++ b/src/stories/Composer.stories.js @@ -36,16 +36,15 @@ function Default(args) { // eslint-disable-next-line react/jsx-props-no-spreading {...args} multiline - textAlignVertical="top" onChangeText={setComment} onPasteFile={setPastedFile} - style={[styles.textInputCompose, styles.w100]} + style={[styles.textInputCompose, styles.w100, styles.verticalAlignTop]} />
Entered Comment (Drop Enabled) {comment} diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js index 8bcbaf31b600..d385cf0613e6 100644 --- a/src/stories/Form.stories.js +++ b/src/stories/Form.stories.js @@ -46,7 +46,7 @@ function Template(args) { ; + return ; } // Arguments can be passed to the component by binding diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js index 28880e1b00d7..ec80a8e908ac 100644 --- a/src/stories/SelectionList.stories.js +++ b/src/stories/SelectionList.stories.js @@ -148,7 +148,7 @@ WithTextInput.args = { textInputLabel: 'Option list', textInputPlaceholder: 'Search something...', textInputMaxLength: 4, - keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD, + inputMode: CONST.INPUT_MODE.NUMERIC, initiallyFocusedOptionKey: 'option-2', onSelectRow: () => {}, onChangeText: () => {}, diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index faece4f44335..42b7860ee263 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -1,5 +1,5 @@ import {CSSProperties} from 'react'; -import {Animated, DimensionValue, ImageStyle, PressableStateCallbackType, TextStyle, ViewStyle} from 'react-native'; +import {Animated, DimensionValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {EdgeInsets} from 'react-native-safe-area-context'; import {ValueOf} from 'type-fest'; import * as Browser from '@libs/Browser'; @@ -16,7 +16,7 @@ import spacing from './utilities/spacing'; import variables from './variables'; type AllStyles = ViewStyle | TextStyle | ImageStyle; -type ParsableStyle = AllStyles | ((state: PressableStateCallbackType) => AllStyles); +type ParsableStyle = StyleProp | ((state: PressableStateCallbackType) => StyleProp); type ColorValue = ValueOf; type AvatarSizeName = ValueOf; @@ -273,7 +273,8 @@ function getDefaultWorkspaceAvatarColor(workspaceName: string): ViewStyle { * Helper method to return eReceipt color code */ function getEReceiptColorCode(transaction: Transaction): EReceiptColorName { - const transactionID = transaction.parentTransactionID ?? transaction.transactionID ?? ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const transactionID = transaction.parentTransactionID || transaction.transactionID || ''; const colorHash = UserUtils.hashText(transactionID.trim(), eReceiptColors.length); @@ -651,7 +652,7 @@ function getMiniReportActionContextMenuWrapperStyle(isReportActionItemGrouped: b ...positioning.r4, ...styles.cursorDefault, position: 'absolute', - zIndex: 1, + zIndex: 8, }; } @@ -748,9 +749,8 @@ function parseStyleAsArray(styleParam: T | T[]): T[] { /** * Parse style function and return Styles object */ -function parseStyleFromFunction(style: ParsableStyle, state: PressableStateCallbackType): AllStyles[] { - const functionAppliedStyle = typeof style === 'function' ? style(state) : style; - return parseStyleAsArray(functionAppliedStyle); +function parseStyleFromFunction(style: ParsableStyle, state: PressableStateCallbackType): StyleProp { + return typeof style === 'function' ? style(state) : style; } /** @@ -1071,7 +1071,7 @@ function getEmojiReactionCounterTextStyle(hasUserReacted: boolean): TextStyle { */ function getDirectionStyle(direction: ValueOf): ViewStyle { if (direction === CONST.DIRECTION.LEFT) { - return {transform: [{rotate: '180deg'}]}; + return {transform: 'rotate(180deg)'}; } return {}; @@ -1097,7 +1097,7 @@ function getGoogleListViewStyle(shouldDisplayBorder: boolean): ViewStyle { } return { - transform: [{scale: 0}], + transform: 'scale(0)', }; } diff --git a/src/styles/ThemeStylesContext.ts b/src/styles/ThemeStylesContext.ts index 1c81ab3b39a5..3df2b19b31bf 100644 --- a/src/styles/ThemeStylesContext.ts +++ b/src/styles/ThemeStylesContext.ts @@ -1,6 +1,6 @@ import React from 'react'; -import styles from './styles'; +import styles, {type Styles} from './styles'; -const ThemeStylesContext = React.createContext(styles); +const ThemeStylesContext = React.createContext(styles); export default ThemeStylesContext; diff --git a/src/styles/ThemeStylesProvider.tsx b/src/styles/ThemeStylesProvider.tsx index 25ce1f58b65e..7f26422e98ce 100644 --- a/src/styles/ThemeStylesProvider.tsx +++ b/src/styles/ThemeStylesProvider.tsx @@ -1,12 +1,9 @@ /* eslint-disable react/jsx-props-no-spreading */ import React, {useMemo} from 'react'; -// TODO: Rename this to "styles" once the app is migrated to theme switching hooks and HOCs -import {stylesGenerator as stylesUntyped} from './styles'; +import {stylesGenerator} from './styles'; import useTheme from './themes/useTheme'; import ThemeStylesContext from './ThemeStylesContext'; -const styles = stylesUntyped; - type ThemeStylesProviderProps = { children: React.ReactNode; }; @@ -14,7 +11,7 @@ type ThemeStylesProviderProps = { function ThemeStylesProvider({children}: ThemeStylesProviderProps) { const theme = useTheme(); - const themeStyles = useMemo(() => styles(theme), [theme]); + const themeStyles = useMemo(() => stylesGenerator(theme), [theme]); return {children}; } diff --git a/src/styles/colors.js b/src/styles/colors.ts similarity index 85% rename from src/styles/colors.js rename to src/styles/colors.ts index 9ac3226a1b80..fbe694e051ee 100644 --- a/src/styles/colors.js +++ b/src/styles/colors.ts @@ -1,7 +1,12 @@ +import {Color} from './themes/types'; + /** - * DO NOT import colors.js into files. Use ../themes/default.js instead. + * DO NOT import colors.js into files. Use the theme switching hooks and HOCs instead. + * For functional components, you can use the `useTheme` and `useThemeStyles` hooks + * For class components, you can use the `withTheme` and `withThemeStyles` HOCs */ -export default { +const colors: Record = { + // Brand Colors black: '#000000', white: '#FFFFFF', ivory: '#fffaf0', @@ -91,3 +96,5 @@ export default { ice700: '#28736D', ice800: '#134038', }; + +export default colors; diff --git a/src/styles/fontFamily/multiFontFamily.ts b/src/styles/fontFamily/multiFontFamily.ts index 2edd17548354..5bd89e0d4bcb 100644 --- a/src/styles/fontFamily/multiFontFamily.ts +++ b/src/styles/fontFamily/multiFontFamily.ts @@ -1,3 +1,5 @@ +import getOperatingSystem from '@libs/getOperatingSystem'; +import CONST from '@src/CONST'; import {multiBold} from './bold'; import FontFamilyStyles from './types'; @@ -16,4 +18,10 @@ const fontFamily: FontFamilyStyles = { MONOSPACE_BOLD_ITALIC: 'ExpensifyMono-Bold, Segoe UI Emoji, Noto Color Emoji', }; +if (getOperatingSystem() === CONST.OS.WINDOWS) { + Object.keys(fontFamily).forEach((key) => { + fontFamily[key as keyof FontFamilyStyles] = fontFamily[key as keyof FontFamilyStyles].replace('Segoe UI Emoji', 'Windows Segoe UI Emoji'); + }); +} + export default fontFamily; diff --git a/src/styles/getModalStyles.ts b/src/styles/getModalStyles.ts index 55f822693b3e..984bf018e42d 100644 --- a/src/styles/getModalStyles.ts +++ b/src/styles/getModalStyles.ts @@ -73,15 +73,7 @@ export default function getModalStyles( }, }; modalContainerStyle = { - // Shadow Styles - shadowColor: themeColors.shadow, - shadowOffset: { - width: 0, - height: 0, - }, - shadowOpacity: 0.1, - shadowRadius: 5, - + boxShadow: '0px 0px 5px 5px rgba(0, 0, 0, 0.1)', borderRadius: 12, overflow: 'hidden', width: variables.sideBarWidth, @@ -105,15 +97,7 @@ export default function getModalStyles( }, }; modalContainerStyle = { - // Shadow Styles - shadowColor: themeColors.shadow, - shadowOffset: { - width: 0, - height: 0, - }, - shadowOpacity: 0.1, - shadowRadius: 5, - + boxShadow: '0px 0px 5px 5px rgba(0, 0, 0, 0.1)', flex: 1, marginTop: isSmallScreenWidth ? 0 : 20, marginBottom: isSmallScreenWidth ? 0 : 20, @@ -140,15 +124,7 @@ export default function getModalStyles( }, }; modalContainerStyle = { - // Shadow Styles - shadowColor: themeColors.shadow, - shadowOffset: { - width: 0, - height: 0, - }, - shadowOpacity: 0.1, - shadowRadius: 5, - + boxShadow: '0px 0px 5px 5px rgba(0, 0, 0, 0.1)', flex: 1, marginTop: isSmallScreenWidth ? 0 : 20, marginBottom: isSmallScreenWidth ? 0 : 20, @@ -173,15 +149,7 @@ export default function getModalStyles( }, }; modalContainerStyle = { - // Shadow Styles - shadowColor: themeColors.shadow, - shadowOffset: { - width: 0, - height: 0, - }, - shadowOpacity: 0.1, - shadowRadius: 5, - + boxShadow: '0px 0px 5px 5px rgba(0, 0, 0, 0.1)', borderRadius: 12, borderWidth: 0, }; diff --git a/src/styles/pointerEventsBoxNone/index.native.ts b/src/styles/pointerEventsBoxNone/index.native.ts new file mode 100644 index 000000000000..05ad2c07db39 --- /dev/null +++ b/src/styles/pointerEventsBoxNone/index.native.ts @@ -0,0 +1,5 @@ +import PointerEventsBoxNone from './types'; + +const pointerEventsBoxNone: PointerEventsBoxNone = {}; + +export default pointerEventsBoxNone; diff --git a/src/styles/pointerEventsBoxNone/index.ts b/src/styles/pointerEventsBoxNone/index.ts new file mode 100644 index 000000000000..0e63e2deda09 --- /dev/null +++ b/src/styles/pointerEventsBoxNone/index.ts @@ -0,0 +1,7 @@ +import PointerEventsBoxNone from './types'; + +const pointerEventsNone: PointerEventsBoxNone = { + pointerEvents: 'box-none', +}; + +export default pointerEventsNone; diff --git a/src/styles/pointerEventsBoxNone/types.ts b/src/styles/pointerEventsBoxNone/types.ts new file mode 100644 index 000000000000..25e85812f4e0 --- /dev/null +++ b/src/styles/pointerEventsBoxNone/types.ts @@ -0,0 +1,5 @@ +import {ViewStyle} from 'react-native'; + +type PointerEventsBoxNone = Pick; + +export default PointerEventsBoxNone; diff --git a/src/styles/styles.ts b/src/styles/styles.ts index 404c5983d7f7..cdfb049e2dce 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -17,9 +17,10 @@ import getPopOverVerticalOffset from './getPopOverVerticalOffset'; import optionAlternateTextPlatformStyles from './optionAlternateTextPlatformStyles'; import overflowXHidden from './overflowXHidden'; import pointerEventsAuto from './pointerEventsAuto'; +import pointerEventsBoxNone from './pointerEventsBoxNone'; import pointerEventsNone from './pointerEventsNone'; import defaultTheme from './themes/default'; -import {ThemeDefault} from './themes/types'; +import {ThemeColors} from './themes/types'; import borders from './utilities/borders'; import cursor from './utilities/cursor'; import display from './utilities/display'; @@ -80,7 +81,7 @@ const touchCalloutNone: Pick = Browser.isMobile // to prevent vertical text offset in Safari for badges, new lineHeight values have been added const lineHeightBadge: Pick = Browser.isSafari() ? {lineHeight: variables.lineHeightXSmall} : {lineHeight: variables.lineHeightNormal}; -const picker = (theme: ThemeDefault) => +const picker = (theme: ThemeColors) => ({ backgroundColor: theme.transparent, color: theme.text, @@ -96,14 +97,14 @@ const picker = (theme: ThemeDefault) => textAlign: 'left', } satisfies TextStyle); -const link = (theme: ThemeDefault) => +const link = (theme: ThemeColors) => ({ color: theme.link, textDecorationColor: theme.link, fontFamily: fontFamily.EXP_NEUE, } satisfies ViewStyle & MixedStyleDeclaration); -const baseCodeTagStyles = (theme: ThemeDefault) => +const baseCodeTagStyles = (theme: ThemeColors) => ({ borderWidth: 1, borderRadius: 5, @@ -116,7 +117,7 @@ const headlineFont = { fontWeight: '500', } satisfies TextStyle; -const webViewStyles = (theme: ThemeDefault) => +const webViewStyles = (theme: ThemeColors) => ({ // As of react-native-render-html v6, don't declare distinct styles for // custom renderers, the API for custom renderers has changed. Declare the @@ -211,7 +212,7 @@ const webViewStyles = (theme: ThemeDefault) => }, } satisfies WebViewStyle); -const styles = (theme: ThemeDefault) => +const styles = (theme: ThemeColors) => ({ // Add all of our utility and helper styles ...spacing, @@ -330,6 +331,14 @@ const styles = (theme: ThemeDefault) => textDecorationLine: 'underline', }, + verticalAlignMiddle: { + verticalAlign: 'middle', + }, + + verticalAlignTop: { + verticalAlign: 'top', + }, + label: { fontSize: variables.fontSizeLabel, lineHeight: variables.lineHeightLarge, @@ -1050,7 +1059,7 @@ const styles = (theme: ThemeDefault) => paddingRight: 12, paddingTop: 10, paddingBottom: 10, - textAlignVertical: 'center', + verticalAlign: 'middle', }, textInputPrefixWrapper: { @@ -1069,7 +1078,7 @@ const styles = (theme: ThemeDefault) => color: theme.text, fontFamily: fontFamily.EXP_NEUE, fontSize: variables.fontSizeNormal, - textAlignVertical: 'center', + verticalAlign: 'middle', }, pickerContainer: { @@ -1651,7 +1660,7 @@ const styles = (theme: ThemeDefault) => chatContentScrollView: { flexGrow: 1, - justifyContent: 'flex-start', + justifyContent: 'flex-end', paddingBottom: 16, }, @@ -1795,13 +1804,13 @@ const styles = (theme: ThemeDefault) => ...overflowXHidden, // On Android, multiline TextInput with height: 'auto' will show extra padding unless they are configured with - // paddingVertical: 0, alignSelf: 'center', and textAlignVertical: 'center' + // paddingVertical: 0, alignSelf: 'center', and verticalAlign: 'middle' paddingHorizontal: variables.avatarChatSpacing, paddingTop: 0, paddingBottom: 0, alignSelf: 'center', - textAlignVertical: 'center', + verticalAlign: 'middle', }, 0, ), @@ -1810,7 +1819,7 @@ const styles = (theme: ThemeDefault) => alignSelf: 'stretch', flex: 1, maxHeight: '100%', - textAlignVertical: 'top', + verticalAlign: 'top', }, // composer padding should not be modified unless thoroughly tested against the cases in this PR: #12669 @@ -2141,6 +2150,8 @@ const styles = (theme: ThemeDefault) => pointerEventsAuto, + pointerEventsBoxNone, + headerBar: { overflow: 'hidden', justifyContent: 'center', @@ -2477,7 +2488,7 @@ const styles = (theme: ThemeDefault) => }, flipUpsideDown: { - transform: [{rotate: '180deg'}], + transform: `rotate(180deg)`, }, navigationScreenCardStyle: { @@ -2778,7 +2789,7 @@ const styles = (theme: ThemeDefault) => alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', - shadowColor: theme.shadow, + boxShadow: `${theme.shadow}`, ...spacing.p5, }, @@ -2877,7 +2888,7 @@ const styles = (theme: ThemeDefault) => }, text: { color: theme.textSupporting, - textAlignVertical: 'center', + verticalAlign: 'middle', fontSize: variables.fontSizeLabel, }, errorDot: { @@ -3218,11 +3229,11 @@ const styles = (theme: ThemeDefault) => miniQuickEmojiReactionText: { fontSize: 15, lineHeight: 20, - textAlignVertical: 'center', + verticalAlign: 'middle', }, emojiReactionBubbleText: { - textAlignVertical: 'center', + verticalAlign: 'middle', }, reactionCounterText: { @@ -3420,7 +3431,6 @@ const styles = (theme: ThemeDefault) => linkPreviewImage: { flex: 1, - resizeMode: 'contain', borderRadius: 8, marginTop: 8, }, @@ -3573,7 +3583,8 @@ const styles = (theme: ThemeDefault) => googlePillButtonContainer: { colorScheme: 'light', height: 40, - width: 219, + width: 300, + overflow: 'hidden', }, thirdPartyLoadingContainer: { @@ -3787,7 +3798,7 @@ const styles = (theme: ThemeDefault) => }, rotate90: { - transform: [{rotate: '90deg'}], + transform: 'rotate(90deg)', }, emojiStatusLHN: { @@ -4013,12 +4024,8 @@ const styles = (theme: ThemeDefault) => }, } satisfies Styles); -// For now we need to export the styles function that takes the theme as an argument -// as something named different than "styles", because a lot of files import the "defaultStyles" -// as "styles", which causes ESLint to throw an error. -// TODO: Remove "stylesGenerator" and instead only return "styles" once the app is migrated to theme switching hooks and HOCs and "styles/theme/default.js" is not used anywhere anymore (GH issue: https://github.com/Expensify/App/issues/27337) const stylesGenerator = styles; const defaultStyles = styles(defaultTheme); export default defaultStyles; -export {stylesGenerator}; +export {stylesGenerator, type Styles}; diff --git a/src/styles/themes/ThemeContext.js b/src/styles/themes/ThemeContext.js deleted file mode 100644 index 30d476c22d9c..000000000000 --- a/src/styles/themes/ThemeContext.js +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import defaultColors from './default'; - -const ThemeContext = React.createContext(defaultColors); - -export default ThemeContext; diff --git a/src/styles/themes/ThemeContext.ts b/src/styles/themes/ThemeContext.ts new file mode 100644 index 000000000000..8c57cc9c7e9f --- /dev/null +++ b/src/styles/themes/ThemeContext.ts @@ -0,0 +1,7 @@ +import React from 'react'; +import darkTheme from './default'; +import {ThemeColors} from './types'; + +const ThemeContext = React.createContext(darkTheme); + +export default ThemeContext; diff --git a/src/styles/themes/ThemeProvider.js b/src/styles/themes/ThemeProvider.tsx similarity index 80% rename from src/styles/themes/ThemeProvider.js rename to src/styles/themes/ThemeProvider.tsx index 58d0baedbe06..50bfb3b045f4 100644 --- a/src/styles/themes/ThemeProvider.js +++ b/src/styles/themes/ThemeProvider.tsx @@ -2,8 +2,8 @@ import PropTypes from 'prop-types'; import React, {useMemo} from 'react'; import CONST from '@src/CONST'; -// Going to eventually import the light theme here too import darkTheme from './default'; +import lightTheme from './light'; import ThemeContext from './ThemeContext'; import useThemePreference from './useThemePreference'; @@ -12,10 +12,10 @@ const propTypes = { children: PropTypes.node.isRequired, }; -function ThemeProvider(props) { +function ThemeProvider(props: React.PropsWithChildren) { const themePreference = useThemePreference(); - const theme = useMemo(() => (themePreference === CONST.THEME.LIGHT ? /* TODO: replace with light theme */ darkTheme : darkTheme), [themePreference]); + const theme = useMemo(() => (themePreference === CONST.THEME.LIGHT ? lightTheme : darkTheme), [themePreference]); return {props.children}; } diff --git a/src/styles/themes/default.ts b/src/styles/themes/default.ts index 98ff8773fb51..dd92b1ce71d9 100644 --- a/src/styles/themes/default.ts +++ b/src/styles/themes/default.ts @@ -1,6 +1,6 @@ import colors from '@styles/colors'; import SCREENS from '@src/SCREENS'; -import type {ThemeBase} from './types'; +import {ThemeColors} from './types'; const darkTheme = { // Figma keys @@ -83,19 +83,18 @@ const darkTheme = { starDefaultBG: 'rgb(254, 228, 94)', loungeAccessOverlay: colors.blue800, mapAttributionText: colors.black, - PAGE_BACKGROUND_COLORS: {}, white: colors.white, -} satisfies ThemeBase; -darkTheme.PAGE_BACKGROUND_COLORS = { - [SCREENS.HOME]: darkTheme.sidebar, - [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, - [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, - [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, - [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, - [SCREENS.SETTINGS.SECURITY]: colors.ice500, - [SCREENS.SETTINGS.STATUS]: colors.green700, - [SCREENS.SETTINGS.ROOT]: darkTheme.sidebar, -}; + PAGE_BACKGROUND_COLORS: { + [SCREENS.HOME]: colors.darkHighlightBackground, + [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, + [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, + [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, + [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, + [SCREENS.SETTINGS.SECURITY]: colors.ice500, + [SCREENS.SETTINGS.STATUS]: colors.green700, + [SCREENS.SETTINGS.ROOT]: colors.darkHighlightBackground, + }, +} satisfies ThemeColors; export default darkTheme; diff --git a/src/styles/themes/light.ts b/src/styles/themes/light.ts index 624c7df0caa8..97fe2322945a 100644 --- a/src/styles/themes/light.ts +++ b/src/styles/themes/light.ts @@ -1,6 +1,6 @@ import colors from '@styles/colors'; import SCREENS from '@src/SCREENS'; -import type {ThemeDefault} from './types'; +import {ThemeColors} from './types'; const lightTheme = { // Figma keys @@ -16,9 +16,9 @@ const lightTheme = { iconSuccessFill: colors.green400, iconReversed: colors.lightAppBackground, iconColorfulBackground: `${colors.ivory}cc`, - textColorfulBackground: colors.ivory, textSupporting: colors.lightSupportingText, text: colors.lightPrimaryText, + textColorfulBackground: colors.ivory, link: colors.blue600, linkHover: colors.blue500, buttonDefaultBG: colors.lightDefaultButton, @@ -83,19 +83,18 @@ const lightTheme = { starDefaultBG: 'rgb(254, 228, 94)', loungeAccessOverlay: colors.blue800, mapAttributionText: colors.black, - PAGE_BACKGROUND_COLORS: {}, white: colors.white, -} satisfies ThemeDefault; -lightTheme.PAGE_BACKGROUND_COLORS = { - [SCREENS.HOME]: lightTheme.sidebar, - [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, - [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, - [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, - [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, - [SCREENS.SETTINGS.SECURITY]: colors.ice500, - [SCREENS.SETTINGS.STATUS]: colors.green700, - [SCREENS.SETTINGS.ROOT]: lightTheme.sidebar, -}; + PAGE_BACKGROUND_COLORS: { + [SCREENS.HOME]: colors.lightHighlightBackground, + [SCREENS.SAVE_THE_WORLD.ROOT]: colors.tangerine800, + [SCREENS.SETTINGS.PREFERENCES]: colors.blue500, + [SCREENS.SETTINGS.WORKSPACES]: colors.pink800, + [SCREENS.SETTINGS.WALLET]: colors.darkAppBackground, + [SCREENS.SETTINGS.SECURITY]: colors.ice500, + [SCREENS.SETTINGS.STATUS]: colors.green700, + [SCREENS.SETTINGS.ROOT]: colors.lightHighlightBackground, + }, +} satisfies ThemeColors; export default lightTheme; diff --git a/src/styles/themes/types.ts b/src/styles/themes/types.ts index 59e8001d29fe..4064dd289650 100644 --- a/src/styles/themes/types.ts +++ b/src/styles/themes/types.ts @@ -1,8 +1,89 @@ -import DeepRecord from '@src/types/utils/DeepRecord'; -import defaultTheme from './default'; +type Color = string; -type ThemeBase = DeepRecord; +type ThemeColors = { + // Figma keys + appBG: Color; + splashBG: Color; + highlightBG: Color; + border: Color; + borderLighter: Color; + borderFocus: Color; + icon: Color; + iconMenu: Color; + iconHovered: Color; + iconSuccessFill: Color; + iconReversed: Color; + iconColorfulBackground: Color; + textSupporting: Color; + text: Color; + textColorfulBackground: Color; + link: Color; + linkHover: Color; + buttonDefaultBG: Color; + buttonHoveredBG: Color; + buttonPressedBG: Color; + danger: Color; + dangerHover: Color; + dangerPressed: Color; + warning: Color; + success: Color; + successHover: Color; + successPressed: Color; + transparent: Color; + signInPage: Color; + dangerSection: Color; -type ThemeDefault = typeof defaultTheme; + // Additional keys + overlay: Color; + inverse: Color; + shadow: Color; + componentBG: Color; + hoverComponentBG: Color; + activeComponentBG: Color; + signInSidebar: Color; + sidebar: Color; + sidebarHover: Color; + heading: Color; + textLight: Color; + textDark: Color; + textReversed: Color; + textBackground: Color; + textMutedReversed: Color; + textError: Color; + offline: Color; + modalBackground: Color; + cardBG: Color; + cardBorder: Color; + spinner: Color; + unreadIndicator: Color; + placeholderText: Color; + heroCard: Color; + uploadPreviewActivityIndicator: Color; + dropUIBG: Color; + receiptDropUIBG: Color; + checkBox: Color; + pickerOptionsTextColor: Color; + imageCropBackgroundColor: Color; + fallbackIconColor: Color; + reactionActiveBackground: Color; + reactionActiveText: Color; + badgeAdHoc: Color; + badgeAdHocHover: Color; + mentionText: Color; + mentionBG: Color; + ourMentionText: Color; + ourMentionBG: Color; + tooltipSupportingText: Color; + tooltipPrimaryText: Color; + skeletonLHNIn: Color; + skeletonLHNOut: Color; + QRLogo: Color; + starDefaultBG: Color; + loungeAccessOverlay: Color; + mapAttributionText: Color; + white: Color; -export type {ThemeBase, ThemeDefault}; + PAGE_BACKGROUND_COLORS: Record; +}; + +export {type ThemeColors, type Color}; diff --git a/src/styles/themes/useTheme.js b/src/styles/themes/useTheme.ts similarity index 50% rename from src/styles/themes/useTheme.js rename to src/styles/themes/useTheme.ts index 8e88b23a7688..8bb4fe73c106 100644 --- a/src/styles/themes/useTheme.js +++ b/src/styles/themes/useTheme.ts @@ -1,11 +1,12 @@ import {useContext} from 'react'; import ThemeContext from './ThemeContext'; +import {ThemeColors} from './types'; -function useTheme() { +function useTheme(): ThemeColors { const theme = useContext(ThemeContext); if (!theme) { - throw new Error('StylesContext was null! Are you sure that you wrapped the component under a ?'); + throw new Error('ThemeContext was null! Are you sure that you wrapped the component under a ?'); } return theme; diff --git a/src/styles/themes/useThemePreference.js b/src/styles/themes/useThemePreference.ts similarity index 58% rename from src/styles/themes/useThemePreference.js rename to src/styles/themes/useThemePreference.ts index 8c26ad931d6d..ac6ac02933c7 100644 --- a/src/styles/themes/useThemePreference.js +++ b/src/styles/themes/useThemePreference.ts @@ -1,29 +1,31 @@ import {useContext, useEffect, useState} from 'react'; -import {Appearance} from 'react-native'; +import {Appearance, ColorSchemeName} from 'react-native'; import {PreferredThemeContext} from '@components/OnyxProvider'; import CONST from '@src/CONST'; +type ThemePreference = typeof CONST.THEME.LIGHT | typeof CONST.THEME.DARK; + function useThemePreference() { - const [themePreference, setThemePreference] = useState(CONST.THEME.DEFAULT); - const [systemTheme, setSystemTheme] = useState(); - const preferredThemeContext = useContext(PreferredThemeContext); + const [themePreference, setThemePreference] = useState(CONST.THEME.DEFAULT); + const [systemTheme, setSystemTheme] = useState(); + const preferredThemeFromStorage = useContext(PreferredThemeContext); useEffect(() => { // This is used for getting the system theme, that can be set in the OS's theme settings. This will always return either "light" or "dark" and will update automatically if the OS theme changes. const systemThemeSubscription = Appearance.addChangeListener(({colorScheme}) => setSystemTheme(colorScheme)); - return systemThemeSubscription.remove; + return () => systemThemeSubscription.remove(); }, []); useEffect(() => { - const theme = preferredThemeContext || CONST.THEME.DEFAULT; + const theme = preferredThemeFromStorage ?? CONST.THEME.DEFAULT; // If the user chooses to use the device theme settings, we need to set the theme preference to the system theme if (theme === CONST.THEME.SYSTEM) { - setThemePreference(systemTheme); + setThemePreference(systemTheme ?? CONST.THEME.DEFAULT); } else { setThemePreference(theme); } - }, [preferredThemeContext, systemTheme]); + }, [preferredThemeFromStorage, systemTheme]); return themePreference; } diff --git a/src/styles/useThemeStyles.ts b/src/styles/useThemeStyles.ts index a5b3baebbaec..69ba43692f49 100644 --- a/src/styles/useThemeStyles.ts +++ b/src/styles/useThemeStyles.ts @@ -5,7 +5,7 @@ function useThemeStyles() { const themeStyles = useContext(ThemeStylesContext); if (!themeStyles) { - throw new Error('StylesContext was null! Are you sure that you wrapped the component under a ?'); + throw new Error('ThemeStylesContext was null! Are you sure that you wrapped the component under a ?'); } // TODO: Remove this "eslint-disable-next" once the theme switching migration is done and styles are fully typed (GH Issue: https://github.com/Expensify/App/issues/27337) diff --git a/src/types/modules/react-native-web-linear-gradient.d.ts b/src/types/modules/react-native-web-linear-gradient.d.ts new file mode 100644 index 000000000000..6909ce3dbde2 --- /dev/null +++ b/src/types/modules/react-native-web-linear-gradient.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +declare module 'react-native-web-linear-gradient' { + import LinearGradient from 'react-native-linear-gradient'; + + export default LinearGradient; +} diff --git a/src/types/modules/react-native-web.d.ts b/src/types/modules/react-native-web.d.ts new file mode 100644 index 000000000000..f7db951eadad --- /dev/null +++ b/src/types/modules/react-native-web.d.ts @@ -0,0 +1,11 @@ +/* eslint-disable import/prefer-default-export */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +declare module 'react-native-web' { + class Clipboard { + static isAvailable(): boolean; + static getString(): Promise; + static setString(text: string): boolean; + } + + export {Clipboard}; +} diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index a816fc77625b..ec857af2eceb 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -35,7 +35,7 @@ declare module 'react-native' { 'aria-haspopup'?: 'dialog' | 'grid' | 'listbox' | 'menu' | 'tree' | false; 'aria-hidden'?: boolean; 'aria-invalid'?: boolean; - 'aria-keyshortcuts'?: string[]; + 'aria-keyshortcuts'?: string; 'aria-label'?: string; 'aria-labelledby'?: idRef; 'aria-level'?: number; @@ -85,7 +85,7 @@ declare module 'react-native' { accessibilityInvalid?: boolean; accessibilityKeyShortcuts?: string[]; accessibilityLabel?: string; - accessibilityLabelledBy?: idRefList; + accessibilityLabelledBy?: idRef; accessibilityLevel?: number; accessibilityLiveRegion?: 'assertive' | 'none' | 'polite'; accessibilityModal?: boolean; @@ -312,7 +312,10 @@ declare module 'react-native' { readonly hovered: boolean; readonly pressed: boolean; } - interface PressableStateCallbackType extends WebPressableStateCallbackType {} + interface PressableStateCallbackType extends WebPressableStateCallbackType { + readonly isScreenReaderActive: boolean; + readonly isDisabled: boolean; + } // Extracted from react-native-web, packages/react-native-web/src/exports/Pressable/index.js interface WebPressableProps extends WebSharedProps { diff --git a/src/types/onyx/Form.ts b/src/types/onyx/Form.ts index cda8c3c1017e..7b7d8d76536a 100644 --- a/src/types/onyx/Form.ts +++ b/src/types/onyx/Form.ts @@ -16,6 +16,11 @@ type AddDebitCardForm = Form & { setupComplete: boolean; }; +type DateOfBirthForm = Form & { + /** Date of birth */ + dob?: string; +}; + export default Form; -export type {AddDebitCardForm}; +export type {AddDebitCardForm, DateOfBirthForm}; diff --git a/src/types/onyx/Login.ts b/src/types/onyx/Login.ts index 60ea5985315e..c770e2f81f90 100644 --- a/src/types/onyx/Login.ts +++ b/src/types/onyx/Login.ts @@ -14,7 +14,7 @@ type Login = { errorFields?: OnyxCommon.ErrorFields; /** Field-specific pending states for offline UI status */ - pendingFields?: OnyxCommon.ErrorFields; + pendingFields?: OnyxCommon.PendingFields; }; export default Login; diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index bafd5e8cbbf0..ef2944d6af82 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -4,7 +4,9 @@ import CONST from '@src/CONST'; type PendingAction = ValueOf; -type ErrorFields = Record | null>; +type PendingFields = Record; + +type ErrorFields = Record; type Errors = Record; @@ -14,4 +16,4 @@ type Icon = { name: string; }; -export type {Icon, PendingAction, ErrorFields, Errors}; +export type {Icon, PendingAction, PendingFields, ErrorFields, Errors}; diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts index 0cd264802128..5637d7e5fdcf 100644 --- a/src/types/onyx/PersonalDetails.ts +++ b/src/types/onyx/PersonalDetails.ts @@ -1,3 +1,5 @@ +import * as OnyxCommon from './OnyxCommon'; + type Timezone = { /** Value of selected timezone */ selected?: string; @@ -28,6 +30,11 @@ type PersonalDetails = { /** Avatar URL of the current user from their personal details */ avatar: string; + /** Avatar thumbnail URL of the current user from their personal details */ + avatarThumbnail?: string; + + originalFileName?: string; + /** Flag to set when Avatar uploading */ avatarUploading?: boolean; @@ -43,10 +50,21 @@ type PersonalDetails = { /** Timezone of the current user from their personal details */ timezone?: Timezone; + /** Whether we are loading the data via the API */ + isLoading?: boolean; + + /** Field-specific server side errors keyed by microtime */ + errorFields?: OnyxCommon.ErrorFields<'avatar'>; + + /** Field-specific pending states for offline UI status */ + pendingFields?: OnyxCommon.PendingFields<'avatar' | 'originalFileName'>; + + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon?: string; /** Status of the current user from their personal details */ + status?: string; }; -export type {Timezone}; - export default PersonalDetails; +export type {Timezone}; diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts index 50ec77212efd..6ef5b75c4a0f 100644 --- a/src/types/onyx/PrivatePersonalDetails.ts +++ b/src/types/onyx/PrivatePersonalDetails.ts @@ -13,6 +13,9 @@ type PrivatePersonalDetails = { /** User's home address */ address?: Address; + + /** Whether we are loading the data via the API */ + isLoading?: boolean; }; export default PrivatePersonalDetails; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index 19908273ad3d..66622f4b29ea 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -43,6 +43,9 @@ type Message = { moderationDecision?: Decision; translationKey?: string; + + /** ID of a task report */ + taskReportID?: string; }; type Person = { diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 3d834d0bcb2b..becf244388fc 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -10,6 +10,7 @@ type Response = { authToken?: string; encryptedAuthToken?: string; message?: string; + shortLivedAuthToken?: string; }; export default Response; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index de5f2eec9f9d..e52389a72b46 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -56,7 +56,7 @@ type Transaction = { created: string; currency: string; errors?: OnyxCommon.Errors; - errorFields?: OnyxCommon.ErrorFields; + errorFields?: OnyxCommon.ErrorFields<'route'>; // The name of the file used for a receipt (formerly receiptFilename) filename?: string; merchant: string; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 93a19b39aad3..f02d3d2f548f 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -8,7 +8,7 @@ import Credentials from './Credentials'; import Currency from './Currency'; import CustomStatusDraft from './CustomStatusDraft'; import Download from './Download'; -import Form, {AddDebitCardForm} from './Form'; +import Form, {AddDebitCardForm, DateOfBirthForm} from './Form'; import FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import Fund from './Fund'; import IOU from './IOU'; @@ -51,58 +51,59 @@ import WalletTransfer from './WalletTransfer'; export type { Account, - Request, + AccountData, + AddDebitCardForm, + BankAccount, + Beta, + BlockedFromConcierge, + Card, Credentials, + Currency, + CustomStatusDraft, + DateOfBirthForm, + Download, + Form, + FrequentlyUsedEmoji, + Fund, IOU, + Login, + MapboxAccessToken, Modal, Network, - CustomStatusDraft, + OnyxUpdateEvent, + OnyxUpdatesFromServer, + PersonalBankAccount, PersonalDetails, - PrivatePersonalDetails, - Task, - Currency, - ScreenShareRequest, - User, - Login, - Session, - Beta, - BlockedFromConcierge, PlaidData, - UserWallet, - WalletOnfido, - WalletAdditionalDetails, - WalletTerms, - BankAccount, - Card, - Fund, - WalletStatement, - PersonalBankAccount, - ReimbursementAccount, - ReimbursementAccountDraft, - FrequentlyUsedEmoji, - WalletTransfer, - MapboxAccessToken, - Download, - PolicyMember, Policy, PolicyCategory, + PolicyMember, + PolicyMembers, + PolicyTag, + PolicyTags, + PrivatePersonalDetails, + RecentlyUsedCategories, + RecentlyUsedTags, + RecentWaypoint, + ReimbursementAccount, + ReimbursementAccountDraft, Report, - ReportMetadata, ReportAction, + ReportActionReactions, ReportActions, ReportActionsDrafts, - ReportActionReactions, + ReportMetadata, + Request, + ScreenShareRequest, SecurityGroup, + Session, + Task, Transaction, - Form, - AddDebitCardForm, - OnyxUpdatesFromServer, - RecentWaypoint, - OnyxUpdateEvent, - RecentlyUsedCategories, - RecentlyUsedTags, - PolicyTag, - PolicyTags, - PolicyMembers, - AccountData, + User, + UserWallet, + WalletAdditionalDetails, + WalletOnfido, + WalletStatement, + WalletTerms, + WalletTransfer, }; diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md index dcd08aeee441..6a4ea3edd1ef 100644 --- a/tests/e2e/ADDING_TESTS.md +++ b/tests/e2e/ADDING_TESTS.md @@ -2,22 +2,27 @@ ## Running your new test in development mode -Typically you'd run all the tests with `npm run test:e2e` on your machine, -this will run the tests with some local settings, however that is not -optimal when you add a new test for which you want to quickly test if it works, as it -still runs the release version of the app. +Typically you'd run all the tests with `npm run test:e2e` on your machine. +This will run the tests with some local settings, however that is not +optimal when you add a new test for which you want to quickly test if it works, as the prior command +still runs the release version of the app, which is hard to debug. I recommend doing the following. -> [!NOTE] -> All of the steps can be executed at once by running XXX (todo) +1. We need to compile a android development app version that has capturing metrics enabled: +```bash +# Make sure that your .env file is the one we need for e2e testing: +cp ./tests/e2e/.env.e2e .env -1. Rename `./index.js` to `./appIndex.js` -2. Create a new `./index.js` with the following content: +# Build the android app like you normally would with +npm run android +``` +2. Rename `./index.js` to `./appIndex.js` +3. Create a new `./index.js` with the following content: ```js -requrire("./src/libs/E2E/reactNativeLaunchingTest.js"); +require('./src/libs/E2E/reactNativeLaunchingTest'); ``` -3. In `./src/libs/E2E/reactNativeLaunchingTest.js` change the main app import to the new `./appIndex.js` file: +4. In `./src/libs/E2E/reactNativeLaunchingTest.js` change the main app import to the new `./appIndex.js` file: ```diff - import '../../../index'; + import '../../../appIndex'; @@ -28,21 +33,17 @@ requrire("./src/libs/E2E/reactNativeLaunchingTest.js"); Now you can start the metro bundler in e2e mode with: -``` -CAPTURE_METRICS=TRUE E2E_Testing=true npm start -- --reset-cache +```bash +CAPTURE_METRICS=true E2E_TESTING=true npm start -- --reset-cache ``` Then we can execute our test with: ``` -npm run test:e2e -- --development --skipInstallDeps --buildMode skip --includes "My new test name" +npm run test:e2e:dev -- --includes "My new test name" ``` -> - `--development` will run the tests with a local config, which will run the tests with fewer iterations -> - `--skipInstallDeps` will skip the `npm install` step, which you probably don't need -> - `--buildMode skip` will skip rebuilding the app, and just run the existing app -> - `--includes "MyTestName"` will only run the test with the name "MyTestName" - +> - `--includes "MyTestName"` will only run the test with the name "MyTestName", but is optional ## Creating a new test diff --git a/tests/e2e/config.dev.js b/tests/e2e/config.dev.js new file mode 100644 index 000000000000..46191ebdee48 --- /dev/null +++ b/tests/e2e/config.dev.js @@ -0,0 +1,5 @@ +module.exports = { + APP_PACKAGE: 'com.expensify.chat.dev', + APP_PATH: './android/app/build/outputs/apk/development/debug/app-development-debug.apk', + RUNS: 8, +}; diff --git a/tests/e2e/config.js b/tests/e2e/config.js index 1e73aa58d3d9..c466000d0b53 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -9,6 +9,7 @@ const OUTPUT_DIR = process.env.WORKING_DIRECTORY || './tests/e2e/results'; const TEST_NAMES = { AppStartTime: 'App start time', OpenSearchPage: 'Open search page TTI', + ReportTyping: 'Report typing', }; /** @@ -69,5 +70,11 @@ module.exports = { [TEST_NAMES.OpenSearchPage]: { name: TEST_NAMES.OpenSearchPage, }, + [TEST_NAMES.ReportTyping]: { + name: TEST_NAMES.ReportTyping, + reportScreen: { + autoFocus: true, + }, + }, }, }; diff --git a/tests/e2e/config.local.js b/tests/e2e/config.local.js index 15b091d8ba70..8cdfc50ac625 100644 --- a/tests/e2e/config.local.js +++ b/tests/e2e/config.local.js @@ -1,5 +1,5 @@ module.exports = { APP_PACKAGE: 'com.expensify.chat.adhoc', APP_PATH: './android/app/build/outputs/apk/e2e/release/app-e2e-release.apk', - RUNS: 8, + RUNS: 4, }; diff --git a/tests/e2e/nativeCommands/NativeCommandsAction.js b/tests/e2e/nativeCommands/NativeCommandsAction.js new file mode 100644 index 000000000000..f2aa4644f7ff --- /dev/null +++ b/tests/e2e/nativeCommands/NativeCommandsAction.js @@ -0,0 +1,22 @@ +const NativeCommandsAction = { + scroll: 'scroll', + type: 'type', + backspace: 'backspace', +}; + +const makeTypeTextCommand = (text) => ({ + actionName: NativeCommandsAction.type, + payload: { + text, + }, +}); + +const makeBackspaceCommand = () => ({ + actionName: NativeCommandsAction.backspace, +}); + +module.exports = { + NativeCommandsAction, + makeTypeTextCommand, + makeBackspaceCommand, +}; diff --git a/tests/e2e/nativeCommands/adbBackspace.js b/tests/e2e/nativeCommands/adbBackspace.js new file mode 100644 index 000000000000..8f41364daed3 --- /dev/null +++ b/tests/e2e/nativeCommands/adbBackspace.js @@ -0,0 +1,10 @@ +const execAsync = require('../utils/execAsync'); +const Logger = require('../utils/logger'); + +const adbBackspace = async () => { + Logger.log(`🔙 Pressing backspace`); + execAsync(`adb shell input keyevent KEYCODE_DEL`); + return true; +}; + +module.exports = adbBackspace; diff --git a/tests/e2e/nativeCommands/adbTypeText.js b/tests/e2e/nativeCommands/adbTypeText.js new file mode 100644 index 000000000000..cbaa9f4434a2 --- /dev/null +++ b/tests/e2e/nativeCommands/adbTypeText.js @@ -0,0 +1,10 @@ +const execAsync = require('../utils/execAsync'); +const Logger = require('../utils/logger'); + +const adbTypeText = async (text) => { + Logger.log(`📝 Typing text: ${text}`); + execAsync(`adb shell input text "${text}"`); + return true; +}; + +module.exports = adbTypeText; diff --git a/tests/e2e/nativeCommands/index.js b/tests/e2e/nativeCommands/index.js new file mode 100644 index 000000000000..bb87c16a6f42 --- /dev/null +++ b/tests/e2e/nativeCommands/index.js @@ -0,0 +1,22 @@ +const adbBackspace = require('./adbBackspace'); +const adbTypeText = require('./adbTypeText'); +const {NativeCommandsAction} = require('./NativeCommandsAction'); + +const executeFromPayload = (actionName, payload) => { + switch (actionName) { + case NativeCommandsAction.scroll: + throw new Error('Not implemented yet'); + case NativeCommandsAction.type: + return adbTypeText(payload.text); + case NativeCommandsAction.backspace: + return adbBackspace(); + default: + throw new Error(`Unknown action: ${actionName}`); + } +}; + +module.exports = { + NativeCommandsAction, + executeFromPayload, + adbTypeText, +}; diff --git a/tests/e2e/server/index.js b/tests/e2e/server/index.js index 3910ef43f798..4c2e00126fd5 100644 --- a/tests/e2e/server/index.js +++ b/tests/e2e/server/index.js @@ -2,6 +2,7 @@ const {createServer} = require('http'); const Routes = require('./routes'); const Logger = require('../utils/logger'); const {SERVER_PORT} = require('../config'); +const {executeFromPayload} = require('../nativeCommands'); const PORT = process.env.PORT || SERVER_PORT; @@ -125,6 +126,26 @@ const createServerInstance = () => { return res.end('ok'); } + case Routes.testNativeCommand: { + getPostJSONRequestData(req, res) + .then((data) => + executeFromPayload(data.actionName, data.payload).then((status) => { + if (status) { + res.end('ok'); + return; + } + res.statusCode = 500; + res.end('Error executing command'); + }), + ) + .catch((error) => { + Logger.error('Error executing command', error); + res.statusCode = 500; + res.end('Error executing command'); + }); + break; + } + default: res.statusCode = 404; res.end('Page not found!'); diff --git a/tests/e2e/server/routes.js b/tests/e2e/server/routes.js index 5aac2fef4dc2..84fc2f89fd9b 100644 --- a/tests/e2e/server/routes.js +++ b/tests/e2e/server/routes.js @@ -7,4 +7,7 @@ module.exports = { // When the app is done running a test it calls this endpoint testDone: '/test_done', + + // Commands to execute from the host machine (there are pre-defined types like scroll or type) + testNativeCommand: '/test_native_command', }; diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index d8e4afd606ac..54cde8f5b336 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -171,20 +171,28 @@ const runTests = async () => { const server = createServerInstance(); await server.start(); - // Create a dict in which we will store the run durations for all tests - const durationsByTestName = {}; + // Create a dict in which we will store the collected metrics for all tests + const resultsByTestName = {}; // Collect results while tests are being executed server.addTestResultListener((testResult) => { if (testResult.error != null) { throw new Error(`Test '${testResult.name}' failed with error: ${testResult.error}`); } - if (testResult.duration < 0) { - return; + let result = 0; + + if ('duration' in testResult) { + if (testResult.duration < 0) { + return; + } + result = testResult.duration; + } + if ('renderCount' in testResult) { + result = testResult.renderCount; } - Logger.log(`[LISTENER] Test '${testResult.name}' took ${testResult.duration}ms`); - durationsByTestName[testResult.name] = (durationsByTestName[testResult.name] || []).concat(testResult.duration); + Logger.log(`[LISTENER] Test '${testResult.name}' measured ${result}`); + resultsByTestName[testResult.name] = (resultsByTestName[testResult.name] || []).concat(result); }); // Run the tests @@ -275,8 +283,8 @@ const runTests = async () => { // Calculate statistics and write them to our work file progressLog = Logger.progressInfo('Calculating statics and writing results'); - for (const testName of _.keys(durationsByTestName)) { - const stats = math.getStats(durationsByTestName[testName]); + for (const testName of _.keys(resultsByTestName)) { + const stats = math.getStats(resultsByTestName[testName]); await writeTestStats( { name: testName, diff --git a/tsconfig.json b/tsconfig.json index 151087fb1321..eafc7c375fdd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "es2021.weakref", "es2022.array", "es2022.object", - "es2022.string" + "es2022.string", + "ES2021.Intl" ], "allowJs": true, "checkJs": false,