diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 4f78ee6b69bf..c106a25c8ab1 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,5 @@
# Every PR gets a review from an internal Expensify engineer
* @Expensify/pullerbear
+
+# Every PR that touches redirects gets reviewed by ring0
+docs/redirects.csv @Expensify/infra
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md
index 5e0e3633f3bc..5c96d8736bcd 100644
--- a/.github/ISSUE_TEMPLATE/Standard.md
+++ b/.github/ISSUE_TEMPLATE/Standard.md
@@ -42,45 +42,8 @@ Which of our officially supported platforms is this issue occurring on?
- [ ] MacOS: Desktop
## Screenshots/Videos
-
-Android: Native
-
-
-
-
-
-Android: mWeb Chrome
-
-
-
-
-
-
-iOS: Native
-
-
-
-
-
-
-iOS: mWeb Safari
-
-
-
-
-
-
-MacOS: Chrome / Safari
-
-
-
-
-
-
-MacOS: Desktop
-
-
+Add any screenshot/video evidence
diff --git a/.github/scripts/createHelpRedirects.sh b/.github/scripts/createHelpRedirects.sh
new file mode 100755
index 000000000000..04f55e19b4fb
--- /dev/null
+++ b/.github/scripts/createHelpRedirects.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+#
+# Adds new routes to the Cloudflare Bulk Redirects list for communityDot to helpDot
+# pages. Does some basic sanity checking.
+
+set -e
+
+source scripts/shellUtils.sh
+
+info "Adding any new redirects from communityDot to helpDot"
+
+declare -r LIST_ID="20eb13215038446a98fd69ccf6d1026d"
+declare -r ZONE_ID="$CLOUDFLARE_ACCOUNT_ID"
+declare -r REDIRECTS_FILE="docs/redirects.csv"
+
+function checkCloudflareResult {
+ RESULTS=$1
+ RESULT_MESSAGE=$(echo "$RESULTS" | jq .success)
+
+ if ! [[ "$RESULT_MESSAGE" == "true" ]]; then
+ ERROR_MESSAGE=$(echo "$RESULTS" | jq .errors)
+ error "Error calling Cloudfalre API: $ERROR_MESSAGE"
+ exit 1
+ fi
+}
+
+declare -a ITEMS_TO_ADD
+
+while read -r line; do
+ # Split each line of the file into a source and destination so we can sanity check
+ # and compare against the current list.
+ read -r -a LINE_PARTS < <(echo "$line" | tr ',' ' ')
+ SOURCE_URL=${LINE_PARTS[0]}
+ DEST_URL=${LINE_PARTS[1]}
+
+ # Make sure the format of the line is as execpted.
+ if [[ "${#LINE_PARTS[@]}" -gt 2 ]]; then
+ error "Found a line with more than one comma: $line"
+ exit 1
+ fi
+
+ # Basic sanity checking to make sure that the source and destination are in expected
+ # subdomains.
+ if ! [[ $SOURCE_URL =~ ^https://community\.expensify\.com ]]; then
+ error "Found source URL that is not a community URL: $SOURCE_URL"
+ exit 1
+ fi
+
+ if ! [[ $DEST_URL =~ ^https://help\.expensify\.com ]]; then
+ error "Found destination URL that is not a help URL: $DEST_URL"
+ exit 1
+ fi
+
+ info "Source: $SOURCE_URL and destination: $DEST_URL appear to be formatted correctly."
+
+ ITEMS_TO_ADD+=("$line")
+
+# This line skips the first line in the csv because the first line is a header row.
+done <<< "$(tail +2 $REDIRECTS_FILE)"
+
+# Sanity check that we should actually be running this and we aren't about to delete
+# every single redirect.
+if [[ "${#ITEMS_TO_ADD[@]}" -lt 1 ]]; then
+ error "No items found to add, why are we running?"
+ exit 1
+fi
+
+# This block builds a single JSON object with all of our updates so we can
+# reduce the number of API calls we make. You cannot add any logging or anything
+# that prints to std out to this block or it will break. We capture all of the std out
+# from this loop and pass it to jq to build the json object. Any non-json will break the
+# jq call at the end.
+PUT_JSON=$(for new in "${ITEMS_TO_ADD[@]}"; do
+ read -r -a LINE_PARTS < <(echo "$new" | tr ',' ' ')
+ SOURCE_URL=${LINE_PARTS[0]}
+ DEST_URL=${LINE_PARTS[1]}
+
+ # We strip the prefix here so that the rule will match both http and https. Since vanilla will eventially be removed,
+ # we need to catch both because we will not have the http > https redirect done by vanilla anymore.
+ NO_PREFIX_SOURCE_URL=${SOURCE_URL/https:\/\//}
+ jq -n --arg source "$NO_PREFIX_SOURCE_URL" --arg dest "$DEST_URL" '{"redirect": {source_url: $source, target_url: $dest}}'
+done | jq -n '. |= [inputs]')
+
+info "Adding redirects for $PUT_JSON"
+
+# We use PUT here instead of POST so that we replace the entire list in place. This has many benefits:
+# 1. We don't have to check if items are already in the list, allowing this script to run faster
+# 2. We can support deleting redirects this way by simply removing them from the list
+# 3. We can support updating redirects this way, in the case of typos or moved destinations.
+#
+# Additionally this API call is async, so after we finish it, we must poll to wait for it to finish to
+# to know that it was actually completed.
+PUT_RESULT=$(curl -s --request PUT --url "https://api.cloudflare.com/client/v4/accounts/$ZONE_ID/rules/lists/$LIST_ID/items" \
+ --header 'Content-Type: application/json' \
+ --header "Authorization: Bearer $CLOUDFLARE_LIST_TOKEN" \
+ --data "$PUT_JSON")
+
+checkCloudflareResult "$PUT_RESULT"
+OPERATION_ID=$(echo "$PUT_RESULT" | jq -r .result.operation_id)
+
+DONE=false
+
+# Poll for completition
+while [[ $DONE == false ]]; do
+ CHECK_RESULT=$(curl -s --request GET --url "https://api.cloudflare.com/client/v4/accounts/$ZONE_ID/rules/lists/bulk_operations/$OPERATION_ID" \
+ --header 'Content-Type: application/json' \
+ --header "Authorization: Bearer $CLOUDFLARE_LIST_TOKEN")
+ checkCloudflareResult "$CHECK_RESULT"
+
+ STATUS=$(echo "$CHECK_RESULT" | jq -r .result.status)
+
+ # Exit on completed or failed, other options are pending or running, in both cases
+ # we want to keep polling.
+ if [[ $STATUS == "completed" ]]; then
+ DONE=true
+ fi
+
+ if [[ $STATUS == "failed" ]]; then
+ ERROR_MESSAGE=$(echo "$CHECK_RESULT" | jq .result.error)
+ error "List update failed with error: $ERROR_MESSAGE"
+ exit 1
+ fi
+done
+
+success "Updated lists successfully"
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/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml
new file mode 100644
index 000000000000..531b8a3812fd
--- /dev/null
+++ b/.github/workflows/updateHelpDotRedirects.yml
@@ -0,0 +1,31 @@
+name: Update Redirects to ExpensifyHelp
+
+on:
+ # Run on any push to main that has changes to the redirects file
+ push:
+ branches:
+ - main
+ paths:
+ - 'docs/redirects.csv'
+
+ # Run on any manual trigger
+ workflow_dispatch:
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "redirects"
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8
+
+ - name: Create help dot redirect
+ env:
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ CLOUDFLARE_LIST_TOKEN: ${{ secrets.CLOUDFLARE_LIST_TOKEN }}
+ run: ./.github/scripts/createHelpRedirects.sh
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 8da6fb1a9b76..c6caab090428 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -95,8 +95,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001039400
- versionName "1.3.94-0"
+ versionCode 1001039606
+ versionName "1.3.96-6"
}
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/emojis/common.js b/assets/emojis/common.ts
similarity index 99%
rename from assets/emojis/common.js
rename to assets/emojis/common.ts
index b7593b6e2960..cbefb21cf2d6 100644
--- a/assets/emojis/common.js
+++ b/assets/emojis/common.ts
@@ -1,12 +1,13 @@
-import Smiley from '../images/emoji.svg';
-import Flags from '../images/emojiCategoryIcons/flag.svg';
-import FoodAndDrink from '../images/emojiCategoryIcons/hamburger.svg';
-import Objects from '../images/emojiCategoryIcons/light-bulb.svg';
-import Symbols from '../images/emojiCategoryIcons/peace-sign.svg';
-import TravelAndPlaces from '../images/emojiCategoryIcons/plane.svg';
-import AnimalsAndNature from '../images/emojiCategoryIcons/plant.svg';
-import Activities from '../images/emojiCategoryIcons/soccer-ball.svg';
-import FrequentlyUsed from '../images/history.svg';
+import Smiley from '@assets/images/emoji.svg';
+import Flags from '@assets/images/emojiCategoryIcons/flag.svg';
+import FoodAndDrink from '@assets/images/emojiCategoryIcons/hamburger.svg';
+import Objects from '@assets/images/emojiCategoryIcons/light-bulb.svg';
+import Symbols from '@assets/images/emojiCategoryIcons/peace-sign.svg';
+import TravelAndPlaces from '@assets/images/emojiCategoryIcons/plane.svg';
+import AnimalsAndNature from '@assets/images/emojiCategoryIcons/plant.svg';
+import Activities from '@assets/images/emojiCategoryIcons/soccer-ball.svg';
+import FrequentlyUsed from '@assets/images/history.svg';
+import {HeaderEmoji, PickerEmojis} from './types';
const skinTones = [
{
@@ -33,9 +34,9 @@ const skinTones = [
code: '🖐🏿',
skinTone: 0,
},
-];
+] as const;
-const emojis = [
+const emojis: PickerEmojis = [
{
header: true,
icon: Smiley,
@@ -7619,7 +7620,7 @@ const emojis = [
},
];
-const categoryFrequentlyUsed = {
+const categoryFrequentlyUsed: HeaderEmoji = {
header: true,
code: 'frequentlyUsed',
icon: FrequentlyUsed,
diff --git a/assets/emojis/en.js b/assets/emojis/en.ts
similarity index 99%
rename from assets/emojis/en.js
rename to assets/emojis/en.ts
index f32a91afe03c..0a1ca7611117 100644
--- a/assets/emojis/en.js
+++ b/assets/emojis/en.ts
@@ -1,4 +1,7 @@
-const enEmojis = {
+import {EmojisList} from './types';
+
+/* eslint-disable @typescript-eslint/naming-convention */
+const enEmojis: EmojisList = {
'😀': {
keywords: ['smile', 'happy', 'face', 'grin'],
},
diff --git a/assets/emojis/es.js b/assets/emojis/es.ts
similarity index 99%
rename from assets/emojis/es.js
rename to assets/emojis/es.ts
index fda12f5f127c..46f825643859 100644
--- a/assets/emojis/es.js
+++ b/assets/emojis/es.ts
@@ -1,4 +1,7 @@
-const esEmojis = {
+import {EmojisList} from './types';
+
+/* eslint-disable @typescript-eslint/naming-convention */
+const esEmojis: EmojisList = {
'😀': {
name: 'sonriendo',
keywords: ['cara', 'divertido', 'feliz', 'sonrisa', 'cara sonriendo'],
diff --git a/assets/emojis/index.js b/assets/emojis/index.js
deleted file mode 100644
index c8dab36f57d9..000000000000
--- a/assets/emojis/index.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import _ from 'underscore';
-import emojis from './common';
-import enEmojis from './en';
-import esEmojis from './es';
-
-const emojiNameTable = _.reduce(
- emojis,
- (prev, cur) => {
- const newValue = prev;
- if (!cur.header) {
- newValue[cur.name] = cur;
- }
- return newValue;
- },
- {},
-);
-
-const emojiCodeTableWithSkinTones = _.reduce(
- emojis,
- (prev, cur) => {
- const newValue = prev;
- if (!cur.header) {
- newValue[cur.code] = cur;
- }
- if (cur.types) {
- cur.types.forEach((type) => {
- newValue[type] = cur;
- });
- }
- return newValue;
- },
- {},
-);
-
-const localeEmojis = {
- en: enEmojis,
- es: esEmojis,
-};
-
-export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis};
-export {skinTones, categoryFrequentlyUsed, default} from './common';
diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts
new file mode 100644
index 000000000000..aade4e557a64
--- /dev/null
+++ b/assets/emojis/index.ts
@@ -0,0 +1,35 @@
+import emojis from './common';
+import enEmojis from './en';
+import esEmojis from './es';
+import {Emoji} from './types';
+
+type EmojiTable = Record;
+
+const emojiNameTable = emojis.reduce((prev, cur) => {
+ const newValue = prev;
+ if (!('header' in cur) && cur.name) {
+ newValue[cur.name] = cur;
+ }
+ return newValue;
+}, {});
+
+const emojiCodeTableWithSkinTones = emojis.reduce((prev, cur) => {
+ const newValue = prev;
+ if (!('header' in cur)) {
+ newValue[cur.code] = cur;
+ }
+ if ('types' in cur && cur.types) {
+ cur.types.forEach((type) => {
+ newValue[type] = cur;
+ });
+ }
+ return newValue;
+}, {});
+
+const localeEmojis = {
+ en: enEmojis,
+ es: esEmojis,
+} as const;
+
+export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis};
+export {skinTones, categoryFrequentlyUsed, default} from './common';
diff --git a/assets/emojis/types.ts b/assets/emojis/types.ts
new file mode 100644
index 000000000000..a42f44ed7fa7
--- /dev/null
+++ b/assets/emojis/types.ts
@@ -0,0 +1,19 @@
+import {SvgProps} from 'react-native-svg';
+
+type Emoji = {
+ code: string;
+ name: string;
+ types?: string[];
+};
+
+type HeaderEmoji = {
+ header: true;
+ icon: React.FC;
+ code: string;
+};
+
+type PickerEmojis = Array;
+
+type EmojisList = Record;
+
+export type {Emoji, HeaderEmoji, EmojisList, PickerEmojis};
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..d8ad66917b82 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 8d423dbc4213..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,9 +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',
- 'lottie-react-native': 'react-native-web-lottie',
+ '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 5c51f16ffc4d..6e02cae677bb 100644
--- a/contributingGuides/CONTRIBUTING.md
+++ b/contributingGuides/CONTRIBUTING.md
@@ -32,12 +32,9 @@ This project and everyone participating in it is governed by the Expensify [Code
At this time, we are not hiring contractors in Crimea, North Korea, Russia, Iran, Cuba, or Syria.
## Slack channels
-All contributors should be a member of **two** Slack channels:
+All contributors should be a member of a shared Slack channel called [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- this channel is used to ask **general questions**, facilitate **discussions**, and make **feature requests**.
-1. [#expensify-open-source](https://expensify.slack.com/archives/C01GTK53T8Q) -- used to ask **general questions**, facilitate **discussions**, and make **feature requests**.
-2. [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) -- used to discuss or report **bugs** specifically.
-
-Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to these two Slack channels, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite!
+Before requesting an invite to Slack please ensure your Upwork account is active, since we only pay via Upwork (see [below](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#payment-for-contributions)). To request an invite to Slack, email contributors@expensify.com with the subject `Slack Channel Invites`. We'll send you an invite!
Note: Do not send direct messages to the Expensify team in Slack or Expensify Chat, they will not be able to respond.
@@ -47,30 +44,21 @@ Note: if you are hired for an Upwork job and have any job-specific questions, pl
If you've found a vulnerability, please email security@expensify.com with the subject `Vulnerability Report` instead of creating an issue.
## Payment for Contributions
-We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing or reporting a bug, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted.
+We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. Please make sure your Upwork profile is **fully verified** before applying, otherwise you run the risk of not being paid. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted.
-Payment for your contributions and bug reports will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is).
+Payment for your contributions will be made no less than 7 days after the pull request is deployed to production to allow for [regression](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions) testing. If you have not received payment after 8 days of the PR being deployed to production, and there are no [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions), please add a comment to the issue mentioning the BugZero team member (Look for the melvin-bot "Triggered auto assignment to... (`Bug`)" to see who this is).
New contributors are limited to working on one job at a time, however experienced contributors may work on numerous jobs simultaneously.
Please be aware that compensation for any support in solving an issue is provided **entirely at Expensify’s discretion**. Personal time or resources applied towards investigating a proposal **will not guarantee compensation**. Compensation is only guaranteed to those who **[propose a solution and get hired for that job](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#propose-a-solution-for-the-job)**. We understand there may be cases where a selected proposal may take inspiration from a previous proposal. Unfortunately, it’s not possible for us to evaluate every individual case and we have no process that can efficiently do so. Issues with higher rewards come with higher risk factors so try to keep things civil and make the best proposal you can. Once again, **any information provided may not necessarily lead to you getting hired for that issue or compensated in any way.**
-**Important:** Payment amounts are variable, dependent on when your PR is merged and if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md). team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge.
-
-**Payment timelines** are based on the day and timestamp the contributor is assigned to the Github issue by an Expensify employee:
-- Merged PR within 3 business days (72 hours) - 50% **bonus**
-- Merged PR within 6 business days (144 hours) - 0% bonus
-- Merged PR within 9 business days (216 hours) - 50% **penalty**
-- No PR within 12 business days - **Contract terminated**
-
-We specify exact hours to make sure we can clearly decide what is eligible for the bonus given our team is global and contributors span across all the timezones.
+**Important:** Payment amounts are variable, dependent on if there are any [regressions](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#regressions). Your PR will be reviewed by a [Contributor+ (C+)](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) team member and an internal engineer. All tests must pass and all code must pass lint checks before a merge.
### Regressions
If a PR causes a regression at any point within the regression period (starting when the code is merged and ending 168 hours (that's 7 days) after being deployed to production):
- payments will be issued 7 days after all regressions are fixed (ie: deployed to production)
- a 50% penalty will be applied to the Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) for each regression on an issue
-- the assigned Contributor and [Contributor+](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BECOME_A_CONTRIBUTOR_PLUS.md) are not eligible for the 50% urgency bonus
The 168 hours (aka 7 days) will be measured by calculating the time between when the PR is merged, and when a bug is posted to the #expensify-bugs Slack channel.
@@ -80,25 +68,6 @@ A job could be fixing a bug or working on a new feature. There are two ways you
#### Finding a job that Expensify posted
This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted.
-#### Raising jobs and bugs
-It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to raise it and claim the bug bounty. If it's a valid bug that we choose to resolve by deploying it to production — either internally or via an external contributor — then we will compensate you $50 for identifying the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first.
-- Note: If you get assigned the job you proposed **and** you complete the job, this $50 for identifying the improvement is *in addition to* the reward you will be paid for completing the job.
-- Note about proposed bugs: Expensify has the right not to pay the $50 reward if the suggested bug has already been reported. Following, if more than one contributor proposes the same bug, the contributor who posted it first in the [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) Slack channel is the one who is eligible for the bonus.
-- Note: whilst you may optionally propose a solution for that job on Slack, solutions are ultimately reviewed in GitHub. The onus is on you to propose the solution on GitHub, and/or ensure the issue creator will include a link to your proposal.
-
-Please follow these steps to propose a job or raise a bug:
-
-1. Check to ensure a GH issue does not already exist for this job in the [New Expensify Issue list](https://github.com/Expensify/App/issues).
-2. Check to ensure the `Bug:` or `Feature Request:` was not already posted in Slack (specifically the #expensify-bugs or #expensify-open-source [Slack channels](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#slack-channels)). Use your best judgement by searching for similar titles, words and issue descriptions.
-3. If your bug or new feature matches with an existing issue, please comment on that Slack thread or GitHub issue with your findings if you think it will help solve the issue.
-4. If there is no existing GitHub issue or Upwork job, check if the issue is happening on prod (as opposed to only happening on dev)
-5. If the issue is just in dev then it means it's a new issue and has not been deployed to production. In this case, you should try to find the offending PR and comment in the issue tied to the PR and ask the assigned users to add the `DeployBlockerCash` label. If you can't find it, follow the reporting instructions in the next item, but note that the issue is a regression only found in dev and not in prod.
-6. If the issue happens in main, staging, or production then report the issue(s) in the #expensify-bugs Slack channel, using the report bug workflow. You can do this by clicking 'Workflow > report Bug', or typing `/Report bug`. View [this guide](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_CREATE_A_PLAN.md) for help creating a plan when proposing a feature request. Please verify the bug's presence on **every** platform mentioned in the bug report template, and confirm this with a screen recording..
- - **Important note/reminder**: never share any information pertaining to a customer of Expensify when describing the bug. This includes, and is not limited to, a customer's name, email, and contact information.
-7. The Applause team will review your job proposal in the appropriate slack channel. If you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:`
-8. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork to receive your payout. No additional work is required. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed.
-9. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed.
-
>**Note:** Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. Here's an example of a good problem/solution:
>
>**Problem:** The app start up time has regressed because we introduced "New Feature" in PR #12345 and is now 1042ms slower because `SomeComponent` is re-rendering 42 times.
@@ -179,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/Expensify-Card-revenue share for ExpensifyApproved!-partners.md b/docs/Expensify-Card-revenue share for ExpensifyApproved!-partners.md
deleted file mode 100644
index f9d18da76ef6..000000000000
--- a/docs/Expensify-Card-revenue share for ExpensifyApproved!-partners.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-title: Expensify Card revenue share for ExpensifyApproved! partners
-description: Earn money when your clients adopt the Expensify Card
----
-
-
-# About
-Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. We're offering 0.5% of the total Expensify Card spend of your clients in cashback returned to your firm. The more your clients spend, the more cashback your firm receives!
- This program is currently only available to US-based ExpensifyApproved! partner accountants.
-
-# How-to
-To benefit from this program, complete the following steps
-1. Ensure that you are listed as the Primary Contact under your client's domain in Expensify. If you're not currently the Primary Contact for your client, you or your client can follow the instructions outlined in [our help article](https://community.expensify.com/discussion/5749/how-to-add-and-remove-domain-admins#:~:text=Domain%20Admins%20have%20total%20control,a%20member%20of%20the%20domain.) to assign you this role.
-2. Add a Business Bank Account to the Primary Contact account. Follow the instructions in [our help article](https://community.expensify.com/discussion/4641/how-to-add-a-deposit-only-bank-account-both-personal-and-business) to get a Business Bank Account added.
-
-# FAQ
-- What if my firm is not permitted to accept revenue share from our clients?
- We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.
-How will I know which clients to pay back?
- Every month you will receive an automated message via new.expensify.com and email providing a breakdown of revenue shared generated per client.
-- What if my firm does not wish to participate in the program?
- Please reach out to your assigned partner manager at new.expensify.com to inform them you would not like to accept the revenue share nor do you want to pass the revenue share to your clients.
diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html
index 0183da296bff..d18ca2199e33 100644
--- a/docs/_includes/footer.html
+++ b/docs/_includes/footer.html
@@ -25,9 +25,6 @@ Features
Invoicing
-
- CPA Card
-
Payroll
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index f085379357c4..46434787d6df 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -35,31 +35,8 @@ html {
}
table {
- margin-bottom: 20px;
border-spacing: 0;
border-collapse: collapse;
- border-radius: 8px;
-
- // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders.
- // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use
- border-style: hidden;
- box-shadow: 0 0 0 1px $color-green-borders;
-}
-
-th:first-child {
- border-top-left-radius: 8px;
-}
-
-th:last-child {
- border-top-right-radius: 8px;
-}
-
-tr:last-child > td:first-child {
- border-bottom-left-radius: 8px;
-}
-
-tr:last-child > td:last-child {
- border-bottom-right-radius: 8px;
}
caption,
@@ -68,13 +45,6 @@ td {
text-align: left;
font-weight: 400;
vertical-align: middle;
- padding: 6px 13px;
- border: 1px solid $color-green-borders;
-}
-
-thead tr th {
- font-weight: bold;
- background-color: $color-green-highlightBG;
}
q,
@@ -395,6 +365,43 @@ button {
}
}
+ table {
+ margin-bottom: 20px;
+ border-radius: 8px;
+
+ // Box shadow is used here because border-radius and border-collapse don't work together. It leads to double borders.
+ // https://stackoverflow.com/questions/628301/the-border-radius-property-and-border-collapsecollapse-dont-mix-how-can-i-use
+ border-style: hidden;
+ box-shadow: 0 0 0 1px $color-green-borders;
+ }
+
+ th:first-child {
+ border-top-left-radius: 8px;
+ }
+
+ th:last-child {
+ border-top-right-radius: 8px;
+ }
+
+ tr:last-child > td:first-child {
+ border-bottom-left-radius: 8px;
+ }
+
+ tr:last-child > td:last-child {
+ border-bottom-right-radius: 8px;
+ }
+
+ th,
+ td {
+ padding: 6px 13px;
+ border: 1px solid $color-green-borders;
+ }
+
+ thead tr th {
+ font-weight: bold;
+ background-color: $color-green-highlightBG;
+ }
+
.img-wrap {
display: flex;
justify-content: space-around;
diff --git a/docs/articles/expensify-classic/account-settings/Copilot.md b/docs/articles/expensify-classic/account-settings/Copilot.md
new file mode 100644
index 000000000000..4fac402b7ced
--- /dev/null
+++ b/docs/articles/expensify-classic/account-settings/Copilot.md
@@ -0,0 +1,69 @@
+---
+title: Copilot
+description: Safely delegate tasks without sharing login information.
+---
+
+# About
+The Copilot feature allows you to safely delegate tasks without sharing login information. Your chosen user can access your account through their own Expensify account, with customizable permissions to manage expenses, create reports, and more. This can even be extended to users outside your policy or domain.
+
+# How-to
+# How to add a Copilot
+1. Log into the Expensify desktop website.
+2. Navigate to *Settings > Account > Account Details > _Copilot: Delegated Access_*.
+3. Enter the email address or phone number of your Copilot and select whether you want to give them Full Access or the ability to Submit Only.
+ - *Full Access Copilot*: Your Copilot will have full access to your account. Nearly every action you can do and everything you can see in your account will also be available to your Copilot. They *will not* have the ability to add or remove other Copilots from your account.
+ - *Submit Only Copilot*: Your Copilot will have the same limitations as a Full Access Copilot, with the added restriction of not being able to approve reports on your behalf.
+4. Click Invite Copilot.
+
+If your Copilot already has an Expensify account, they will get an email notifying them that they can now access your account from within their account as well.
+If they do not already have an Expensify account, they will be provided with a link to create one. Once they have created their Expensify account, they will be able to access your account from within their own account.
+
+# How to use Copilot
+A designated copilot can access another account via the Expensify website or the mobile app.
+
+## How to switch to Copilot mode (on the Expensify website):
+1. Click your profile icon in the upper left side of the page.
+2. In the “Copilot Access” section of the dropdown, choose the account you wish to access.
+3. When you Copilot into someone else’s account, the Expensify header will change color and an airplane icon will appear.
+4. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”.
+
+## How to switch to Copilot Mode (on the mobile app):
+1. Tap on the menu icon on the top left-hand side of the screen, then tap your profile icon.
+2. Tap “Switch to Copilot Mode”, then choose the account you wish to access.
+3. You can return to your own account at any time by accessing the user menu and choosing “Return to your account”.
+
+# How to remove a Copilot
+If you ever need to remove a Copilot, you can do so by following the below steps:
+1. Log into the Expensify desktop website
+2. Navigate to *Settings > Your Account > Account Details > _Copilot: Delegated Access_*
+3. Click the red X next to the Copilot you'd like to remove
+
+
+# Deep Dive
+## Copilot Permissions
+A Copilot can do the following actions in your account:
+- Prepare expenses on your behalf
+- Approve and reimburse others' expenses on your behalf (Note: this applies only to **Full Access** Copilots)
+- View and make changes to your account/domain/policy settings
+- View all expenses you can see within your own account
+
+## Copilot restrictions
+A Copilot cannot do the following actions in your account:
+- Change or reset your password
+- Add/remove other Copilots
+
+## Forwarding receipts to receipts@expensify.com as a Copilot
+To ensure a receipt is routed to the Expensify account in which you are a copilot rather than your own you’ll need to do the following:
+1. Forward the email to receipts@expensify.com
+2. Put the email of the account in which you are a copilot in the subject line
+3. Send
+
+
+# FAQ
+## Can a Copilot's Secondary Login be used to forward receipts?
+Yes! A Copilot can use any of the email addresses tied to their account to forward receipts into the account of the person they're assisting.
+
+## I'm in Copilot mode for an account; Can I add another Copilot to that account on their behalf?
+No, only the original account holder can add another Copilot to the account.
+## Is there a restriction on the number of Copilots I can have or the number of users for whom I can act as a Copilot?
+There is no limit! You can have as many Copilots as you like, and you can be a Copilot for as many users as you need.
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/Brex.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md
deleted file mode 100644
index a060e37146a5..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Brex.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Brex
-description: Brex
----
-## 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/billing-and-subscriptions/Overview.md b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
similarity index 99%
rename from docs/articles/expensify-classic/billing-and-subscriptions/Overview.md
rename to docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
index b835db54cbf2..30a507a1f9df 100644
--- a/docs/articles/expensify-classic/billing-and-subscriptions/Overview.md
+++ b/docs/articles/expensify-classic/billing-and-subscriptions/Billing-Overview.md
@@ -1,5 +1,5 @@
---
-title: Billing in Expensify
+title: Billing Overview
description: An overview of how billing works in Expensify.
---
# Overview
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/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md
similarity index 98%
rename from docs/articles/expensify-classic/expensify-card/Card-Settings.md
rename to docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md
index a8d56f267757..3e2eb2deec46 100644
--- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md
+++ b/docs/articles/expensify-classic/expensify-card/Admin-Card-Settings-and-Features.md
@@ -1,6 +1,6 @@
---
-title: Expensify Card Settings
-description: Admin Card Settings and Features
+title: Admin Card Settings and Features
+description: An in-depth look into the Expensify Card program's admin controls and settings.
---
# Overview
diff --git a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md b/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md
deleted file mode 100644
index 9888edd139ac..000000000000
--- a/docs/articles/expensify-classic/expensify-card/Connect-To-Indirect-Integration.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Connect to Indirect Integration
-description: Connect to Indirect Integration
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/expensify-card/File-A-Dispute.md b/docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md
similarity index 100%
rename from docs/articles/expensify-classic/expensify-card/File-A-Dispute.md
rename to docs/articles/expensify-classic/expensify-card/Dispute-A-Transaction.md
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/get-paid-back/Third-Party-Payments.md b/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md
deleted file mode 100644
index a8cddcdfdd42..000000000000
--- a/docs/articles/expensify-classic/get-paid-back/Third-Party-Payments.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Third Party Payments
-description: Third Party Payments
----
-## Resource Coming Soon!
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/insights-and-custom-reporting/Fringe-Benefits.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md
new file mode 100644
index 000000000000..267c938a3edf
--- /dev/null
+++ b/docs/articles/expensify-classic/insights-and-custom-reporting/Fringe-Benefits.md
@@ -0,0 +1,43 @@
+---
+title: Fringe Benefits
+description: How to track your Fringe Benefits
+---
+# Overview
+If you’re looking to track and report expense data to calculate Fringe Benefits Tax (FBT), you can use Expensify’s special workflow that allows you to capture extra information and use a template to export to a spreadsheet.
+
+# How to set up Fringe Benefit Tax
+
+## Add Attendee Count Tags
+First, you’ll need to add these two tags to your Workspace:
+1) Number of Internal Attendees
+2) Number of External Attendees
+
+These tags must be named exactly as written above, ensuring there are no extra spaces at the beginning or at the end. You’ll need to set the tags to be numbers 00 - 10 or whatever number you wish to go up to (up to the maximum number of attendees you would expect at any one time), one tag per number i.e. “01”, “02”, “03” etc. These tags can be added in addition to those that are pulled in from your accounting solution. Follow these [instructions](https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/Tags#gsc.tab=0) to add tags.
+
+## Add Payroll Code
+Go to **Settings > Workspaces > Group > _Workspace Name_ > Categories** and within the categories you wish to track FBT against, select **Edit Category** and add the code “TAG”:
+
+## Enable Workflow
+Once you’ve added both tags (Internal Attendees and External Attendees) and added the payroll code “TAG” to FBT categories, you can send a request to Expensify at concierge@expensify.com to enable the FBT workflow. Please send the following request:
+>“Can you please add the custom workflow/DEW named FRINGE_BENEFIT_TAX to my company workspace named ?”
+Once the FBT workflow is enabled, it will require anything with the code “TAG” to include the two attendee count tags in order to be submitted.
+
+
+# For Users
+Once these steps are completed, users who create expenses coded with any category that has the payroll code “TAG” (e.g. Entertainment Expenses) but don’t add the internal and external attendee counts, will not be able to submit their expenses.
+# For Admins
+You are now able to create and run a report, which shows all expenses under these categories and also shows the number of internal and external attendees. Because we don’t presume to know all of the data points you wish to capture, you’ll need to create a Custom CSV export.
+Here are a couple of examples of Excel formulas to use to report on attendees:
+- `{expense:tag:ntag-1}` outputs the first tag the user chooses.
+- `{expense:tag:ntag-3}` outputs the third tag the user chooses.
+
+Your expenses may have multiple levels of coding, i.e.:
+- GL Code (Category)
+- Department (Tag 1)
+- Location (Tag 2)
+- Number of Internal Attendees (Tag 3)
+- Number of External Attendees (Tag 4)
+
+In the above case, you’ll want to use `{expense:tag:ntag-3}` and `{expense:tag:ntag-4}` as formulas to report on the number of internal and external attendees.
+
+Our article on [Custom Templates](https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates#gsc.tab=0) shows how to create a custom CSV.
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/integrations/travel-integrations/TripCatcher.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/integrations/travel-integrations/TripCatcher.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/integrations/travel-integrations/TripCatcher.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/manage-employees-and-report-approvals/Adding-Users.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Adding-Users.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md
index 1f69c1eee8f4..4c64ab1cefe4 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md
@@ -52,28 +52,28 @@ This document explains how to manage employee expense reports and approval workf
- *Final Approver (Finance/Accountant):* This is the person listed as the 'Approves to' in the Settings of the Second Approver.
- This is what this setup looks like in the Workspace Members table.
- Bryan submits his reports to Jim for 1st level approval.
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Screenshot showing the People section of the workspace]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png){:width="100%"}
- All of the reports Jim approves are submitted to Kevin. Kevin is the 'approves to' in Jim's Settings.
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Screenshot of Policy Member Editor]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png){:width="100%"}
- All of the reports Kevin approves are submitted to Lucy. Lucy is the 'approves to' in Kevin's Settings.
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png){:width="100%"}
- Lucy is the final approver, so she doesn't submit her reports to anyone for review.
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Screenshot of Policy Member Editor Final Approver]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png){:width="100%"}
- The final outcome: The member in the Submits To line is different than the person noted as the Approves To.
### Adding additional approver levels
- You can also set a specific approver for Reports Totals in Settings.
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Screenshot of Policy Member Editor Approves to]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png){:width="100%"}
- An example: The submitter's manager can approve any report up to a certain limit, let's say $500, and forward it to accounting. However, if a report is over that $500 limit, it has to be also approved by the department head before being forwarded to accounting.
- To configure, click on Edit Settings next to the approving manager's email address and set the "If Report Total is Over" and "Then Approves to" fields.
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
-![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Screenshot of Workspace Member Settings]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png){:width="100%"}
+![Screenshot of Policy Member Editor Configure]({{site.url}}/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png){:width="100%"}
### Setting category approvals
@@ -89,7 +89,7 @@ This document explains how to manage employee expense reports and approval workf
- To add a category approver in your Workspace:
- Navigate to *Settings > Policies > Group > [Workspace Name] > Categories*
- Click *"Edit Settings"* next to the category that requires the additional approver
- - Select an approver and click *“Save”*
+ - Select an approver and click *"Save"*
#### Tag approver
@@ -106,4 +106,4 @@ Category and Tag approvers are inserted at the beginning of the approval workflo
### Workflow enforcement
-- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement by following the steps below. As a Workspace Admin, you can choose to enforce your approval workflow by going.
\ No newline at end of file
+- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement. As a Workspace Admin, you can choose to enforce your approval workflow by going to Settings > Workspaces > Group > [Workspace Name] > People > Approval Mode. When enabled (which is the default setting for a new workspace), submitters and approvers must adhere to the set approval workflow (recommended). This setting does not apply to Workspace Admins, who are free to submit outside of this workflow
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md
index 76ebba9ef76b..33ffe7172603 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Removing-Members.md
@@ -11,6 +11,8 @@ Removing a member from a workspace disables their ability to use the workspace.
3. Select the member you'd like to remove and click the **Remove** button at the top of the Members table.
4. If this member was an approver, make sure that reports are not routing to them in the workflow.
+![image of members table in a workspace]({{site.url}}/assets/images/ExpensifyHelp_RemovingMembers.png){:width="100%"}
+
# FAQ
## Will reports from this member on this workspace still be available?
diff --git a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md b/docs/articles/expensify-classic/send-payments/Pay-Invoices.md
deleted file mode 100644
index e5e6799c268c..000000000000
--- a/docs/articles/expensify-classic/send-payments/Pay-Invoices.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Pay Invoices
-description: Pay Invoices
----
-## 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/docs/redirects.csv b/docs/redirects.csv
new file mode 100644
index 000000000000..d4fb7723bd0f
--- /dev/null
+++ b/docs/redirects.csv
@@ -0,0 +1,2 @@
+sourceURL,targetURL
+https://community.expensify.com/discussion/5634/deep-dive-how-long-will-it-take-for-me-to-receive-my-reimbursement,https://help.expensify.com/articles/expensify-classic/get-paid-back/reports/Reimbursements
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index cdd6e463d12b..dc1fdd58b542 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.94
+ 1.3.96
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.94.0
+ 1.3.96.6
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 34d617586b7b..910ad06f7517 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.3.94
+ 1.3.96
CFBundleSignature
????
CFBundleVersion
- 1.3.94.0
+ 1.3.96.6
diff --git a/metro.config.js b/metro.config.js
index aaeea8e015a9..a620d3adc926 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: defaultAssetExts,
- sourceExts: [...defaultSourceExts, 'jsx'],
+ // 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'],
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 a93b482bc004..ec183c9b946c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,16 @@
{
"name": "new.expensify",
- "version": "1.3.94-0",
+ "version": "1.3.96-6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.94-0",
+ "version": "1.3.96-6",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@expensify/react-native-web": "0.18.15",
+ "@dotlottie/react-player": "^1.6.3",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -21,6 +21,7 @@
"@invertase/react-native-apple-authentication": "^2.2.2",
"@kie/act-js": "^2.0.1",
"@kie/mock-github": "^1.0.0",
+ "@lottiefiles/react-lottie-player": "^3.5.3",
"@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52",
"@onfido/react-native-sdk": "8.3.0",
"@react-native-async-storage/async-storage": "^1.17.10",
@@ -61,7 +62,7 @@
"jest-expo": "^49.0.0",
"jest-when": "^3.5.2",
"lodash": "4.17.21",
- "lottie-react-native": "^6.3.1",
+ "lottie-react-native": "^6.4.0",
"mapbox-gl": "^2.15.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.31",
@@ -97,7 +98,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",
@@ -115,8 +116,8 @@
"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-web-lottie": "^1.4.4",
"react-native-webview": "^11.17.2",
"react-pdf": "^6.2.2",
"react-plaid-link": "3.3.2",
@@ -2940,6 +2941,14 @@
"node": ">=10.0.0"
}
},
+ "node_modules/@dotlottie/react-player": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz",
+ "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/@dword-design/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz",
@@ -3673,25 +3682,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/bunyan": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz",
@@ -7480,6 +7470,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@lottiefiles/react-lottie-player": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz",
+ "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==",
+ "dependencies": {
+ "lottie-web": "^5.10.2"
+ },
+ "peerDependencies": {
+ "react": "16 - 18"
+ }
+ },
"node_modules/@lwc/eslint-plugin-lwc": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz",
@@ -28080,16 +28081,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",
@@ -41050,15 +41041,23 @@
}
},
"node_modules/lottie-react-native": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz",
- "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz",
+ "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==",
"peerDependencies": {
+ "@dotlottie/react-player": "^1.6.1",
+ "@lottiefiles/react-lottie-player": "^3.5.3",
"react": "*",
"react-native": ">=0.46",
"react-native-windows": ">=0.63.x"
},
"peerDependenciesMeta": {
+ "@dotlottie/react-player": {
+ "optional": true
+ },
+ "@lottiefiles/react-lottie-player": {
+ "optional": true
+ },
"react-native-windows": {
"optional": true
}
@@ -44728,12 +44727,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",
@@ -47853,17 +47846,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",
@@ -48143,7 +48136,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",
@@ -48167,23 +48159,10 @@
"react-native-web": "*"
}
},
- "node_modules/react-native-web-lottie": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz",
- "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==",
- "license": "MIT",
- "dependencies": {
- "lottie-web": "^5.7.1"
- },
- "peerDependencies": {
- "react-native-web": "*"
- }
- },
"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",
@@ -58542,6 +58521,12 @@
"integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
"dev": true
},
+ "@dotlottie/react-player": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/@dotlottie/react-player/-/react-player-1.6.3.tgz",
+ "integrity": "sha512-wktLksV1LzV2qAAMocdBxn2e0J7XUraztLH2DnrlBYUgdy5Cz4FyV8+BPLftcyVD7r/4+0X448hEvK7tFQiLng==",
+ "requires": {}
+ },
"@dword-design/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@dword-design/dedent/-/dedent-0.7.0.tgz",
@@ -58976,20 +58961,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/bunyan": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@expo/bunyan/-/bunyan-4.0.0.tgz",
@@ -61908,6 +61879,14 @@
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
"dev": true
},
+ "@lottiefiles/react-lottie-player": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/@lottiefiles/react-lottie-player/-/react-lottie-player-3.5.3.tgz",
+ "integrity": "sha512-6pGbiTMjGnPddR1ur8M/TIDCiogZMc1aKIUbMEKXKAuNeYwZ2hvqwBJ+w5KRm88ccdcU88C2cGyLVsboFlSdVQ==",
+ "requires": {
+ "lottie-web": "^5.10.2"
+ }
+ },
"@lwc/eslint-plugin-lwc": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@lwc/eslint-plugin-lwc/-/eslint-plugin-lwc-0.11.0.tgz",
@@ -76886,15 +76865,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": {
@@ -86068,9 +86038,9 @@
}
},
"lottie-react-native": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.3.1.tgz",
- "integrity": "sha512-M18nAVYeGMF//bhL27D2zuMcrFPH0jbD/deBvcWi0CCcfZf6LQfx45xt+cuDqwr5nh6dMm+ta8KfZJmkbNhtlg==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.4.0.tgz",
+ "integrity": "sha512-wFO/gLPN1KliyznBa8OtYWkc9Vn9OEmIg1/b1536KANFtGaFAeoAGhijVxYKF3UPKJgjJYFmqg0W//FVrSXj+g==",
"requires": {}
},
"lottie-web": {
@@ -88768,11 +88738,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",
@@ -91053,9 +91018,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",
@@ -91231,7 +91196,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",
@@ -91246,8 +91210,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=="
}
}
},
@@ -91257,14 +91220,6 @@
"integrity": "sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==",
"requires": {}
},
- "react-native-web-lottie": {
- "version": "1.4.4",
- "resolved": "https://registry.npmjs.org/react-native-web-lottie/-/react-native-web-lottie-1.4.4.tgz",
- "integrity": "sha512-W0jZiOf2u3Us6yASdpgAuL1+3Gw1EU/Wi5QAf6brzhXmJnq6/FMGCTf5zvSaX0yIurr9qcYB40DwAb4HwA6frg==",
- "requires": {
- "lottie-web": "^5.7.1"
- }
- },
"react-native-webview": {
"version": "11.23.0",
"requires": {
diff --git a/package.json b/package.json
index d2b851900222..3576d4a8bd72 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.94-0",
+ "version": "1.3.96-6",
"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",
@@ -58,7 +59,7 @@
"setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1"
},
"dependencies": {
- "@expensify/react-native-web": "0.18.15",
+ "@dotlottie/react-player": "^1.6.3",
"@formatjs/intl-datetimeformat": "^6.10.0",
"@formatjs/intl-getcanonicallocales": "^2.2.0",
"@formatjs/intl-listformat": "^7.2.2",
@@ -69,6 +70,7 @@
"@invertase/react-native-apple-authentication": "^2.2.2",
"@kie/act-js": "^2.0.1",
"@kie/mock-github": "^1.0.0",
+ "@lottiefiles/react-lottie-player": "^3.5.3",
"@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52",
"@onfido/react-native-sdk": "8.3.0",
"@react-native-async-storage/async-storage": "^1.17.10",
@@ -109,7 +111,7 @@
"jest-expo": "^49.0.0",
"jest-when": "^3.5.2",
"lodash": "4.17.21",
- "lottie-react-native": "^6.3.1",
+ "lottie-react-native": "^6.4.0",
"mapbox-gl": "^2.15.0",
"moment": "^2.29.4",
"moment-timezone": "^0.5.31",
@@ -145,7 +147,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",
@@ -163,8 +165,8 @@
"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-web-lottie": "^1.4.4",
"react-native-webview": "^11.17.2",
"react-pdf": "^6.2.2",
"react-plaid-link": "3.3.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/patches/react-native-web-lottie+1.4.4.patch b/patches/react-native-web-lottie+1.4.4.patch
deleted file mode 100644
index c82c33b5a7fe..000000000000
--- a/patches/react-native-web-lottie+1.4.4.patch
+++ /dev/null
@@ -1,9 +0,0 @@
-diff --git a/node_modules/react-native-web-lottie/dist/index.js b/node_modules/react-native-web-lottie/dist/index.js
-index 7cd6b42..9c2b356 100644
---- a/node_modules/react-native-web-lottie/dist/index.js
-+++ b/node_modules/react-native-web-lottie/dist/index.js
-@@ -1 +1 @@
--var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _extends2=_interopRequireDefault(require("@babel/runtime/helpers/extends"));var _classCallCheck2=_interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));var _createClass2=_interopRequireDefault(require("@babel/runtime/helpers/createClass"));var _possibleConstructorReturn2=_interopRequireDefault(require("@babel/runtime/helpers/possibleConstructorReturn"));var _getPrototypeOf3=_interopRequireDefault(require("@babel/runtime/helpers/getPrototypeOf"));var _inherits2=_interopRequireDefault(require("@babel/runtime/helpers/inherits"));var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/louislagrange/Documents/Projets/react-native-web-community/react-native-web-lottie/src/index.js";var Animation=function(_PureComponent){(0,_inherits2.default)(Animation,_PureComponent);function Animation(){var _getPrototypeOf2;var _this;(0,_classCallCheck2.default)(this,Animation);for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++){args[_key]=arguments[_key];}_this=(0,_possibleConstructorReturn2.default)(this,(_getPrototypeOf2=(0,_getPrototypeOf3.default)(Animation)).call.apply(_getPrototypeOf2,[this].concat(args)));_this.animationDOMNode=null;_this.loadAnimation=function(props){if(_this.anim){_this.anim.destroy();}_this.anim=_lottieWeb.default.loadAnimation({container:_this.animationDOMNode,animationData:props.source,renderer:'svg',loop:props.loop||false,autoplay:props.autoPlay,rendererSettings:props.rendererSettings||{}});if(props.onAnimationFinish){_this.anim.addEventListener('complete',props.onAnimationFinish);}};_this.setAnimationDOMNode=function(ref){return _this.animationDOMNode=_reactDom.default.findDOMNode(ref);};_this.play=function(){if(!_this.anim){return;}for(var _len2=arguments.length,frames=new Array(_len2),_key2=0;_key2<_len2;_key2++){frames[_key2]=arguments[_key2];}_this.anim.playSegments(frames,true);};_this.reset=function(){if(!_this.anim){return;}_this.anim.stop();};return _this;}(0,_createClass2.default)(Animation,[{key:"componentDidMount",value:function componentDidMount(){var _this2=this;this.loadAnimation(this.props);if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.addListener(function(progress){var value=progress.value;var frame=value/(1/_this2.anim.getDuration(true));_this2.anim.goToAndStop(frame,true);});}}},{key:"componentWillUnmount",value:function componentWillUnmount(){if(typeof this.props.progress==='object'&&this.props.progress._listeners){this.props.progress.removeAllListeners();}}},{key:"UNSAFE_componentWillReceiveProps",value:function UNSAFE_componentWillReceiveProps(nextProps){if(this.props.source&&nextProps.source&&this.props.source.nm!==nextProps.source.nm){this.loadAnimation(nextProps);}}},{key:"render",value:function render(){return _react.default.createElement(_View.default,{style:this.props.style,ref:this.setAnimationDOMNode,__source:{fileName:_jsxFileName,lineNumber:71}});}}]);return Animation;}(_react.PureComponent);var _default=_react.default.forwardRef(function(props,ref){return _react.default.createElement(Animation,(0,_extends2.default)({},props,{ref:typeof ref=='function'?function(c){return ref(c&&c.anim);}:ref,__source:{fileName:_jsxFileName,lineNumber:76}}));});exports.default=_default;
-\ No newline at end of file
-+var _interopRequireWildcard=require("@babel/runtime/helpers/interopRequireWildcard");var _interopRequireDefault=require("@babel/runtime/helpers/interopRequireDefault");Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _react=_interopRequireWildcard(require("react"));var _reactDom=_interopRequireDefault(require("react-dom"));var _View=_interopRequireDefault(require("react-native-web/dist/exports/View"));var _lottieWeb=_interopRequireDefault(require("lottie-web"));var _jsxFileName="/Users/roryabraham/react-native-web-lottie/src/index.js";function Animation(_ref){var source=_ref.source,_ref$renderer=_ref.renderer,renderer=_ref$renderer===void 0?'svg':_ref$renderer,_ref$loop=_ref.loop,loop=_ref$loop===void 0?false:_ref$loop,_ref$autoPlay=_ref.autoPlay,autoPlay=_ref$autoPlay===void 0?false:_ref$autoPlay,_ref$rendererSettings=_ref.rendererSettings,rendererSettings=_ref$rendererSettings===void 0?{}:_ref$rendererSettings,_ref$style=_ref.style,style=_ref$style===void 0?{}:_ref$style;var nm=source.nm;var anim=(0,_react.useRef)(null);var animationDOMNode=(0,_react.useRef)(null);(0,_react.useEffect)(function(){var _anim$current;(_anim$current=anim.current)==null?void 0:_anim$current.destroy();anim.current=_lottieWeb.default.loadAnimation({container:animationDOMNode.current,animationData:source,renderer:renderer,loop:loop,autoPlay:autoPlay,rendererSettings:rendererSettings});return function(){var _anim$current2;(_anim$current2=anim.current)==null?void 0:_anim$current2.destroy();};},[nm]);return _react.default.createElement(_View.default,{style:style,ref:function ref(r){return animationDOMNode.current=_reactDom.default.findDOMNode(r);},__source:{fileName:_jsxFileName,lineNumber:36}});}var _default=_react.default.memo(Animation);exports.default=_default;
-\ No newline at end of file
diff --git a/src/CONST.ts b/src/CONST.ts
index 155e50a35cde..8cb2bb6c8893 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: {
@@ -144,7 +147,6 @@ const CONST = {
DESKTOP: `${ACTIVE_EXPENSIFY_URL}NewExpensify.dmg`,
},
DATE: {
- MOMENT_FORMAT_STRING: 'YYYY-MM-DD',
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
LOCAL_TIME_FORMAT: 'h:mm a',
@@ -260,6 +262,7 @@ const CONST = {
CUSTOM_STATUS: 'customStatus',
NEW_DOT_TAGS: 'newDotTags',
NEW_DOT_SAML: 'newDotSAML',
+ VIOLATIONS: 'violations',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -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,
@@ -1327,6 +1336,8 @@ const CONST = {
SPECIAL_CHAR: /[,/?"{}[\]()&^%;`$=#<>!*]/g,
+ FIRST_SPACE: /.+?(?=\s)/,
+
get SPECIAL_CHAR_OR_EMOJI() {
return new RegExp(`[~\\n\\s]|(_\\b(?!$))|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
},
@@ -2694,13 +2705,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..fa72a99b5fa2 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -275,42 +275,72 @@ const ONYXKEYS = {
/** List of Form ids */
FORMS: {
ADD_DEBIT_CARD_FORM: 'addDebitCardForm',
+ ADD_DEBIT_CARD_FORM_DRAFT: 'addDebitCardFormDraft',
WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm',
+ WORKSPACE_SETTINGS_FORM_DRAFT: 'workspaceSettingsFormDraft',
WORKSPACE_RATE_AND_UNIT_FORM: 'workspaceRateAndUnitForm',
+ WORKSPACE_RATE_AND_UNIT_FORM_DRAFT: 'workspaceRateAndUnitFormDraft',
CLOSE_ACCOUNT_FORM: 'closeAccount',
+ CLOSE_ACCOUNT_FORM_DRAFT: 'closeAccountDraft',
PROFILE_SETTINGS_FORM: 'profileSettingsForm',
+ PROFILE_SETTINGS_FORM_DRAFT: 'profileSettingsFormDraft',
DISPLAY_NAME_FORM: 'displayNameForm',
+ DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft',
ROOM_NAME_FORM: 'roomNameForm',
+ ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft',
WELCOME_MESSAGE_FORM: 'welcomeMessageForm',
+ WELCOME_MESSAGE_FORM_DRAFT: 'welcomeMessageFormDraft',
LEGAL_NAME_FORM: 'legalNameForm',
+ LEGAL_NAME_FORM_DRAFT: 'legalNameFormDraft',
WORKSPACE_INVITE_MESSAGE_FORM: 'workspaceInviteMessageForm',
+ WORKSPACE_INVITE_MESSAGE_FORM_DRAFT: 'workspaceInviteMessageFormDraft',
DATE_OF_BIRTH_FORM: 'dateOfBirthForm',
+ DATE_OF_BIRTH_FORM_DRAFT: 'dateOfBirthFormDraft',
HOME_ADDRESS_FORM: 'homeAddressForm',
+ HOME_ADDRESS_FORM_DRAFT: 'homeAddressFormDraft',
NEW_ROOM_FORM: 'newRoomForm',
+ NEW_ROOM_FORM_DRAFT: 'newRoomFormDraft',
ROOM_SETTINGS_FORM: 'roomSettingsForm',
+ ROOM_SETTINGS_FORM_DRAFT: 'roomSettingsFormDraft',
NEW_TASK_FORM: 'newTaskForm',
+ NEW_TASK_FORM_DRAFT: 'newTaskFormDraft',
EDIT_TASK_FORM: 'editTaskForm',
+ EDIT_TASK_FORM_DRAFT: 'editTaskFormDraft',
MONEY_REQUEST_DESCRIPTION_FORM: 'moneyRequestDescriptionForm',
+ MONEY_REQUEST_DESCRIPTION_FORM_DRAFT: 'moneyRequestDescriptionFormDraft',
MONEY_REQUEST_MERCHANT_FORM: 'moneyRequestMerchantForm',
+ MONEY_REQUEST_MERCHANT_FORM_DRAFT: 'moneyRequestMerchantFormDraft',
MONEY_REQUEST_AMOUNT_FORM: 'moneyRequestAmountForm',
+ MONEY_REQUEST_AMOUNT_FORM_DRAFT: 'moneyRequestAmountFormDraft',
MONEY_REQUEST_DATE_FORM: 'moneyRequestCreatedForm',
+ MONEY_REQUEST_DATE_FORM_DRAFT: 'moneyRequestCreatedFormDraft',
NEW_CONTACT_METHOD_FORM: 'newContactMethodForm',
+ NEW_CONTACT_METHOD_FORM_DRAFT: 'newContactMethodFormDraft',
WAYPOINT_FORM: 'waypointForm',
WAYPOINT_FORM_DRAFT: 'waypointFormDraft',
SETTINGS_STATUS_SET_FORM: 'settingsStatusSetForm',
+ SETTINGS_STATUS_SET_FORM_DRAFT: 'settingsStatusSetFormDraft',
SETTINGS_STATUS_CLEAR_AFTER_FORM: 'settingsStatusClearAfterForm',
+ SETTINGS_STATUS_CLEAR_AFTER_FORM_DRAFT: 'settingsStatusClearAfterFormDraft',
SETTINGS_STATUS_SET_CLEAR_AFTER_FORM: 'settingsStatusSetClearAfterForm',
+ SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT: 'settingsStatusSetClearAfterFormDraft',
PRIVATE_NOTES_FORM: 'privateNotesForm',
+ PRIVATE_NOTES_FORM_DRAFT: 'privateNotesFormDraft',
I_KNOW_A_TEACHER_FORM: 'iKnowTeacherForm',
+ I_KNOW_A_TEACHER_FORM_DRAFT: 'iKnowTeacherFormDraft',
INTRO_SCHOOL_PRINCIPAL_FORM: 'introSchoolPrincipalForm',
+ INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT: 'introSchoolPrincipalFormDraft',
REPORT_PHYSICAL_CARD_FORM: 'requestPhysicalCardForm',
+ REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft',
REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm',
+ REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft',
},
} as const;
type OnyxKeysMap = typeof ONYXKEYS;
type OnyxCollectionKey = ValueOf;
type OnyxKey = DeepValueOf>;
+type OnyxFormKey = ValueOf | OnyxKeysMap['REIMBURSEMENT_ACCOUNT'] | OnyxKeysMap['REIMBURSEMENT_ACCOUNT_DRAFT'];
type OnyxValues = {
[ONYXKEYS.ACCOUNT]: OnyxTypes.Account;
@@ -411,36 +441,68 @@ type OnyxValues = {
// Forms
[ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: OnyxTypes.AddDebitCardForm;
+ [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM_DRAFT]: OnyxTypes.AddDebitCardForm;
[ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.DISPLAY_NAME_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.ROOM_NAME_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.ROOM_NAME_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.LEGAL_NAME_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.LEGAL_NAME_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.DateOfBirthForm;
+ [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM_DRAFT]: OnyxTypes.DateOfBirthForm;
[ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.NEW_ROOM_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.NEW_TASK_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.NEW_TASK_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.EDIT_TASK_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.EDIT_TASK_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WAYPOINT_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.WAYPOINT_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form;
};
type OnyxKeyValue = OnyxEntry;
export default ONYXKEYS;
-export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue};
+export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue, OnyxFormKey, OnyxKeysMap};
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/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js
index 7010ab514617..566b6c709423 100644
--- a/src/components/AddPlaidBankAccount.js
+++ b/src/components/AddPlaidBankAccount.js
@@ -240,7 +240,7 @@ function AddPlaidBankAccount({
/>
{bankName}
-
+
({
- language: preferredLocale,
- types: resultTypes,
- components: isLimitedToUSA ? 'country:us' : undefined,
+ language: props.preferredLocale,
+ types: props.resultTypes,
+ components: props.isLimitedToUSA ? 'country:us' : undefined,
}),
- [preferredLocale, resultTypes, isLimitedToUSA],
+ [props.preferredLocale, props.resultTypes, props.isLimitedToUSA],
);
- const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
+ const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
const saveLocationDetails = (autocompleteData, details) => {
const addressComponents = details.address_components;
@@ -188,7 +169,7 @@ function AddressSearch({
// to this component which don't match the usual properties coming from auto-complete. In that case, only a limited
// amount of data massaging needs to happen for what the parent expects to get from this function.
if (_.size(details)) {
- onPress({
+ props.onPress({
address: lodashGet(details, 'description'),
lat: lodashGet(details, 'geometry.location.lat', 0),
lng: lodashGet(details, 'geometry.location.lng', 0),
@@ -275,7 +256,7 @@ function AddressSearch({
// Not all pages define the Address Line 2 field, so in that case we append any additional address details
// (e.g. Apt #) to Address Line 1
- if (subpremise && typeof renamedInputKeys.street2 === 'undefined') {
+ if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') {
values.street += `, ${subpremise}`;
}
@@ -284,19 +265,19 @@ function AddressSearch({
values.country = country;
}
- if (inputID) {
- _.each(values, (inputValue, key) => {
- const inputKey = lodashGet(renamedInputKeys, key, key);
+ if (props.inputID) {
+ _.each(values, (value, key) => {
+ const inputKey = lodashGet(props.renamedInputKeys, key, key);
if (!inputKey) {
return;
}
- onInputChange(inputValue, inputKey);
+ props.onInputChange(value, inputKey);
});
} else {
- onInputChange(values);
+ props.onInputChange(values);
}
- onPress(values);
+ props.onPress(values);
};
/** Gets the user's current location and registers success/error callbacks */
@@ -326,7 +307,7 @@ function AddressSearch({
lng: successData.coords.longitude,
address: CONST.YOUR_LOCATION_TEXT,
};
- onPress(location);
+ props.onPress(location);
},
(errorData) => {
if (!shouldTriggerGeolocationCallbacks.current) {
@@ -344,16 +325,16 @@ function AddressSearch({
};
const renderHeaderComponent = () =>
- predefinedPlaces.length > 0 && (
+ props.predefinedPlaces.length > 0 && (
<>
{/* This will show current location button in list if there are some recent destinations */}
{shouldShowCurrentLocationButton && (
)}
- {!value && {translate('common.recentDestinations')} }
+ {!props.value && {props.translate('common.recentDestinations')} }
>
);
@@ -365,26 +346,6 @@ function AddressSearch({
};
}, []);
- const listEmptyComponent = useCallback(
- () =>
- network.isOffline || !isTyping ? null : (
- {translate('common.noResultsFound')}
- ),
- [isTyping, translate, network.isOffline],
- );
-
- const listLoader = useCallback(
- () => (
-
-
-
- ),
- [],
- );
-
return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
@@ -411,10 +372,20 @@ function AddressSearch({
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
- predefinedPlaces={predefinedPlaces}
- listEmptyComponent={listEmptyComponent}
- listLoaderComponent={listLoader}
- renderHeaderComponent={renderHeaderComponent}
+ predefinedPlaces={props.predefinedPlaces}
+ listEmptyComponent={
+ props.network.isOffline || !isTyping ? null : (
+ {props.translate('common.noResultsFound')}
+ )
+ }
+ listLoaderComponent={
+
+
+
+ }
renderRow={(data) => {
const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text;
const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text;
@@ -425,6 +396,7 @@ function AddressSearch({
);
}}
+ renderHeaderComponent={renderHeaderComponent}
onPress={(data, details) => {
saveLocationDetails(data, details);
setIsTyping(false);
@@ -439,31 +411,34 @@ function AddressSearch({
query={query}
requestUrl={{
useOnPlatform: 'all',
- url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
+ url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
}}
textInputProps={{
InputComp: TextInput,
ref: (node) => {
- if (!innerRef) {
+ if (!props.innerRef) {
return;
}
- if (_.isFunction(innerRef)) {
- innerRef(node);
+ if (_.isFunction(props.innerRef)) {
+ props.innerRef(node);
return;
}
// eslint-disable-next-line no-param-reassign
- innerRef.current = node;
+ props.innerRef.current = node;
},
- label,
- containerStyles,
- errorText,
- hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint,
- value,
- defaultValue,
- inputID,
- shouldSaveDraft,
+ label: props.label,
+ containerStyles: props.containerStyles,
+ errorText: props.errorText,
+ hint:
+ displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping)
+ ? undefined
+ : props.hint,
+ value: props.value,
+ defaultValue: props.defaultValue,
+ inputID: props.inputID,
+ shouldSaveDraft: props.shouldSaveDraft,
onFocus: () => {
setIsFocused(true);
},
@@ -473,24 +448,24 @@ function AddressSearch({
setIsFocused(false);
setIsTyping(false);
}
- onBlur();
+ props.onBlur();
},
autoComplete: 'off',
onInputChange: (text) => {
setSearchValue(text);
setIsTyping(true);
- if (inputID) {
- onInputChange(text);
+ if (props.inputID) {
+ props.onInputChange(text);
} else {
- onInputChange({street: text});
+ props.onInputChange({street: text});
}
// If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
- if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) {
+ if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) {
setDisplayListViewBorder(false);
}
},
- maxLength: maxInputLength,
+ maxLength: props.maxInputLength,
spellCheck: false,
}}
styles={{
@@ -511,18 +486,17 @@ function AddressSearch({
}}
inbetweenCompo={
// We want to show the current location button even if there are no recent destinations
- predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
+ props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
) : (
<>>
)
}
- placeholder=""
/>
setLocationErrorCode(null)}
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/AttachmentModal.js b/src/components/AttachmentModal.js
index 96d22df5f07b..c7d286a8b539 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -444,16 +444,21 @@ function AttachmentModal(props) {
) : (
Boolean(sourceForAttachmentView) &&
shouldLoadAttachment && (
-
+ // We need the following View component on android native
+ // So that the event will propagate properly and
+ // the Password protected preview will be shown for pdf attachement we are about to send.
+
+
+
)
)}
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 2477a084b63b..d96a54b7a6b5 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -170,6 +170,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/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js
similarity index 78%
rename from src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
rename to src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js
index 9ab0b45f8c8f..40887ddee697 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/BaseAttachmentViewPdf.js
@@ -3,7 +3,18 @@ import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCa
import PDFView from '@components/PDFView';
import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes';
-function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) {
+function BaseAttachmentViewPdf({
+ file,
+ encryptedSourceUrl,
+ isFocused,
+ isUsedInCarousel,
+ onPress,
+ onScaleChanged: onScaleChangedProp,
+ onToggleKeyboard,
+ onLoadComplete,
+ errorLabelStyles,
+ style,
+}) {
const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
useEffect(() => {
@@ -16,7 +27,7 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse
const onScaleChanged = useCallback(
(scale) => {
- onScaleChangedProp();
+ onScaleChangedProp(scale);
// When a pdf is shown in a carousel, we want to disable the pager scroll when the pdf is zoomed in
if (isUsedInCarousel) {
@@ -49,7 +60,8 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse
);
}
-AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
-AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
+BaseAttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
+BaseAttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
+BaseAttachmentViewPdf.displayName = 'BaseAttachmentViewPdf';
-export default memo(AttachmentViewPdf);
+export default memo(BaseAttachmentViewPdf);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js
new file mode 100644
index 000000000000..46afd23daa4c
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js
@@ -0,0 +1,68 @@
+import React, {memo, useCallback, useContext} from 'react';
+import {StyleSheet, View} from 'react-native';
+import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import Animated, {useSharedValue} from 'react-native-reanimated';
+import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext';
+import styles from '@styles/styles';
+import BaseAttachmentViewPdf from './BaseAttachmentViewPdf';
+import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes';
+
+function AttachmentViewPdf(props) {
+ const {onScaleChanged, ...restProps} = props;
+ const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+ const scaleRef = useSharedValue(1);
+ const offsetX = useSharedValue(0);
+ const offsetY = useSharedValue(0);
+
+ const Pan = Gesture.Pan()
+ .manualActivation(true)
+ .onTouchesMove((evt) => {
+ if (offsetX.value !== 0 && offsetY.value !== 0) {
+ // if the value of X is greater than Y and the pdf is not zoomed in,
+ // enable the pager scroll so that the user
+ // can swipe to the next attachment otherwise disable it.
+ if (Math.abs(evt.allTouches[0].absoluteX - offsetX.value) > Math.abs(evt.allTouches[0].absoluteY - offsetY.value) && scaleRef.value === 1) {
+ attachmentCarouselPagerContext.shouldPagerScroll.value = true;
+ } else {
+ attachmentCarouselPagerContext.shouldPagerScroll.value = false;
+ }
+ }
+ offsetX.value = evt.allTouches[0].absoluteX;
+ offsetY.value = evt.allTouches[0].absoluteY;
+ });
+
+ const updateScale = useCallback(
+ (scale) => {
+ scaleRef.value = scale;
+ },
+ [scaleRef],
+ );
+
+ return (
+
+
+
+ {
+ updateScale(scale);
+ onScaleChanged();
+ }}
+ />
+
+
+
+ );
+}
+
+AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
+AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
+
+export default memo(AttachmentViewPdf);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js
new file mode 100644
index 000000000000..103ff292760f
--- /dev/null
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.ios.js
@@ -0,0 +1,17 @@
+import React, {memo} from 'react';
+import BaseAttachmentViewPdf from './BaseAttachmentViewPdf';
+import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes';
+
+function AttachmentViewPdf(props) {
+ return (
+
+ );
+}
+
+AttachmentViewPdf.propTypes = attachmentViewPdfPropTypes;
+AttachmentViewPdf.defaultProps = attachmentViewPdfDefaultProps;
+
+export default memo(AttachmentViewPdf);
diff --git a/src/components/Avatar.js b/src/components/Avatar.js
index 2a52862faafd..ee42f0c03e30 100644
--- a/src/components/Avatar.js
+++ b/src/components/Avatar.js
@@ -94,10 +94,7 @@ function Avatar(props) {
const avatarTestId = imageError ? 'AvatarIcon' : fallbackAvatarTestID;
return (
-
+
{_.isFunction(props.source) || _.isNumber(props.source) || (imageError && (_.isFunction(fallbackAvatar) || _.isNumber(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 f83c7a89a40e..106ca5750a22 100644
--- a/src/components/AvatarCropModal/AvatarCropModal.js
+++ b/src/components/AvatarCropModal/AvatarCropModal.js
@@ -410,7 +410,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 826423a5b642..ac03a2f5fbcf 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.js
@@ -265,7 +265,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) => (
{
- if (!validateSubmitShortcut(this.props.isFocused, this.props.isDisabled, this.props.isLoading, e)) {
- return;
- }
- this.props.onPress();
- },
- shortcutConfig.descriptionKey,
- shortcutConfig.modifiers,
- true,
- this.props.allowBubble,
- this.props.enterKeyEventListenerPriority,
- false,
- );
- }
-
- componentWillUnmount() {
- // Cleanup event listeners
- if (!this.unsubscribe) {
- return;
- }
- this.unsubscribe();
- }
-
- renderContent() {
- if (this.props.children) {
- return this.props.children;
+function Button({
+ allowBubble,
+ text,
+ shouldShowRightIcon,
+
+ icon,
+ iconRight,
+ iconFill,
+ iconStyles,
+ iconRightStyles,
+
+ small,
+ large,
+ medium,
+
+ isLoading,
+ isDisabled,
+
+ onPress,
+ onLongPress,
+ onPressIn,
+ onPressOut,
+ onMouseDown,
+
+ pressOnEnter,
+ enterKeyEventListenerPriority,
+
+ style,
+ innerStyles,
+ textStyles,
+
+ shouldUseDefaultHover,
+ success,
+ danger,
+ children,
+
+ shouldRemoveRightBorderRadius,
+ shouldRemoveLeftBorderRadius,
+ shouldEnableHapticFeedback,
+
+ id,
+ accessibilityLabel,
+ forwardedRef,
+}) {
+ const isFocused = useIsFocused();
+
+ const keyboardShortcutCallback = useCallback(
+ (event) => {
+ if (!validateSubmitShortcut(isFocused, isDisabled, isLoading, event)) {
+ return;
+ }
+ onPress();
+ },
+ [isDisabled, isFocused, isLoading, onPress],
+ );
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, {
+ isActive: pressOnEnter,
+ shouldBubble: allowBubble,
+ priority: enterKeyEventListenerPriority,
+ shouldPreventDefault: false,
+ });
+
+ const renderContent = () => {
+ if (children) {
+ return children;
}
const textComponent = (
- {this.props.text}
+ {text}
);
- if (this.props.icon || this.props.shouldShowRightIcon) {
+ if (icon || shouldShowRightIcon) {
return (
- {this.props.icon && (
-
+ {icon && (
+
)}
{textComponent}
- {this.props.shouldShowRightIcon && (
-
+ {shouldShowRightIcon && (
+
)}
@@ -258,87 +276,85 @@ class Button extends Component {
}
return textComponent;
- }
-
- render() {
- return (
- {
- if (e && e.type === 'click') {
- e.currentTarget.blur();
- }
-
- if (this.props.shouldEnableHapticFeedback) {
- HapticFeedback.press();
- }
- return this.props.onPress(e);
- }}
- onLongPress={(e) => {
- if (this.props.shouldEnableHapticFeedback) {
- HapticFeedback.longPress();
- }
- this.props.onLongPress(e);
- }}
- onPressIn={this.props.onPressIn}
- onPressOut={this.props.onPressOut}
- onMouseDown={this.props.onMouseDown}
- disabled={this.props.isLoading || this.props.isDisabled}
- wrapperStyle={[
- this.props.isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {},
- styles.buttonContainer,
- this.props.shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
- this.props.shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- ...StyleUtils.parseStyleAsArray(this.props.style),
- ]}
- style={[
- styles.button,
- this.props.small ? styles.buttonSmall : undefined,
- this.props.medium ? styles.buttonMedium : undefined,
- this.props.large ? styles.buttonLarge : undefined,
- this.props.success ? styles.buttonSuccess : undefined,
- this.props.danger ? styles.buttonDanger : undefined,
- this.props.isDisabled && (this.props.success || this.props.danger) ? styles.buttonOpacityDisabled : undefined,
- this.props.isDisabled && !this.props.danger && !this.props.success ? styles.buttonDisabled : undefined,
- this.props.shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
- this.props.shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- this.props.icon || this.props.shouldShowRightIcon ? styles.alignItemsStretch : undefined,
- ...this.props.innerStyles,
- ]}
- hoverStyle={[
- this.props.shouldUseDefaultHover && !this.props.isDisabled ? styles.buttonDefaultHovered : undefined,
- this.props.success && !this.props.isDisabled ? styles.buttonSuccessHovered : undefined,
- this.props.danger && !this.props.isDisabled ? styles.buttonDangerHovered : undefined,
- ]}
- nativeID={this.props.nativeID}
- accessibilityLabel={this.props.accessibilityLabel}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- hoverDimmingValue={1}
- >
- {this.renderContent()}
- {this.props.isLoading && (
-
- )}
-
- );
- }
+ };
+
+ return (
+ {
+ if (event && event.type === 'click') {
+ event.currentTarget.blur();
+ }
+
+ if (shouldEnableHapticFeedback) {
+ HapticFeedback.press();
+ }
+ return onPress(event);
+ }}
+ onLongPress={(event) => {
+ if (shouldEnableHapticFeedback) {
+ HapticFeedback.longPress();
+ }
+ onLongPress(event);
+ }}
+ onPressIn={onPressIn}
+ onPressOut={onPressOut}
+ onMouseDown={onMouseDown}
+ disabled={isLoading || isDisabled}
+ wrapperStyle={[
+ isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {},
+ styles.buttonContainer,
+ shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
+ shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
+ ...StyleUtils.parseStyleAsArray(style),
+ ]}
+ style={[
+ styles.button,
+ small ? styles.buttonSmall : undefined,
+ medium ? styles.buttonMedium : undefined,
+ large ? styles.buttonLarge : undefined,
+ success ? styles.buttonSuccess : undefined,
+ danger ? styles.buttonDanger : undefined,
+ isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined,
+ isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
+ shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
+ shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
+ icon || shouldShowRightIcon ? styles.alignItemsStretch : undefined,
+ ...innerStyles,
+ ]}
+ hoverStyle={[
+ shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
+ success && !isDisabled ? styles.buttonSuccessHovered : undefined,
+ danger && !isDisabled ? styles.buttonDangerHovered : undefined,
+ ]}
+ id={id}
+ accessibilityLabel={accessibilityLabel}
+ role={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ hoverDimmingValue={1}
+ >
+ {renderContent()}
+ {isLoading && (
+
+ )}
+
+ );
}
Button.propTypes = propTypes;
Button.defaultProps = defaultProps;
+Button.displayName = 'Button';
+
+const ButtonWithRef = React.forwardRef((props, ref) => (
+
+));
+
+ButtonWithRef.displayName = 'ButtonWithRef';
-export default compose(
- withNavigationFallback,
- withNavigationFocus,
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+export default withNavigationFallback(ButtonWithRef);
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 f18ec346dfa2..86dba1d2a932 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -7,7 +7,6 @@ import variables from '@styles/variables';
import Checkbox from './Checkbox';
import FormHelpMessage from './FormHelpMessage';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
-import refPropTypes from './refPropTypes';
import Text from './Text';
/**
@@ -55,7 +54,7 @@ const propTypes = {
defaultValue: PropTypes.bool,
/** React ref being forwarded to the Checkbox input */
- forwardedRef: refPropTypes,
+ forwardedRef: PropTypes.func,
/** The ID used to uniquely identify the input in a Form */
/* eslint-disable-next-line react/no-unused-prop-types */
@@ -109,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.heading}
{props.description}
diff --git a/src/components/ConnectBankAccountButton.js b/src/components/ConnectBankAccountButton.js
index 64d2421c7d37..2c66bcc200da 100644
--- a/src/components/ConnectBankAccountButton.js
+++ b/src/components/ConnectBankAccountButton.js
@@ -30,7 +30,7 @@ const defaultProps = {
};
function ConnectBankAccountButton(props) {
- const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, '');
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
return props.network.isOffline ? (
{`${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/CurrentWalletBalance.js b/src/components/CurrentWalletBalance.js
deleted file mode 100644
index c73c0815a003..000000000000
--- a/src/components/CurrentWalletBalance.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {withOnyx} from 'react-native-onyx';
-import compose from '@libs/compose';
-import * as CurrencyUtils from '@libs/CurrencyUtils';
-import styles from '@styles/styles';
-import ONYXKEYS from '@src/ONYXKEYS';
-import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** The user's wallet account */
- userWallet: PropTypes.shape({
- /** The user's current wallet balance */
- currentBalance: PropTypes.number,
- }),
-
- /** Styles of the amount */
- // eslint-disable-next-line react/forbid-prop-types
- balanceStyles: PropTypes.arrayOf(PropTypes.object),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- userWallet: {
- // Default to zero if userWallet and currentBalance is not set yet to avoid NaN
- currentBalance: 0,
- },
- balanceStyles: [],
-};
-
-function CurrentWalletBalance(props) {
- const formattedBalance = CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance);
- return {`${formattedBalance}`} ;
-}
-
-CurrentWalletBalance.propTypes = propTypes;
-CurrentWalletBalance.defaultProps = defaultProps;
-CurrentWalletBalance.displayName = 'CurrentWalletBalance';
-export default compose(
- withLocalize,
- withOnyx({
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- }),
-)(CurrentWalletBalance);
diff --git a/src/components/CurrentWalletBalance.tsx b/src/components/CurrentWalletBalance.tsx
new file mode 100644
index 000000000000..e915252d6835
--- /dev/null
+++ b/src/components/CurrentWalletBalance.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import {StyleProp, TextStyle} from 'react-native';
+import {OnyxEntry, withOnyx} from 'react-native-onyx';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import styles from '@styles/styles';
+import ONYXKEYS from '@src/ONYXKEYS';
+import UserWallet from '@src/types/onyx/UserWallet';
+import Text from './Text';
+
+type CurrentWalletBalanceOnyxProps = {
+ /** The user's wallet account */
+ userWallet: OnyxEntry;
+};
+
+type CurrentWalletBalanceProps = CurrentWalletBalanceOnyxProps & {
+ balanceStyles?: StyleProp;
+};
+
+function CurrentWalletBalance({userWallet, balanceStyles}: CurrentWalletBalanceProps) {
+ const formattedBalance = CurrencyUtils.convertToDisplayString(userWallet?.currentBalance ?? 0);
+ return {formattedBalance} ;
+}
+
+CurrentWalletBalance.displayName = 'CurrentWalletBalance';
+
+export default withOnyx({
+ userWallet: {
+ key: ONYXKEYS.USER_WALLET,
+ },
+})(CurrentWalletBalance);
diff --git a/src/components/DatePicker/datepickerPropTypes.js b/src/components/DatePicker/datepickerPropTypes.js
index c895d919cd33..02d11806b8af 100644
--- a/src/components/DatePicker/datepickerPropTypes.js
+++ b/src/components/DatePicker/datepickerPropTypes.js
@@ -1,18 +1,18 @@
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 = {
...fieldPropTypes,
/**
- * The datepicker supports any value that `moment` can parse.
+ * The datepicker supports any value that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
*/
value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
/**
- * The datepicker supports any defaultValue that `moment` can parse.
+ * The datepicker supports any defaultValue that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
*/
defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]),
diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js
index 002cf5587e44..17d1e2e14e71 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 moment from 'moment';
+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';
@@ -7,7 +7,7 @@ import styles from '@styles/styles';
import CONST from '@src/CONST';
import {defaultProps, propTypes} from './datepickerPropTypes';
-function DatePicker({value, defaultValue, label, placeholder, errorText, containerStyles, disabled, onBlur, onInputChange, maxDate, minDate}, outerRef) {
+const DatePicker = forwardRef(({value, defaultValue, label, placeholder, errorText, containerStyles, disabled, onBlur, onInputChange, maxDate, minDate}, outerRef) => {
const ref = useRef();
const [isPickerVisible, setIsPickerVisible] = useState(false);
@@ -20,8 +20,7 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain
setIsPickerVisible(false);
if (event.type === 'set') {
- const asMoment = moment(selectedDate, true);
- onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ onInputChange(format(selectedDate, CONST.DATE.FNS_FORMAT_STRING));
}
};
@@ -39,14 +38,15 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain
[showPicker],
);
- const dateAsText = value || defaultValue ? moment(value || defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+ const date = value || defaultValue;
+ const dateAsText = date ? format(parseISO(date), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
{isPickerVisible && (
);
-}
+});
DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
DatePicker.displayName = 'DatePicker';
-export default forwardRef(DatePicker);
+export default DatePicker;
diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js
index 705792b0fad8..8b884c29b07f 100644
--- a/src/components/DatePicker/index.ios.js
+++ b/src/components/DatePicker/index.ios.js
@@ -1,6 +1,6 @@
import RNDatePicker from '@react-native-community/datetimepicker';
+import {format, parseISO} from 'date-fns';
import isFunction from 'lodash/isFunction';
-import moment from 'moment';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Button, Keyboard, View} from 'react-native';
import Popover from '@components/Popover';
@@ -13,8 +13,9 @@ import CONST from '@src/CONST';
import {defaultProps, propTypes} from './datepickerPropTypes';
function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLocale, minDate, maxDate, label, disabled, onBlur, placeholder, containerStyles, errorText}) {
+ const dateValue = value || defaultValue;
const [isPickerVisible, setIsPickerVisible] = useState(false);
- const [selectedDate, setSelectedDate] = useState(moment(value || defaultValue).toDate());
+ const [selectedDate, setSelectedDate] = useState(dateValue ? new Date(dateValue) : new Date());
const {isKeyboardShown} = useKeyboardState();
const {translate} = useLocalize();
const initialValue = useRef(null);
@@ -65,8 +66,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca
*/
const selectDate = () => {
setIsPickerVisible(false);
- const asMoment = moment(selectedDate, true);
- onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ onInputChange(format(selectedDate, CONST.DATE.FNS_FORMAT_STRING));
};
/**
@@ -77,7 +77,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca
setSelectedDate(date);
};
- const dateAsText = value || defaultValue ? moment(value || defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : '';
+ const dateAsText = dateValue ? format(parseISO(dateValue), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
@@ -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 a5b282b22c73..16d2eb2668a3 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,4 +1,4 @@
-import moment from 'moment';
+import {format, isValid, parseISO} from 'date-fns';
import React, {useEffect, useRef} from 'react';
import _ from 'underscore';
import TextInput from '@components/TextInput';
@@ -13,8 +13,8 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
useEffect(() => {
// Adds nice native datepicker on web/desktop. Not possible to set this through props
inputRef.current.setAttribute('type', 'date');
- inputRef.current.setAttribute('max', moment(maxDate).format(CONST.DATE.MOMENT_FORMAT_STRING));
- inputRef.current.setAttribute('min', moment(minDate).format(CONST.DATE.MOMENT_FORMAT_STRING));
+ inputRef.current.setAttribute('max', format(new Date(maxDate), CONST.DATE.FNS_FORMAT_STRING));
+ inputRef.current.setAttribute('min', format(new Date(minDate), CONST.DATE.FNS_FORMAT_STRING));
inputRef.current.classList.add('expensify-datepicker');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -29,9 +29,9 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
return;
}
- const asMoment = moment(text, true);
- if (asMoment.isValid()) {
- onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING));
+ const date = 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/DistanceRequest/index.js b/src/components/DistanceRequest/index.js
index 3a41d52eb7f4..eb6811d02323 100644
--- a/src/components/DistanceRequest/index.js
+++ b/src/components/DistanceRequest/index.js
@@ -150,13 +150,8 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe
return ErrorUtils.getLatestErrorField(transaction, 'route');
}
- // Initially, both waypoints will be null, and if we give fallback value as empty string that will result in true condition, that's why different default values.
- if (_.keys(waypoints).length === 2 && lodashGet(waypoints, 'waypoint0.address', 'address1') === lodashGet(waypoints, 'waypoint1.address', 'address2')) {
- return {0: translate('iou.error.duplicateWaypointsErrorMessage')};
- }
-
if (_.size(validatedWaypoints) < 2) {
- return {0: translate('iou.error.emptyWaypointsErrorMessage')};
+ return {0: translate('iou.error.atLeastTwoDifferentWaypoints')};
}
};
diff --git a/src/components/EmojiPicker/CategoryShortcutButton.js b/src/components/EmojiPicker/CategoryShortcutButton.js
index ec4b51a5e9da..95038caf06b1 100644
--- a/src/components/EmojiPicker/CategoryShortcutButton.js
+++ b/src/components/EmojiPicker/CategoryShortcutButton.js
@@ -39,7 +39,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/FormProvider.js b/src/components/Form/FormProvider.js
index 85408323c9f2..92baa9727832 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -71,8 +71,6 @@ const propTypes = {
shouldValidateOnChange: PropTypes.bool,
};
-const VALIDATE_DELAY = 200;
-
const defaultProps = {
isSubmitButtonVisible: true,
formState: {
@@ -248,28 +246,19 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
// as this is already happening by the value prop.
defaultValue: undefined,
onTouched: (event) => {
- setTimeout(() => {
- setTouchedInput(inputID);
- }, VALIDATE_DELAY);
+ setTouchedInput(inputID);
if (_.isFunction(propsToParse.onTouched)) {
propsToParse.onTouched(event);
}
},
onPress: (event) => {
- setTimeout(() => {
- setTouchedInput(inputID);
- }, VALIDATE_DELAY);
+ setTouchedInput(inputID);
if (_.isFunction(propsToParse.onPress)) {
propsToParse.onPress(event);
}
},
- onPressOut: (event) => {
- // To prevent validating just pressed inputs, we need to set the touched input right after
- // onValidate and to do so, we need to delays setTouchedInput of the same amount of time
- // as the onValidate is delayed
- setTimeout(() => {
- setTouchedInput(inputID);
- }, VALIDATE_DELAY);
+ onPressIn: (event) => {
+ setTouchedInput(inputID);
if (_.isFunction(propsToParse.onPressIn)) {
propsToParse.onPressIn(event);
}
@@ -285,7 +274,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
- }, VALIDATE_DELAY);
+ }, 200);
}
if (_.isFunction(propsToParse.onBlur)) {
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/Form/InputWrapper.js b/src/components/Form/InputWrapper.js
index b2e6f4477e89..99237fd8db43 100644
--- a/src/components/Form/InputWrapper.js
+++ b/src/components/Form/InputWrapper.js
@@ -1,13 +1,12 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useContext} from 'react';
-import refPropTypes from '@components/refPropTypes';
import FormContext from './FormContext';
const propTypes = {
InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired,
inputID: PropTypes.string.isRequired,
valueType: PropTypes.string,
- forwardedRef: refPropTypes,
+ forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
};
const defaultProps = {
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) => (
-
-
- );
-}
-
-FullScreenLoadingIndicator.propTypes = propTypes;
-FullScreenLoadingIndicator.defaultProps = defaultProps;
-FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator';
-
-export default FullScreenLoadingIndicator;
diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx
new file mode 100644
index 000000000000..b4483d2e0113
--- /dev/null
+++ b/src/components/FullscreenLoadingIndicator.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import {ActivityIndicator, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
+import styles from '@styles/styles';
+import themeColors from '@styles/themes/default';
+
+type FullScreenLoadingIndicatorProps = {
+ style?: StyleProp;
+};
+
+function FullScreenLoadingIndicator({style}: FullScreenLoadingIndicatorProps) {
+ return (
+
+
+
+ );
+}
+
+FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator';
+
+export default FullScreenLoadingIndicator;
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js
index be70af0adb4f..9079a7f3c091 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js
@@ -11,6 +11,7 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import * as Url from '@libs/Url';
import styles from '@styles/styles';
import * as Link from '@userActions/Link';
+import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
@@ -52,6 +53,10 @@ function AnchorRenderer(props) {
// If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation
// instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag)
if (internalNewExpensifyPath && hasSameOrigin) {
+ if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(internalNewExpensifyPath)) {
+ Session.signOutAndRedirectToSignIn();
+ return;
+ }
Navigation.navigate(internalNewExpensifyPath);
return;
}
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js
index ab8e88cf088a..03d3afe53d4e 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js
@@ -20,7 +20,6 @@ function EditedRenderer(props) {
return (
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')}
>
diff --git a/src/components/Image/index.js b/src/components/Image/index.js
index c2800511ff45..ef1a69e19c12 100644
--- a/src/components/Image/index.js
+++ b/src/components/Image/index.js
@@ -69,4 +69,5 @@ const ImageWithOnyx = React.memo(
imagePropsAreEqual,
);
ImageWithOnyx.resizeMode = RESIZE_MODES;
+
export default ImageWithOnyx;
diff --git a/src/components/Image/index.native.js b/src/components/Image/index.native.js
index f936009b858a..084a11ad7338 100644
--- a/src/components/Image/index.native.js
+++ b/src/components/Image/index.native.js
@@ -59,4 +59,5 @@ const ImageWithOnyx = withOnyx({
})(Image);
ImageWithOnyx.resizeMode = RESIZE_MODES;
ImageWithOnyx.resolveDimensions = resolveDimensions;
+
export default ImageWithOnyx;
diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js
index 09656d700917..a733466e1ae2 100644
--- a/src/components/ImageView/index.js
+++ b/src/components/ImageView/index.js
@@ -262,7 +262,7 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) {
}}
onPressIn={onContainerPressIn}
onPress={onContainerPress}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGE}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGE}
accessibilityLabel={fileName}
>
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..bf552c06b4d7 100644
--- a/src/components/InvertedFlatList/index.js
+++ b/src/components/InvertedFlatList/index.js
@@ -1,8 +1,6 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useEffect, useRef} from 'react';
-import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native';
-import _ from 'underscore';
-import styles from '@styles/styles';
+import {DeviceEventEmitter, FlatList} from 'react-native';
import CONST from '@src/CONST';
import BaseInvertedFlatList from './BaseInvertedFlatList';
@@ -24,22 +22,14 @@ const propTypes = {
// It's a HACK alert since FlatList has inverted scrolling on web
function InvertedFlatList(props) {
const {innerRef, contentContainerStyle} = props;
- const listRef = React.createRef();
const lastScrollEvent = useRef(null);
const scrollEndTimeout = useRef(null);
const updateInProgress = useRef(false);
const eventHandler = useRef(null);
- useEffect(() => {
- if (!_.isFunction(innerRef)) {
- // eslint-disable-next-line no-param-reassign
- innerRef.current = listRef.current;
- } else {
- innerRef(listRef);
- }
-
- return () => {
+ useEffect(
+ () => () => {
if (scrollEndTimeout.current) {
clearTimeout(scrollEndTimeout.current);
}
@@ -47,8 +37,9 @@ function InvertedFlatList(props) {
if (eventHandler.current) {
eventHandler.current.remove();
}
- };
- }, [innerRef, listRef]);
+ },
+ [innerRef],
+ );
/**
* Emits when the scrolling is in progress. Also,
@@ -114,9 +105,9 @@ function InvertedFlatList(props) {
{},
};
+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 5aefdbbd6cfb..c556c188efab 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 ebba2ffe0587..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,48 +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}`,
- },
- reportActions: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
- canEvict: false,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- selector: personalDetailsSelector,
- },
- 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,
- },
- policy: {
- key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`,
- },
- // 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},
- }),
- // 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/Lottie/Lottie.tsx b/src/components/Lottie/Lottie.tsx
index cf689224278f..6ee3bb544ed7 100644
--- a/src/components/Lottie/Lottie.tsx
+++ b/src/components/Lottie/Lottie.tsx
@@ -2,13 +2,18 @@ import LottieView, {LottieViewProps} from 'lottie-react-native';
import React, {forwardRef} from 'react';
import styles from '@styles/styles';
-const Lottie = forwardRef((props: LottieViewProps, ref) => (
-
-));
+const Lottie = forwardRef((props: LottieViewProps, ref) => {
+ const aspectRatioStyle = styles.aspectRatioLottie(props.source);
+
+ return (
+
+ );
+});
export default Lottie;
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 337c1ce36a75..ed87672d8ecd 100644
--- a/src/components/MultipleAvatars.js
+++ b/src/components/MultipleAvatars.js
@@ -226,8 +226,7 @@ function MultipleAvatars(props) {
]}
>
{`+${avatars.length - props.maxAvatarsInRow}`}
@@ -290,8 +289,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 0300b4bf476f..58ab42a9b56a 100644
--- a/src/components/NewDatePicker/CalendarPicker/index.js
+++ b/src/components/NewDatePicker/CalendarPicker/index.js
@@ -1,5 +1,5 @@
+import {addMonths, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, subMonths} from 'date-fns';
import Str from 'expensify-common/lib/str';
-import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
@@ -8,6 +8,7 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Text from '@components/Text';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import DateUtils from '@libs/DateUtils';
import getButtonState from '@libs/getButtonState';
import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
@@ -34,8 +35,8 @@ const propTypes = {
const defaultProps = {
value: new Date(),
- minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(),
- maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(),
+ minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR),
+ maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR),
onSelected: () => {},
};
@@ -46,16 +47,15 @@ class CalendarPicker extends React.PureComponent {
if (props.minDate >= props.maxDate) {
throw new Error('Minimum date cannot be greater than the maximum date.');
}
-
- let currentDateView = moment(props.value, CONST.DATE.MOMENT_FORMAT_STRING).toDate();
+ let currentDateView = new Date(props.value);
if (props.maxDate < currentDateView) {
currentDateView = props.maxDate;
} else if (props.minDate > currentDateView) {
currentDateView = props.minDate;
}
- const minYear = moment(this.props.minDate).year();
- const maxYear = moment(this.props.maxDate).year();
+ const minYear = getYear(new Date(this.props.minDate));
+ const maxYear = getYear(new Date(this.props.maxDate));
this.state = {
currentDateView,
@@ -79,7 +79,7 @@ class CalendarPicker extends React.PureComponent {
onYearSelected(year) {
this.setState((prev) => {
- const newCurrentDateView = moment(prev.currentDateView).set('year', year).toDate();
+ const newCurrentDateView = setYear(new Date(prev.currentDateView), year);
return {
currentDateView: newCurrentDateView,
@@ -99,9 +99,9 @@ class CalendarPicker extends React.PureComponent {
onDayPressed(day) {
this.setState(
(prev) => ({
- currentDateView: moment(prev.currentDateView).set('date', day).toDate(),
+ currentDateView: setDate(new Date(prev.currentDateView), day),
}),
- () => this.props.onSelected(moment(this.state.currentDateView).format('YYYY-MM-DD')),
+ () => this.props.onSelected(format(new Date(this.state.currentDateView), CONST.DATE.FNS_FORMAT_STRING)),
);
}
@@ -109,24 +109,24 @@ class CalendarPicker extends React.PureComponent {
* Handles the user pressing the previous month arrow of the calendar picker.
*/
moveToPrevMonth() {
- this.setState((prev) => ({currentDateView: moment(prev.currentDateView).subtract(1, 'months').toDate()}));
+ this.setState((prev) => ({currentDateView: subMonths(new Date(prev.currentDateView), 1)}));
}
/**
* Handles the user pressing the next month arrow of the calendar picker.
*/
moveToNextMonth() {
- this.setState((prev) => ({currentDateView: moment(prev.currentDateView).add(1, 'months').toDate()}));
+ this.setState((prev) => ({currentDateView: addMonths(new Date(prev.currentDateView), 1)}));
}
render() {
- const monthNames = _.map(moment.localeData(this.props.preferredLocale).months(), Str.recapitalize);
- const daysOfWeek = _.map(moment.localeData(this.props.preferredLocale).weekdays(), (day) => day.toUpperCase());
+ const monthNames = _.map(DateUtils.getMonthNames(this.props.preferredLocale), Str.recapitalize);
+ const daysOfWeek = _.map(DateUtils.getDaysOfWeek(this.props.preferredLocale), (day) => day.toUpperCase());
const currentMonthView = this.state.currentDateView.getMonth();
const currentYearView = this.state.currentDateView.getFullYear();
const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView);
- const hasAvailableDatesNextMonth = moment(this.props.maxDate).endOf('month').endOf('day') >= moment(this.state.currentDateView).add(1, 'months');
- const hasAvailableDatesPrevMonth = moment(this.props.minDate).startOf('month').startOf('day') <= moment(this.state.currentDateView).subtract(1, 'months');
+ const hasAvailableDatesNextMonth = startOfDay(endOfMonth(new Date(this.props.maxDate))) > addMonths(new Date(this.state.currentDateView), 1);
+ const hasAvailableDatesPrevMonth = startOfDay(new Date(this.props.minDate)) < endOfMonth(subMonths(new Date(this.state.currentDateView), 1));
return (
@@ -201,12 +201,11 @@ class CalendarPicker extends React.PureComponent {
style={styles.flexRow}
>
{_.map(week, (day, index) => {
- const currentDate = moment([currentYearView, currentMonthView, day]);
- const isBeforeMinDate = currentDate < moment(this.props.minDate).startOf('day');
- const isAfterMaxDate = currentDate > moment(this.props.maxDate).startOf('day');
+ const currentDate = new Date(currentYearView, currentMonthView, day);
+ const isBeforeMinDate = currentDate < startOfDay(new Date(this.props.minDate));
+ const isAfterMaxDate = currentDate > startOfDay(new Date(this.props.maxDate));
const isDisabled = !day || isBeforeMinDate || isAfterMaxDate;
- const isSelected = moment(this.props.value).isSame(moment([currentYearView, currentMonthView, day]), 'day');
-
+ const isSelected = isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day));
return (
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 30e15ac43bfc..351e5034cfb4 100644
--- a/src/components/NewDatePicker/index.js
+++ b/src/components/NewDatePicker/index.js
@@ -1,12 +1,12 @@
+import {setYear} from 'date-fns';
import _ from 'lodash';
-import moment from 'moment';
import PropTypes from 'prop-types';
import React, {useEffect, useState} from 'react';
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';
@@ -14,13 +14,13 @@ import CalendarPicker from './CalendarPicker';
const propTypes = {
/**
- * The datepicker supports any value that `moment` can parse.
+ * The datepicker supports any value that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
*/
value: PropTypes.string,
/**
- * The datepicker supports any defaultValue that `moment` can parse.
+ * The datepicker supports any defaultValue that `new Date()` can parse.
* `onInputChange` would always be called with a Date (or null)
*/
defaultValue: PropTypes.string,
@@ -39,8 +39,8 @@ const propTypes = {
const datePickerDefaultProps = {
...defaultBaseTextInputPropTypes,
- minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(),
- maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(),
+ minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR),
+ maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR),
value: undefined,
};
@@ -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 dcd2de42bcb2..000000000000
--- a/src/components/Pressable/PressableWithDelayToggle.js
+++ /dev/null
@@ -1,155 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
-import sourcePropTypes from '@components/Image/sourcePropTypes';
-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: sourcePropTypes,
-
- /** The icon to display once the pressable is pressed */
- iconChecked: sourcePropTypes,
-
- /**
- * 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/QRCode/index.tsx b/src/components/QRCode.tsx
similarity index 100%
rename from src/components/QRCode/index.tsx
rename to src/components/QRCode.tsx
diff --git a/src/components/RNTextInput.js b/src/components/RNTextInput.js
deleted file mode 100644
index d308c42f4947..000000000000
--- a/src/components/RNTextInput.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-// eslint-disable-next-line no-restricted-imports
-import {TextInput} from 'react-native';
-import Animated from 'react-native-reanimated';
-import _ from 'underscore';
-
-const propTypes = {
- /** A ref to forward to the text input */
- forwardedRef: PropTypes.func,
-};
-
-const defaultProps = {
- forwardedRef: () => {},
-};
-
-// Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet
-const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
-
-function RNTextInput(props) {
- return (
- {
- if (!_.isFunction(props.forwardedRef)) {
- return;
- }
- props.forwardedRef(ref);
- }}
- // eslint-disable-next-line
- {...props}
- />
- );
-}
-
-RNTextInput.propTypes = propTypes;
-RNTextInput.defaultProps = defaultProps;
-RNTextInput.displayName = 'RNTextInput';
-
-const RNTextInputWithRef = React.forwardRef((props, ref) => (
-
-));
-
-RNTextInputWithRef.displayName = 'RNTextInputWithRef';
-
-export default RNTextInputWithRef;
diff --git a/src/components/RNTextInput.tsx b/src/components/RNTextInput.tsx
new file mode 100644
index 000000000000..28555abe3266
--- /dev/null
+++ b/src/components/RNTextInput.tsx
@@ -0,0 +1,29 @@
+import React, {ForwardedRef} from 'react';
+// eslint-disable-next-line no-restricted-imports
+import {TextInput, TextInputProps} from 'react-native';
+import Animated, {AnimatedProps} from 'react-native-reanimated';
+
+// Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet
+const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef>>) {
+ return (
+ {
+ if (typeof ref !== 'function') {
+ return;
+ }
+ ref(refHandle);
+ }}
+ // eslint-disable-next-line
+ {...props}
+ />
+ );
+}
+
+RNTextInputWithRef.displayName = 'RNTextInputWithRef';
+
+export default React.forwardRef(RNTextInputWithRef);
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/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js
index 46aff91c93ea..590cfc5c7b11 100644
--- a/src/components/ReimbursementAccountLoadingIndicator.js
+++ b/src/components/ReimbursementAccountLoadingIndicator.js
@@ -39,6 +39,7 @@ function ReimbursementAccountLoadingIndicator(props) {
autoPlay
loop
style={styles.loadingVBAAnimation}
+ webStyle={styles.loadingVBAAnimationWeb}
/>
{translate('reimbursementAccountLoadingAnimation.explanationLine')}
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index 97043fbd055d..f05f678eb6ed 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -175,6 +175,8 @@ function MoneyRequestPreview(props) {
const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : [];
+ const hasPendingWaypoints = lodashGet(props.transaction, 'pendingFields.waypoints', null);
+
const getSettledMessage = () => {
if (isExpensifyCardTransaction) {
return props.translate('common.done');
@@ -223,7 +225,7 @@ function MoneyRequestPreview(props) {
const getDisplayAmountText = () => {
if (isDistanceRequest) {
- return requestAmount ? CurrencyUtils.convertToDisplayString(requestAmount, props.transaction.currency) : props.translate('common.tbd');
+ return requestAmount && !hasPendingWaypoints ? CurrencyUtils.convertToDisplayString(requestAmount, props.transaction.currency) : props.translate('common.tbd');
}
if (isScanning) {
@@ -274,7 +276,9 @@ function MoneyRequestPreview(props) {
) : (
- {getPreviewHeaderText() + (isSettled ? ` • ${getSettledMessage()}` : '')}
+
+ {getPreviewHeaderText() + (isSettled ? ` • ${getSettledMessage()}` : '')}
+
{hasFieldErrors && (
{shouldShowMerchant && (
- {requestMerchant}
+
+ {hasPendingWaypoints ? requestMerchant.replace(CONST.REGEX.FIRST_SPACE, props.translate('common.tbd')) : requestMerchant}
+
)}
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index aa1813fa6e4d..7c5ffe88ef0a 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -99,7 +99,8 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
let formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : '';
- if (isDistanceRequest && !formattedTransactionAmount) {
+ const hasPendingWaypoints = lodashGet(transaction, 'pendingFields.waypoints', null);
+ if (isDistanceRequest && (!formattedTransactionAmount || hasPendingWaypoints)) {
formattedTransactionAmount = translate('common.tbd');
}
const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency);
@@ -206,7 +207,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
{receiptImageComponent}
diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js
index b41307864de6..e44e27bb7ec0 100644
--- a/src/components/ReportActionItem/ReportActionItemImages.js
+++ b/src/components/ReportActionItem/ReportActionItemImages.js
@@ -1,11 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
+import {Polygon, Svg} from 'react-native-svg';
import _ from 'underscore';
import Text from '@components/Text';
import transactionPropTypes from '@components/transactionPropTypes';
import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
+import theme from '@styles/themes/default';
import variables from '@styles/variables';
import ReportActionItemImage from './ReportActionItemImage';
@@ -68,6 +70,8 @@ function ReportActionItemImages({images, size, total, isHovered}) {
const hoverStyle = isHovered ? styles.reportPreviewBoxHoverBorder : undefined;
+ const triangleWidth = variables.reportActionItemImagesMoreCornerTriangleWidth;
+
return (
{_.map(shownImages, ({thumbnail, image, transaction}, index) => {
@@ -89,7 +93,16 @@ function ReportActionItemImages({images, size, total, isHovered}) {
{isLastImage && remaining > 0 && (
-
+
+
+
{remaining > MAX_REMAINING ? `${MAX_REMAINING}+` : `+${remaining}`}
)}
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 00a4526b382f..45fe7d42e299 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -129,17 +129,24 @@ function ReportPreview(props) {
const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3);
const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, (transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction));
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(props.iouReportID);
- const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts;
- const previewSubtitle = hasOnlyOneReceiptRequest
- ? TransactionUtils.getMerchant(transactionsWithReceipts[0])
- : props.translate('iou.requestCount', {
- count: numberOfRequests,
- scanningReceipts: numberOfScanningReceipts,
- });
+ let formattedMerchant = numberOfRequests === 1 && hasReceipts ? TransactionUtils.getMerchant(transactionsWithReceipts[0]) : null;
+ const hasPendingWaypoints = formattedMerchant && hasOnlyDistanceRequests && _.every(transactionsWithReceipts, (transaction) => lodashGet(transaction, 'pendingFields.waypoints', null));
+ if (hasPendingWaypoints) {
+ formattedMerchant = formattedMerchant.replace(CONST.REGEX.FIRST_SPACE, props.translate('common.tbd'));
+ }
+ const previewSubtitle =
+ formattedMerchant ||
+ props.translate('iou.requestCount', {
+ count: numberOfRequests,
+ scanningReceipts: numberOfScanningReceipts,
+ });
const shouldShowSubmitButton = isReportDraft && reimbursableSpend !== 0;
const getDisplayAmount = () => {
+ if (hasPendingWaypoints) {
+ return props.translate('common.tbd');
+ }
if (totalDisplaySpend) {
return CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.iouReport.currency);
}
@@ -192,7 +199,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 63ece9fcb3e1..b31de0d22f4c 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -7,17 +7,21 @@ import _ from 'underscore';
import Checkbox from '@components/Checkbox';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
+import {usePersonalDetails} from '@components/OnyxProvider';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
+import refPropTypes from '@components/refPropTypes';
import RenderHTML from '@components/RenderHTML';
+import {showContextMenuForReport} from '@components/ShowContextMenuContext';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
+import ControlSelection from '@libs/ControlSelection';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import getButtonState from '@libs/getButtonState';
import * as LocalePhoneNumber from '@libs/LocalePhoneNumber';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
import * as Session from '@userActions/Session';
@@ -27,9 +31,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
const propTypes = {
- /** All personal details asssociated with user */
- personalDetailsList: PropTypes.objectOf(personalDetailsPropType),
-
/** The ID of the associated taskReport */
taskReportID: PropTypes.string.isRequired,
@@ -52,6 +53,16 @@ const propTypes = {
ownerAccountID: PropTypes.number,
}),
+ /** The chat report associated with taskReport */
+ chatReportID: PropTypes.string.isRequired,
+
+ /** Popover context menu anchor, used for showing context menu */
+ contextMenuAnchor: refPropTypes,
+
+ /** Callback for updating context menu active state, used for showing context menu */
+ checkIfContextMenuActive: PropTypes.func,
+
+ /* Onyx Props */
...withLocalizePropTypes,
...withCurrentUserPersonalDetailsPropTypes,
@@ -59,12 +70,12 @@ const propTypes = {
const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
- personalDetailsList: {},
taskReport: {},
isHovered: false,
};
function TaskPreview(props) {
+ const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
// The reportAction might not contain details regarding the taskReport
// Only the direct parent reportAction will contain details about the taskReport
// Other linked reportActions will only contain the taskReportID and we will grab the details from there
@@ -73,8 +84,8 @@ function TaskPreview(props) {
: props.action.childStateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.action.childStatusNum === CONST.REPORT.STATUS.APPROVED;
const taskTitle = props.taskReport.reportName || props.action.childReportName;
const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(props.taskReport) || props.action.childManagerAccountID;
- const assigneeLogin = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'login'], '');
- const assigneeDisplayName = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'displayName'], '');
+ const assigneeLogin = lodashGet(personalDetails, [taskAssigneeAccountID, 'login'], '');
+ const assigneeDisplayName = lodashGet(personalDetails, [taskAssigneeAccountID, 'displayName'], '');
const taskAssignee = assigneeDisplayName || LocalePhoneNumber.formatPhoneNumber(assigneeLogin);
const htmlForTaskPreview =
taskAssignee && taskAssigneeAccountID !== 0
@@ -90,8 +101,11 @@ function TaskPreview(props) {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.taskReportID))}
+ onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
+ 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')}
>
@@ -132,9 +146,5 @@ export default compose(
key: ({taskReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
initialValue: {},
},
- personalDetailsList: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- initialValue: {},
- },
}),
)(TaskPreview);
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/ReportWelcomeText.js b/src/components/ReportWelcomeText.js
index e1a6d4d4f6c1..213d41c94a3c 100644
--- a/src/components/ReportWelcomeText.js
+++ b/src/components/ReportWelcomeText.js
@@ -82,11 +82,7 @@ function ReportWelcomeText(props) {
{isPolicyExpenseChat && (
<>
{props.translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartOne')}
-
- {/* Use the policyExpenseChat owner's first name or their display name if it's undefined or an empty string */}
- {lodashGet(props.personalDetails, [props.report.ownerAccountID, 'firstName']) ||
- lodashGet(props.personalDetails, [props.report.ownerAccountID, 'displayName'], '')}
-
+ {ReportUtils.getDisplayNameForParticipant(props.report.ownerAccountID)}
{props.translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartTwo')}
{ReportUtils.getPolicyName(props.report)}
{props.translate('reportActionsView.beginningOfChatHistoryPolicyExpenseChatPartThree')}
diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.js
index 0b8a299e1280..9ed9d5face09 100644
--- a/src/components/RoomHeaderAvatars.js
+++ b/src/components/RoomHeaderAvatars.js
@@ -38,7 +38,7 @@ function RoomHeaderAvatars(props) {
+
{_.map(iconsToDisplay, (icon, index) => (
- );
-}
-
-SVGImage.propTypes = propTypes;
-SVGImage.displayName = 'SVGImage';
-
-export default SVGImage;
diff --git a/src/components/SVGImage/index.native.js b/src/components/SVGImage/index.native.js
deleted file mode 100644
index 78b1f8ef7e78..000000000000
--- a/src/components/SVGImage/index.native.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import {SvgCssUri} from 'react-native-svg';
-import propTypes from './propTypes';
-
-function SVGImage(props) {
- return (
-
- );
-}
-
-SVGImage.propTypes = propTypes;
-SVGImage.displayName = 'SVGImage';
-
-export default SVGImage;
diff --git a/src/components/SVGImage/propTypes.js b/src/components/SVGImage/propTypes.js
deleted file mode 100644
index 4e02ad42fde9..000000000000
--- a/src/components/SVGImage/propTypes.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** The asset to render. */
- src: PropTypes.string.isRequired,
-
- /** The width of the image. */
- width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
-
- /** The height of the image. */
- height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
-
- /** The resize mode of the image. */
- resizeMode: PropTypes.oneOf(['cover', 'contain', 'stretch', 'repeat', 'center']),
-};
-
-export default propTypes;
diff --git a/src/components/SafeArea/index.ios.js b/src/components/SafeArea/index.ios.tsx
similarity index 65%
rename from src/components/SafeArea/index.ios.js
rename to src/components/SafeArea/index.ios.tsx
index 2ef716d0e977..2e26d6de0334 100644
--- a/src/components/SafeArea/index.ios.js
+++ b/src/components/SafeArea/index.ios.tsx
@@ -1,23 +1,19 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {SafeAreaView} from 'react-native-safe-area-context';
import styles from '@styles/styles';
+import SafeAreaProps from './types';
-function SafeArea(props) {
+function SafeArea({children}: SafeAreaProps) {
return (
- {props.children}
+ {children}
);
}
-SafeArea.propTypes = {
- /** App content */
- children: PropTypes.node.isRequired,
-};
SafeArea.displayName = 'SafeArea';
export default SafeArea;
diff --git a/src/components/SafeArea/index.js b/src/components/SafeArea/index.js
deleted file mode 100644
index e68fd1c402ce..000000000000
--- a/src/components/SafeArea/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export default ({children}) => children;
diff --git a/src/components/SafeArea/index.tsx b/src/components/SafeArea/index.tsx
new file mode 100644
index 000000000000..94090ef48642
--- /dev/null
+++ b/src/components/SafeArea/index.tsx
@@ -0,0 +1,3 @@
+import SafeAreaProps from './types';
+
+export default ({children}: SafeAreaProps) => children;
diff --git a/src/components/SafeArea/types.ts b/src/components/SafeArea/types.ts
new file mode 100644
index 000000000000..89faea46ab71
--- /dev/null
+++ b/src/components/SafeArea/types.ts
@@ -0,0 +1,5 @@
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type SafeAreaProps = ChildrenProps;
+
+export default SafeAreaProps;
diff --git a/src/components/SafeAreaConsumer.js b/src/components/SafeAreaConsumer.tsx
similarity index 55%
rename from src/components/SafeAreaConsumer.js
rename to src/components/SafeAreaConsumer.tsx
index 25f22ed61ec4..7df73dbdb65f 100644
--- a/src/components/SafeAreaConsumer.js
+++ b/src/components/SafeAreaConsumer.tsx
@@ -1,29 +1,34 @@
-import PropTypes from 'prop-types';
import React from 'react';
-import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
+import type {DimensionValue} from 'react-native';
+import {EdgeInsets, SafeAreaInsetsContext} from 'react-native-safe-area-context';
import * as StyleUtils from '@styles/StyleUtils';
-const propTypes = {
- /** Children to render. */
- children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
+type ChildrenProps = {
+ paddingTop?: DimensionValue;
+ paddingBottom?: DimensionValue;
+ insets?: EdgeInsets;
+ safeAreaPaddingBottomStyle: {
+ paddingBottom?: DimensionValue;
+ };
+};
+
+type SafeAreaConsumerProps = {
+ children: React.FC;
};
/**
* This component is a light wrapper around the SafeAreaInsetsContext.Consumer. There are several places where we
* may need not just the insets, but the computed styles so we save a few lines of code with this.
- *
- * @param {Object} props
- * @returns {React.Component}
*/
-function SafeAreaConsumer(props) {
+function SafeAreaConsumer({children}: SafeAreaConsumerProps) {
return (
{(insets) => {
- const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets);
- return props.children({
+ const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined);
+ return children({
paddingTop,
paddingBottom,
- insets,
+ insets: insets ?? undefined,
safeAreaPaddingBottomStyle: {paddingBottom},
});
}}
@@ -32,5 +37,5 @@ function SafeAreaConsumer(props) {
}
SafeAreaConsumer.displayName = 'SafeAreaConsumer';
-SafeAreaConsumer.propTypes = propTypes;
+
export default SafeAreaConsumer;
diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js
index 0ade615423b8..f9173c15da7d 100644
--- a/src/components/ScreenWrapper/index.js
+++ b/src/components/ScreenWrapper/index.js
@@ -11,6 +11,7 @@ import OfflineIndicator from '@components/OfflineIndicator';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import TestToolsModal from '@components/TestToolsModal';
import useEnvironment from '@hooks/useEnvironment';
+import useInitialDimensions from '@hooks/useInitialWindowDimensions';
import useKeyboardState from '@hooks/useKeyboardState';
import useNetwork from '@hooks/useNetwork';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -20,142 +21,155 @@ import toggleTestToolsModal from '@userActions/TestTool';
import CONST from '@src/CONST';
import {defaultProps, propTypes} from './propTypes';
-function ScreenWrapper({
- shouldEnableMaxHeight,
- includePaddingTop,
- keyboardAvoidingViewBehavior,
- includeSafeAreaPaddingBottom,
- shouldEnableKeyboardAvoidingView,
- shouldEnablePickerAvoiding,
- headerGapStyles,
- children,
- shouldShowOfflineIndicator,
- offlineIndicatorStyle,
- style,
- shouldDismissKeyboardBeforeClose,
- onEntryTransitionEnd,
- testID,
-}) {
- const {windowHeight, isSmallScreenWidth} = useWindowDimensions();
- const keyboardState = useKeyboardState();
- const {isDevelopment} = useEnvironment();
- const {isOffline} = useNetwork();
- const navigation = useNavigation();
- const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
- const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined;
- const isKeyboardShown = lodashGet(keyboardState, 'isKeyboardShown', false);
-
- const panResponder = useRef(
- PanResponder.create({
- onStartShouldSetPanResponderCapture: (e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
- onPanResponderRelease: toggleTestToolsModal,
- }),
- ).current;
-
- const keyboardDissmissPanResponder = useRef(
- PanResponder.create({
- onMoveShouldSetPanResponderCapture: (e, gestureState) => {
- const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy);
- const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile();
-
- return isHorizontalSwipe && shouldDismissKeyboard;
- },
- onPanResponderGrant: Keyboard.dismiss,
- }),
- ).current;
-
- useEffect(() => {
- const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (event) => {
- // Prevent firing the prop callback when user is exiting the page.
- if (lodashGet(event, 'data.closing')) {
- return;
- }
-
- setDidScreenTransitionEnd(true);
- onEntryTransitionEnd();
- });
-
- // We need to have this prop to remove keyboard before going away from the screen, to avoid previous screen look weird for a brief moment,
- // also we need to have generic control in future - to prevent closing keyboard for some rare cases in which beforeRemove has limitations
- // described here https://reactnavigation.org/docs/preventing-going-back/#limitations
- const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose
- ? navigation.addListener('beforeRemove', () => {
- if (!isKeyboardShown) {
- return;
- }
- Keyboard.dismiss();
- })
- : undefined;
-
- return () => {
- unsubscribeTransitionEnd();
-
- if (beforeRemoveSubscription) {
- beforeRemoveSubscription();
- }
- };
- // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return (
-
- {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => {
- const paddingStyle = {};
-
- if (includePaddingTop) {
- paddingStyle.paddingTop = paddingTop;
+const ScreenWrapper = React.forwardRef(
+ (
+ {
+ shouldEnableMaxHeight,
+ shouldEnableMinHeight,
+ includePaddingTop,
+ keyboardAvoidingViewBehavior,
+ includeSafeAreaPaddingBottom,
+ shouldEnableKeyboardAvoidingView,
+ shouldEnablePickerAvoiding,
+ headerGapStyles,
+ children,
+ shouldShowOfflineIndicator,
+ offlineIndicatorStyle,
+ style,
+ shouldDismissKeyboardBeforeClose,
+ onEntryTransitionEnd,
+ testID,
+ },
+ ref,
+ ) => {
+ const {windowHeight, isSmallScreenWidth} = useWindowDimensions();
+ const {initialHeight} = useInitialDimensions();
+ const keyboardState = useKeyboardState();
+ const {isDevelopment} = useEnvironment();
+ const {isOffline} = useNetwork();
+ const navigation = useNavigation();
+ const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
+ const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined;
+ const minHeight = shouldEnableMinHeight ? initialHeight : undefined;
+ const isKeyboardShown = lodashGet(keyboardState, 'isKeyboardShown', false);
+
+ const isKeyboardShownRef = useRef();
+
+ isKeyboardShownRef.current = lodashGet(keyboardState, 'isKeyboardShown', false);
+
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponderCapture: (e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
+ onPanResponderRelease: toggleTestToolsModal,
+ }),
+ ).current;
+
+ const keyboardDissmissPanResponder = useRef(
+ PanResponder.create({
+ onMoveShouldSetPanResponderCapture: (e, gestureState) => {
+ const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy);
+ const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile();
+
+ return isHorizontalSwipe && shouldDismissKeyboard;
+ },
+ onPanResponderGrant: Keyboard.dismiss,
+ }),
+ ).current;
+
+ useEffect(() => {
+ const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (event) => {
+ // Prevent firing the prop callback when user is exiting the page.
+ if (lodashGet(event, 'data.closing')) {
+ return;
}
- // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked.
- if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator)) {
- paddingStyle.paddingBottom = paddingBottom;
+ setDidScreenTransitionEnd(true);
+ onEntryTransitionEnd();
+ });
+
+ // We need to have this prop to remove keyboard before going away from the screen, to avoid previous screen look weird for a brief moment,
+ // also we need to have generic control in future - to prevent closing keyboard for some rare cases in which beforeRemove has limitations
+ // described here https://reactnavigation.org/docs/preventing-going-back/#limitations
+ const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose
+ ? navigation.addListener('beforeRemove', () => {
+ if (!isKeyboardShownRef.current) {
+ return;
+ }
+ Keyboard.dismiss();
+ })
+ : undefined;
+
+ return () => {
+ unsubscribeTransitionEnd();
+
+ if (beforeRemoveSubscription) {
+ beforeRemoveSubscription();
}
+ };
+ // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => {
+ const paddingStyle = {};
+
+ if (includePaddingTop) {
+ paddingStyle.paddingTop = paddingTop;
+ }
+
+ // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked.
+ if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator)) {
+ paddingStyle.paddingBottom = paddingBottom;
+ }
- return (
-
+ return (
-
-
-
- {isDevelopment && }
- {isDevelopment && }
- {
- // If props.children is a function, call it to provide the insets to the children.
- _.isFunction(children)
- ? children({
- insets,
- safeAreaPaddingBottomStyle,
- didScreenTransitionEnd,
- })
- : children
- }
- {isSmallScreenWidth && shouldShowOfflineIndicator && }
-
-
+
+
+ {isDevelopment && }
+ {isDevelopment && }
+ {
+ // If props.children is a function, call it to provide the insets to the children.
+ _.isFunction(children)
+ ? children({
+ insets,
+ safeAreaPaddingBottomStyle,
+ didScreenTransitionEnd,
+ })
+ : children
+ }
+ {isSmallScreenWidth && shouldShowOfflineIndicator && }
+
+
+
-
- );
- }}
-
- );
-}
+ );
+ }}
+
+ );
+ },
+);
ScreenWrapper.displayName = 'ScreenWrapper';
ScreenWrapper.propTypes = propTypes;
diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js
index 76c0f81975e4..c98968bb112b 100644
--- a/src/components/ScreenWrapper/propTypes.js
+++ b/src/components/ScreenWrapper/propTypes.js
@@ -37,6 +37,9 @@ const propTypes = {
/** Whether to use the maxHeight (true) or use the 100% of the height (false) */
shouldEnableMaxHeight: PropTypes.bool,
+ /** Whether to use the minHeight. Use true for screens where the window height are changing because of Virtual Keyboard */
+ shouldEnableMinHeight: PropTypes.bool,
+
/** Array of additional styles for header gap */
headerGapStyles: PropTypes.arrayOf(PropTypes.object),
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 0b5f57b0e9d8..66406856ade1 100644
--- a/src/components/SelectionList/selectionListPropTypes.js
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -139,8 +139,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/TextWithEllipsis/index.js b/src/components/TextWithEllipsis/index.js
deleted file mode 100644
index 8c2cd0dd9557..000000000000
--- a/src/components/TextWithEllipsis/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import Text from '@components/Text';
-import stylePropTypes from '@styles/stylePropTypes';
-import styles from '@styles/styles';
-import * as StyleUtils from '@styles/StyleUtils';
-
-const propTypes = {
- /** Leading text before the ellipsis */
- leadingText: PropTypes.string.isRequired,
-
- /** Text after the ellipsis */
- trailingText: PropTypes.string.isRequired,
-
- /** Styles for leading and trailing text */
- textStyle: stylePropTypes,
-
- /** Styles for leading text View */
- leadingTextParentStyle: stylePropTypes,
-
- /** Styles for parent View */
- wrapperStyle: stylePropTypes,
-};
-
-const defaultProps = {
- textStyle: {},
- leadingTextParentStyle: {},
- wrapperStyle: {},
-};
-
-function TextWithEllipsis(props) {
- return (
-
-
-
- {props.leadingText}
-
-
-
- {props.trailingText}
-
-
- );
-}
-
-TextWithEllipsis.propTypes = propTypes;
-TextWithEllipsis.defaultProps = defaultProps;
-TextWithEllipsis.displayName = 'TextWithEllipsis';
-
-export default TextWithEllipsis;
diff --git a/src/components/TextWithEllipsis/index.tsx b/src/components/TextWithEllipsis/index.tsx
new file mode 100644
index 000000000000..1afa05309337
--- /dev/null
+++ b/src/components/TextWithEllipsis/index.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import Text from '@components/Text';
+import styles from '@styles/styles';
+
+type TextWithEllipsisProps = {
+ /** Leading text before the ellipsis */
+ leadingText: string;
+
+ /** Text after the ellipsis */
+ trailingText: string;
+
+ /** Styles for leading and trailing text */
+ textStyle?: StyleProp;
+
+ /** Styles for leading text View */
+ leadingTextParentStyle?: StyleProp;
+
+ /** Styles for parent View */
+ wrapperStyle?: StyleProp;
+};
+
+function TextWithEllipsis({leadingText, trailingText, textStyle, leadingTextParentStyle, wrapperStyle}: TextWithEllipsisProps) {
+ return (
+
+
+
+ {leadingText}
+
+
+
+ {trailingText}
+
+
+ );
+}
+
+TextWithEllipsis.displayName = 'TextWithEllipsis';
+
+export default TextWithEllipsis;
diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js
index 5cf5d8241d40..810c2494f037 100644
--- a/src/components/ThreeDotsMenu/index.js
+++ b/src/components/ThreeDotsMenu/index.js
@@ -111,7 +111,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/UserDetailsTooltip/BaseUserDetailsTooltip.web.js b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
index 871173d01b56..9d89ccaa8889 100644
--- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip.web.js
@@ -2,23 +2,24 @@ import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import React, {useCallback} from 'react';
import {Text, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import Avatar from '@components/Avatar';
+import {usePersonalDetails} from '@components/OnyxProvider';
import Tooltip from '@components/Tooltip';
import useLocalize from '@hooks/useLocalize';
import * as LocalePhoneNumber from '@libs/LocalePhoneNumber';
+import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import styles from '@styles/styles';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import {defaultProps, propTypes} from './userDetailsTooltipPropTypes';
function BaseUserDetailsTooltip(props) {
const {translate} = useLocalize();
+ const personalDetails = usePersonalDetails();
- const userDetails = lodashGet(props.personalDetailsList, props.accountID, props.fallbackUserDetails);
- let userDisplayName = userDetails.displayName ? userDetails.displayName.trim() : '';
+ const userDetails = lodashGet(personalDetails, props.accountID, props.fallbackUserDetails);
+ let userDisplayName = ReportUtils.getDisplayNameForParticipant(props.accountID);
let userLogin = (userDetails.login || '').trim() && !_.isEqual(userDetails.login, userDetails.displayName) ? Str.removeSMSDomain(userDetails.login) : '';
let userAvatar = userDetails.avatar;
let userAccountID = props.accountID;
@@ -26,8 +27,8 @@ function BaseUserDetailsTooltip(props) {
// We replace the actor's email, name, and avatar with the Copilot manually for now. This will be improved upon when
// the Copilot feature is implemented.
if (props.delegateAccountID) {
- const delegateUserDetails = lodashGet(props.personalDetailsList, props.delegateAccountID, {});
- const delegateUserDisplayName = delegateUserDetails.displayName ? delegateUserDetails.displayName.trim() : '';
+ const delegateUserDetails = lodashGet(personalDetails, props.delegateAccountID, {});
+ const delegateUserDisplayName = ReportUtils.getDisplayNameForParticipant(props.delegateAccountID);
userDisplayName = `${delegateUserDisplayName} (${translate('reportAction.asCopilot')} ${userDisplayName})`;
userLogin = delegateUserDetails.login;
userAvatar = delegateUserDetails.avatar;
@@ -78,8 +79,4 @@ BaseUserDetailsTooltip.propTypes = propTypes;
BaseUserDetailsTooltip.defaultProps = defaultProps;
BaseUserDetailsTooltip.displayName = 'BaseUserDetailsTooltip';
-export default withOnyx({
- personalDetailsList: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
-})(BaseUserDetailsTooltip);
+export default BaseUserDetailsTooltip;
diff --git a/src/components/ValidateCode/JustSignedInModal.js b/src/components/ValidateCode/JustSignedInModal.js
index e07f046c9779..b29ab3d69701 100644
--- a/src/components/ValidateCode/JustSignedInModal.js
+++ b/src/components/ValidateCode/JustSignedInModal.js
@@ -51,4 +51,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}
>
;
type OnyxProps = {
- /** Personal details of all the users, including current user */
- personalDetails: OnyxEntry>;
-
/** Session of the current user */
session: OnyxEntry;
};
@@ -34,8 +33,9 @@ export default function (
WrappedComponent: ComponentType>,
): ComponentType & RefAttributes, keyof OnyxProps>> {
function WithCurrentUserPersonalDetails(props: Omit, ref: ForwardedRef) {
+ const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
const accountID = props.session?.accountID ?? 0;
- const accountPersonalDetails = props.personalDetails?.[accountID];
+ const accountPersonalDetails = personalDetails?.[accountID];
const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo(
() => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}),
[accountPersonalDetails, accountID],
@@ -55,9 +55,6 @@ export default function (
const withCurrentUserPersonalDetails = React.forwardRef(WithCurrentUserPersonalDetails);
return withOnyx & RefAttributes, OnyxProps>({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js
deleted file mode 100644
index a03c1155fa46..000000000000
--- a/src/components/withNavigationFallback.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import {NavigationContext} from '@react-navigation/core';
-import React, {forwardRef, useContext, useMemo} from 'react';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-import refPropTypes from './refPropTypes';
-
-export default function (WrappedComponent) {
- function WithNavigationFallback(props) {
- const context = useContext(NavigationContext);
-
- const navigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []);
-
- return context ? (
-
- ) : (
-
-
-
- );
- }
- WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`;
- WithNavigationFallback.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithNavigationFallback.defaultProps = {
- forwardedRef: undefined,
- };
-
- 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
new file mode 100644
index 000000000000..5878c8b3371f
--- /dev/null
+++ b/src/hooks/useInitialWindowDimensions/index.js
@@ -0,0 +1,56 @@
+// eslint-disable-next-line no-restricted-imports
+import {useEffect, useState} from 'react';
+import {Dimensions} from 'react-native';
+
+/**
+ * A convenience hook that provides initial size (width and height).
+ * An initial height allows to know the real height of window,
+ * while the standard useWindowDimensions hook return the height minus Virtual keyboard height
+ * @returns {Object} with information about initial width and height
+ */
+export default function () {
+ const [dimensions, setDimensions] = useState(() => {
+ const window = Dimensions.get('window');
+ const screen = Dimensions.get('screen');
+
+ return {
+ screenHeight: screen.height,
+ screenWidth: screen.width,
+ initialHeight: window.height,
+ initialWidth: window.width,
+ };
+ });
+
+ useEffect(() => {
+ const onDimensionChange = (newDimensions) => {
+ const {window, screen} = newDimensions;
+
+ setDimensions((oldState) => {
+ if (screen.width !== oldState.screenWidth || screen.height !== oldState.screenHeight || window.height > oldState.initialHeight) {
+ return {
+ initialHeight: window.height,
+ initialWidth: window.width,
+ screenHeight: screen.height,
+ screenWidth: screen.width,
+ };
+ }
+
+ return oldState;
+ });
+ };
+
+ const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChange);
+
+ return () => {
+ if (!dimensionsEventListener) {
+ return;
+ }
+ dimensionsEventListener.remove();
+ };
+ }, []);
+
+ return {
+ initialWidth: dimensions.initialWidth,
+ 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 cf4f7e66101f..d99b3c7d04d1 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -592,8 +592,8 @@ export default {
genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later',
genericEditFailureMessage: 'Unexpected error editing the money request, please try again later',
genericSmartscanFailureMessage: 'Transaction is missing fields',
- duplicateWaypointsErrorMessage: 'Please remove duplicate waypoints',
- emptyWaypointsErrorMessage: 'Please enter at least two waypoints',
+ atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses',
+ splitBillMultipleParticipantsErrorMessage: 'Split bill is only allowed between a single workspace or individual users. Please update your selection.',
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
enableWallet: 'Enable Wallet',
@@ -810,7 +810,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',
},
@@ -1637,6 +1636,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!",
@@ -1843,7 +1843,7 @@ export default {
levelThreeResult: 'Message removed from channel plus anonymous warning and message is reported for review.',
},
teachersUnitePage: {
- teachersUnite: 'Teachers unite!',
+ teachersUnite: 'Teachers Unite',
joinExpensifyOrg: 'Join Expensify.org in eliminating injustice around the world and help teachers split their expenses for classrooms in need!',
iKnowATeacher: 'I know a teacher',
iAmATeacher: 'I am a teacher',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index f1e24a7a6777..dea7760a35ce 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -479,7 +479,7 @@ export default {
buttonSearch: 'Buscar',
buttonMySettings: 'Mi configuración',
fabNewChat: 'Iniciar chat',
- fabNewChatExplained: 'Iniciar chat',
+ fabNewChatExplained: 'Iniciar chat (Acción flotante)',
chatPinned: 'Chat fijado',
draftedMessage: 'Mensaje borrador',
listOfChatMessages: 'Lista de mensajes del chat',
@@ -586,8 +586,8 @@ export default {
genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde',
genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde',
genericSmartscanFailureMessage: 'La transacción tiene campos vacíos',
- duplicateWaypointsErrorMessage: 'Por favor elimina los puntos de ruta duplicados',
- emptyWaypointsErrorMessage: 'Por favor introduce al menos dos puntos de ruta',
+ atLeastTwoDifferentWaypoints: 'Por favor introduce al menos dos direcciones diferentes',
+ splitBillMultipleParticipantsErrorMessage: 'Solo puedes dividir una cuenta entre un único espacio de trabajo o con usuarios individuales. Por favor actualiza tu selección.',
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`,
enableWallet: 'Habilitar Billetera',
@@ -805,7 +805,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',
},
@@ -1660,6 +1659,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!',
@@ -2326,7 +2326,7 @@ export default {
levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.',
},
teachersUnitePage: {
- teachersUnite: '¡Profesores unidos!',
+ teachersUnite: 'Profesores Unidos',
joinExpensifyOrg: 'Únete a Expensify.org para eliminar la injusticia en todo el mundo y ayuda a los profesores a dividir sus gastos para las aulas más necesitadas.',
iKnowATeacher: 'Yo conozco a un profesor',
iAmATeacher: 'Soy profesor',
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/DateUtils.ts b/src/libs/DateUtils.ts
index 13853189ed26..b956b5adcc51 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -1,5 +1,7 @@
import {
addDays,
+ eachDayOfInterval,
+ eachMonthOfInterval,
endOfDay,
endOfWeek,
format,
@@ -133,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');
@@ -255,6 +263,38 @@ function getCurrentTimezone(): Required {
return timezone;
}
+/**
+ * @returns [January, Fabruary, March, April, May, June, July, August, ...]
+ */
+function getMonthNames(preferredLocale: string): string[] {
+ if (preferredLocale) {
+ setLocale(preferredLocale);
+ }
+ const fullYear = new Date().getFullYear();
+ const monthsArray = eachMonthOfInterval({
+ start: new Date(fullYear, 0, 1), // January 1st of the current year
+ end: new Date(fullYear, 11, 31), // December 31st of the current year
+ });
+
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ return monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
+}
+
+/**
+ * @returns [Monday, Thuesday, Wednesday, ...]
+ */
+function getDaysOfWeek(preferredLocale: string): string[] {
+ if (preferredLocale) {
+ setLocale(preferredLocale);
+ }
+ const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const endOfCurrentWeek = endOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const daysOfWeek = eachDayOfInterval({start: startOfCurrentWeek, end: endOfCurrentWeek});
+
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ return daysOfWeek.map((date) => format(date, 'eeee'));
+}
+
// Used to throttle updates to the timezone when necessary
let lastUpdatedTimezoneTime = new Date();
@@ -373,6 +413,8 @@ const DateUtils = {
isToday,
isTomorrow,
isYesterday,
+ getMonthNames,
+ getDaysOfWeek,
formatWithUTCTimeZone,
};
diff --git a/src/libs/DistanceRequestUtils.js b/src/libs/DistanceRequestUtils.js
index 0cc4e39d83af..0f994cc54f93 100644
--- a/src/libs/DistanceRequestUtils.js
+++ b/src/libs/DistanceRequestUtils.js
@@ -90,7 +90,7 @@ const getDistanceMerchant = (hasRoute, distanceInMeters, unit, rate, currency, t
const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer');
const unitString = distanceInUnits === 1 ? singularDistanceUnit : distanceUnit;
const ratePerUnit = rate ? PolicyUtils.getUnitRateValue({rate}, toLocaleDigit) : translate('common.tbd');
- const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `;
+ const currencySymbol = rate ? CurrencyUtils.getCurrencySymbol(currency) || `${currency} ` : '';
return `${distanceInUnits} ${unitString} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`;
};
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/openApp.js b/src/libs/E2E/apiMocks/openApp.js
index d50f4462cfd9..5e77d3912441 100644
--- a/src/libs/E2E/apiMocks/openApp.js
+++ b/src/libs/E2E/apiMocks/openApp.js
@@ -2131,7 +2131,7 @@ export default () => ({
report_2543745284790730: {
reportID: '2543745284790730',
ownerAccountID: 17,
- managerEmail: 'fake6@gmail.com',
+ managerID: 16,
currency: 'USD',
chatReportID: '98817646',
state: 'SUBMITTED',
@@ -2143,7 +2143,7 @@ export default () => ({
report_4249286573496381: {
reportID: '4249286573496381',
ownerAccountID: 17,
- managerEmail: 'christoph+hightraffic@margelo.io',
+ managerID: 21,
currency: 'USD',
chatReportID: '4867098979334014',
state: 'SUBMITTED',
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/EmojiTrie.ts b/src/libs/EmojiTrie.ts
index e98eb8cd720d..ed03e101ebdc 100644
--- a/src/libs/EmojiTrie.ts
+++ b/src/libs/EmojiTrie.ts
@@ -24,11 +24,14 @@ type LocalizedEmojis = Record;
type Suggestion = {
code: string;
types?: string[];
- name?: string;
+ name: string;
};
type EmojiMetaData = {
suggestions?: Suggestion[];
+ code?: string;
+ types?: string[];
+ name?: string;
};
Timing.start(CONST.TIMING.TRIE_INITIALIZATION);
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.ts
similarity index 56%
rename from src/libs/EmojiUtils.js
rename to src/libs/EmojiUtils.ts
index 4ade70537b0b..1308faa65d20 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.ts
@@ -1,83 +1,93 @@
import {getUnixTime} from 'date-fns';
import Str from 'expensify-common/lib/str';
-import lodashGet from 'lodash/get';
-import lodashMin from 'lodash/min';
-import lodashSum from 'lodash/sum';
+import memoize from 'lodash/memoize';
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
+import {SvgProps} from 'react-native-svg';
import * as Emojis from '@assets/emojis';
+import {Emoji, HeaderEmoji, PickerEmojis} from '@assets/emojis/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import {FrequentlyUsedEmoji} from '@src/types/onyx';
import emojisTrie from './EmojiTrie';
-let frequentlyUsedEmojis = [];
+type HeaderIndice = {code: string; index: number; icon: React.FC};
+type EmojiSpacer = {code: string; spacer: boolean};
+type EmojiPickerList = Array;
+type ReplacedEmoji = {text: string; emojis: Emoji[]};
+type UserReactions = {
+ id: string;
+ skinTones: Record;
+};
+type UserReactionsWithTimestamps = UserReactions & {
+ oldestTimestamp: string;
+};
+type UsersReactionsList = {
+ createdAt: string;
+ users: Record;
+};
+type TimestampedUsersReactions = Record;
+type EnrichedUserReactions = {
+ createdAt: string;
+ oldestTimestamp: string;
+ users: TimestampedUsersReactions;
+};
+
+let frequentlyUsedEmojis: FrequentlyUsedEmoji[] = [];
Onyx.connect({
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
callback: (val) => {
- frequentlyUsedEmojis = _.map(val, (item) => {
- const emoji = Emojis.emojiCodeTableWithSkinTones[item.code];
- if (emoji) {
- return {...emoji, count: item.count, lastUpdatedAt: item.lastUpdatedAt};
- }
- });
+ if (!val) {
+ return;
+ }
+ frequentlyUsedEmojis =
+ val
+ ?.map((item) => {
+ const emoji = Emojis.emojiCodeTableWithSkinTones[item.code];
+ return {...emoji, count: item.count, lastUpdatedAt: item.lastUpdatedAt};
+ })
+ .filter((emoji): emoji is FrequentlyUsedEmoji => !!emoji) ?? [];
},
});
-/**
- *
- * @param {String} name
- * @returns {Object}
- */
-const findEmojiByName = (name) => Emojis.emojiNameTable[name];
+const findEmojiByName = (name: string): Emoji => Emojis.emojiNameTable[name];
-/**
- *
- * @param {String} code
- * @returns {Object}
- */
-const findEmojiByCode = (code) => Emojis.emojiCodeTableWithSkinTones[code];
+const findEmojiByCode = (code: string): Emoji => Emojis.emojiCodeTableWithSkinTones[code];
-/**
- *
- * @param {Object} emoji
- * @param {String} lang
- * @returns {String}
- */
-const getEmojiName = (emoji, lang = CONST.LOCALES.DEFAULT) => {
+const getEmojiName = (emoji: Emoji, lang: 'en' | 'es' = CONST.LOCALES.DEFAULT): string => {
if (lang === CONST.LOCALES.DEFAULT) {
return emoji.name;
}
- return _.get(Emojis.localeEmojis, [lang, emoji.code, 'name'], '');
+ return Emojis.localeEmojis?.[lang]?.[emoji.code]?.name ?? '';
};
/**
* Given an English emoji name, get its localized version
- *
- * @param {String} name
- * @param {String} lang
- * @returns {String}
*/
-const getLocalizedEmojiName = (name, lang) => {
+const getLocalizedEmojiName = (name: string, lang: 'en' | 'es'): string => {
if (lang === CONST.LOCALES.DEFAULT) {
return name;
}
- return _.get(Emojis.localeEmojis, [lang, _.get(Emojis.emojiNameTable, [name, 'code'], ''), 'name'], '');
+ const emojiCode = Emojis.emojiNameTable[name]?.code ?? '';
+ return Emojis.localeEmojis[lang]?.[emojiCode]?.name ?? '';
};
/**
* Get the unicode code of an emoji in base 16.
- * @param {String} input
- * @returns {String}
*/
-const getEmojiUnicode = _.memoize((input) => {
+const getEmojiUnicode = memoize((input: string) => {
if (input.length === 0) {
return '';
}
if (input.length === 1) {
- return _.map(input.charCodeAt(0).toString().split(' '), (val) => parseInt(val, 10).toString(16)).join(' ');
+ return input
+ .charCodeAt(0)
+ .toString()
+ .split(' ')
+ .map((val) => parseInt(val, 10).toString(16))
+ .join(' ');
}
const pairs = [];
@@ -100,25 +110,20 @@ const getEmojiUnicode = _.memoize((input) => {
pairs.push(input.charCodeAt(i));
}
}
- return _.map(pairs, (val) => parseInt(val, 10).toString(16)).join(' ');
+ return pairs.map((val) => parseInt(String(val), 10).toString(16)).join(' ');
});
/**
* Function to remove Skin Tone and utf16 surrogates from Emoji
- * @param {String} emojiCode
- * @returns {String}
*/
-function trimEmojiUnicode(emojiCode) {
+function trimEmojiUnicode(emojiCode: string): string {
return emojiCode.replace(/(fe0f|1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)$/, '').trim();
}
/**
* Validates first character is emoji in text string
- *
- * @param {String} message
- * @returns {Boolean}
*/
-function isFirstLetterEmoji(message) {
+function isFirstLetterEmoji(message: string): boolean {
const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
@@ -126,16 +131,13 @@ function isFirstLetterEmoji(message) {
return false;
}
- return trimmedMessage.indexOf(match[0]) === 0;
+ return trimmedMessage.startsWith(match[0]);
}
/**
* Validates that this message contains only emojis
- *
- * @param {String} message
- * @returns {Boolean}
*/
-function containsOnlyEmojis(message) {
+function containsOnlyEmojis(message: string): boolean {
const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
@@ -144,33 +146,32 @@ function containsOnlyEmojis(message) {
}
const codes = [];
- _.map(match, (emoji) =>
- _.map(getEmojiUnicode(emoji).split(' '), (code) => {
- if (!CONST.INVISIBLE_CODEPOINTS.includes(code)) {
- codes.push(code);
- }
- return code;
- }),
+ match.map((emoji) =>
+ getEmojiUnicode(emoji)
+ .split(' ')
+ .map((code) => {
+ if (!(CONST.INVISIBLE_CODEPOINTS as readonly string[]).includes(code)) {
+ codes.push(code);
+ }
+ return code;
+ }),
);
// Emojis are stored as multiple characters, so we're using spread operator
// to iterate over the actual emojis, not just characters that compose them
- const messageCodes = _.filter(
- _.map([...trimmedMessage], (char) => getEmojiUnicode(char)),
- (string) => string.length > 0 && !CONST.INVISIBLE_CODEPOINTS.includes(string),
- );
+ const messageCodes = [...trimmedMessage]
+ .map((char) => getEmojiUnicode(char))
+ .filter((string) => string.length > 0 && !(CONST.INVISIBLE_CODEPOINTS as readonly string[]).includes(string));
return codes.length === messageCodes.length;
}
/**
* Get the header emojis with their code, icon and index
- * @param {Object[]} emojis
- * @returns {Object[]}
*/
-function getHeaderEmojis(emojis) {
- const headerIndices = [];
- _.each(emojis, (emoji, index) => {
- if (!emoji.header) {
+function getHeaderEmojis(emojis: PickerEmojis): HeaderIndice[] {
+ const headerIndices: HeaderIndice[] = [];
+ emojis.forEach((emoji, index) => {
+ if (!('header' in emoji)) {
return;
}
headerIndices.push({code: emoji.code, index, icon: emoji.icon});
@@ -180,11 +181,8 @@ function getHeaderEmojis(emojis) {
/**
* Get number of empty spaces to be filled to get equal emojis for every row
- * @param {Number} emojiCount
- * @param {Number} suffix
- * @returns {Object[]}
*/
-function getDynamicSpacing(emojiCount, suffix) {
+function getDynamicSpacing(emojiCount: number, suffix: number): EmojiSpacer[] {
const spacerEmojis = [];
let modLength = CONST.EMOJI_NUM_PER_ROW - (emojiCount % CONST.EMOJI_NUM_PER_ROW);
@@ -201,13 +199,11 @@ function getDynamicSpacing(emojiCount, suffix) {
/**
* Add dynamic spaces to emoji categories
- * @param {Object[]} emojis
- * @returns {Object[]}
*/
-function addSpacesToEmojiCategories(emojis) {
- let updatedEmojis = [];
- _.each(emojis, (emoji, index) => {
- if (emoji.header) {
+function addSpacesToEmojiCategories(emojis: PickerEmojis): EmojiPickerList {
+ let updatedEmojis: EmojiPickerList = [];
+ emojis.forEach((emoji, index) => {
+ if ('header' in emoji) {
updatedEmojis = updatedEmojis.concat(getDynamicSpacing(updatedEmojis.length, index), [emoji], getDynamicSpacing(1, index));
return;
}
@@ -218,31 +214,28 @@ function addSpacesToEmojiCategories(emojis) {
/**
* Get a merged array with frequently used emojis
- * @param {Object[]} emojis
- * @returns {Object[]}
*/
-function mergeEmojisWithFrequentlyUsedEmojis(emojis) {
+function mergeEmojisWithFrequentlyUsedEmojis(emojis: PickerEmojis): EmojiPickerList {
if (frequentlyUsedEmojis.length === 0) {
return addSpacesToEmojiCategories(emojis);
}
- const mergedEmojis = [Emojis.categoryFrequentlyUsed].concat(frequentlyUsedEmojis, emojis);
+ const mergedEmojis = [Emojis.categoryFrequentlyUsed, ...frequentlyUsedEmojis, ...emojis];
return addSpacesToEmojiCategories(mergedEmojis);
}
/**
* Get the updated frequently used emojis list by usage
- * @param {Object|Object[]} newEmoji
- * @return {Object[]}
*/
-function getFrequentlyUsedEmojis(newEmoji) {
+function getFrequentlyUsedEmojis(newEmoji: Emoji | Emoji[]): FrequentlyUsedEmoji[] {
let frequentEmojiList = [...frequentlyUsedEmojis];
const maxFrequentEmojiCount = CONST.EMOJI_FREQUENT_ROW_COUNT * CONST.EMOJI_NUM_PER_ROW - 1;
+
const currentTimestamp = getUnixTime(new Date());
- _.each([].concat(newEmoji), (emoji) => {
+ (Array.isArray(newEmoji) ? [...newEmoji] : [newEmoji]).forEach((emoji) => {
let currentEmojiCount = 1;
- const emojiIndex = _.findIndex(frequentEmojiList, (e) => e.code === emoji.code);
+ const emojiIndex = frequentEmojiList.findIndex((e) => e.code === emoji.code);
if (emojiIndex >= 0) {
currentEmojiCount = frequentEmojiList[emojiIndex].count + 1;
frequentEmojiList.splice(emojiIndex, 1);
@@ -264,14 +257,10 @@ function getFrequentlyUsedEmojis(newEmoji) {
/**
* Given an emoji item object, return an emoji code based on its type.
- *
- * @param {Object} item
- * @param {Number} preferredSkinToneIndex
- * @returns {String}
*/
-const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => {
+const getEmojiCodeWithSkinColor = (item: Emoji, preferredSkinToneIndex: number): string => {
const {code, types} = item;
- if (types && types[preferredSkinToneIndex]) {
+ if (types?.[preferredSkinToneIndex]) {
return types[preferredSkinToneIndex];
}
@@ -281,10 +270,10 @@ const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => {
/**
* Extracts emojis from a given text.
*
- * @param {String} text - The text to extract emojis from.
- * @returns {Object[]} An array of emoji codes.
+ * @param text - The text to extract emojis from.
+ * @returns An array of emoji codes.
*/
-function extractEmojis(text) {
+function extractEmojis(text: string): Emoji[] {
if (!text) {
return [];
}
@@ -296,9 +285,11 @@ function extractEmojis(text) {
return [];
}
- const emojis = [];
- for (let i = 0; i < parsedEmojis.length; i++) {
- const character = parsedEmojis[i];
+ const emojis: Emoji[] = [];
+
+ // Text can contain similar emojis as well as their skin tone variants. Create a Set to remove duplicate emojis from the search.
+
+ for (const character of parsedEmojis) {
const emoji = Emojis.emojiCodeTableWithSkinTones[character];
if (emoji) {
emojis.push(emoji);
@@ -310,15 +301,15 @@ function extractEmojis(text) {
/**
* Take the current emojis and the former emojis and return the emojis that were added, if we add an already existing emoji, we also return it
- * @param {Object[]} currentEmojis The array of current emojis
- * @param {Object[]} formerEmojis The array of former emojis
- * @returns {Object[]} The array of added emojis
+ * @param currentEmojis The array of current emojis
+ * @param formerEmojis The array of former emojis
+ * @returns The array of added emojis
*/
-function getAddedEmojis(currentEmojis, formerEmojis) {
- const newEmojis = [...currentEmojis];
+function getAddedEmojis(currentEmojis: Emoji[], formerEmojis: Emoji[]): Emoji[] {
+ const newEmojis: Emoji[] = [...currentEmojis];
// We are removing the emojis from the newEmojis array if they were already present before.
formerEmojis.forEach((formerEmoji) => {
- const indexOfAlreadyPresentEmoji = _.findIndex(newEmojis, (newEmoji) => newEmoji.code === formerEmoji.code);
+ const indexOfAlreadyPresentEmoji = newEmojis.findIndex((newEmoji) => newEmoji.code === formerEmoji.code);
if (indexOfAlreadyPresentEmoji >= 0) {
newEmojis.splice(indexOfAlreadyPresentEmoji, 1);
}
@@ -329,20 +320,15 @@ function getAddedEmojis(currentEmojis, formerEmojis) {
/**
* Replace any emoji name in a text with the emoji icon.
* If we're on mobile, we also add a space after the emoji granted there's no text after it.
- *
- * @param {String} text
- * @param {Number} preferredSkinTone
- * @param {String} lang
- * @returns {Object}
*/
-function replaceEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT) {
+function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: 'en' | 'es' = CONST.LOCALES.DEFAULT): ReplacedEmoji {
const trie = emojisTrie[lang];
if (!trie) {
return {text, emojis: []};
}
let newText = text;
- const emojis = [];
+ const emojis: Emoji[] = [];
const emojiData = text.match(CONST.REGEX.EMOJI_NAME);
if (!emojiData || emojiData.length === 0) {
return {text: newText, emojis};
@@ -352,18 +338,18 @@ function replaceEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
let checkEmoji = trie.search(name);
// If the user has selected a language other than English, and the emoji doesn't exist in that language,
// we will check if the emoji exists in English.
- if (lang !== CONST.LOCALES.DEFAULT && (!checkEmoji || !checkEmoji.metaData.code)) {
+ if (lang !== CONST.LOCALES.DEFAULT && !checkEmoji?.metaData?.code) {
const englishTrie = emojisTrie[CONST.LOCALES.DEFAULT];
if (englishTrie) {
const englishEmoji = englishTrie.search(name);
checkEmoji = englishEmoji;
}
}
- if (checkEmoji && checkEmoji.metaData.code) {
- let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData, preferredSkinTone);
+ if (checkEmoji?.metaData?.code && checkEmoji?.metaData?.name) {
+ let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
emojis.push({
name,
- code: checkEmoji.metaData.code,
+ code: checkEmoji.metaData?.code,
types: checkEmoji.metaData.types,
});
@@ -382,12 +368,8 @@ function replaceEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
/**
* Find all emojis in a text and replace them with their code.
- * @param {String} text
- * @param {Number} preferredSkinTone
- * @param {String} lang
- * @returns {Object}
*/
-function replaceAndExtractEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT) {
+function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji {
const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang);
return {
@@ -398,12 +380,9 @@ function replaceAndExtractEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_S
/**
* Suggest emojis when typing emojis prefix after colon
- * @param {String} text
- * @param {String} lang
- * @param {Number} [limit] - matching emojis limit
- * @returns {Array}
+ * @param [limit] - matching emojis limit
*/
-function suggestEmojis(text, lang, limit = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS) {
+function suggestEmojis(text: string, lang: keyof typeof emojisTrie, limit = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined {
const trie = emojisTrie[lang];
if (!trie) {
return [];
@@ -414,23 +393,25 @@ function suggestEmojis(text, lang, limit = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMO
return [];
}
- const matching = [];
+ const matching: Emoji[] = [];
const nodes = trie.getAllMatchingWords(emojiData[0].toLowerCase().slice(1), limit);
- for (let j = 0; j < nodes.length; j++) {
- if (nodes[j].metaData.code && !_.find(matching, (obj) => obj.name === nodes[j].name)) {
+ for (const node of nodes) {
+ if (node.metaData?.code && !matching.find((obj) => obj.name === node.name)) {
if (matching.length === limit) {
return matching;
}
- matching.push({code: nodes[j].metaData.code, name: nodes[j].name, types: nodes[j].metaData.types});
+ matching.push({code: node.metaData.code, name: node.name, types: node.metaData.types});
+ }
+ const suggestions = node.metaData.suggestions;
+ if (!suggestions) {
+ return;
}
- const suggestions = nodes[j].metaData.suggestions;
- for (let i = 0; i < suggestions.length; i++) {
+ for (const suggestion of suggestions) {
if (matching.length === limit) {
return matching;
}
- const suggestion = suggestions[i];
- if (!_.find(matching, (obj) => obj.name === suggestion.name)) {
+ if (!matching.find((obj) => obj.name === suggestion.name)) {
matching.push({...suggestion});
}
}
@@ -440,12 +421,9 @@ function suggestEmojis(text, lang, limit = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMO
/**
* Retrieve preferredSkinTone as Number to prevent legacy 'default' String value
- *
- * @param {Number | String} val
- * @returns {Number}
*/
-const getPreferredSkinToneIndex = (val) => {
- if (!_.isNull(val) && !_.isUndefined(val) && Number.isInteger(Number(val))) {
+const getPreferredSkinToneIndex = (val: string | number): number | string => {
+ if (val !== null && Number.isInteger(Number(val))) {
return val;
}
@@ -455,11 +433,8 @@ const getPreferredSkinToneIndex = (val) => {
/**
* Given an emoji object it returns the correct emoji code
* based on the users preferred skin tone.
- * @param {Object} emoji
- * @param {String | Number} preferredSkinTone
- * @returns {String}
*/
-const getPreferredEmojiCode = (emoji, preferredSkinTone) => {
+const getPreferredEmojiCode = (emoji: Emoji, preferredSkinTone: number): string => {
if (emoji.types) {
const emojiCodeWithSkinTone = emoji.types[preferredSkinTone];
@@ -477,61 +452,45 @@ const getPreferredEmojiCode = (emoji, preferredSkinTone) => {
* Given an emoji object and a list of senders it will return an
* array of emoji codes, that represents all used variations of the
* emoji, sorted by the reaction timestamp.
- * @param {Object} emojiAsset
- * @param {String} emojiAsset.name
- * @param {String} emojiAsset.code
- * @param {String[]} [emojiAsset.types]
- * @param {Array} users
- * @return {string[]}
- * */
-const getUniqueEmojiCodes = (emojiAsset, users) => {
- const emojiCodes = _.reduce(
- users,
- (result, userSkinTones) => {
- _.each(lodashGet(userSkinTones, 'skinTones'), (createdAt, skinTone) => {
- const emojiCode = getPreferredEmojiCode(emojiAsset, skinTone);
- if (!!emojiCode && (!result[emojiCode] || createdAt < result[emojiCode])) {
- // eslint-disable-next-line no-param-reassign
- result[emojiCode] = createdAt;
- }
- });
- return result;
- },
- {},
- );
+ */
+const getUniqueEmojiCodes = (emojiAsset: Emoji, users: TimestampedUsersReactions): string[] => {
+ const emojiCodes: Record = Object.values(users ?? {}).reduce((result: Record, userSkinTones) => {
+ Object.keys(userSkinTones?.skinTones ?? {}).forEach((skinTone) => {
+ const createdAt = userSkinTones.skinTones[Number(skinTone)];
+ const emojiCode = getPreferredEmojiCode(emojiAsset, Number(skinTone));
+ if (!!emojiCode && (!result[emojiCode] || createdAt < result[emojiCode])) {
+ // eslint-disable-next-line no-param-reassign
+ result[emojiCode] = createdAt;
+ }
+ });
+ return result;
+ }, {});
- return _.chain(emojiCodes)
- .pairs()
- .sortBy((entry) => new Date(entry[1])) // Sort by values (timestamps)
- .map((entry) => entry[0]) // Extract keys (emoji codes)
- .value();
+ return Object.keys(emojiCodes ?? {}).sort((a, b) => (new Date(emojiCodes[a]) > new Date(emojiCodes[b]) ? 1 : -1));
};
/**
* Given an emoji reaction object and its name, it populates it with the oldest reaction timestamps.
- * @param {Object} emoji
- * @param {String} emojiName
- * @returns {Object}
*/
-const enrichEmojiReactionWithTimestamps = (emoji, emojiName) => {
- let oldestEmojiTimestamp = null;
+const enrichEmojiReactionWithTimestamps = (emoji: UsersReactionsList, emojiName: string): EnrichedUserReactions => {
+ let oldestEmojiTimestamp: string | null = null;
- const usersWithTimestamps = _.chain(emoji.users)
- .pick(_.identity)
- .mapObject((user, id) => {
- const oldestUserTimestamp = lodashMin(_.values(user.skinTones));
+ const usersWithTimestamps: Record = {};
+ Object.keys(emoji.users ?? {}).forEach((id) => {
+ const user = emoji?.users?.[id];
+ const userTimestamps = Object.values(user?.skinTones ?? {});
+ const oldestUserTimestamp = userTimestamps.reduce((min, curr) => (curr < min ? curr : min), userTimestamps[0]);
- if (!oldestEmojiTimestamp || oldestUserTimestamp < oldestEmojiTimestamp) {
- oldestEmojiTimestamp = oldestUserTimestamp;
- }
+ if (!oldestEmojiTimestamp || oldestUserTimestamp < oldestEmojiTimestamp) {
+ oldestEmojiTimestamp = oldestUserTimestamp;
+ }
- return {
- ...user,
- id,
- oldestTimestamp: oldestUserTimestamp,
- };
- })
- .value();
+ usersWithTimestamps[id] = {
+ ...user,
+ id,
+ oldestTimestamp: oldestUserTimestamp,
+ };
+ });
return {
...emoji,
@@ -539,24 +498,21 @@ const enrichEmojiReactionWithTimestamps = (emoji, emojiName) => {
// Just in case two emojis have the same timestamp, also combine the timestamp with the
// emojiName so that the order will always be the same. Without this, the order can be pretty random
// and shift around a little bit.
- oldestTimestamp: (oldestEmojiTimestamp || emoji.createdAt) + emojiName,
+ oldestTimestamp: (oldestEmojiTimestamp ?? emoji.createdAt) + emojiName,
};
};
/**
* Returns true if the accountID has reacted to the report action (with the given skin tone).
* Uses the NEW FORMAT for "emojiReactions"
- * @param {String} accountID
- * @param {Array} usersReactions - all the users reactions
- * @param {Number} [skinTone]
- * @returns {boolean}
+ * @param usersReactions - all the users reactions
*/
-function hasAccountIDEmojiReacted(accountID, usersReactions, skinTone) {
- if (_.isUndefined(skinTone)) {
+function hasAccountIDEmojiReacted(accountID: string, usersReactions: TimestampedUsersReactions, skinTone?: number) {
+ if (skinTone === undefined) {
return Boolean(usersReactions[accountID]);
}
const userReaction = usersReactions[accountID];
- if (!userReaction || !userReaction.skinTones || !_.size(userReaction.skinTones)) {
+ if (!userReaction?.skinTones || !Object.values(userReaction?.skinTones ?? {}).length) {
return false;
}
return Boolean(userReaction.skinTones[skinTone]);
@@ -564,22 +520,19 @@ function hasAccountIDEmojiReacted(accountID, usersReactions, skinTone) {
/**
* Given an emoji reaction and current user's account ID, it returns the reusable details of the emoji reaction.
- * @param {String} emojiName
- * @param {Object} reaction
- * @param {String} currentUserAccountID
- * @returns {Object}
*/
-const getEmojiReactionDetails = (emojiName, reaction, currentUserAccountID) => {
+const getEmojiReactionDetails = (emojiName: string, reaction: UsersReactionsList, currentUserAccountID: string) => {
const {users, oldestTimestamp} = enrichEmojiReactionWithTimestamps(reaction, emojiName);
const emoji = findEmojiByName(emojiName);
const emojiCodes = getUniqueEmojiCodes(emoji, users);
- const reactionCount = lodashSum(_.map(users, (user) => _.size(user.skinTones)));
+ const reactionCount = Object.values(users ?? {})
+ .map((user) => Object.values(user?.skinTones ?? {}).length)
+ .reduce((sum, curr) => sum + curr, 0);
const hasUserReacted = hasAccountIDEmojiReacted(currentUserAccountID, users);
- const userAccountIDs = _.chain(users)
- .sortBy('oldestTimestamp')
- .map((user) => Number(user.id))
- .value();
+ const userAccountIDs = Object.values(users ?? {})
+ .sort((a, b) => (a.oldestTimestamp > b.oldestTimestamp ? 1 : -1))
+ .map((user) => Number(user.id));
return {
emoji,
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/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
deleted file mode 100644
index 20baf44b23f4..000000000000
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import {createStackNavigator} from '@react-navigation/stack';
-import React from 'react';
-import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper';
-import getCurrentUrl from '@libs/Navigation/currentUrl';
-import FreezeWrapper from '@libs/Navigation/FreezeWrapper';
-import styles from '@styles/styles';
-import SCREENS from '@src/SCREENS';
-
-const Stack = createStackNavigator();
-
-const url = getCurrentUrl();
-const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
-
-function CentralPaneNavigator() {
- return (
-
-
-
-
-
- );
-}
-
-export default CentralPaneNavigator;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js
new file mode 100644
index 000000000000..a1646011e560
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js
@@ -0,0 +1,33 @@
+import {createStackNavigator} from '@react-navigation/stack';
+import React from 'react';
+import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper';
+import getCurrentUrl from '@libs/Navigation/currentUrl';
+import styles from '@styles/styles';
+import SCREENS from '@src/SCREENS';
+
+const Stack = createStackNavigator();
+
+const url = getCurrentUrl();
+const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
+
+function BaseCentralPaneNavigator() {
+ return (
+
+
+
+ );
+}
+
+export default BaseCentralPaneNavigator;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js
new file mode 100644
index 000000000000..711dd468c77d
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import BaseCentralPaneNavigator from './BaseCentralPaneNavigator';
+
+// We don't need to use freeze wraper on web because we don't render all report routes anyway.
+// You can see this optimalization in the customStackNavigator.
+function CentralPaneNavigator() {
+ return ;
+}
+
+export default CentralPaneNavigator;
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js
new file mode 100644
index 000000000000..45ab2f070717
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/index.native.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import FreezeWrapper from '@libs/Navigation/FreezeWrapper';
+import BaseCentralPaneNavigator from './BaseCentralPaneNavigator';
+
+function CentralPaneNavigator() {
+ return (
+
+
+
+ );
+}
+
+export default CentralPaneNavigator;
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/AppNavigator/createCustomStackNavigator/index.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js
index ae36f4aff9ad..8924b01e2acb 100644
--- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.js
@@ -1,8 +1,9 @@
import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native';
import {StackView} from '@react-navigation/stack';
import PropTypes from 'prop-types';
-import React, {useRef} from 'react';
+import React, {useMemo, useRef} from 'react';
import useWindowDimensions from '@hooks/useWindowDimensions';
+import NAVIGATORS from '@src/NAVIGATORS';
import CustomRouter from './CustomRouter';
const propTypes = {
@@ -25,6 +26,26 @@ const defaultProps = {
screenOptions: undefined,
};
+function reduceReportRoutes(routes) {
+ const result = [];
+ let count = 0;
+ const reverseRoutes = [...routes].reverse();
+
+ reverseRoutes.forEach((route) => {
+ if (route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR) {
+ // Remove all report routes except the last 3. This will improve performance.
+ if (count < 3) {
+ result.push(route);
+ count++;
+ }
+ } else {
+ result.push(route);
+ }
+ });
+
+ return result.reverse();
+}
+
function ResponsiveStackNavigator(props) {
const {isSmallScreenWidth} = useWindowDimensions();
@@ -40,12 +61,22 @@ function ResponsiveStackNavigator(props) {
getIsSmallScreenWidth: () => isSmallScreenWidthRef.current,
});
+ const stateToRender = useMemo(() => {
+ const result = reduceReportRoutes(state.routes);
+
+ return {
+ ...state,
+ index: result.length - 1,
+ routes: [...result],
+ };
+ }, [state]);
+
return (
diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js
new file mode 100644
index 000000000000..ae36f4aff9ad
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/index.native.js
@@ -0,0 +1,60 @@
+import {createNavigatorFactory, useNavigationBuilder} from '@react-navigation/native';
+import {StackView} from '@react-navigation/stack';
+import PropTypes from 'prop-types';
+import React, {useRef} from 'react';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import CustomRouter from './CustomRouter';
+
+const propTypes = {
+ /* Determines if the navigator should render the StackView (narrow) or ThreePaneView (wide) */
+ isSmallScreenWidth: PropTypes.bool.isRequired,
+
+ /* Children for the useNavigationBuilder hook */
+ children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
+
+ /* initialRouteName for this navigator */
+ initialRouteName: PropTypes.oneOf([PropTypes.string, PropTypes.undefined]),
+
+ /* Screen options defined for this navigator */
+ // eslint-disable-next-line react/forbid-prop-types
+ screenOptions: PropTypes.object,
+};
+
+const defaultProps = {
+ initialRouteName: undefined,
+ screenOptions: undefined,
+};
+
+function ResponsiveStackNavigator(props) {
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ const isSmallScreenWidthRef = useRef(isSmallScreenWidth);
+
+ isSmallScreenWidthRef.current = isSmallScreenWidth;
+
+ const {navigation, state, descriptors, NavigationContent} = useNavigationBuilder(CustomRouter, {
+ children: props.children,
+ screenOptions: props.screenOptions,
+ initialRouteName: props.initialRouteName,
+ // Options for useNavigationBuilder won't update on prop change, so we need to pass a getter for the router to have the current state of isSmallScreenWidth.
+ getIsSmallScreenWidth: () => isSmallScreenWidthRef.current,
+ });
+
+ return (
+
+
+
+ );
+}
+
+ResponsiveStackNavigator.defaultProps = defaultProps;
+ResponsiveStackNavigator.propTypes = propTypes;
+ResponsiveStackNavigator.displayName = 'ResponsiveStackNavigator';
+
+export default createNavigatorFactory(ResponsiveStackNavigator);
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 54d09b75eff2..5cd04375ce62 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -385,9 +385,7 @@ function getLastMessageTextForReport(report) {
let lastMessageTextFromReport = '';
const lastActionName = lodashGet(lastReportAction, 'actionName', '');
- if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) {
- lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`;
- } else if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) {
+ if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) {
const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage);
} else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) {
@@ -404,6 +402,8 @@ function getLastMessageTextForReport(report) {
lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report);
} else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) {
lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction);
+ } else if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) {
+ lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`;
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 2eb1d3b02f25..5200e5803ee3 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -46,6 +46,10 @@ function canUseTags(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas);
}
+function canUseViolations(betas: Beta[]): boolean {
+ return betas?.includes(CONST.BETAS.VIOLATIONS) || canUseAllBetas(betas);
+}
+
/**
* Link previews are temporarily disabled.
*/
@@ -64,4 +68,5 @@ export default {
canUseCustomStatus,
canUseTags,
canUseLinkPreviews,
+ canUseViolations,
};
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index 38b4823d54c6..5ee177b8f831 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -36,17 +36,19 @@ function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string
const hasEReceipt = transaction?.hasEReceipt;
- if (hasEReceipt) {
- return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
- }
+ if (!Object.hasOwn(transaction?.pendingFields ?? {}, 'waypoints')) {
+ if (hasEReceipt) {
+ return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
+ }
- // For local files, we won't have a thumbnail yet
- if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
- return {thumbnail: null, image: path};
- }
+ // For local files, we won't have a thumbnail yet
+ if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
+ return {thumbnail: null, image: path};
+ }
- if (isReceiptImage) {
- return {thumbnail: `${path}.1024.jpg`, image: path};
+ if (isReceiptImage) {
+ return {thumbnail: `${path}.1024.jpg`, image: path};
+ }
}
const {fileExtension} = FileUtils.splitExtensionFromFileName(filename) as FileNameAndExtension;
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 11e11f549682..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;
}
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index edc71302d0ca..8bc3e0df8d9f 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -535,7 +535,8 @@ function isExpensifyOnlyParticipantInReport(report) {
*/
function canCreateTaskInReport(report) {
const otherReportParticipants = _.without(lodashGet(report, 'participantAccountIDs', []), currentUserAccountID);
- const areExpensifyAccountsOnlyOtherParticipants = _.every(otherReportParticipants, (accountID) => _.contains(CONST.EXPENSIFY_ACCOUNT_IDS, accountID));
+ const areExpensifyAccountsOnlyOtherParticipants =
+ otherReportParticipants.length >= 1 && _.every(otherReportParticipants, (accountID) => _.contains(CONST.EXPENSIFY_ACCOUNT_IDS, accountID));
if (areExpensifyAccountsOnlyOtherParticipants && isDM(report)) {
return false;
}
@@ -823,6 +824,16 @@ function getReport(reportID) {
return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {};
}
+/**
+ * Get the notification preference given a report
+ *
+ * @param {Object} report
+ * @returns {String}
+ */
+function getReportNotificationPreference(report) {
+ return lodashGet(report, 'notificationPreference', '');
+}
+
/**
* Returns whether or not the author of the action is this user
*
@@ -1250,6 +1261,7 @@ function getPersonalDetailsForAccountID(accountID) {
return (
(allPersonalDetails && allPersonalDetails[accountID]) || {
avatar: UserUtils.getDefaultAvatar(accountID),
+ isOptimisticPersonalDetail: true,
}
);
}
@@ -1259,28 +1271,39 @@ function getPersonalDetailsForAccountID(accountID) {
*
* @param {Number} accountID
* @param {Boolean} [shouldUseShortForm]
+ * @param {Boolean} shouldFallbackToHidden
* @returns {String}
*/
-function getDisplayNameForParticipant(accountID, shouldUseShortForm = false) {
+function getDisplayNameForParticipant(accountID, shouldUseShortForm = false, shouldFallbackToHidden = true) {
if (!accountID) {
return '';
}
const personalDetails = getPersonalDetailsForAccountID(accountID);
+ // this is to check if account is an invite/optimistically created one
+ // and prevent from falling back to 'Hidden', so a correct value is shown
+ // when searching for a new user
+ if (lodashGet(personalDetails, 'isOptimisticPersonalDetail') === true) {
+ return personalDetails.login || '';
+ }
const longName = personalDetails.displayName;
const shortName = personalDetails.firstName || longName;
+ if (!longName && !personalDetails.login && shouldFallbackToHidden) {
+ return Localize.translateLocal('common.hidden');
+ }
return shouldUseShortForm ? shortName : longName;
}
/**
* @param {Object} personalDetailsList
* @param {Boolean} isMultipleParticipantReport
+ * @param {Boolean} shouldFallbackToHidden
* @returns {Array}
*/
-function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantReport) {
+function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantReport, shouldFallbackToHidden = true) {
return _.chain(personalDetailsList)
.map((user) => {
const accountID = Number(user.accountID);
- const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport) || user.login || '';
+ const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport, shouldFallbackToHidden) || user.login || '';
const avatar = UserUtils.getDefaultAvatar(accountID);
let pronouns = user.pronouns;
@@ -1423,6 +1446,10 @@ function requiresAttentionFromCurrentUser(option, parentReportAction = {}) {
return false;
}
+ if (isArchivedRoom(option)) {
+ return false;
+ }
+
if (isArchivedRoom(getReport(option.parentReportID))) {
return false;
}
@@ -1496,15 +1523,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,
@@ -2361,13 +2391,12 @@ function getOptimisticDataForParentReportAction(reportID, lastVisibleActionCreat
* Builds an optimistic reportAction for the parent report when a task is created
* @param {String} taskReportID - Report ID of the task
* @param {String} taskTitle - Title of the task
- * @param {String} taskAssignee - Email of the person assigned to the task
* @param {Number} taskAssigneeAccountID - AccountID of the person assigned to the task
* @param {String} text - Text of the comment
* @param {String} parentReportID - Report ID of the parent report
* @returns {Object}
*/
-function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssignee, taskAssigneeAccountID, text, parentReportID) {
+function buildOptimisticTaskCommentReportAction(taskReportID, taskTitle, taskAssigneeAccountID, text, parentReportID) {
const reportAction = buildOptimisticAddCommentReportAction(text);
reportAction.reportAction.message[0].taskReportID = taskReportID;
@@ -2422,7 +2451,7 @@ function buildOptimisticIOUReport(payeeAccountID, payerAccountID, total, chatRep
// We don't translate reportName because the server response is always in English
reportName: `${payerEmail} owes ${formattedTotal}`,
- notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
parentReportID: chatReportID,
};
}
@@ -2461,7 +2490,7 @@ function buildOptimisticExpenseReport(chatReportID, policyID, payeeAccountID, to
state: CONST.REPORT.STATE.SUBMITTED,
stateNum: CONST.REPORT.STATE_NUM.PROCESSING,
total: storedTotal,
- notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
parentReportID: chatReportID,
};
}
@@ -3158,7 +3187,7 @@ function buildTransactionThread(reportAction, moneyRequestReportID) {
'',
undefined,
undefined,
- CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS,
+ CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
reportAction.reportActionID,
moneyRequestReportID,
);
@@ -3303,6 +3332,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas,
(report.participantAccountIDs &&
report.participantAccountIDs.length === 0 &&
!isChatThread(report) &&
+ !isPublicRoom(report) &&
!isUserCreatedPolicyRoom(report) &&
!isArchivedRoom(report) &&
!isMoneyRequestReport(report) &&
@@ -3932,7 +3962,6 @@ function shouldDisableRename(report, policy) {
/**
* Returns the onyx data needed for the task assignee chat
* @param {Number} accountID
- * @param {String} assigneeEmail
* @param {Number} assigneeAccountID
* @param {String} taskReportID
* @param {String} assigneeChatReportID
@@ -3941,7 +3970,7 @@ function shouldDisableRename(report, policy) {
* @param {Object} assigneeChatReport
* @returns {Object}
*/
-function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) {
+function getTaskAssigneeChatOnyxData(accountID, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) {
// Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task
let optimisticAssigneeAddComment;
// Set if this is a new chat that needs to be created for the assignee
@@ -4009,7 +4038,7 @@ function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID
// If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned
if (assigneeChatReportID !== parentReportID) {
const displayname = lodashGet(allPersonalDetails, [assigneeAccountID, 'displayName']) || lodashGet(allPersonalDetails, [assigneeAccountID, 'login'], '');
- optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `assigned to ${displayname}`, parentReportID);
+ optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `assigned to ${displayname}`, parentReportID);
const lastAssigneeCommentText = formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text);
const optimisticAssigneeReport = {
lastVisibleActionCreated: currentTime,
@@ -4077,13 +4106,14 @@ function getParticipantsIDs(report) {
*/
function getIOUReportActionDisplayMessage(reportAction) {
const originalMessage = _.get(reportAction, 'originalMessage', {});
- let displayMessage;
+ let translationKey;
if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) {
- const {amount, currency, IOUReportID} = originalMessage;
+ const {IOUReportID} = originalMessage;
+ const {amount, currency} = originalMessage.IOUDetails || originalMessage;
const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
const iouReport = getReport(IOUReportID);
const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID, true);
- let translationKey;
+
switch (originalMessage.paymentType) {
case CONST.IOU.PAYMENT_TYPE.ELSEWHERE:
translationKey = 'iou.paidElsewhereWithAmount';
@@ -4096,17 +4126,23 @@ function getIOUReportActionDisplayMessage(reportAction) {
translationKey = '';
break;
}
- displayMessage = Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName});
- } else {
- const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID);
- const {amount, currency, comment} = getTransactionDetails(transaction);
- const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
- displayMessage = Localize.translateLocal('iou.requestedAmount', {
- formattedAmount,
- comment,
+ return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName});
+ }
+
+ const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID);
+ const {amount, currency, comment} = getTransactionDetails(transaction);
+ const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
+ const isRequestSettled = isSettled(originalMessage.IOUReportID);
+ if (isRequestSettled) {
+ return Localize.translateLocal('iou.payerSettled', {
+ amount: formattedAmount,
});
}
- return displayMessage;
+ translationKey = ReportActionsUtils.isSplitBillAction(reportAction) ? 'iou.didSplitAmount' : 'iou.requestedAmount';
+ return Localize.translateLocal(translationKey, {
+ formattedAmount,
+ comment,
+ });
}
/**
@@ -4151,6 +4187,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,
@@ -4200,6 +4247,7 @@ export {
getDisplayNamesStringFromTooltips,
getReportName,
getReport,
+ getReportNotificationPreference,
getReportIDFromLink,
getRouteFromLink,
getDeletedParentActionMessageForChatReport,
@@ -4310,4 +4358,6 @@ export {
shouldUseFullTitleToDisplay,
parseReportRouteParams,
getReimbursementQueuedActionMessage,
+ getPersonalDetailsForAccountID,
+ getRoom,
};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 4aa708d5882d..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,
);
@@ -411,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) {
@@ -452,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/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 2cc63e63e753..00ce8c55dbd7 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -417,6 +417,7 @@ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = fal
}
let lastWaypointIndex = -1;
+ let waypointIndex = -1;
return waypointValues.reduce((acc, currentWaypoint, index) => {
const previousWaypoint = waypointValues[lastWaypointIndex];
@@ -431,9 +432,10 @@ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = fal
return acc;
}
- const validatedWaypoints: WaypointCollection = {...acc, [`waypoint${reArrangeIndexes ? lastWaypointIndex + 1 : index}`]: currentWaypoint};
+ const validatedWaypoints: WaypointCollection = {...acc, [`waypoint${reArrangeIndexes ? waypointIndex + 1 : index}`]: currentWaypoint};
- lastWaypointIndex += 1;
+ lastWaypointIndex = index;
+ waypointIndex += 1;
return validatedWaypoints;
}, {});
diff --git a/src/libs/actions/FormActions.js b/src/libs/actions/FormActions.js
deleted file mode 100644
index 2a4e1dbd5d27..000000000000
--- a/src/libs/actions/FormActions.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import Onyx from 'react-native-onyx';
-
-/**
- * @param {String} formID
- * @param {Boolean} isLoading
- */
-function setIsLoading(formID, isLoading) {
- Onyx.merge(formID, {isLoading});
-}
-
-/**
- * @param {String} formID
- * @param {Object} errors
- */
-function setErrors(formID, errors) {
- Onyx.merge(formID, {errors});
-}
-
-/**
- * @param {String} formID
- * @param {Object} errorFields
- */
-function setErrorFields(formID, errorFields) {
- Onyx.merge(formID, {errorFields});
-}
-
-/**
- * @param {String} formID
- * @param {Object} draftValues
- */
-function setDraftValues(formID, draftValues) {
- Onyx.merge(`${formID}Draft`, draftValues);
-}
-
-export {setIsLoading, setErrors, setErrorFields, setDraftValues};
diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts
new file mode 100644
index 000000000000..ecaf38dc44f2
--- /dev/null
+++ b/src/libs/actions/FormActions.ts
@@ -0,0 +1,26 @@
+import Onyx from 'react-native-onyx';
+import {KeyValueMapping, NullishDeep} from 'react-native-onyx/lib/types';
+import {OnyxFormKey} from '@src/ONYXKEYS';
+import {Form} from '@src/types/onyx';
+import * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+
+type ExcludeDraft = T extends `${string}Draft` ? never : T;
+type OnyxFormKeyWithoutDraft = ExcludeDraft;
+
+function setIsLoading(formID: OnyxFormKey, isLoading: boolean) {
+ Onyx.merge(formID, {isLoading} satisfies Form);
+}
+
+function setErrors(formID: OnyxFormKey, errors: OnyxCommon.Errors) {
+ Onyx.merge(formID, {errors} satisfies Form);
+}
+
+function setErrorFields(formID: OnyxFormKey, errorFields: OnyxCommon.ErrorFields) {
+ Onyx.merge(formID, {errorFields} satisfies Form);
+}
+
+function setDraftValues(formID: T, draftValues: NullishDeep) {
+ Onyx.merge(`${formID}Draft`, draftValues);
+}
+
+export {setDraftValues, setErrorFields, setErrors, setIsLoading};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 9be87312775a..acdbc200842b 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -1803,7 +1803,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
// Update the last message of the chat report
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport);
const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {
- payer: updatedMoneyRequestReport.managerEmail,
+ payer: ReportUtils.getPersonalDetailsForAccountID(updatedMoneyRequestReport.managerID).login || '',
amount: CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedMoneyRequestReport.currency),
});
updatedChatReport.lastMessageText = messageText;
@@ -2048,7 +2048,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
updatedReportPreviewAction = {...reportPreviewAction};
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport);
const messageText = Localize.translateLocal(hasNonReimbursableTransactions ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {
- payer: updatedIOUReport.managerEmail,
+ payer: ReportUtils.getPersonalDetailsForAccountID(updatedIOUReport.managerID).login || '',
amount: CurrencyUtils.convertToDisplayString(updatedIOUReport.total, updatedIOUReport.currency),
});
updatedReportPreviewAction.message[0].text = messageText;
@@ -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,
},
},
@@ -2694,7 +2697,6 @@ function submitReport(expenseReport) {
'SubmitReport',
{
reportID: expenseReport.reportID,
- managerEmail: expenseReport.managerEmail,
managerAccountID: expenseReport.managerID,
reportActionID: optimisticSubmittedReportAction.reportActionID,
},
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 3f7dc76b174d..a10e7e01da03 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -328,6 +328,10 @@ function addActions(reportID, text = '', file) {
lastReadTime: currentTime,
};
+ if (ReportUtils.getReportNotificationPreference(ReportUtils.getReport(reportID)) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) {
+ optimisticReport.notificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS;
+ }
+
// Optimistically add the new actions to the store before waiting to save them to the server
const optimisticReportActions = {};
if (text) {
@@ -1542,6 +1546,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI
// The participants include the current user (admin), and for restricted rooms, the policy members. Participants must not be empty.
const members = visibility === CONST.REPORT.VISIBILITY.RESTRICTED ? policyMembersAccountIDs : [];
const participants = _.unique([currentUserAccountID, ...members]);
+ const parsedWelcomeMessage = ReportUtils.getParsedComment(welcomeMessage);
const policyReport = ReportUtils.buildOptimisticChatReport(
participants,
reportName,
@@ -1557,7 +1562,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI
CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY,
'',
'',
- welcomeMessage,
+ parsedWelcomeMessage,
);
const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE);
@@ -1622,7 +1627,7 @@ function addPolicyReport(policyID, reportName, visibility, policyMembersAccountI
reportID: policyReport.reportID,
createdReportActionID: createdReportAction.reportActionID,
writeCapability,
- welcomeMessage,
+ welcomeMessage: parsedWelcomeMessage,
},
{optimisticData, successData, failureData},
);
@@ -1999,7 +2004,13 @@ function openReportFromDeepLink(url, isAuthenticated) {
navigateToConciergeChat(true);
return;
}
- Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH);
+ if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) {
+ Navigation.isNavigationReady().then(() => {
+ Session.signOutAndRedirectToSignIn();
+ });
+ return;
+ }
+ 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/Session/index.ts b/src/libs/actions/Session/index.ts
index 74d2f609ab9b..ba6127801102 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -871,6 +871,33 @@ function waitForUserSignIn(): Promise {
});
}
+/**
+ * check if the route can be accessed by anonymous user
+ *
+ * @param {string} route
+ */
+
+const canAccessRouteByAnonymousUser = (route: string) => {
+ const reportID = ReportUtils.getReportIDFromLink(route);
+ if (reportID) {
+ return true;
+ }
+ const parsedReportRouteParams = ReportUtils.parseReportRouteParams(route);
+ let routeRemovedReportId = route;
+ if ((parsedReportRouteParams as {reportID: string})?.reportID) {
+ routeRemovedReportId = route.replace((parsedReportRouteParams as {reportID: string})?.reportID, ':reportID');
+ }
+ if (route.startsWith('/')) {
+ routeRemovedReportId = routeRemovedReportId.slice(1);
+ }
+ const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route];
+
+ if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) {
+ return true;
+ }
+ return false;
+};
+
export {
beginSignIn,
beginAppleSignIn,
@@ -900,4 +927,5 @@ export {
toggleTwoFactorAuth,
validateTwoFactorAuth,
waitForUserSignIn,
+ canAccessRouteByAnonymousUser,
};
diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js
index 76396b1f31b8..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;
@@ -71,7 +72,7 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail
// Parent ReportAction indicating that a task has been created
const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail);
- const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `task for ${title}`, parentReportID);
+ const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID);
optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID;
const currentTime = DateUtils.getDBTime();
@@ -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,
@@ -148,7 +153,6 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail
if (assigneeChatReport) {
assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData(
currentUserAccountID,
- assigneeEmail,
assigneeAccountID,
taskReportID,
assigneeChatReportID,
@@ -187,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();
@@ -436,6 +444,13 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi
let assigneeChatReportOnyxData;
const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0;
+ const optimisticReport = {
+ reportName,
+ managerID: assigneeAccountID || report.managerID,
+ pendingFields: {
+ ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ },
+ };
const optimisticData = [
{
@@ -446,14 +461,7 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`,
- value: {
- reportName,
- managerID: assigneeAccountID || report.managerID,
- managerEmail: assigneeEmail || report.managerEmail,
- pendingFields: {
- ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
- },
- },
+ value: optimisticReport,
},
];
const successData = [
@@ -472,16 +480,20 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`,
- value: {assignee: report.managerEmail, assigneeAccountID: report.managerID},
+ value: {managerID: report.managerID},
},
];
// If we make a change to the assignee, we want to add a comment to the assignee's chat
// Check if the assignee actually changed
if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID && assigneeChatReport) {
+ const participants = lodashGet(report, 'participantAccountIDs', []);
+ if (!participants.includes(assigneeAccountID)) {
+ optimisticReport.participantAccountIDs = [...participants, assigneeAccountID];
+ }
+
assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData(
currentUserAccountID,
- assigneeEmail,
assigneeAccountID,
report.reportID,
assigneeChatReportID,
@@ -498,8 +510,7 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi
'EditTaskAssignee',
{
taskReportID: report.reportID,
- assignee: assigneeEmail || report.managerEmail,
- assigneeAccountID: assigneeAccountID || report.managerID,
+ assignee: assigneeEmail,
editedTaskReportActionID: editTaskReportAction.reportActionID,
assigneeChatReportID,
assigneeChatReportActionID:
@@ -635,7 +646,9 @@ function setParentReportID(parentReportID) {
*/
function clearOutTaskInfoAndNavigate(reportID) {
clearOutTaskInfo();
- setParentReportID(reportID);
+ if (reportID && reportID !== '0') {
+ setParentReportID(reportID);
+ }
Navigation.navigate(ROUTES.NEW_TASK_DETAILS);
}
@@ -875,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,
@@ -930,7 +955,7 @@ export {
cancelTask,
dismissModalAndClearOutTaskInfo,
getTaskAssigneeAccountID,
- clearEditTaskErrors,
+ clearTaskErrors,
canModifyTask,
getTaskReportActionMessage,
};
diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index a649267c0ae9..285fd5b251df 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -110,6 +110,10 @@ function removeWaypoint(transactionID: string, currentIndex: string) {
const waypointValues = Object.values(existingWaypoints);
const removed = waypointValues.splice(index, 1);
+ if (removed.length === 0) {
+ return;
+ }
+
const isRemovedWaypointEmpty = removed.length > 0 && !TransactionUtils.waypointHasValidAddress(removed[0] ?? {});
// When there are only two waypoints we are adding empty waypoint back
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/getPermittedDecimalSeparator/index.ios.js b/src/libs/getPermittedDecimalSeparator/index.ios.js
deleted file mode 100644
index ce37b5ffeba3..000000000000
--- a/src/libs/getPermittedDecimalSeparator/index.ios.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// On iOS keyboard can only have one symbol at a time (either dot or comma) so we accept both
-// Details: https://expensify.slack.com/archives/C01GTK53T8Q/p1658936908481629
-export default () => '.,';
diff --git a/src/libs/getPermittedDecimalSeparator/index.ios.ts b/src/libs/getPermittedDecimalSeparator/index.ios.ts
new file mode 100644
index 000000000000..f7c2d1eeaa9f
--- /dev/null
+++ b/src/libs/getPermittedDecimalSeparator/index.ios.ts
@@ -0,0 +1,7 @@
+// On iOS keyboard can only have one symbol at a time (either dot or comma) so we accept both
+// Details: https://expensify.slack.com/archives/C01GTK53T8Q/p1658936908481629
+import GetPermittedDecimalSeparator from './types';
+
+const getPermittedDecimalSeparator: GetPermittedDecimalSeparator = () => '.,';
+
+export default getPermittedDecimalSeparator;
diff --git a/src/libs/getPermittedDecimalSeparator/index.js b/src/libs/getPermittedDecimalSeparator/index.js
deleted file mode 100644
index 03fa644fc327..000000000000
--- a/src/libs/getPermittedDecimalSeparator/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import getOperatingSystem from '@libs/getOperatingSystem';
-import CONST from '@src/CONST';
-import getPermittedDecimalSeparatorIOS from './index.ios';
-
-export default (localizedSeparator) => {
- if (getOperatingSystem() === CONST.OS.IOS) {
- return getPermittedDecimalSeparatorIOS();
- }
-
- return localizedSeparator;
-};
diff --git a/src/libs/getPermittedDecimalSeparator/index.ts b/src/libs/getPermittedDecimalSeparator/index.ts
new file mode 100644
index 000000000000..120e9e046aff
--- /dev/null
+++ b/src/libs/getPermittedDecimalSeparator/index.ts
@@ -0,0 +1,14 @@
+import getOperatingSystem from '@libs/getOperatingSystem';
+import CONST from '@src/CONST';
+import getPermittedDecimalSeparatorIOS from './index.ios';
+import GetPermittedDecimalSeparator from './types';
+
+const getPermittedDecimalSeparator: GetPermittedDecimalSeparator = (localizedSeparator) => {
+ if (getOperatingSystem() === CONST.OS.IOS) {
+ return getPermittedDecimalSeparatorIOS(localizedSeparator);
+ }
+
+ return localizedSeparator;
+};
+
+export default getPermittedDecimalSeparator;
diff --git a/src/libs/getPermittedDecimalSeparator/types.ts b/src/libs/getPermittedDecimalSeparator/types.ts
new file mode 100644
index 000000000000..88c271b608a1
--- /dev/null
+++ b/src/libs/getPermittedDecimalSeparator/types.ts
@@ -0,0 +1,3 @@
+type GetPermittedDecimalSeparator = (localizedSeparator: string) => string;
+
+export default GetPermittedDecimalSeparator;
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 b31e9b58cbe9..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.repotID, 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 db041b80a06e..cb89f2360d91 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/RequestorStep.js b/src/pages/ReimbursementAccount/RequestorStep.js
index 0eddb727d56d..4a3dd2f0917f 100644
--- a/src/pages/ReimbursementAccount/RequestorStep.js
+++ b/src/pages/ReimbursementAccount/RequestorStep.js
@@ -70,7 +70,11 @@ const validate = (values) => {
return errors;
};
-function RequestorStep({reimbursementAccount, shouldShowOnfido, onBackButtonPress, getDefaultStateForField}) {
+/**
+ * Workaround for forwardRef + propTypes issue.
+ * See https://stackoverflow.com/questions/59716140/using-forwardref-with-proptypes-and-eslint
+ */
+const RequestorStep = React.forwardRef(({reimbursementAccount, shouldShowOnfido, onBackButtonPress, getDefaultStateForField}, ref) => {
const {translate} = useLocalize();
const defaultValues = useMemo(
@@ -108,6 +112,7 @@ function RequestorStep({reimbursementAccount, shouldShowOnfido, onBackButtonPres
if (shouldShowOnfido) {
return (
@@ -116,6 +121,7 @@ function RequestorStep({reimbursementAccount, shouldShowOnfido, onBackButtonPres
return (
@@ -190,9 +196,9 @@ function RequestorStep({reimbursementAccount, shouldShowOnfido, onBackButtonPres
);
-}
+});
RequestorStep.propTypes = propTypes;
RequestorStep.displayName = 'RequestorStep';
-export default React.forwardRef(RequestorStep);
+export default RequestorStep;
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/SearchPage.js b/src/pages/SearchPage.js
index f312ef2aa307..3e7731efc7b2 100755
--- a/src/pages/SearchPage.js
+++ b/src/pages/SearchPage.js
@@ -65,15 +65,13 @@ class SearchPage extends Component {
this.searchRendered = this.searchRendered.bind(this);
this.selectReport = this.selectReport.bind(this);
this.onChangeText = this.onChangeText.bind(this);
+ this.updateOptions = this.updateOptions.bind(this);
this.debouncedUpdateOptions = _.debounce(this.updateOptions.bind(this), 75);
-
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getSearchOptions(props.reports, props.personalDetails, '', props.betas);
-
this.state = {
searchValue: '',
- recentReports,
- personalDetails,
- userToInvite,
+ recentReports: {},
+ personalDetails: {},
+ userToInvite: {},
};
}
@@ -186,6 +184,7 @@ class SearchPage extends Component {
{({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
diff --git a/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js b/src/pages/TeachersUnite/ImTeacherUpdateEmailPage.js
index 5554731bdc31..8953750266ba 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 (
@@ -32,7 +33,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}
/>
@@ -41,7 +42,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)}
/>
-
+
);
}
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/ParticipantLocalTime.js b/src/pages/home/report/ParticipantLocalTime.js
index 17f87f0391ea..2ce0054a3e59 100644
--- a/src/pages/home/report/ParticipantLocalTime.js
+++ b/src/pages/home/report/ParticipantLocalTime.js
@@ -45,6 +45,10 @@ function ParticipantLocalTime(props) {
const reportRecipientDisplayName = lodashGet(props, 'participant.firstName') || lodashGet(props, 'participant.displayName');
+ if (!reportRecipientDisplayName) {
+ return null;
+ }
+
return (
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 98%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
index a254ca8b0d83..b69e65e854d7 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();
@@ -530,12 +532,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}
@@ -551,6 +552,7 @@ function ComposerWithSuggestions({
setIsFullComposerAvailable={setIsFullComposerAvailable}
isComposerFullSize={isComposerFullSize}
value={value}
+ testID="composer"
numberOfLines={numberOfLines}
onNumberOfLinesChange={updateNumberOfLines}
shouldCalculateCaretPosition
@@ -588,6 +590,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/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js
index f39a70a960cf..0050b56800cc 100644
--- a/src/pages/home/report/ReportActionCompose/Suggestions.js
+++ b/src/pages/home/report/ReportActionCompose/Suggestions.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, {useCallback, useImperativeHandle, useRef} from 'react';
+import {View} from 'react-native';
import SuggestionEmoji from './SuggestionEmoji';
import SuggestionMention from './SuggestionMention';
import * as SuggestionProps from './suggestionProps';
@@ -108,7 +109,7 @@ function Suggestions({
};
return (
- <>
+
- >
+
);
}
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index a7a3bc0739f3..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';
@@ -353,11 +354,16 @@ function ReportActionItem(props) {
);
} else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) {
children = (
-
+
+
+
);
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) {
const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail);
@@ -436,8 +442,7 @@ function ReportActionItem(props) {
onPress={() => updateHiddenState(!isHidden)}
>
{isHidden ? props.translate('moderation.revealMessage') : props.translate('moderation.hideMessage')}
@@ -571,23 +576,31 @@ function ReportActionItem(props) {
if (ReportUtils.isCanceledTaskReport(props.report, parentReportAction)) {
content = (
<>
-
- ${props.translate('parentReportAction.deletedTask')}`} />
-
-
+
+
+
+ ${props.translate('parentReportAction.deletedTask')}`} />
+
+
+
>
);
} else {
content = (
-
+ <>
+
+
+
+
+ >
);
}
}
@@ -642,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}
@@ -760,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) &&
@@ -773,7 +787,6 @@ export default compose(
prevProps.report.description === nextProps.report.description &&
ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) &&
prevProps.report.managerID === nextProps.report.managerID &&
- prevProps.report.managerEmail === nextProps.report.managerEmail &&
prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine &&
lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) &&
lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) &&
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 329952769a4f..955e024bd7a8 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -85,7 +85,7 @@ const showWorkspaceDetails = (reportID) => {
function ReportActionItemSingle(props) {
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const actorAccountID = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport ? props.iouReport.managerID : props.action.actorAccountID;
- let {displayName} = personalDetails[actorAccountID] || {};
+ let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID);
const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID] || {};
let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]);
@@ -113,7 +113,7 @@ function ReportActionItemSingle(props) {
// 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;
const secondaryUserDetails = personalDetails[secondaryAccountId] || {};
- const secondaryDisplayName = lodashGet(secondaryUserDetails, 'displayName', '');
+ const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId);
displayName = `${primaryDisplayName} & ${secondaryDisplayName}`;
secondaryAvatar = {
source: UserUtils.getAvatar(secondaryUserDetails.avatar, secondaryAccountId),
@@ -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 e26aae7be7c5..759e73aa90e5 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -303,12 +303,9 @@ function ReportActionsList({
if (!currentUnreadMarker) {
const nextMessage = sortedReportActions[index + 1];
const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
- const isNextMessageRead = !isMessageUnread(nextMessage, report.lastReadTime);
- const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
- shouldDisplay = isCurrentMessageUnread && (!nextMessage || isNextMessageRead) && isWithinVisibleThreshold;
-
- if (shouldDisplay && !messageManuallyMarkedUnread) {
- shouldDisplay = reportAction.actorAccountID !== Report.getCurrentUserAccountID();
+ shouldDisplay = isCurrentMessageUnread && (!nextMessage || !isMessageUnread(nextMessage, report.lastReadTime));
+ if (!messageManuallyMarkedUnread) {
+ shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
}
} else {
shouldDisplay = reportAction.reportActionID === currentUnreadMarker;
@@ -368,9 +365,10 @@ function ReportActionsList({
const lastReportAction = useMemo(() => _.last(sortedReportActions) || {}, [sortedReportActions]);
const listFooterComponent = useCallback(() => {
- // Skip this hook on the first render, as we are not sure if more actions are going to be loaded
- // Therefore showing the skeleton on footer might be misleading
- if (!hasFooterRendered.current) {
+ // Skip this hook on the first render (when online), as we are not sure if more actions are going to be loaded,
+ // Therefore showing the skeleton on footer might be misleading.
+ // When offline, there should be no second render, so we should show the skeleton if the corresponding loading prop is present
+ if (!isOffline && !hasFooterRendered.current) {
hasFooterRendered.current = true;
return null;
}
@@ -383,7 +381,7 @@ function ReportActionsList({
lastReportActionName={lastReportAction.actionName}
/>
);
- }, [isLoadingInitialReportActions, isLoadingOlderReportActions, lastReportAction.actionName]);
+ }, [isLoadingInitialReportActions, isLoadingOlderReportActions, lastReportAction.actionName, isOffline]);
const onLayoutInner = useCallback(
(event) => {
@@ -393,17 +391,18 @@ function ReportActionsList({
);
const listHeaderComponent = useCallback(() => {
- if (!hasHeaderRendered.current) {
+ if (!isOffline && !hasHeaderRendered.current) {
hasHeaderRendered.current = true;
return null;
}
+
return (
);
- }, [isLoadingNewerReportActions]);
+ }, [isLoadingNewerReportActions, isOffline]);
return (
<>
@@ -415,6 +414,7 @@ function ReportActionsList({
{}}
extraData={extraData}
- testID="report-actions-list"
/>
>
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index f9f029881eef..01ec967d76b1 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
@@ -149,8 +177,8 @@ function ReportActionsView(props) {
* displaying.
*/
const loadOlderChats = () => {
- // Only fetch more if we are not already fetching so that we don't initiate duplicate requests.
- if (props.isLoadingOlderReportActions) {
+ // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline.
+ if (props.network.isOffline || props.isLoadingOlderReportActions) {
return;
}
@@ -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;
}
@@ -313,10 +345,6 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
- if (lodashGet(newProps, 'report.managerEmail') !== lodashGet(oldProps, 'report.managerEmail')) {
- return false;
- }
-
if (lodashGet(newProps, 'report.total') !== lodashGet(oldProps, 'report.total')) {
return false;
}
@@ -338,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 f55fcecc4145..fd80171967e7 100644
--- a/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js
+++ b/src/pages/home/sidebar/GlobalNavigation/GlobalNavigationMenuItem.js
@@ -36,7 +36,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/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index df64c0d7d7d0..e1a686a4ab8f 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -240,7 +240,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/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js
index 17893ce98f0b..125a83cd0fd3 100644
--- a/src/pages/iou/MoneyRequestSelectorPage.js
+++ b/src/pages/iou/MoneyRequestSelectorPage.js
@@ -12,6 +12,7 @@ import TabSelector from '@components/TabSelector/TabSelector';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import compose from '@libs/compose';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as IOUUtils from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator';
@@ -94,6 +95,7 @@ function MoneyRequestSelectorPage(props) {
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
index c1cf43da83dc..8cd73d7dd993 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';
@@ -104,7 +103,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;
}
@@ -210,7 +209,7 @@ function ReceiptSelector({route, transactionID, iou, report}) {
{({openPicker}) => (
{
openPicker({
onPicked: (file) => {
@@ -229,7 +228,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
@@ -209,12 +228,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);
@@ -239,7 +261,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/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 79cf48ce634d..6b570ee872c3 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -71,10 +71,13 @@ function MoneyRequestConfirmPage(props) {
const [receiptFile, setReceiptFile] = useState();
const participants = useMemo(
() =>
- _.map(props.iou.participants, (participant) => {
- const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
- return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, props.personalDetails);
- }),
+ _.chain(props.iou.participants)
+ .map((participant) => {
+ const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
+ return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, props.personalDetails);
+ })
+ .filter((participant) => !!participant.login || !!participant.text)
+ .value(),
[props.iou.participants, props.personalDetails],
);
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(props.report)), [props.report]);
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/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index 790793787e57..af6163729944 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -4,6 +4,8 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import Button from '@components/Button';
+import FormHelpMessage from '@components/FormHelpMessage';
import OptionsSelector from '@components/OptionsSelector';
import refPropTypes from '@components/refPropTypes';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
@@ -262,11 +264,38 @@ function MoneyRequestParticipantsSelector({
// Right now you can't split a request with a workspace and other additional participants
// This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent
- // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants
+ // the app from crashing on native when you try to do this, we'll going to show error message if you have a workspace and other participants
const hasPolicyExpenseChatParticipant = _.some(participants, (participant) => participant.isPolicyExpenseChat);
- const shouldShowConfirmButton = !(participants.length > 1 && hasPolicyExpenseChatParticipant);
+ const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant;
const isAllowedToSplit = !isDistanceRequest && iouType !== CONST.IOU.TYPE.SEND;
+ const handleConfirmSelection = useCallback(() => {
+ if (shouldShowSplitBillErrorMessage) {
+ return;
+ }
+
+ navigateToSplit();
+ }, [shouldShowSplitBillErrorMessage, navigateToSplit]);
+
+ const footerContent = (
+
+ {shouldShowSplitBillErrorMessage && (
+
+ )}
+
+
+ );
+
return (
0 ? safeAreaPaddingBottomStyle : {}]}>
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 fd5a0a7d9992..25c2fd896c48 100644
--- a/src/pages/settings/AboutPage/AboutPage.js
+++ b/src/pages/settings/AboutPage/AboutPage.js
@@ -115,10 +115,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}
/>
Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
-
- (nameOnCardRef.current = ref)}
spellCheck={false}
/>
-
-
-
-
-
-
+
- (
{`${translate('common.iAcceptThe')}`}
@@ -210,7 +198,7 @@ function DebitCardPage(props) {
)}
style={[styles.mt4]}
/>
-
+
);
}
diff --git a/src/pages/settings/Wallet/PaymentMethodList.js b/src/pages/settings/Wallet/PaymentMethodList.js
index 5ba521e94412..da17401543eb 100644
--- a/src/pages/settings/Wallet/PaymentMethodList.js
+++ b/src/pages/settings/Wallet/PaymentMethodList.js
@@ -277,7 +277,7 @@ function PaymentMethodList({
*
* @return {React.Component}
*/
- const renderListEmptyComponent = () => {translate('paymentMethodList.addFirstPaymentMethod')} ;
+ const renderListEmptyComponent = () => {translate('paymentMethodList.addFirstPaymentMethod')} ;
const renderListFooterComponent = useCallback(
() => (
diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js
index 38d9722e2ed7..2387f5f23e6c 100644
--- a/src/pages/settings/Wallet/WalletPage/WalletPage.js
+++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js
@@ -257,10 +257,6 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
Navigation.navigate(source === CONST.KYC_WALL_SOURCE.ENABLE_WALLET ? ROUTES.SETTINGS_WALLET : ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
};
- useEffect(() => {
- PaymentMethods.openWalletPage();
- }, []);
-
useEffect(() => {
// If the user was previously offline, skip debouncing showing the loader
if (!network.isOffline) {
@@ -275,7 +271,7 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
return;
}
PaymentMethods.openWalletPage();
- }, [network.isOffline]);
+ }, [network.isOffline, bankAccountList, cardList, fundList]);
useEffect(() => {
if (!shouldListenForResize) {
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/SignInHeroImage.js b/src/pages/signin/SignInHeroImage.js
index 8ed9a168b328..5acd5268d572 100644
--- a/src/pages/signin/SignInHeroImage.js
+++ b/src/pages/signin/SignInHeroImage.js
@@ -34,6 +34,7 @@ function SignInHeroImage(props) {
loop
autoPlay
style={[styles.alignSelfCenter, imageSize]}
+ webStyle={{...styles.alignSelfCenter, ...imageSize}}
/>
);
}
diff --git a/src/pages/signin/SignInModal.js b/src/pages/signin/SignInModal.js
index 725209537a6d..aec6d0d07604 100644
--- a/src/pages/signin/SignInModal.js
+++ b/src/pages/signin/SignInModal.js
@@ -24,7 +24,7 @@ function SignInModal() {
shouldEnableMaxHeight
testID={SignInModal.displayName}
>
-
+
);
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..cecf04afebad 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
@@ -331,6 +331,7 @@ function BaseValidateCodeForm(props) {
errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''}
hasError={hasError}
autoFocus
+ key="twoFactorAuthCode"
/>
)}
{hasError && }
@@ -341,7 +342,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')}
@@ -360,6 +361,7 @@ function BaseValidateCodeForm(props) {
errorText={formError.validateCode ? props.translate(formError.validateCode) : ''}
hasError={hasError}
autoFocus
+ key="validateCode"
/>
{hasError && }
@@ -376,7 +378,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/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js
index 234f961d0470..65ec35766332 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.js
@@ -365,6 +365,7 @@ function WorkspaceMembersPage(props) {
source: UserUtils.getAvatar(details.avatar, accountID),
name: props.formatPhoneNumber(details.login),
type: CONST.ICON_TYPE_AVATAR,
+ id: accountID,
},
],
errors: policyMember.errors,
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 589c3756042f..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: {
@@ -3329,7 +3340,6 @@ const styles = (theme: ThemeDefault) =>
eReceiptAmount: {
...headlineFont,
fontSize: variables.fontSizeXXXLarge,
- lineHeight: variables.lineHeightXXXLarge,
color: colors.green400,
},
@@ -3421,7 +3431,6 @@ const styles = (theme: ThemeDefault) =>
linkPreviewImage: {
flex: 1,
- resizeMode: 'contain',
borderRadius: 8,
marginTop: 8,
},
@@ -3574,7 +3583,8 @@ const styles = (theme: ThemeDefault) =>
googlePillButtonContainer: {
colorScheme: 'light',
height: 40,
- width: 219,
+ width: 300,
+ overflow: 'hidden',
},
thirdPartyLoadingContainer: {
@@ -3749,21 +3759,6 @@ const styles = (theme: ThemeDefault) =>
reportActionItemImagesMoreCornerTriangle: {
position: 'absolute',
- bottom: 0,
- right: 0,
- width: 0,
- height: 0,
- borderStyle: 'solid',
- borderWidth: 0,
- borderBottomWidth: 40,
- borderLeftWidth: 40,
- borderColor: 'transparent',
- borderBottomColor: theme.cardBG,
- },
-
- reportActionItemImagesMoreCornerTriangleHighlighted: {
- borderColor: 'transparent',
- borderBottomColor: theme.border,
},
assignedCardsIconContainer: {
@@ -3803,7 +3798,7 @@ const styles = (theme: ThemeDefault) =>
},
rotate90: {
- transform: [{rotate: '90deg'}],
+ transform: 'rotate(90deg)',
},
emojiStatusLHN: {
@@ -4029,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/styles/variables.ts b/src/styles/variables.ts
index 382ed3e032d1..7bad3b1b0fb7 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -174,6 +174,7 @@ export default {
reportActionImagesSingleImageHeight: 147,
reportActionImagesDoubleImageHeight: 138,
reportActionImagesMultipleImageHeight: 110,
+ reportActionItemImagesMoreCornerTriangleWidth: 40,
bankCardWidth: 40,
bankCardHeight: 26,
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/actions/IOUTest.js b/tests/actions/IOUTest.js
index 2c6b94a2d7d5..76098d72f52e 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -78,6 +78,8 @@ describe('actions/IOU', () => {
const iouReport = iouReports[0];
iouReportID = iouReport.reportID;
+ expect(iouReport.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
// They should be linked together
expect(chatReport.participantAccountIDs).toEqual([CARLOS_ACCOUNT_ID]);
expect(chatReport.iouReportID).toBe(iouReport.reportID);
@@ -243,6 +245,8 @@ describe('actions/IOU', () => {
const iouReport = _.find(allReports, (report) => report.type === CONST.REPORT.TYPE.IOU);
iouReportID = iouReport.reportID;
+ expect(iouReport.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
// They should be linked together
expect(chatReport.iouReportID).toBe(iouReportID);
expect(chatReport.hasOutstandingIOU).toBe(true);
@@ -572,6 +576,8 @@ describe('actions/IOU', () => {
const iouReport = iouReports[0];
iouReportID = iouReport.reportID;
+ expect(iouReport.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
// They should be linked together
expect(chatReport.participantAccountIDs).toEqual([CARLOS_ACCOUNT_ID]);
expect(chatReport.iouReportID).toBe(iouReport.reportID);
@@ -982,6 +988,7 @@ describe('actions/IOU', () => {
expect(carlosChatReport.hasOutstandingIOU).toBe(true);
expect(carlosChatReport.iouReportID).toBe(carlosIOUReport.reportID);
expect(carlosIOUReport.chatReportID).toBe(carlosChatReport.reportID);
+ expect(carlosIOUReport.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
expect(julesChatReport.hasOutstandingIOU).toBe(true);
expect(julesChatReport.iouReportID).toBe(julesIOUReport.reportID);
@@ -990,6 +997,7 @@ describe('actions/IOU', () => {
expect(vitChatReport.hasOutstandingIOU).toBe(true);
expect(vitChatReport.iouReportID).toBe(vitIOUReport.reportID);
expect(vitIOUReport.chatReportID).toBe(vitChatReport.reportID);
+ expect(carlosIOUReport.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
resolve();
},
@@ -1528,8 +1536,8 @@ describe('actions/IOU', () => {
);
expect(updatedChatReport).toEqual(
expect.objectContaining({
- lastMessageHtml: 'undefined owes $200.00',
- lastMessageText: 'undefined owes $200.00',
+ lastMessageHtml: `${CARLOS_EMAIL} owes $200.00`,
+ lastMessageText: `${CARLOS_EMAIL} owes $200.00`,
}),
);
resolve();
@@ -2171,6 +2179,8 @@ describe('actions/IOU', () => {
// Given a transaction thread
thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID);
+ expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
callback: (val) => (reportActions = val),
@@ -2248,6 +2258,9 @@ describe('actions/IOU', () => {
// Given a transaction thread
thread = ReportUtils.buildTransactionThread(createIOUAction);
+
+ expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs);
jest.advanceTimersByTime(10);
Report.openReport(thread.reportID, userLogins, thread, createIOUAction.reportActionID);
@@ -2332,6 +2345,8 @@ describe('actions/IOU', () => {
jest.advanceTimersByTime(10);
thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID);
+ expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
callback: (val) => (reportActions = val),
@@ -2545,6 +2560,8 @@ describe('actions/IOU', () => {
jest.advanceTimersByTime(10);
thread = ReportUtils.buildTransactionThread(createIOUAction, IOU_REPORT_ID);
+ expect(thread.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+
Onyx.connect({
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`,
callback: (val) => (reportActions = val),
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/tests/perf-test/ReportActionCompose.perf-test.js b/tests/perf-test/ReportActionCompose.perf-test.js
new file mode 100644
index 000000000000..ccc1037942b8
--- /dev/null
+++ b/tests/perf-test/ReportActionCompose.perf-test.js
@@ -0,0 +1,162 @@
+import {fireEvent, screen} from '@testing-library/react-native';
+import React from 'react';
+import Onyx from 'react-native-onyx';
+import {measurePerformance} from 'reassure';
+import ComposeProviders from '../../src/components/ComposeProviders';
+import {LocaleContextProvider} from '../../src/components/LocaleContextProvider';
+import OnyxProvider from '../../src/components/OnyxProvider';
+import {KeyboardStateProvider} from '../../src/components/withKeyboardState';
+import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions';
+import * as Localize from '../../src/libs/Localize';
+import ONYXKEYS from '../../src/ONYXKEYS';
+import ReportActionCompose from '../../src/pages/home/report/ReportActionCompose/ReportActionCompose';
+import * as LHNTestUtils from '../utils/LHNTestUtils';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+// mock PortalStateContext
+jest.mock('@gorhom/portal');
+
+jest.mock('react-native-reanimated', () => ({
+ ...jest.requireActual('react-native-reanimated/mock'),
+ useAnimatedRef: jest.fn,
+}));
+
+jest.mock('../../src/libs/Permissions', () => ({
+ canUseTasks: jest.fn(() => true),
+}));
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ addListener: () => jest.fn(),
+ }),
+ useIsFocused: () => ({
+ navigate: jest.fn(),
+ }),
+ };
+});
+
+jest.mock('../../src/libs/actions/EmojiPickerAction', () => {
+ const actualEmojiPickerAction = jest.requireActual('../../src/libs/actions/EmojiPickerAction');
+ return {
+ ...actualEmojiPickerAction,
+ emojiPickerRef: {
+ current: {
+ isEmojiPickerVisible: false,
+ },
+ },
+ showEmojiPicker: jest.fn(),
+ hideEmojiPicker: jest.fn(),
+ isActive: () => true,
+ };
+});
+
+beforeAll(() =>
+ Onyx.init({
+ keys: ONYXKEYS,
+ safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ registerStorageEventListener: () => {},
+ }),
+);
+
+// Initialize the network key for OfflineWithFeedback
+beforeEach(() => {
+ Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});
+});
+
+function ReportActionComposeWrapper() {
+ return (
+
+ jest.fn()}
+ reportID="1"
+ disabled={false}
+ report={LHNTestUtils.getFakeReport()}
+ />
+
+ );
+}
+const mockEvent = {preventDefault: jest.fn()};
+
+test('should render Composer with text input interactions', async () => {
+ const scenario = async () => {
+ // Query for the composer
+ const composer = await screen.findByTestId('composer');
+
+ expect(composer).toBeDefined();
+ fireEvent.changeText(composer, '@test');
+
+ // Query for the suggestions
+ await screen.findByTestId('suggestions');
+
+ // scroll to hide suggestions
+ fireEvent.scroll(composer);
+
+ // press to block suggestions
+ fireEvent.press(composer);
+ };
+
+ return waitForBatchedUpdates().then(() => measurePerformance( , {scenario}));
+});
+
+test('should press add attachemnt button', async () => {
+ const scenario = async () => {
+ // Query for the attachment button
+ const hintAttachmentButtonText = Localize.translateLocal('reportActionCompose.addAction');
+ const attachmentButton = await screen.findByLabelText(hintAttachmentButtonText);
+
+ expect(attachmentButton).toBeDefined();
+ fireEvent.press(attachmentButton, mockEvent);
+ };
+
+ return waitForBatchedUpdates().then(() => measurePerformance( , {scenario}));
+});
+
+test('should press add emoji button', async () => {
+ const scenario = async () => {
+ // Query for the emoji button
+ const hintEmojiButtonText = Localize.translateLocal('reportActionCompose.emoji');
+ const emojiButton = await screen.findByLabelText(hintEmojiButtonText);
+
+ expect(emojiButton).toBeDefined();
+ fireEvent.press(emojiButton);
+ };
+
+ return waitForBatchedUpdates().then(() => measurePerformance( , {scenario}));
+});
+
+test('should press send message button', async () => {
+ const scenario = async () => {
+ // Query for the send button
+ const hintSendButtonText = Localize.translateLocal('common.send');
+ const sendButton = await screen.findByLabelText(hintSendButtonText);
+
+ expect(sendButton).toBeDefined();
+ fireEvent.press(sendButton);
+ };
+
+ return waitForBatchedUpdates().then(() => measurePerformance( , {scenario}));
+});
+
+test('render composer with attachement modal interactions', async () => {
+ const scenario = async () => {
+ const hintAddAttachmentButtonText = Localize.translateLocal('reportActionCompose.addAttachment');
+ const hintAssignTaskButtonText = Localize.translateLocal('newTaskPage.assignTask');
+ const hintSplitBillButtonText = Localize.translateLocal('iou.splitBill');
+
+ // Query for the attachment modal items
+ const addAttachmentButton = await screen.findByLabelText(hintAddAttachmentButtonText);
+ fireEvent.press(addAttachmentButton, mockEvent);
+
+ const splitBillButton = await screen.findByLabelText(hintSplitBillButtonText);
+ fireEvent.press(splitBillButton, mockEvent);
+
+ const assignTaskButton = await screen.findByLabelText(hintAssignTaskButtonText);
+ fireEvent.press(assignTaskButton, mockEvent);
+ };
+
+ return waitForBatchedUpdates().then(() => measurePerformance( , {scenario}));
+});
diff --git a/tests/perf-test/ReportActionsList.perf-test.js b/tests/perf-test/ReportActionsList.perf-test.js
index f4dcd969923f..991ca82da1b6 100644
--- a/tests/perf-test/ReportActionsList.perf-test.js
+++ b/tests/perf-test/ReportActionsList.perf-test.js
@@ -13,6 +13,7 @@ import {ActionListContext, ReactionListContext} from '../../src/pages/home/Repor
import variables from '../../src/styles/variables';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import PusherHelper from '../utils/PusherHelper';
+import * as ReportTestUtils from '../utils/ReportTestUtils';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
@@ -58,7 +59,7 @@ afterAll(() => {
const mockOnLayout = jest.fn();
const mockOnScroll = jest.fn();
-const mockLoadMoreChats = jest.fn();
+const mockLoadChats = jest.fn();
const mockRef = {current: null};
// Initialize the network key for OfflineWithFeedback
@@ -74,49 +75,6 @@ afterEach(() => {
PusherHelper.teardown();
});
-const getFakeReportAction = (index) => ({
- actionName: 'ADDCOMMENT',
- actorAccountID: index,
- automatic: false,
- avatar: '',
- created: '2023-09-12 16:27:35.124',
- isAttachment: true,
- isFirstItem: false,
- lastModified: '2021-07-14T15:00:00Z',
- message: [
- {
- html: 'hey',
- isDelatedParentAction: false,
- isEdited: false,
- reactions: [],
- text: 'test',
- type: 'TEXT',
- whisperedTo: [],
- },
- ],
- originalMessage: {
- html: 'hey',
- lastModified: '2021-07-14T15:00:00Z',
- },
- pendingAction: null,
- person: [
- {
- type: 'TEXT',
- style: 'strong',
- text: 'email@test.com',
- },
- ],
- previousReportActionID: '1',
- reportActionID: index.toString(),
- reportActionTimestamp: 1696243169753,
- sequenceNumber: 2,
- shouldShow: true,
- timestamp: 1696243169,
- whisperedToAccountIDs: [],
-});
-
-const getMockedSortedReportActions = (length = 100) => Array.from({length}, (__, i) => getFakeReportAction(i));
-
const currentUserAccountID = 5;
function ReportActionsListWrapper() {
@@ -125,11 +83,13 @@ function ReportActionsListWrapper() {
diff --git a/tests/perf-test/ReportActionsUtils.perf-test.js b/tests/perf-test/ReportActionsUtils.perf-test.js
new file mode 100644
index 000000000000..f643206c9f49
--- /dev/null
+++ b/tests/perf-test/ReportActionsUtils.perf-test.js
@@ -0,0 +1,93 @@
+import Onyx from 'react-native-onyx';
+import {measureFunction} from 'reassure';
+import _ from 'underscore';
+import CONST from '../../src/CONST';
+import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils';
+import ONYXKEYS from '../../src/ONYXKEYS';
+import * as LHNTestUtils from '../utils/LHNTestUtils';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+jest.setTimeout(60000);
+
+beforeAll(() =>
+ Onyx.init({
+ keys: ONYXKEYS,
+ safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ registerStorageEventListener: () => {},
+ }),
+);
+
+// Clear out Onyx after each test so that each test starts with a clean slate
+afterEach(() => {
+ Onyx.clear();
+});
+
+const getMockedReportActionsMap = (reportsLength = 10, actionsPerReportLength = 100) => {
+ const mockReportActions = Array.from({length: actionsPerReportLength}, (_reportAction, i) => {
+ const reportActionKey = i + 1;
+ const email = `actor+${reportActionKey}@mail.com`;
+ const reportAction = LHNTestUtils.getFakeReportAction(email);
+
+ return {[reportActionKey]: reportAction};
+ });
+
+ const reportKeysMap = Array.from({length: reportsLength}, (_report, i) => {
+ const key = i + 1;
+
+ return {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${key}`]: _.assign({}, ...mockReportActions)};
+ });
+
+ return _.assign({}, ...reportKeysMap);
+};
+
+const mockedReportActionsMap = getMockedReportActionsMap(2, 10000);
+
+/**
+ * This function will be executed 20 times and the average time will be used on the comparison.
+ * It will fail based on the CI configuration around Reassure:
+ * @see /.github/workflows/reassurePerformanceTests.yml
+ *
+ * Max deviation on the duration is set to 20% at the time of writing.
+ *
+ * More on the measureFunction API:
+ * @see https://callstack.github.io/reassure/docs/api#measurefunction-function
+ */
+test('getLastVisibleAction on 10k reportActions', async () => {
+ const reportId = '1';
+
+ await Onyx.multiSet({
+ ...mockedReportActionsMap,
+ });
+ await waitForBatchedUpdates();
+ await measureFunction(() => ReportActionsUtils.getLastVisibleAction(reportId), {runs: 20});
+});
+
+test('getLastVisibleAction on 10k reportActions with actionsToMerge', async () => {
+ const reportId = '1';
+ const parentReportActionId = '1';
+ const fakeParentAction = mockedReportActionsMap[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportId}`][parentReportActionId];
+ const actionsToMerge = {
+ [parentReportActionId]: {
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
+ previousMessage: fakeParentAction.message,
+ message: [
+ {
+ translationKey: '',
+ type: 'COMMENT',
+ html: '',
+ text: '',
+ isEdited: true,
+ isDeletedParentAction: true,
+ },
+ ],
+ errors: null,
+ linkMetaData: [],
+ },
+ };
+
+ await Onyx.multiSet({
+ ...mockedReportActionsMap,
+ });
+ await waitForBatchedUpdates();
+ await measureFunction(() => ReportActionsUtils.getLastVisibleAction(reportId, actionsToMerge), {runs: 20});
+});
diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js
new file mode 100644
index 000000000000..578546cb4679
--- /dev/null
+++ b/tests/perf-test/ReportScreen.perf-test.js
@@ -0,0 +1,225 @@
+import {fireEvent, screen} from '@testing-library/react-native';
+import React from 'react';
+import Onyx from 'react-native-onyx';
+import {measurePerformance} from 'reassure';
+import ComposeProviders from '../../src/components/ComposeProviders';
+import DragAndDropProvider from '../../src/components/DragAndDrop/Provider';
+import {LocaleContextProvider} from '../../src/components/LocaleContextProvider';
+import OnyxProvider from '../../src/components/OnyxProvider';
+import {CurrentReportIDContextProvider} from '../../src/components/withCurrentReportID';
+import {KeyboardStateProvider} from '../../src/components/withKeyboardState';
+import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions';
+import CONST from '../../src/CONST';
+import * as Localize from '../../src/libs/Localize';
+import ONYXKEYS from '../../src/ONYXKEYS';
+import {ReportAttachmentsProvider} from '../../src/pages/home/report/ReportAttachmentsContext';
+import ReportScreen from '../../src/pages/home/ReportScreen';
+import * as LHNTestUtils from '../utils/LHNTestUtils';
+import PusherHelper from '../utils/PusherHelper';
+import * as ReportTestUtils from '../utils/ReportTestUtils';
+import * as TestHelper from '../utils/TestHelper';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates';
+
+jest.setTimeout(60000);
+
+jest.mock('react-native-reanimated', () => ({
+ ...jest.requireActual('react-native-reanimated/mock'),
+ useSharedValue: jest.fn,
+ useAnimatedStyle: jest.fn,
+ useAnimatedRef: jest.fn,
+}));
+
+jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
+ function WithNavigationFocus(props) {
+ return (
+
+ );
+ }
+
+ WithNavigationFocus.displayName = 'WithNavigationFocus';
+
+ return WithNavigationFocus;
+});
+
+jest.mock('../../src/hooks/useEnvironment', () =>
+ jest.fn(() => ({
+ environment: 'development',
+ environmentURL: 'https://new.expensify.com',
+ isProduction: false,
+ isDevelopment: true,
+ })),
+);
+
+jest.mock('../../src/libs/Permissions', () => ({
+ canUseTasks: jest.fn(() => true),
+ canUseLinkPreviews: jest.fn(() => true),
+}));
+
+jest.mock('../../src/libs/Navigation/Navigation');
+
+const mockedNavigate = jest.fn();
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useFocusEffect: jest.fn(),
+ useIsFocused: () => ({
+ navigate: mockedNavigate,
+ }),
+ useRoute: () => jest.fn(),
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ addListener: () => jest.fn(),
+ }),
+ createNavigationContainerRef: jest.fn(),
+ };
+});
+
+// mock PortalStateContext
+jest.mock('@gorhom/portal');
+
+beforeAll(() =>
+ Onyx.init({
+ keys: ONYXKEYS,
+ safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
+ registerStorageEventListener: () => {},
+ }),
+);
+
+// Initialize the network key for OfflineWithFeedback
+beforeEach(() => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+ wrapOnyxWithWaitForBatchedUpdates(Onyx);
+ Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});
+});
+
+// Clear out Onyx after each test so that each test starts with a clean state
+afterEach(() => {
+ Onyx.clear();
+ PusherHelper.teardown();
+});
+
+function ReportScreenWrapper(args) {
+ return (
+
+
+
+ );
+}
+
+test('should render ReportScreen with composer interactions', () => {
+ const scenario = async () => {
+ // Query for the report list
+ await screen.findByTestId('report-actions-list');
+
+ // Query for the composer
+ const composer = await screen.findByTestId('composer');
+ expect(composer).toBeDefined();
+
+ // Type in the composer
+ fireEvent.changeText(composer, 'Test message');
+
+ const hintSendButtonText = Localize.translateLocal('common.send');
+
+ // Query for the send button
+ const sendButton = await screen.findByLabelText(hintSendButtonText);
+ expect(sendButton).toBeDefined();
+
+ // Click on the send button
+ fireEvent.press(sendButton);
+
+ const hintHeaderText = Localize.translateLocal('common.back');
+
+ // Query for the header
+ const header = await screen.findByLabelText(hintHeaderText);
+ expect(header).toBeDefined();
+ };
+
+ const policy = {
+ policyID: 1,
+ name: 'Testing Policy',
+ };
+
+ const report = LHNTestUtils.getFakeReport();
+ const reportActions = ReportTestUtils.getMockedReportsMap(1000);
+ const mockRoute = {params: {reportID: '1'}};
+
+ return waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ [ONYXKEYS.IS_SIDEBAR_LOADED]: true,
+ [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS],
+ [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: {
+ isLoadingReportActions: false,
+ },
+ }),
+ )
+ .then(() => measurePerformance( , {scenario}));
+});
+
+test('should press of the report item', () => {
+ const scenario = async () => {
+ // Query for the report list
+ await screen.findByTestId('report-actions-list');
+
+ // Query for the composer
+ await screen.findByTestId('composer');
+
+ const hintReportPreviewText = Localize.translateLocal('iou.viewDetails');
+
+ // Query for report preview buttons
+ const reportPreviewButtons = await screen.findAllByLabelText(hintReportPreviewText);
+
+ expect(reportPreviewButtons.length).toBeDefined();
+
+ // click on the report preview button
+ fireEvent.press(reportPreviewButtons[0]);
+ };
+
+ const policy = {
+ policyID: 123,
+ name: 'Testing Policy',
+ };
+
+ const report = LHNTestUtils.getFakeReport();
+ const reportActions = ReportTestUtils.getMockedReportsMap(1000);
+ const mockRoute = {params: {reportID: '2'}};
+
+ return waitForBatchedUpdates()
+ .then(() =>
+ Onyx.multiSet({
+ [ONYXKEYS.IS_SIDEBAR_LOADED]: true,
+ [`${ONYXKEYS.COLLECTION.REPORT}${mockRoute.params.reportID}`]: report,
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockRoute.params.reportID}`]: reportActions,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.BETAS]: [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS],
+ [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
+ [`${ONYXKEYS.COLLECTION.REPORT_METADATA}${mockRoute.params.reportID}`]: {
+ isLoadingReportActions: false,
+ },
+ }),
+ )
+ .then(() => measurePerformance( , {scenario}));
+});
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 38a348d1f698..f4cf1c33ba17 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -139,7 +139,8 @@ function signInAndGetAppWithUnreadChat() {
// Render the App and sign in as a test user.
render( );
return waitForBatchedUpdatesWithAct()
- .then(() => {
+ .then(async () => {
+ await waitForBatchedUpdatesWithAct();
const hintText = Localize.translateLocal('loginForm.loginForm');
const loginForm = screen.queryAllByLabelText(hintText);
expect(loginForm).toHaveLength(1);
diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js
index 5d0c22459710..ae0888c6f0ed 100644
--- a/tests/unit/CalendarPickerTest.js
+++ b/tests/unit/CalendarPickerTest.js
@@ -1,17 +1,10 @@
import {fireEvent, render, within} from '@testing-library/react-native';
-import {addYears, eachMonthOfInterval, format, subYears} from 'date-fns';
+import {addYears, subYears} from 'date-fns';
import CalendarPicker from '../../src/components/NewDatePicker/CalendarPicker';
import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
-DateUtils.setLocale(CONST.LOCALES.EN);
-const fullYear = new Date().getFullYear();
-const monthsArray = eachMonthOfInterval({
- start: new Date(fullYear, 0, 1), // January 1st of the current year
- end: new Date(fullYear, 11, 31), // December 31st of the current year
-});
-// eslint-disable-next-line rulesdir/prefer-underscore-method
-const monthNames = monthsArray.map((monthDate) => format(monthDate, CONST.DATE.MONTH_FORMAT));
+const monthNames = DateUtils.getMonthNames(CONST.LOCALES.EN);
jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({navigate: jest.fn()}),
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 9898063cc496..72d7d64f1283 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -130,6 +130,7 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast =
actor,
actorAccountID: 1,
reportActionID: `${++lastFakeReportActionID}`,
+ actionName: CONST.REPORT.ACTIONS.TYPE.CREATED,
shouldShow: true,
timestamp,
reportActionTimestamp: timestamp,
@@ -142,6 +143,55 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast =
],
whisperedToAccountIDs: [],
automatic: false,
+ message: [
+ {
+ type: 'COMMENT',
+ html: 'hey',
+ text: 'hey',
+ isEdited: false,
+ whisperedTo: [],
+ isDeletedParentAction: false,
+ reactions: [
+ {
+ emoji: 'heart',
+ users: [
+ {
+ accountID: 1,
+ skinTone: -1,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ originalMessage: {
+ childReportID: `${++lastFakeReportActionID}`,
+ emojiReactions: {
+ heart: {
+ createdAt: '2023-08-28 15:27:52',
+ users: {
+ 1: {
+ skinTones: {
+ '-1': '2023-08-28 15:27:52',
+ },
+ },
+ },
+ },
+ },
+ html: 'hey',
+ lastModified: '2023-08-28 15:28:12.432',
+ reactions: [
+ {
+ emoji: 'heart',
+ users: [
+ {
+ accountID: 1,
+ skinTone: -1,
+ },
+ ],
+ },
+ ],
+ },
};
}
diff --git a/tests/utils/ReportTestUtils.js b/tests/utils/ReportTestUtils.js
new file mode 100644
index 000000000000..48e5ebfaa56d
--- /dev/null
+++ b/tests/utils/ReportTestUtils.js
@@ -0,0 +1,61 @@
+import _ from 'underscore';
+
+const actionNames = ['ADDCOMMENT', 'IOU', 'REPORTPREVIEW'];
+
+const getFakeReportAction = (index, actionName) => ({
+ actionName,
+ actorAccountID: index,
+ automatic: false,
+ avatar: '',
+ created: '2023-09-12 16:27:35.124',
+ isAttachment: true,
+ isFirstItem: false,
+ lastModified: '2021-07-14T15:00:00Z',
+ message: [
+ {
+ html: 'hey',
+ isDelatedParentAction: false,
+ isEdited: false,
+ reactions: [],
+ text: 'test',
+ type: 'TEXT',
+ whisperedTo: [],
+ },
+ ],
+ originalMessage: {
+ html: 'hey',
+ lastModified: '2021-07-14T15:00:00Z',
+ // IOUReportID: index,
+ linkedReportID: index.toString(),
+ },
+ pendingAction: null,
+ person: [
+ {
+ type: 'TEXT',
+ style: 'strong',
+ text: 'email@test.com',
+ },
+ ],
+ previousReportActionID: '1',
+ reportActionID: index.toString(),
+ reportActionTimestamp: 1696243169753,
+ sequenceNumber: 0,
+ shouldShow: true,
+ timestamp: 1696243169,
+ whisperedToAccountIDs: [],
+});
+
+const getMockedSortedReportActions = (length = 100) => Array.from({length}, (__, i) => getFakeReportAction(i));
+
+const getMockedReportsMap = (length = 100) => {
+ const mockReports = Array.from({length}, (__, i) => {
+ const reportID = i + 1;
+ const actionName = i === 0 ? 'CREATED' : actionNames[i % actionNames.length];
+ const reportAction = getFakeReportAction(reportID, actionName);
+
+ return {[reportID]: reportAction};
+ });
+ return _.assign({}, ...mockReports);
+};
+
+export {getFakeReportAction, getMockedSortedReportActions, getMockedReportsMap};
diff --git a/tsconfig.json b/tsconfig.json
index 049ea075bcbd..f6cdf909a0e0 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,