diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml
index ecb0b87a6416..907b1e7be6ca 100644
--- a/.github/workflows/authorChecklist.yml
+++ b/.github/workflows/authorChecklist.yml
@@ -13,7 +13,8 @@ jobs:
runs-on: ubuntu-latest
if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]'
steps:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v4
- name: authorChecklist.js
uses: ./.github/actions/javascript/authorChecklist
diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml
index dde65f5a1503..9a1cac41ed69 100644
--- a/.github/workflows/createDeployChecklist.yml
+++ b/.github/workflows/createDeployChecklist.yml
@@ -14,15 +14,7 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
- - name: Set up git for OSBotify
- id: setupGitForOSBotify
- uses: ./.github/actions/composite/setupGitForOSBotifyApp
- with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
- OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
-
- name: Create or update deploy checklist
uses: ./.github/actions/javascript/createOrUpdateStagingDeploy
with:
- GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f6deaae963e4..63148f9e4eb5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -14,8 +14,9 @@ jobs:
with:
ref: staging
token: ${{ secrets.OS_BOTIFY_TOKEN }}
-
- - uses: ./.github/actions/composite/setupGitForOSBotifyApp
+
+ - name: Setup git for OSBotify
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -38,7 +39,8 @@ jobs:
ref: production
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: ./.github/actions/composite/setupGitForOSBotifyApp
+ - name: Setup git for OSBotify
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml
index b55354b95571..cb5dc6d28b32 100644
--- a/.github/workflows/deployBlocker.yml
+++ b/.github/workflows/deployBlocker.yml
@@ -22,6 +22,13 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
+ - name: Escape html characters in GH issue title
+ env:
+ GH_ISSUE_TITLE: ${{ github.event.issue.title }}
+ run: |
+ escaped_title=$(echo "$GH_ISSUE_TITLE" | sed -e 's/&/\&/g; s/\</g; s/>/\>/g; s/"/\"/g; s/'"'"'/\'/g; s/|/\|/g')
+ echo "GH_ISSUE_TITLE=$escaped_title" >> "$GITHUB_ENV"
+
- name: 'Post the issue in the #expensify-open-source slack room'
if: ${{ success() }}
uses: 8398a7/action-slack@v3
@@ -32,7 +39,7 @@ jobs:
channel: '#expensify-open-source',
attachments: [{
color: "#DB4545",
- text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ toJSON(github.event.issue.title) }}>',
+ text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ env.GH_ISSUE_TITLE }}>'
}]
}
env:
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index 016fe89ccfce..92b4e52c159c 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -211,7 +211,9 @@ jobs:
test_spec_file: tests/e2e/TestSpec.yml
test_spec_type: APPIUM_NODE_TEST_SPEC
remote_src: false
- file_artifacts: Customer Artifacts.zip
+ file_artifacts: |
+ Customer Artifacts.zip
+ Test spec output.txt
log_artifacts: debug.log
cleanup: true
timeout: 5400
@@ -220,6 +222,7 @@ jobs:
if: failure()
run: |
echo ${{ steps.schedule-awsdf-main.outputs.data }}
+ cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt"
unzip "Customer Artifacts.zip" -d mainResults
cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index 7fb5feaf6084..2285eec56065 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -18,7 +18,8 @@ jobs:
ref: main
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: ./.github/actions/composite/setupGitForOSBotifyApp
+ - name: Setup git for OSBotify
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -82,7 +83,7 @@ jobs:
ref: staging
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - name: Setup Git for OSBotify
+ - name: Setup git for OSBotify
id: setupGitForOSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
@@ -124,7 +125,7 @@ jobs:
ref: main
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - name: Setup Git for OSBotify
+ - name: Setup git for OSBotify
uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index 291bd80816b9..bf27006e34a2 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -56,6 +56,12 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
+ - name: Setup Java
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'oracle'
+ java-version: '17'
+
- name: Setup Ruby
uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index 6f222398d04b..94a51a2d11bd 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -84,6 +84,12 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
+ - name: Setup Java
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'oracle'
+ java-version: '17'
+
- name: Setup Ruby
uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
diff --git a/.github/workflows/testGithubActionsWorkflows.yml b/.github/workflows/testGithubActionsWorkflows.yml
new file mode 100644
index 000000000000..58de3ba2d9f3
--- /dev/null
+++ b/.github/workflows/testGithubActionsWorkflows.yml
@@ -0,0 +1,35 @@
+name: Test GitHub Actions workflows
+
+on:
+ workflow_dispatch:
+ workflow_call:
+ pull_request:
+ types: [opened, reopened, edited, synchronize]
+ branches-ignore: [staging, production]
+ paths: ['.github/**']
+
+jobs:
+ testGHWorkflows:
+ if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' || github.event_name == 'workflow_call' }}
+ runs-on: ubuntu-latest
+ env:
+ CI: true
+ name: test GitHub Workflows
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Setup Node
+ uses: Expensify/App/.github/actions/composite/setupNode@main
+
+ - name: Setup Homebrew
+ uses: Homebrew/actions/setup-homebrew@master
+
+ - name: Install Act
+ run: brew install act
+
+ - name: Set ACT_BINARY
+ run: echo "ACT_BINARY=$(which act)" >> "$GITHUB_ENV"
+
+ - name: Run tests
+ run: npm run workflow-test
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 1f80908b02b5..0951b194430b 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -24,8 +24,16 @@ jobs:
- name: Check for new JavaScript files
run: |
git fetch origin main --no-tags --depth=1
- count_new_js=$(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js' | wc -l)
+
+ # Explanation:
+ # - comm is used to get the intersection between two bash arrays
+ # - git diff is used to see the files that were added on this branch
+ # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main
+ # - wc counts the words in the result of the intersection
+ count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
if [ "$count_new_js" -gt "0" ]; then
echo "ERROR: Found new JavaScript files in the /src/libs, /src/hooks, /src/styles, or /src/languages directories; use TypeScript instead."
exit 1
fi
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/Gemfile.lock b/Gemfile.lock
index 079b5a5b742b..93dab195ebdd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -18,21 +18,21 @@ GEM
rubyzip (~> 2.0)
artifactory (3.0.15)
atomos (0.1.3)
- aws-eventstream (1.2.0)
- aws-partitions (1.824.0)
- aws-sdk-core (3.181.1)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.857.0)
+ aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
- aws-sdk-kms (1.71.0)
- aws-sdk-core (~> 3, >= 3.177.0)
+ aws-sdk-kms (1.73.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.134.0)
- aws-sdk-core (~> 3, >= 3.181.0)
+ aws-sdk-s3 (1.140.0)
+ aws-sdk-core (~> 3, >= 3.188.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6)
- aws-sigv4 (1.6.0)
+ aws-sigv4 (1.7.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
claide (1.1.0)
@@ -81,14 +81,13 @@ GEM
declarative (0.0.20)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
- domain_name (0.5.20190701)
- unf (>= 0.0.5, < 1.0.0)
+ domain_name (0.6.20231109)
dotenv (2.8.1)
emoji_regex (3.2.3)
escape (0.0.4)
ethon (0.16.0)
ffi (>= 1.15.0)
- excon (0.103.0)
+ excon (0.104.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -118,7 +117,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.2.7)
- fastlane (2.215.1)
+ fastlane (2.217.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -166,9 +165,9 @@ GEM
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.49.0)
+ google-apis-androidpublisher_v3 (0.53.0)
google-apis-core (>= 0.11.0, < 2.a)
- google-apis-core (0.11.1)
+ google-apis-core (0.11.2)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -181,23 +180,23 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
- google-apis-storage_v1 (0.19.0)
- google-apis-core (>= 0.9.0, < 2.a)
+ google-apis-storage_v1 (0.29.0)
+ google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
- google-cloud-storage (1.44.0)
+ google-cloud-storage (1.45.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
- google-apis-storage_v1 (~> 0.19.0)
+ google-apis-storage_v1 (~> 0.29.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
- googleauth (1.8.0)
+ googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
@@ -229,7 +228,7 @@ GEM
os (1.1.4)
plist (3.7.0)
public_suffix (4.0.7)
- rake (13.0.6)
+ rake (13.1.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
@@ -262,13 +261,10 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
- unf (0.1.4)
- unf_ext
- unf_ext (0.0.8.2)
- unicode-display_width (2.4.2)
+ unicode-display_width (2.5.0)
webrick (1.8.1)
word_wrap (1.0.0)
- xcodeproj (1.22.0)
+ xcodeproj (1.23.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index c538ddb6ca52..4db8a0836477 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001040107
- versionName "1.4.1-7"
+ versionCode 1001040601
+ versionName "1.4.6-1"
}
flavorDimensions "default"
diff --git a/assets/images/empty-state_background-fade-dark.png b/assets/images/empty-state_background-fade-dark.png
new file mode 100644
index 000000000000..1caf5630bee3
Binary files /dev/null and b/assets/images/empty-state_background-fade-dark.png differ
diff --git a/assets/images/empty-state_background-fade-light.png b/assets/images/empty-state_background-fade-light.png
new file mode 100644
index 000000000000..98456609b502
Binary files /dev/null and b/assets/images/empty-state_background-fade-light.png differ
diff --git a/assets/images/empty-state_background-fade.png b/assets/images/empty-state_background-fade.png
deleted file mode 100644
index 816ff7343310..000000000000
Binary files a/assets/images/empty-state_background-fade.png and /dev/null differ
diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js
index 22d035368c42..8c74ebfd1686 100644
--- a/config/webpack/webpack.common.js
+++ b/config/webpack/webpack.common.js
@@ -66,7 +66,8 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({
template: 'web/index.html',
filename: 'index.html',
splashLogo: fs.readFileSync(path.resolve(__dirname, `../../assets/images/new-expensify${mapEnvToLogoSuffix(envFile)}.svg`), 'utf-8'),
- usePolyfillIO: platform === 'web',
+ isWeb: platform === 'web',
+ isProduction: envFile === '.env.production',
isStaging: envFile === '.env.staging',
}),
new FontPreloadPlugin({
diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md
index d7a94c2a4337..32d3919efbe4 100644
--- a/contributingGuides/NAVIGATION.md
+++ b/contributingGuides/NAVIGATION.md
@@ -40,9 +40,22 @@ When creating RHP flows, you have to remember a couple things:
An example of adding `Settings_Workspaces` page:
-1. Add path to `ROUTES.js`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/ROUTES.js#L36
+1. Add path to `ROUTES.ts`: https://github.com/Expensify/App/blob/main/src/ROUTES.ts
+
+```ts
+export const ROUTES = {
+ // static route
+ SETTINGS_WORKSPACES: 'settings/workspaces',
+ // dynamic route
+ SETTINGS_WORKSPACES: {
+ route: 'settings/:accountID',
+ getRoute: (accountID: number) => `settings/${accountID}` as const,
+ },
+};
+
+```
-2. Add `Settings_Workspaces` page to proper RHP flow in `linkingConfig.js`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/linkingConfig.js#L40-L42
+2. Add `Settings_Workspaces` page to proper RHP flow in `linkingConfig.ts`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/linkingConfig.js#L40-L42
3. Add your page to proper navigator (it should be aligned with where you've put it in the previous step) https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/AppNavigator/ModalStackNavigators.js#L334-L338
diff --git a/docs/articles/expensify-classic/account-settings/Account-Details.md b/docs/articles/expensify-classic/account-settings/Account-Details.md
index 8f80a35f3525..bc4b94bf8a51 100644
--- a/docs/articles/expensify-classic/account-settings/Account-Details.md
+++ b/docs/articles/expensify-classic/account-settings/Account-Details.md
@@ -59,3 +59,11 @@ Is your Secondary Login (personal email) invalidated in your company account? If
3. You will be presented with a confirmation message saying Expensify sent you an email with a validation link
4. Head to your personal email account and follow the prompts
5. You'll receive a link in the email to click that will unlink the two accounts
+
+# FAQ
+## The profile picture on my account updated automatically. Why did this happen?
+Our focus is always on making your experience user-friendly and saving you valuable time. One of the ways we achieve this is by utilizing a public API to retrieve public data linked to your email address.
+
+This tool searches for public accounts or profiles associated with your email address, such as on LinkedIn. When it identifies one, it pulls in the uploaded profile picture and name to Expensify.
+
+While this automated process is generally accurate, there may be instances where it's not entirely correct. If this happens, we apologize for any inconvenience caused. The good news is that rectifying such situations is a straightforward process. You can quickly update your information manually by following the directions provided above, ensuring your data is accurate and up to date in no time.
diff --git a/docs/articles/expensify-classic/account-settings/Close-Account.md b/docs/articles/expensify-classic/account-settings/Close-Account.md
index 5e18490fc357..c25c22de9704 100644
--- a/docs/articles/expensify-classic/account-settings/Close-Account.md
+++ b/docs/articles/expensify-classic/account-settings/Close-Account.md
@@ -1,5 +1,123 @@
---
-title: Close Account
+title: Close Account
description: Close Account
----
-## Resource Coming Soon!
+---
+# Overview
+
+Here is a walk through of how to close an Expensify account through the website or mobile app.
+
+# How to close your account
+On the Web:
+
+1. Go to **Settings** in the left hand menu. Click **Account**.
+2. Click “Close Account”
+3. Follow the prompts to verify your email or phone number.
+4. Your account will be closed, and all data will be deleted.*
+
+{:width="100%"}
+
+On the Mobile App:
+
+Open the app and tap the three horizontal lines in the upper left corner.
+Select Settings from the menu.
+Look for the "Close Account" option in the "Others" section. (If you don’t see this option, you have a Domain Controlled account and will need to ask your Domain Admin to delete your account.)
+Complete the verification process using your email or phone number.
+Your account will be closed, and all data will be deleted.*
+
+{:width="100%"}
+
+These instructions may vary depending on the specific device you are using, so be sure to follow the steps as they appear on your screen.
+
+*Note: Transactions shared with other accounts will still be visible to those accounts. (Example: A report submitted to your company and reimbursed will not be deleted from your company’s account.) Additionally, we are required to retain certain records of transactions in compliance with laws in various jurisdictions.
+
+# How to reopen your account
+
+If your Expensify account is closed and not associated with a verified domain, you can reopen it with the following steps:
+
+1. Visit [expensify.com](https://www.expensify.com/).
+2. Attempt to sign in using your email address or phone number associated with the closed account.
+3. After entering your user name, you will see a prompt to reopen your account.
+4. Click on **Reopen Account**.
+5. A magic link will be sent to your email address.
+6. Access your email and click on the magic link. This link will take you to Expensify and reopen your account.
+7. Follow the prompts to create a new password associated with your account.
+8. Your account is now reopened. Any previously approved expense data will still be visible in the account.
+
+Note: Reports submitted and closed on an Individual workspace will not be retained since they have not been approved or shared with anyone else in Expensify.
+
+That's it! Your account is successfully reopened, and you can access your historical data that was shared with other accounts. Remember to recreate any workspaces and adjust settings if needed.
+
+# How to Reopen a Domain-controlled account
+Once an account has been **Closed** by a Domain Admin, it can be reopened by any Domain Admin on that domain.
+
+The Domain Admin will simply need to invite the previously closed account in the same manner that new accounts are invited to the Domain. The user will receive a magic link to their email account which they can use to Reopen the account.
+
+# How to retain a free account to keep historical expenses
+If you no longer need a group workspace or have a more advanced workspace than necessary in Expensify, and you want to downgrade while retaining your historical data, here's what you should do:
+
+1. If you're part of a group workspace, request the Workspace Admin to remove you, or if you own the workspace, delete it to downgrade to a free version.
+2. Once you've removed or been removed from a workspace, start using your free Expensify account. Your submitted expenses will still be saved, allowing you to access the historical data.
+3. Domain Admins in the company will still retain access to approved and reimbursed expenses.
+4. To keep your data, avoid closing your account. Account closures are irreversible and will result in the deletion of all your unapproved data.
+
+# Deep Dive
+
+## I’m unable to close my account
+
+If you're encountering an error message while trying to close your Expensify account, it's important to pinpoint the specific error. Encountering an error when trying to close your account is typically only experienced if the account has been an admin on a company’s Expensify workspace. (Especially if the account was involved in responsibilities like reimbursing employees or exporting expense reports.)
+
+In order to avoid users accidentally creating issues for their company, Expensify prevents account closure if you still have any individual responsibilities related to a Workspace within the platform. To successfully close your account, you need to ensure that your workspace no longer relies on your account to operate.
+
+Here are the reasons why you might encounter an error when trying to close your Expensify account, along with the actions required to address each of these issues:
+
+- **Account Under a Validated Domain**: If your account is associated with a validated domain, only a Domain Admin can close it. You can find this option in your Domain Admin's settings under Settings > Domains > Domain Members. Afterward, if you have a secondary personal login, you can delete it by following the instructions mentioned above.
+- **Sole Domain Admin for Your Company**: If you are the only Domain Admin for your company's domain, you must appoint another Domain Admin before you can close your account. This is to avoid accidentally prohibiting your entire company from using Expensify. You can do this by going to Settings > Domains > [Domain Name] > Domain Admins and making the necessary changes, or you can reset the entire domain.
+- **Workspace Billing Owner with an Annual Subscription**: If you are the Workspace Billing Owner with an Annual Subscription, you need to downgrade from the Annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces.
+- **Ownership of a Company Workspace or Outstanding Balance**: If you own a company workspace or there is an outstanding balance owed to Expensify, you must take one of the following actions before closing your account:
+
+ - Make the payment for the outstanding balance.
+ - Have another user take over billing for the workspace.
+ - Request a refund for your initial bill.
+ - Delete the workspace.
+
+- **Preferred Exporter for Your Workspace Integration**: If you are the "Preferred Exporter" for a workspace Integration, you must update the Preferred Exporter before closing your account. You can do this by navigating to **Settings** > **Workspaces** > **Group** > [Workspace name] > **Connections** > **Configure** and selecting any Workspace Admin from the dropdown menu as the Preferred Exporter.
+- **Verified Business Account with Outstanding Balance or Locked Status**: If you have a Verified Business Account with an outstanding balance or if the account is locked, you should wait for all payments to settle or unlock the account. To settle the amount owed, go to **Settings** > **Account** > **Payments** > **Bank Accounts** and take the necessary steps.
+
+## Validate the account to close it
+
+Sometimes, you may find yourself with an Expensify account that you don't need. This could be due to various reasons like a company inviting you for reimbursement, a vendor inviting you to pay, or a mistaken sign-up.
+
+In such cases, you have two options:
+
+**Option 1**: Retain the Account for Future Use
+You can keep the Expensify account just in case you need it later.
+
+**Option 2**: Close the Account
+
+Start by verifying your email or phone number
+
+Before closing the account, you need to verify that you have access to the email or phone number associated with it. This ensures that only you can close the account.
+
+Here's how to do it:
+
+1. Go to [www.expensify.com](http://www.expensify.com/).
+2. Enter your email address or phone number (whichever is associated with the unwanted account).
+3. Click the **Resend Link** button.
+4. Check your Home Page for the most recent email with the subject line "Please validate your Expensify login." Click the link provided in the email to validate your email address.
+ - If it's an account linked to a phone number, tap the link sent to your phone.
+5. After clicking the validation link, you'll be directed to an Expensify Home Page.
+6. Navigate to **Settings** > **Account** > **Account Details** > **Close Account**.
+7. Click the **Close My Account** button.
+ - Re-enter the email address or phone number of the account when prompted.
+ - Check the box that says, "I understand all of my unsubmitted expense data will be deleted."
+ - Click the **Close My Account** button.
+
+By following these steps, you can easily verify your email or phone number and close an unwanted Expensify account.
+
+# FAQ
+
+## What should I do if I'm not directed to my account when clicking the validate option from my phone or email?
+It's possible your browser has blocked this, either because of some existing cache or extension. In this case, you should follow the Reset Password flow to reset the password and manually gain access with the new password, along with your email address.
+
+## Why don't I see the Close Account option?
+It's possible your account is on a managed company domain. In this case, only the admins from that company can close it.
diff --git a/docs/articles/expensify-classic/account-settings/Notification-Troubbleshooting.md b/docs/articles/expensify-classic/account-settings/Notification-Troubbleshooting.md
new file mode 100644
index 000000000000..22df0dc7f6ca
--- /dev/null
+++ b/docs/articles/expensify-classic/account-settings/Notification-Troubbleshooting.md
@@ -0,0 +1,75 @@
+---
+title: Notification Troubleshooting
+description: This article is about how to troubleshoot notifications from Expensify.
+---
+
+# Overview
+Sometimes, members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.).
+
+# Here's how to troubleshoot missing Expensify notifications:
+
+1. **No error message, but the email is never received**
+The email might be delayed; give it 30-60 minutes to arrive in your inbox.
+Check **Email Preferences** on the web via **Settings > Your Account > Preferences**In the **Contact Preferences** section. Ensure that the relevant boxes are checked for the email type you're missing. Check your email spam and trash folders, as Expensify messages might end up there inadvertently.
+Check to make sure you haven't unintentionally blocked Expensify emails and whitelist [expensify.com](https://community.expensify.com/home/leaving?allowTrusted=1&target=http%3A%2F%2Fexpensify.com%2F), mg.expensify.com, and [amazonSES.com](https://community.expensify.com/home/leaving?allowTrusted=1&target=http%3A%2F%2Famazonses.com%2F) with your email provider.
+
+2. **A "We're having trouble emailing you" banner at the top of your screen**
+Verify that your email address in your account settings is correct and is a real deliverable email address.
+Re-send Verification Email: Look for an option to re-send a verification email, usually provided when this banner appears.
+
+{:width="100%"}
+
+# Deep Dive
+
+**For Private Domains**:
+
+If your organization uses a private domain, consult your IT department or IT person to ensure that the following domains are whitelisted to receive our emails: expensify.com, mg.expensify.com, and amazonSES.com. These domains are the sources of various notification emails, so make sure they aren't being blocked.
+
+**For Public Domains (e.g., Gmail, Yahoo, Hotmail)**:
+
+To whitelist our emails on public email services:
+
+1. Check your Spam Folder: Search for messages from expensify.com in your Spam folder, open them, and click "Not Spam" at the top of the message.
+2. Create a Filter: Set up a filter that identifies the entire expensify.com domain and directs all incoming messages to your inbox, preventing them from going to Spam.
+3. Add Specific Contacts: While optional, adding specific email addresses from Expensify as contacts can further prevent emails from going to Spam.
+
+Please note that even if you receive emails from our Concierge support communication, ensure that both expensify.com and mg.expensify.com are whitelisted as they use different servers.
+
+**Email Server Blocking**:
+Your email server may be blocking our emails due to spam filters or other services. Check with your IT department to investigate and resolve any server-level email blocking issues.
+
+**Mimecast**:
+If your company uses Mimecast, a service that can affect email deliverability, check with your IT department. If Mimecast is in use, reach out to us at concierge@expensify.com through a new email, as this should ensure delivery to your inbox. Mimecast should eventually recognize the Expensify domain, preventing future filtering.
+
+**For Outlook Users**:
+For Outlook users specifically:
+
+1. Click the gear icon in Outlook and select "View all Outlook settings."
+2. Choose "Mail" from the settings menu.
+3. Under the "Junk email" submenu, click "Add" under "Safe senders and domains."
+4. Enter the email address you want to whitelist.
+5. Click "Save."
+
+When you click the "Settings" link in the banner in Expensify, you'll be directed to your account settings page, where you may encounter a few different scenarios:
+
+- "Temporarily Suspended Emails": If the message mentions "temporarily suspended emails to," follow the steps provided in the yellow box. This situation typically occurs when we can't find a valid inbox to send our emails to. Possible reasons include:
+ - A misspelled email address during account creation.
+ - Use of a distribution list email (acting as an "alias" email) without a linked inbox.
+ - An auto-responder that has been responding to our emails for an extended period.
+- To resolve this issue, confirm that the email address is indeed associated with an active inbox. Then, click the link that says "here," and your email should be unblocked shortly.
+- SMTP Error (Gray Box): In some cases, you might encounter a gray box with an SMTP error message. This error can vary, but it typically looks something like this:
+
+{:width="100%"}
+
+**These look a bit cryptic, yes, but hang in there!**
+
+The error messages you see are the raw message text received from your email provider's server to Amazon. These messages can vary in text, but the best course of action is to follow the link provided (by copying and pasting) in the text for the next steps.
+
+**Scenario 1**: If the message in the gray box includes "mimecast.com": It means that our emails are being blocked by the server. In this case, you should contact your IT person or team to address the issue.
+
+**Scenario 2**: If the message in the gray box mentions "blacklist at org/.com/.net," or resembles the screenshot provided, it indicates that your IT team has configured your email to use a third-party email reputation or blacklisting service. Here's what you need to know:
+- All our emails are SPF and DKIM-signed, meaning they are cryptographically signed as coming from us and are not spam.
+- The problem arises because we send mail from a cloud-based service. This means that the sender's IP serves multiple vendors, including Expensify. If one of those vendors is marked as spam, it can block all messages from that IP, even if they're from different vendors (including us).
+- The better approach is for the server to flag spam via DKIM and SPF (rather than solely relying on the sender's IP address), as our messages are correctly signed and encrypted to prevent spoofing.
+
+To resolve these issues, consider discussing them with your IT team, as they can help implement the necessary changes to ensure you receive our emails without interruption.
diff --git a/docs/articles/expensify-classic/account-settings/Preferences.md b/docs/articles/expensify-classic/account-settings/Preferences.md
index 532da4d8a986..8131cd0e80c5 100644
--- a/docs/articles/expensify-classic/account-settings/Preferences.md
+++ b/docs/articles/expensify-classic/account-settings/Preferences.md
@@ -1,5 +1,28 @@
---
-title: Preferences
-description: Preferences
+title: Account Preferences
+description: Expensify Account Preferences
---
-## Resource Coming Soon!
+
+# Overview
+Customize your Expensify experience by updating your account details and preferences. Here you can update your profile picture, adjust contact preferences, and perform other actions to personalize your account.
+
+# How to manage contact preferences
+To edit your notification preferences or unsubscribe from Expensify updates:
+- On the web, navigate to **Settings > Account > Preferences**
+Scroll down to find the ‘Contact Preferences’ section. To stop receiving a specific type of email, uncheck the corresponding box.
+
+# How to set your time zone
+Wherever you are, we'll time-stamp your report actions according to your local time. This helps you keep track of when submissions or approvals occurred. Setting your time zone in Expensify is simple and ensures accurate time-stamping for your report actions, especially in the comments section of the expense report you're reviewing.
+
+To set your time zone:
+Navigate to **Settings > Account > Preferences > Scroll down to Time Zone**
+
+{:width="100%"}
+
+ **Note:** To set your time zone automatically based on your location, tick the box that says **Set my time zone automatically**.
+
+If you prefer to set your time zone manually, leave the box unticked and select your time zone from the searchable list of locations.
+
+When you add a comment to a report, all the report actions will be time-stamped in your local time. Adjusting your time zone to the appropriate location makes tracking and understanding submission and approval times much easier.
+
+{:width="100%"}
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md
index d8c7c145a670..958e423273ce 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Desktop.md
@@ -76,6 +76,7 @@ Expensify's integration with QuickBooks brings in your Chart of Accounts as Cate
3. Expensify offers Auto-Categorization to automatically assign expenses to the appropriate expense categories.
4. If needed, you can edit the names of the imported Categories to simplify expense coding for your employees. Keep in mind that if you make changes to these accounts in QuickBooks Desktop, the category names in Expensify will update to match them during the next sync.
5. _**Important:**_ Each expense must have a category selected to export to QuickBooks Desktop. The selected category must be one imported from QuickBooks Desktop; you cannot manually create categories within Expensify policy settings.
+
## Classes
Classes can be imported from QuickBooks as either tags (line-item level) or report fields (header level).
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md
index 3ee1c8656b4b..4075aaf18016 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md
@@ -1,5 +1,322 @@
---
-title: Coming Soon
-description: Coming Soon
+title: QuickBooks Online
+description: Everything you need to know about using Expensify's direct integration with QuickBooks Online.
---
-## Resource Coming Soon!
+# Overview
+
+The Expensify integration with QuickBooks Online brings in your expense accounts and other data and even exports reports directly to QuickBooks for easy reconciliation. Plus, with advanced features in QuickBooks Online, you can fine-tune coding settings in Expensify for automated data export to optimize your accounting workflow.
+
+## Before connecting
+
+It's crucial to understand the requirements based on your specific QuickBooks subscription:
+
+- While all the features are available in Expensify, their accessibility may vary depending on your QuickBooks Online subscription.
+- An error will occur if you try to export to QuickBooks with a feature enabled that isn't part of your subscription.
+- Please be aware that Expensify does not support the Self-Employed subscription in QuickBooks Online.
+
+{:width="100%"}
+
+# How to connect to QuickBooks Online
+
+## Step 1: Setup employees in QuickBooks Online
+
+Employees must be set up as either Vendors or Employees in QuickBooks Online. Make sure to include the submitter's email in their record.
+
+If you use vendor records, you can export as Vendor Bills, Checks, or Journal Entries. If you use employee records, you can export as Checks or Journal Entries (if exporting against a liability account).
+
+Additional Options for Streamlined Setup:
+
+- Automatic Vendor Creation: Enable “Automatically Create Entities” in your connection settings to automatically generate Vendor or Employee records upon export for submitters that don't already exist in QBO.
+- Employee Setup Considerations: If setting up submitters as Employees, ensure you activate QuickBooks Online Payroll. This will grant access to the Employee Profile tab to input employee email addresses.
+
+## Step 2: Connect Expensify and QuickBooks Online
+
+- Navigate to Settings > Workspaces > Group > [Workspace Name] > Connections > QuickBooks Online. Click Connect to QuickBooks.
+- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace). Then click Authorize.
+- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace):
+
+## Exporting historical Reports to QuickBooks Online:
+
+After connecting QuickBooks Online to Expensify, you may receive a prompt to export all historical reports from Expensify. To export multiple reports at once, follow these steps:
+
+- Go to the Reports page on the web.
+- Tick the checkbox next to the reports you want to export.
+- Click 'Export To' and select 'QuickBooks Online' from the drop-down list.
+
+If you don't want to export specific reports, click “Mark as manually entered” on the report.
+
+# How to configure export settings for QuickBooks Online
+
+Our QuickBooks Online integration offers a range of features. This section will focus on Export Settings and how to set them up.
+
+## Preferred Exporter
+
+Any Workspace admin can export to your accounting integration, but the Preferred Exporter can be chosen to automate specific steps. You can set this role from Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Export > Preferred Exporter.
+
+The Preferred Exporter:
+
+- Is the user whose Concierge performs all automated exports on behalf of.
+- Is the only user who will see reports awaiting export in their **Home.**
+- Must be a **Domain Admin** if you have set individual GL accounts for Company Card export.
+- Must be a **Domain Admin** if this is the Preferred Workspace for any Expensify Card domain using Automatic Reconciliation.
+
+## Date
+
+When exporting reports to QuickBooks Online, you can choose the report's **submitted date**, the report's **exported date**, or the **date of the last expense on the report.**
+
+Most export options (Check, Journal Entry, and Vendor Bill) will create a single itemized entry with one date.
+Please note that if you choose a Credit Card or Debit Card for non-reimbursable expenses, we'll use the transaction date on each expense during export.
+
+# Reimbursable expenses
+
+Reimbursable expenses export to QuickBooks Online as:
+
+- Vendor Bills
+- Checks
+- Journal Entries
+
+## Vendor bill (recommended)
+
+This is a single itemized vendor bill for each Expensify report. If the accounting period is closed, we will post the vendor bill on the first day of the next open period. If you export as Vendor Bills, you can also choose to Sync reimbursed reports (set on the Advanced tab). **An A/P account is required to export to a vendor bill.**
+
+The submitter will be listed as the vendor in the vendor bill.
+
+{:width="100%"}
+
+## Check
+
+This is a single itemized check for each Expensify report. You can mark a check to be printed later in QuickBooks Online.
+
+{:width="100%"}
+
+## Journal entry
+
+This is a single itemized journal entry for each Expensify report.
+
+{:width="100%"}
+
+# Non-reimbursable expenses
+
+Non-reimbursable expenses export to QuickBooks Online as:
+
+- Credit Card expenses
+- Debit Card Expenses
+- Vendor Bills
+
+## Credit/debit card
+
+Using Credit/Debit Card Transactions:
+
+- Each expense will be exported as a bank transaction with its transaction date.
+- If you split an expense in Expensify, we'll consolidate it into a single credit card transaction in QuickBooks with multiple line items posted to the corresponding General Ledger accounts.
+
+Pro-Tip: To ensure the payee field in QuickBooks Online reflects the merchant name for Credit Card expenses, ensure there's a matching Vendor in QuickBooks Online. Expensify checks for an exact match during export. If none are found, the payee will be mapped to a vendor we create and labeled as Credit Card Misc. or Debit Card Misc.
+
+{:width="100%"}
+
+If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in QuickBooks.
+
+## Vendor Bill
+
+- A single detailed vendor bill is generated for each Expensify report. If the accounting period is closed, the vendor bill will be posted on the first day of the next open period. If you choose to export non-reimbursable expenses as Vendor Bills, you can assign a default vendor to the bill.
+- The export will use your default vendor if you have Default Vendor enabled. If the Default Vendor is disabled, the report's submitter will be set as the Vendor in QuickBooks.
+
+## Billable Expenses
+
+- In Expensify, you can designate expenses as billable. These will be exported to QuickBooks Online with the billable flag. This feature applies only to expenses exported as Vendor Bills or Checks. To maximize this functionality, ensure that any billable expense is associated with a Customer/Job.
+
+## Export Invoices
+
+If you are creating Invoices in Expensify and exporting these to QuickBooks Online, this is the account the invoice will appear against.
+
+# Configure coding for QuickBooks Online
+
+The coding tab is where your information is configured for Expensify; this will allow employees to code expenses and reports accurately.
+
+- Categories
+- Classes and/or Customers/Projects
+- Locations
+- Items
+- Tax
+
+## Categories
+
+QuickBooks Online expense accounts will be automatically imported into Expensify as Categories.
+
+## Account Import
+
+Equity type accounts will also be imported as categories.
+
+Important notes:
+
+- Other Current Liabilities can only be exported as Journal Entries if the submitter is set up as an Employee in QuickBooks.
+- Exchange Gain or Loss detail type does not import.
+
+Recommended steps to take after importing the expense accounts from QuickBooks to Expensify:
+
+- Go to Settings > Workspaces > Groups > [Workspace Name] > Categories to see the accounts imported from QuickBooks Online.
+- Use the enable/disable button to choose which Categories to make available to your employees, and set Category specific rules via the blue settings cog.
+- If necessary, edit the names of imported Categories to make expense coding easier for your employees. (Please Note: If you make any changes to these accounts in QuickBooks Online, the category names on Expensify's side will revert to match the name of the account in QuickBooks Online the next time you sync).
+- If you use Items in QuickBooks Online, you can import them into Expensify as Categories.
+
+Please note that each expense has to have a category selected to export to QuickBooks Online. The chosen category has to be imported from QuickBooks Online and cannot be manually created within the Workspace settings.
+
+## Classes and Customers/Projects
+
+If you use Classes or Customers/Projects in QuickBooks Online, you can import those into Expensify as Tags or Report Fields:
+
+- Tags let you apply a Class and/or Customer/Project to each expense.
+- Report Fields enables you to apply a Class and/or Customer/Project to all expenses on a report.
+
+Note: Although Projects can be imported into Expensify and coded to expenses, due to the limitations of the QuickBooks API, expenses cannot be created within the Projects module in QuickBooks.
+
+## Locations
+
+Locations can be imported into Expensify as a Report Field or, if you export reimbursable expenses as Journal Entries and non-reimbursable expenses as Credit/Debit Card, you can import Locations as Tags.
+
+## Items
+
+If you use Items in QuickBooks Online, you can import Items defined with Purchasing Information (with or without Sales Information) into Expensify as Categories.
+
+## Tax
+
+- Using our tax tracking feature, you can assign a tax rate and amount to each expense.
+- To activate tax tracking, go to connection configuration and enable it. This will automatically import purchasing taxes from QuickBooks Online into Expensify.
+- After the connection is set, navigate to Settings > Workspaces > Groups > [Workspace Name] > Tax. Here, you can view the taxes imported from QuickBooks Online.
+- Use the enable/disable button to choose which taxes are accessible to your employees.
+- Set a default tax for the Company Workspace, which will automatically apply to all new expenses.
+- Please note that, at present, tax cannot be exported to Journal Entries in QuickBooks Online.
+- Expensify performs a daily sync to ensure your information is up-to-date. This minimizes errors from outdated QuickBooks Online data and saves you time on syncing.
+
+# How to configure advanced settings for QuickBooks Online
+
+The advanced settings are where functionality for automating and customizing the QuickBooks Online integration can be enabled.
+Navigate to this section of your Workspace by following Settings > Workspaces > Group > [Workspace Name] > Connections > Configure button > Advanced tab.
+
+## Auto Sync
+With QuickBooks Online auto-sync, once a non-reimbursable report is final approved in Expensify, it's automatically queued for export to QuickBooks Online. For expenses eligible for reimbursement with a linked business bank account, they'll sync when marked as reimbursed.
+
+## Newly Imported Categories
+
+This setting determines the default status of newly imported categories from QuickBooks Online to Expensify, either enabled or disabled.
+
+## Invite Employees
+
+Enabling this automatically invites all Employees from QuickBooks Online to the connected Expensify Company Workspace. If not, you can manually invite or import them using a CSV file.
+
+## Automatically Create Entities
+
+When exporting reimbursable expenses as Vendor Bills or Journal Entries, Expensify will automatically create a vendor in QuickBooks if one doesn't exist. It will also generate a customer when exporting Invoices.
+
+## Sync Reimbursed Reports
+
+Enabling this marks the Vendor Bill as paid in QuickBooks Online when you reimburse a report via ACH direct deposit in Expensify. If reimbursing outside Expensify, marking the Vendor Bill as paid will automatically in QuickBooks Online update the report as reimbursed in Expensify. Note: After enabling this feature, select your QuickBooks Account in the drop-down, indicating the bank account for reimbursements.
+
+## Collection Account
+
+If you are exporting Invoices from Expensify to Quickbooks Online, this is the account the Invoice will appear against once marked as Paid.
+
+# Deep Dive
+
+## Preventing Duplicate Transactions in QuickBooks
+
+When importing a banking feed directly into QuickBooks Online while also importing transactions from Expensify, it's possible to encounter duplicate entries in QuickBooks. To prevent this, follow these steps:
+
+Step 1: Complete the Approval Process in Expensify
+
+- Before exporting any expenses to QuickBooks Online, ensure they are added to a report and the report receives approval. Depending on your Workspace setup, reports may require approval from one or more individuals. The approval process concludes when the last user who views the report selects "Final Approve."
+
+Step 2: Exporting Reports to QuickBooks Online
+
+- To ensure expenses exported from Expensify match seamlessly in the QuickBooks Banking platform, make sure these expenses are marked as non-reimbursable within Expensify and that “Credit Card” is selected as the non-reimbursable export option for your expenses.
+
+Step 3: Importing Your Credit Card Transactions into QuickBooks Online
+
+- After completing Steps 1 and 2, you can import your credit card transactions into QuickBooks Online. These imported banking transactions will align with the ones brought in from Expensify. QuickBooks Online will guide you through the process of matching these transactions, similar to the example below:
+
+{:width="100%"}
+
+## Tax in QuickBooks Online
+
+If your country applies taxes on sales (like GST, HST, or VAT), you can utilize Expensify's Tax Tracking along with your QuickBooks Online tax rates. Please note: Tax Tracking is not available for Workspaces linked to the US version of QuickBooks Online. If you need assistance applying taxes after reports are exported, contact QuickBooks.
+
+To get started:
+
+- Go to Settings > Workspaces > Group > [Workspace Name] > Connections, and click Configure.
+- Navigate to the Coding tab.
+- Turn on **Tax**.
+- Click Save. This imports the Tax Name and rate from QuickBooks Online.
+- Visit Settings > Workspaces > Group > [Workspace Name] > Tax to view the imported taxes.
+- Use the enable/disable button in the Tax tab to choose which taxes your employees can use.
+
+Remember, you can also set a default tax rate for the entire Workspace. This will be automatically applied to all new expenses. The user can still choose a different tax rate for each expense.
+
+Tax information can't be sent to Journal Entries in QuickBooks Online. Also, when dealing with multiple tax rates, where one receipt has different tax rates (like in the EU, UK, and Canada), users should split the expense into the respective parts and set the appropriate tax rate for each part.
+
+## Multi-currency
+
+When working with QuickBooks Online Multi-Currency, there are some things to remember when exporting Vendor Bills and Check! Make sure the vendor's currency and the Accounts Payable (A/P) bank account match.
+
+In QuickBooks Online, the currency conversion rates are not applied when exporting. All transactions will be exported with a 1:1 conversion rate, so for example, if a vendor's currency is CAD (Canadian Dollar) and the home currency is USD (US Dollar), the export will show these currencies without applying conversion rates.
+
+{:width="100%"}
+
+To correct this, you must manually update the conversion rate after the report has been exported to QuickBooks Online.
+
+Specifically for Vendor Bills:
+
+If multi-currency is enabled and the Vendor's currency is different from the Workspace currency, OR if QuickBooks Online home currency is foreign from the Workspace currency, then:
+
+- We create the Vendor Bill in the Vendor's currency (this is a QuickBooks Online requirement - we don't have a choice)
+- We set the exchange rate between the home currency and the Vendor's currency
+- We convert line item amounts to the vendor's currency
+
+Let's consider this example:
+
+- QuickBooks Online home currency is USD
+- Vendor's currency is VND
+- Workspace (report) currency is JPY
+
+Upon export, we:
+
+1. Specified the bill is in VND
+2. Set the exchange rate between VND and USD (home currency), computed at the time of export.
+3. Converted line items from JPY (currency in Expensify) to VND
+4. QuickBooks Online automatically computed the USD amount (home currency) based on the exchange rate we specified
+5. Journal Entries, Credit Card, and Debit Card:
+
+Multi-currency exports will fail as the account currency must match both the vendor and home currencies.
+
+## Report Fields
+
+Report fields are a handy way to collect specific information for a report tailored to your organization's needs. They can specify a project, business trip, client, location, and more!
+
+When integrating Expensify with Your Accounting Software, you can create your report fields in your accounting software so the next time you sync your Workspace, these fields will be imported into Expensify.
+
+To select how a specific field imports to Expensify, head to Settings > Workspaces > Group >
+[Workspace Name] > Connections > Accounting Integrations > QuickBooks Online > Configure > Coding.
+
+Here are the QuickBooks Online fields that can be mapped as a report field within Expensify:
+
+- Classes
+- Customers/Projects
+- Locations
+
+# FAQ
+
+## What happens if the report can't be exported to QuickBooks Online automatically?
+
+If a report encounters an issue during automatic export to QuickBooks Online, you'll receive an email with details about the problem, including any specific error messages. These messages will also be recorded in the report's history section.
+
+The report will be placed in your Home for your attention. You can address the issues there. If you need further assistance, refer to our QuickBooks Online Export Errors page or export the report manually.
+
+## How can I ensure that I final approve reports before they're exported to QuickBooks Online?
+
+To ensure reports are reviewed before export, set up your Workspaces with the appropriate workflow in Expensify. Additionally, consider changing your Workspace settings to enforce expense Workspace workflows strictly. This guarantees that your Workspace's workflow is consistently followed.
+
+## What happens to existing approved and reimbursed reports if I enable Auto Sync?
+
+- If Auto Sync was disabled when your Workspace was linked to QuickBooks Online, enabling it won't impact existing reports that haven't been exported.
+- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in QuickBooks Online during the next sync.
+- If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync.
+- Reports that have yet to be exported to QuickBooks Online won't be automatically exported.
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md
index 3ee1c8656b4b..32ce41d3cbf3 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md
@@ -1,5 +1,189 @@
---
-title: Coming Soon
-description: Coming Soon
+title: How To Manage Employees and Reports > Approving Reports
+description: This page will help you understand the lifecycle of a report and how to approve reports that are submitted to you.
---
-## Resource Coming Soon!
+# About
+This article provides a comprehensive guide on report management within our platform. From viewing, editing, and submitting your employees' Open reports to handling rejections and unapproving, as well as harnessing the power of our "Guided Review" feature. Additionally, we'll delve into best practices for Concierge, offering insights on how to streamline and automate your report approval processes for maximum efficiency.
+Let's dive in!
+
+
+# How-to manage reports
+This section covers the most essential information a user needs to operate a feature i.e. what to click on. We’ll go over any action the user might take when configuring or using the feature, starting from configuration and moving to usage.
+
+
+What options does a user have when configuring this feature?
+
+
+What options does a user have then interacting with this feature?
+
+
+What elements of this feature are pay-walled vs. free?
+
+
+As a Workspace admin, you have the ability to view, edit, and submit your employees' Open reports.
+
+
+We recommend beginning this process from the web version of Expensify because it offers more functionality compared to the mobile app. Here's how to get started:
+Click on the "Reports" tab.
+Select the "All Submitters" and "Open" filters.
+This will display all employee reports on your Workspaces that have not yet been submitted.
+
+## Viewing Employee Reports
+Viewing employee reports can vary depending on whether you're using the web or mobile versions of Expensify. We generally recommend using the web version for this purpose, as it offers the following advantages:
+
+
+You will only receive reports directly submitted to you when using the mobile app.
+
+
+The option to filter reports via the Reports page is exclusively available in the web version, making it more convenient when reviewing multiple reports during a session.
+
+## Viewing employee reports on the mobile app
+When using the mobile app to view reports, please note the following:
+
+
+Tapping on the Reports list will only display your own reports; you won't see reports from other Workspace members.
+
+
+To view another Workspace member's report in the Expensify app, it must be submitted directly to you, and you must access it through a link from an email or via Home.
+
+
+When you access a report in this manner, you will have the option to approve/reject it or go through the review process if there are expenses that require your attention.
+
+
+Once you've approved or rejected the report, it won't be accessible in the app anymore. To view it again, please visit the website and follow the steps mentioned above.
+
+## Editing employee reports
+If a report has been submitted directly to you, follow these steps to edit the expense details. Please note that you cannot change the expense amount; to make changes affecting the report total, you must reject it and return it to the employee.
+
+
+Here's what to do:
+- Click on any expense within the report to edit the expense details.
+- Remember that you cannot modify the expense amount directly. To make changes affecting the report total, reject the report, and it will be sent back to the employee for revisions.
+- If you're a Workspace admin and need to edit a report that wasn't submitted directly to you, use the "Take Control" button at the top of the report. Keep in mind that taking control of a report will disrupt the approval workflow.
+
+Additionally, here are some other editing options for Admins:
+- Undelete deleted company card expenses via the Reconciliation Dashboard (requires Domain Admin privileges).
+- Add unreported company card expenses to an existing Open (unsubmitted) report or create a new report via the Reconciliation Dashboard (requires Domain Admin privileges).
+- Add or modify expense coding, including Category, Tag(s), and Attendees.
+- Attach a receipt to an expense that doesn't have one.
+- Move card expenses between two Open (unsubmitted) reports.
+- Merge duplicate expenses (only applicable if they are not card transactions).
+- Change the Workspace associated with a report.
+
+
+## Submitting Employee Reports
+As a Workspace Admin, you have the option to submit any of your employee's Open reports. If an employee is unable to do it themselves, a Workspace admin can submit an expense report on their behalf to initiate the approval process. Follow these steps:
+Click the "Submit" button located at the top of the report.
+
+
+## Report History and Comments
+Please keep in mind that any changes made by the admin are tracked under "Report History and Comments." If you change the reimbursable status of an expense (e.g., from Reimbursable to Non-Reimbursable), an email notification will be sent to the employee to notify them of this change.
+
+
+## Rejecting or Unapproving a Report
+If you need to reject a report that has been submitted to you or Unapprove a report that has already been approved.
+
+
+To reject the report, click Reject rather than beginning the Review process. If there are multiple approvers involved, you can choose how far back to reject the report.
+
+
+Rejecting a report will return the report back to the submitter in an Open status or, in the case of a multi-level approval workflow, back to the previous approver in a Processing status (awaiting their approval). You may need to do this if the submitter is not ready to submit the report, or perhaps the report as a whole needs to be rejected based on the configuration of your organization's expense Workspace.
+
+
+## Unapprove a Report
+You can click the red Unapprove button at the top of the report to undo approving a report. Keep in mind that you'll only see the Unapprove button if you're a report approver on an admin that has taken control of the report.
+
+
+## Marking a Report as Reimbursed Outside of Expensify
+If you are reimbursing reports via paper check, through payroll or any other method that takes place outside of Expensify, you'll want to keep track of which reports have been taken care of by marking reports as reimbursed.
+
+
+1. Log into your Expensify account using your preferred web browser, (ie: Chrome or Safari)
+2. Head to your Reports page and locate the report
+3. Click the report name to open it
+4. Click on Reimburse
+5. Choose "I'll do it manually - just mark it as reimbursed". This will change the report status to Reimbursed
+6. The submitter can then go into the report and confirm that they received the reimbursement by clicking the button at the top of the report.
+7. This will change the report status to Reimbursed: CONFIRMED
+
+
+# How to Use Guided Review to Approve Reports
+Guided Review helps alert you to what might be out-of-Workspace for an Expense Report. You'll be guided through all report violations and warnings and given the option to Reject or Edit items that need review prior to approving a report.
+
+
+Guided Review helps approvers quickly identify reports that need more attention so they can pass over reports that can be quickly approved. Both Submitters and Approvers have actionable notifications for the following: violations, warnings, and notices. These notifications are important since they will be included in “review mode” for the approver to make clear approve or reject decisions.
+
+
+Via the Website:
+1. Simply click Review at the top left of the report and the system will begin to walk you through the entire report.
+2. Choose to Reject, View, or skip over an item needing review. If you wish to stop the process at any time, click the X in the progress bar in the top right corner.
+Reject: This will remove the expense from the report and send it back to the submitter. An email will be sent to the submitter explaining this expense has been rejected.
+View: This will allow you to open the expense so you can view and fix any incorrect data.
+Next: This will allow you to skip over the current item and move forward to review the rest of the report.
+Finish: Click this to finish reviewing the report!
+3. Click the Finish button if you are done reviewing, or reject/edit the last item to finish the review process.
+4. Approve the report! Approve and Forward the report if there is another person who needs to review the report in your approval workflow, or you can Final Approve if you are the final approver. Note: When in Guided Review, you'll automatically Approve the report adhering to your Company's Approval Workflow once you Approve the final expense on the report. You'll then be immediately taken to the next report requiring your attention - making Approving multiple expenses a quick and painless process!
+
+
+
+
+Via the Mobile App:
+1. From Home, under Reports that need your attention, click Begin Review, and the system will bring you to the first expense on the oldest report in Home.
+2. Edit the expense: Make any necessary edits to the expense by tapping the corresponding field. Be sure to address any Violations and Notes on the expense! Notes are indicated at the top of the expense with a yellow exclamation point, while violations appear on the expense with a red exclamation point:
+3. Choose Reject or Accept at the top of the expense.
+
+
+Reject: This will remove the expense from the report and send it back to the submitter. An email will be sent to the submitter explaining this expense has been rejected, and a comment will be added to the report it was rejected from. If this is the only expense on the report, the entire report will be rejected (and the expense will remain on the report).
+
+
+If Scheduled Submit is being used, rejected expenses will auto-report to the next Open report on the Workspace (as if it were a new expense). If an open report doesn't exist, Concierge will create a new one.
+
+
+
+If Scheduled Submit is not being used, any rejected expenses will be Unreported in the submitter's account and need to be manually applied to a new report.
+
+Accept: This will move to the next expense on the report, leaving behind any outstanding violations or notes. If this is the last expense on the report, you'll be all done!
+Once you've made it through all of the expenses on the report, you'll be all set!
+
+
+# Deep Dive
+## Concierge Report Management
+
+
+Concierge report approval removes the need for you to manually click "Approve" on endless reports! Instead, you can set up your group Workspace to capture all the requirements you have for your team's expenses. As long as all the rules have been followed and Concierge's additional audit is passed (more below), we will automatically approve such reports on behalf of the approver after submission.
+
+Before you start:
+Ensure are a Workspace admin on a group Workspace
+Set your workflow to Submit-and-Approve or Advanced Approval workflow
+
+
+## Then follow these steps:
+Set up your group Workspace so that all of your expense requirements are defined. Setting automatic categories for employees and category rules (e.g., maximum amounts, receipt requirements, etc.) are great examples!
+
+
+Navigate to Settings > Workspaces > Group > [Workspace Name] > Members.
+
+
+Scroll down to Approval Mode and select either Submit-and-Approve or Advanced Approval.
+
+
+Under Expense Approvals, select a Manual Approval Threshold greater than $0.
+
+
+With this setup, manual approval will only be required:
+- For reports that fail audit (i.e. there is at least one Workspace violation on the report)
+- For reports that contain at least one expense over the Manual Approval Threshold
+- For any percentage of reports that you'd like to spot-check (this is set at 5% or 1 in 20 by default).
+- If the report meets all of the requirements you specify in your Workspace settings and all expenses are under the Manual Approval Threshold, then Concierge will automatically move your report through each step of your designated approval workflow (unless it's routed for a spot-check).
+
+
+
+
+## Concierge Receipt Audit
+Concierge Receipt Audit is a real-time audit and compliance of receipts submitted by employees and Workspace users. Concierge checks every receipt for accuracy and compliance, flagging any expenses that seem fishy before expense reports are even submitted for approval. All risky expenses are highlighted for manual review, leaving you with more control over and visibility into employee expenses.
+
+
+1. Concierge will SmartScan every receipt to verify the data input by the user matches the currency, date, and amount on the physical receipt.
+2. After the report is submitted for approval, Concierge highlights any differences between the SmartScanned values and the employee's chosen input.
+3. Each receipt that has been verified will show the "Verified" logo.
+
diff --git a/docs/articles/expensify-classic/send-payments/Pay-Bills.md b/docs/articles/expensify-classic/send-payments/Pay-Bills.md
index 41c0146126ba..8a5c7c5c7f88 100644
--- a/docs/articles/expensify-classic/send-payments/Pay-Bills.md
+++ b/docs/articles/expensify-classic/send-payments/Pay-Bills.md
@@ -1,5 +1,110 @@
---
title: Pay Bills
-description: Pay Bills
+description: How to receive and pay company bills in Expensify
---
-## Resource Coming Soon!
+
+
+# Overview
+Simplify your back office by receiving bills from vendors and suppliers in Expensify. Anyone with or without an Expensify account can send you a bill, and Expensify will file it as a Bill and help you issue the payment.
+
+# How to Receive Vendor or Supplier Bills in Expensify
+
+There are three ways to get a vendor or supplier bill into Expensify:
+
+**Option 1:** Have vendors send bills to Expensify directly: Ask your vendors to email all bills to your Expensify billing intake email.
+
+**Option 2:** Forward bills to Expensify: If your bills are emailed to you, you can forward those bills to your Expensify billing intake email yourself.
+
+**Option 3:** Manually upload bills to Expensify: If you receive physical bills, you can manually create a Bill in Expensify on the web from the Reports page:
+1. Click **New Report** and choose **Bill**
+2. Add the expense details and vendor's email address to the pop-up window
+3. Upload a pdf/image of the bill
+4. Click **Submit**
+
+# How to Pay Bills
+
+There are multiple ways to pay Bills in Expensify. Let’s go over each method below:
+
+## ACH bank-to-bank transfer
+
+To use this payment method, you must have a business bank account connected to your Expensify account.
+
+To pay with an ACH bank-to-bank transfer:
+
+1. Sign in to your Expensify account on the web at www.expensify.com.
+2. Go to the Inbox or Reports page and locate the Bill that needs to be paid.
+3. Click the **Pay** button to be redirected to the Bill.
+4. Choose the ACH option from the drop-down list.
+5. Follow the prompts to connect your business bank account to Expensify.
+
+**Fees:** None
+
+## Pay using a credit or debit card
+
+This option is available to all US and International customers receiving an bill from a US vendor with a US business bank account.
+
+To pay with a credit or debit card:
+1. Sign-in to your Expensify account on the web app at www.expensify.com.
+2, Click on the Bill you’d like to pay to see the details.
+3, Click the **Pay** button.
+4. You’ll be prompted to enter your credit card or debit card details.
+
+**Fees:** Includes 2.9% credit card payment fee
+
+## Venmo
+
+If both you and the vendor have Venmo setup in their Expensify account, you can opt to pay the bill through Venmo.
+
+**Fees:** Venmo charges a 3% sender’s fee
+
+## Pay Outside of Expensify
+
+If you are not able to pay using one of the above methods, then you can mark the Bill as paid manually in Expensify to update its status and indicate that you have made payment outside Expensify.
+
+To mark a Bill as paid outside of Expensify:
+
+1. Sign-in to your Expensify account on the web app at www.expensify.com.
+2. Click on the Bill you’d like to pay to see the details.
+3. Click on the **Reimburse** button.
+4. Choose **I’ll do it manually**
+
+**Fees:** None
+
+# FAQ
+
+## What is my company's billing intake email?
+Your billing intake email is [yourdomain.com]@expensify.cash. Example, if your domain is `company.io` your billing email is `company.io@expensify.cash`.
+
+## When a vendor or supplier bill is sent to Expensify, who receives it?
+
+Bills are received by the Primary Contact for the domain. This is the email address listed at **Settings > Domains > Domain Admins**.
+
+## Who can view a Bill in Expensify?
+
+Only the primary contact of the domain can view a Bill.
+
+## Who can pay a Bill?
+
+Only the primary domain contact (owner of the bill) will be able to pay the Mill.
+
+## How can you share access to Bills?
+
+To give others the ability to view a Bill, the primary contact can manually “share” the Bill under the Details section of the report via the Sharing Options button.
+To give someone else the ability to pay Bills, the primary domain contact will need to grant those individuals Copilot access to the primary domain contact's account.
+
+## Is Bill Pay supported internationally?
+
+Payments are currently only supported for users paying in United States Dollars (USD).
+
+## What’s the difference between a Bill and an Invoice in Expensify?
+
+A Bill is a payable which represents an amount owed to a payee (usually a vendor or supplier), and is usually created from a vendor invoice. An Invoice is a receivable, and indicates an amount owed to you by someone else.
+
+# Deep Dive: How company bills and vendor invoices are processed in Expensify
+
+Here is how a vendor or supplier bill goes from received to paid in Expensify:
+
+1. When a vendor or supplier bill is received in Expensify via, the document is SmartScanned automatically and a Bill is created. The Bill is owned by the primary domain contact, who will see the Bill on the Reports page on their default group policy.
+2. When the Bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow. Each time the Bill is approved, it is visible in the next approver's Inbox.
+3. The final approver pays the Bill from their Expensify account on the web via one of the methods.
+4. The Bill is coded with the relevant imported GL codes from a connected accounting software. After it has finished going through the approval workflow the Bill can be exported back to the accounting package connected to the policy.
diff --git a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md
index 669d960275e6..25ccdefad261 100644
--- a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md
+++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md
@@ -24,30 +24,30 @@ After downloading the app, log into your new.expensify.com account (you’ll use
## How to send messages
-Click **+** then **Send message** in New Expensify
-Choose **Chat**
-Search for any name, email or phone number
-Select the individual to begin chatting
+1. Click **+** then **Send message** in New Expensify
+2. Choose **Chat**
+3. Search for any name, email or phone number
+4. Select the individual to begin chatting
## How to create a group
-Click **+**, then **Send message** in New Expensify
-Search for any name, email or phone number
-Click **Add to group**
-Group participants are listed with a green check
-Repeat steps 1-3 to add more participants to the group
-Click **Create chat** to start chatting
+1. Click **+**, then **Send message** in New Expensify
+2. Search for any name, email or phone number
+3. Click **Add to group**
+4. Group participants are listed with a green check
+5. Repeat steps 1-3 to add more participants to the group
+6. Click **Create chat** to start chatting
## How to create a room
-Click **+**, then **Send message** in New Expensify
-Click **Room**
-Enter a room name that doesn’t already exist on the intended Workspace
-Choose the Workspace you want to associate the room with.
-Choose the room’s visibility setting:
-Private: Only people explicitly invited can find the room*
-Restricted: Workspace members can find the room*
-Public: Anyone can find the room
+1. Click **+**, then **Send message** in New Expensify
+2. Click **Room**
+3. Enter a room name that doesn’t already exist on the intended Workspace
+4. Choose the Workspace you want to associate the room with.
+5. Choose the room’s visibility setting:
+6. Private: Only people explicitly invited can find the room*
+7. Restricted: Workspace members can find the room*
+8. Public: Anyone can find the room
*Anyone, including non-Workspace Members, can be invited to a Private or Restricted room.
@@ -56,26 +56,29 @@ Public: Anyone can find the room
You can invite people to a Group or Room by @mentioning them or from the Members pane.
## Mentions:
-Type **@** and start typing the person’s name or email address
-Choose one or more contacts
-Input message, if desired, then send
+
+1. Type **@** and start typing the person’s name or email address
+2. Choose one or more contacts
+3. Input message, if desired, then send
## Members pane invites:
-Click the **Room** or **Group** header
-Select **Members**
-Click **Invite**
-Find and select any contact/s you’d like to invite
-Click **Next**
-Write a custom invitation if you like
-Click **Invite**
+
+1. Click the **Room** or **Group** header
+2. Select **Members**
+3. Click **Invite**
+4. Find and select any contact/s you’d like to invite
+5. Click **Next**
+6. Write a custom invitation if you like
+7. Click **Invite**
## Members pane removals:
-Click the **Room** or **Group** header
-Select **Members**
-Find and select any contact/s you’d like to remove
-Click **Remove**
-Click **Remove members**
+
+1. Click the **Room** or **Group** header
+2. Select **Members**
+3. Find and select any contact/s you’d like to remove
+4. Click **Remove**
+5. Click **Remove members**
## How to format text
diff --git a/docs/assets/images/Cancel Reimbursement.png b/docs/assets/images/Cancel Reimbursement.png
new file mode 100644
index 000000000000..a1322202ded3
Binary files /dev/null and b/docs/assets/images/Cancel Reimbursement.png differ
diff --git a/docs/assets/images/CompanyCards_Assign.png b/docs/assets/images/CompanyCards_Assign.png
new file mode 100644
index 000000000000..53effeb56b88
Binary files /dev/null and b/docs/assets/images/CompanyCards_Assign.png differ
diff --git a/docs/assets/images/CompanyCards_EmailAssign.png b/docs/assets/images/CompanyCards_EmailAssign.png
new file mode 100644
index 000000000000..a3d9683518a7
Binary files /dev/null and b/docs/assets/images/CompanyCards_EmailAssign.png differ
diff --git a/docs/assets/images/CompanyCards_Unassign.png b/docs/assets/images/CompanyCards_Unassign.png
new file mode 100644
index 000000000000..14a2fdc205a7
Binary files /dev/null and b/docs/assets/images/CompanyCards_Unassign.png differ
diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png
new file mode 100644
index 000000000000..8a102375d1c8
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png differ
diff --git a/docs/assets/images/ExpensifyHelp_EmailError.png b/docs/assets/images/ExpensifyHelp_EmailError.png
new file mode 100644
index 000000000000..203f0cfe2b76
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_EmailError.png differ
diff --git a/docs/assets/images/ExpensifyHelp_SMTPError.png b/docs/assets/images/ExpensifyHelp_SMTPError.png
new file mode 100644
index 000000000000..b95ea70a9dfa
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_SMTPError.png differ
diff --git a/docs/assets/images/ExpensifyHelp_Time.png b/docs/assets/images/ExpensifyHelp_Time.png
new file mode 100644
index 000000000000..a042ab60f681
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_Time.png differ
diff --git a/docs/assets/images/ExpensifyHelp_Timezone.png b/docs/assets/images/ExpensifyHelp_Timezone.png
new file mode 100644
index 000000000000..d2afc9e693e7
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_Timezone.png differ
diff --git a/docs/assets/images/QBO1.png b/docs/assets/images/QBO1.png
new file mode 100644
index 000000000000..911c02db70d3
Binary files /dev/null and b/docs/assets/images/QBO1.png differ
diff --git a/docs/assets/images/QBO2-Bill.png b/docs/assets/images/QBO2-Bill.png
new file mode 100644
index 000000000000..1aacd5980df9
Binary files /dev/null and b/docs/assets/images/QBO2-Bill.png differ
diff --git a/docs/assets/images/QBO3-Checktoprint.png b/docs/assets/images/QBO3-Checktoprint.png
new file mode 100644
index 000000000000..b2ab0d6f01ce
Binary files /dev/null and b/docs/assets/images/QBO3-Checktoprint.png differ
diff --git a/docs/assets/images/QBO4-JournalEntry.png b/docs/assets/images/QBO4-JournalEntry.png
new file mode 100644
index 000000000000..289ccef5e3f9
Binary files /dev/null and b/docs/assets/images/QBO4-JournalEntry.png differ
diff --git a/docs/assets/images/QBO5-Expense.png b/docs/assets/images/QBO5-Expense.png
new file mode 100644
index 000000000000..32abf4d31b8e
Binary files /dev/null and b/docs/assets/images/QBO5-Expense.png differ
diff --git a/docs/assets/images/QBO6-Check.png b/docs/assets/images/QBO6-Check.png
new file mode 100644
index 000000000000..54b5a0ce26a9
Binary files /dev/null and b/docs/assets/images/QBO6-Check.png differ
diff --git a/docs/assets/images/QBO7-Transactions.png b/docs/assets/images/QBO7-Transactions.png
new file mode 100644
index 000000000000..40fa7036ed47
Binary files /dev/null and b/docs/assets/images/QBO7-Transactions.png differ
diff --git a/docs/assets/images/Reimbursing Default.png b/docs/assets/images/Reimbursing Default.png
new file mode 100644
index 000000000000..23ffd557ca14
Binary files /dev/null and b/docs/assets/images/Reimbursing Default.png differ
diff --git a/docs/assets/images/Reimbursing Manual Warning.png b/docs/assets/images/Reimbursing Manual Warning.png
new file mode 100644
index 000000000000..2579e21fe2e3
Binary files /dev/null and b/docs/assets/images/Reimbursing Manual Warning.png differ
diff --git a/docs/assets/images/Reimbursing Manual.png b/docs/assets/images/Reimbursing Manual.png
new file mode 100644
index 000000000000..3b9eb27bfa0b
Binary files /dev/null and b/docs/assets/images/Reimbursing Manual.png differ
diff --git a/docs/assets/images/Reimbursing Reports Dropdown.png b/docs/assets/images/Reimbursing Reports Dropdown.png
new file mode 100644
index 000000000000..2e9c6329ae19
Binary files /dev/null and b/docs/assets/images/Reimbursing Reports Dropdown.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 25d3f8c4a3c3..5ce1b7f51147 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.1
+ 1.4.6CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.1.7
+ 1.4.6.1ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 3d9e32ca6d05..f9675bc7cc27 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.1
+ 1.4.6CFBundleSignature????CFBundleVersion
- 1.4.1.7
+ 1.4.6.1
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index d94e36b0b3c9..e0615c4a52fa 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -256,7 +256,7 @@ PODS:
- Onfido (~> 28.3.0)
- React
- OpenSSL-Universal (1.1.1100)
- - Plaid (4.1.0)
+ - Plaid (4.7.0)
- PromisesObjC (2.2.0)
- RCT-Folly (2021.07.22.00):
- boost
@@ -569,7 +569,7 @@ PODS:
- react-native-config/App (= 1.4.6)
- react-native-config/App (1.4.6):
- React-Core
- - react-native-document-picker (8.1.1):
+ - react-native-document-picker (8.2.1):
- React-Core
- react-native-flipper (0.159.0):
- React-Core
@@ -585,12 +585,12 @@ PODS:
- React-Core
- react-native-pager-view (6.2.0):
- React-Core
- - react-native-pdf (6.7.1):
+ - react-native-pdf (6.7.3):
- React-Core
- react-native-performance (5.1.0):
- React-Core
- - react-native-plaid-link-sdk (10.0.0):
- - Plaid (~> 4.1.0)
+ - react-native-plaid-link-sdk (10.8.0):
+ - Plaid (~> 4.7.0)
- React-Core
- react-native-quick-sqlite (8.0.0-beta.2):
- React
@@ -1212,7 +1212,7 @@ SPEC CHECKSUMS:
Onfido: c7d010d9793790d44a07799d9be25aa8e3814ee7
onfido-react-native-sdk: b346a620af5669f9fecb6dc3052314a35a94ad9f
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
- Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2
+ Plaid: 431ef9be5314a1345efb451bc5e6b067bfb3b4c6
PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
RCTRequired: c0569ecc035894e4a68baecb30fe6a7ea6e399f9
@@ -1233,7 +1233,7 @@ SPEC CHECKSUMS:
react-native-blob-util: 99f4d79189252f597fe0d810c57a3733b1b1dea6
react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151
react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e
- react-native-document-picker: f68191637788994baed5f57d12994aa32cf8bf88
+ react-native-document-picker: 69ca2094d8780cfc1e7e613894d15290fdc54bba
react-native-flipper: dc5290261fbeeb2faec1bdc57ae6dd8d562e1de4
react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903
react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56
@@ -1241,9 +1241,9 @@ SPEC CHECKSUMS:
react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5
react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2
react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df
- react-native-pdf: 7c0e91ada997bac8bac3bb5bea5b6b81f5a3caae
+ react-native-pdf: b4ca3d37a9a86d9165287741c8b2ef4d8940c00e
react-native-performance: cef2b618d47b277fb5c3280b81a3aad1e72f2886
- react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c
+ react-native-plaid-link-sdk: df1618a85a615d62ff34e34b76abb7a56497fbc1
react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4
react-native-render-html: 96c979fe7452a0a41559685d2f83b12b93edac8c
react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a
diff --git a/jest.config.js b/jest.config.js
index efd72d20694f..611a0248b491 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -24,7 +24,7 @@ module.exports = {
},
testEnvironment: 'jsdom',
setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'],
- setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js'],
+ setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'],
cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'\\.(lottie)$': '/__mocks__/fileMock.js',
diff --git a/package-lock.json b/package-lock.json
index 124570368227..289c13c27e27 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.1-7",
+ "version": "1.4.6-1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.1-7",
+ "version": "1.4.6-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,9 +51,8 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
"fbjs": "^3.0.2",
- "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
@@ -79,7 +78,7 @@
"react-native-config": "^1.4.5",
"react-native-dev-menu": "^4.1.1",
"react-native-device-info": "^10.3.0",
- "react-native-document-picker": "^8.0.0",
+ "react-native-document-picker": "^8.2.1",
"react-native-draggable-flatlist": "^4.0.1",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
@@ -95,11 +94,11 @@
"react-native-modal": "^13.0.0",
"react-native-onyx": "1.0.118",
"react-native-pager-view": "^6.2.0",
- "react-native-pdf": "^6.7.1",
+ "react-native-pdf": "^6.7.3",
"react-native-performance": "^5.1.0",
"react-native-permissions": "^3.9.3",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2",
- "react-native-plaid-link-sdk": "^10.0.0",
+ "react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
"react-native-reanimated": "3.5.4",
@@ -25887,10 +25886,9 @@
}
},
"node_modules/crypto-js": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz",
- "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==",
- "license": "MIT"
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/css-box-model": {
"version": "1.2.1",
@@ -29906,8 +29904,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
- "integrity": "sha512-gxW5C5LHt1yYLWik1X1aipE05gcVZZJxyp9MC8Kxr5jKtuVm4OeNeYCFWhSgfIrfszfY7VAdofzALcWLAMjxqg==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
+ "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -30788,28 +30786,6 @@
"readable-stream": "^2.3.6"
}
},
- "node_modules/focus-trap": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
- "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
- "dependencies": {
- "tabbable": "^6.2.0"
- }
- },
- "node_modules/focus-trap-react": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
- "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
- "dependencies": {
- "focus-trap": "^7.5.2",
- "tabbable": "^6.2.0"
- },
- "peerDependencies": {
- "prop-types": "^15.8.1",
- "react": ">=16.3.0",
- "react-dom": ">=16.3.0"
- }
- },
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -44248,8 +44224,9 @@
}
},
"node_modules/react-native-document-picker": {
- "version": "8.1.1",
- "license": "MIT",
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.2.1.tgz",
+ "integrity": "sha512-luH2hKdq4cUwE651OscyGderLMsCusOsBzw4MBca91CgprlAGVMm1/pDwJDV5t9LIewVK8DIgXGXzgrsusKVhA==",
"dependencies": {
"invariant": "^2.2.4"
},
@@ -44499,11 +44476,11 @@
}
},
"node_modules/react-native-pdf": {
- "version": "6.7.1",
- "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.1.tgz",
- "integrity": "sha512-zszQygtNBYoUfEtP/fV7zhzGeohDlUksh2p3OzshLrxdY9mw7Tm5VXAxYq4d8HsomRJUbFlJ7rHaTU9AQL800g==",
+ "version": "6.7.3",
+ "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz",
+ "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==",
"dependencies": {
- "crypto-js": "^3.2.0",
+ "crypto-js": "4.2.0",
"deprecated-react-native-prop-types": "^2.3.0"
},
"peerDependencies": {
@@ -44559,8 +44536,12 @@
}
},
"node_modules/react-native-plaid-link-sdk": {
- "version": "10.0.0",
- "license": "MIT",
+ "version": "10.8.0",
+ "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-10.8.0.tgz",
+ "integrity": "sha512-rhyI19SZdwKCsHtkJ0ZOCD/r0vNLS1vqUAS3HPPa97IIN6nS2ln9krLA7lFfMKtWxY5Z5d73SqTmqhd1qqdNuA==",
+ "dependencies": {
+ "react-native-plaid-link-sdk": "^10.4.0"
+ },
"peerDependencies": {
"react": "*",
"react-native": ">=0.66.0"
@@ -49067,11 +49048,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
- "node_modules/tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
- },
"node_modules/table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
@@ -71544,9 +71520,9 @@
}
},
"crypto-js": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz",
- "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q=="
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"css-box-model": {
"version": "1.2.1",
@@ -74445,9 +74421,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
- "integrity": "sha512-gxW5C5LHt1yYLWik1X1aipE05gcVZZJxyp9MC8Kxr5jKtuVm4OeNeYCFWhSgfIrfszfY7VAdofzALcWLAMjxqg==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
+ "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -75103,23 +75079,6 @@
"readable-stream": "^2.3.6"
}
},
- "focus-trap": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
- "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
- "requires": {
- "tabbable": "^6.2.0"
- }
- },
- "focus-trap-react": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
- "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
- "requires": {
- "focus-trap": "^7.5.2",
- "tabbable": "^6.2.0"
- }
- },
"follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -84745,7 +84704,9 @@
"requires": {}
},
"react-native-document-picker": {
- "version": "8.1.1",
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.2.1.tgz",
+ "integrity": "sha512-luH2hKdq4cUwE651OscyGderLMsCusOsBzw4MBca91CgprlAGVMm1/pDwJDV5t9LIewVK8DIgXGXzgrsusKVhA==",
"requires": {
"invariant": "^2.2.4"
}
@@ -84883,11 +84844,11 @@
"requires": {}
},
"react-native-pdf": {
- "version": "6.7.1",
- "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.1.tgz",
- "integrity": "sha512-zszQygtNBYoUfEtP/fV7zhzGeohDlUksh2p3OzshLrxdY9mw7Tm5VXAxYq4d8HsomRJUbFlJ7rHaTU9AQL800g==",
+ "version": "6.7.3",
+ "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz",
+ "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==",
"requires": {
- "crypto-js": "^3.2.0",
+ "crypto-js": "4.2.0",
"deprecated-react-native-prop-types": "^2.3.0"
}
},
@@ -84919,8 +84880,12 @@
}
},
"react-native-plaid-link-sdk": {
- "version": "10.0.0",
- "requires": {}
+ "version": "10.8.0",
+ "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-10.8.0.tgz",
+ "integrity": "sha512-rhyI19SZdwKCsHtkJ0ZOCD/r0vNLS1vqUAS3HPPa97IIN6nS2ln9krLA7lFfMKtWxY5Z5d73SqTmqhd1qqdNuA==",
+ "requires": {
+ "react-native-plaid-link-sdk": "^10.4.0"
+ }
},
"react-native-qrcode-svg": {
"version": "6.2.0",
@@ -88110,11 +88075,6 @@
"version": "2.0.15",
"dev": true
},
- "tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
- },
"table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
diff --git a/package.json b/package.json
index fde0eed5d910..de4be2ace0f4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.1-7",
+ "version": "1.4.6-1",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -98,9 +98,8 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
"fbjs": "^3.0.2",
- "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
@@ -126,7 +125,7 @@
"react-native-config": "^1.4.5",
"react-native-dev-menu": "^4.1.1",
"react-native-device-info": "^10.3.0",
- "react-native-document-picker": "^8.0.0",
+ "react-native-document-picker": "^8.2.1",
"react-native-draggable-flatlist": "^4.0.1",
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
@@ -142,11 +141,11 @@
"react-native-modal": "^13.0.0",
"react-native-onyx": "1.0.118",
"react-native-pager-view": "^6.2.0",
- "react-native-pdf": "^6.7.1",
+ "react-native-pdf": "^6.7.3",
"react-native-performance": "^5.1.0",
"react-native-permissions": "^3.9.3",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2",
- "react-native-plaid-link-sdk": "^10.0.0",
+ "react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
"react-native-reanimated": "3.5.4",
diff --git a/scripts/build-desktop.sh b/scripts/build-desktop.sh
index 88ab17e7a2bd..025559dc4671 100755
--- a/scripts/build-desktop.sh
+++ b/scripts/build-desktop.sh
@@ -25,4 +25,4 @@ npx webpack --config config/webpack/webpack.desktop.js --env envFile=$ENV_FILE
title "Building Desktop App Archive Using Electron"
info ""
shift 1
-npx electron-builder --config config/electronBuilder.config.js "$@"
+npx electron-builder --config config/electronBuilder.config.js --publish always "$@"
diff --git a/scripts/start-android.sh b/scripts/start-android.sh
old mode 100644
new mode 100755
diff --git a/src/App.js b/src/App.js
index 698dfe4437b2..ac34ece5c6c7 100644
--- a/src/App.js
+++ b/src/App.js
@@ -24,6 +24,7 @@ import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
import * as Session from './libs/actions/Session';
import * as Environment from './libs/Environment/Environment';
import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext';
+import ThemeIllustrationsProvider from './styles/illustrations/ThemeIllustrationsProvider';
import ThemeProvider from './styles/themes/ThemeProvider';
import ThemeStylesProvider from './styles/ThemeStylesProvider';
@@ -64,6 +65,7 @@ function App() {
EnvironmentProvider,
ThemeProvider,
ThemeStylesProvider,
+ ThemeIllustrationsProvider,
]}
>
diff --git a/src/CONST.ts b/src/CONST.ts
index 4024158d0805..8d928df71ef0 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -248,88 +248,9 @@ const CONST = {
BETAS: {
ALL: 'all',
CHRONOS_IN_CASH: 'chronosInCash',
- PAY_WITH_EXPENSIFY: 'payWithExpensify',
- FREE_PLAN: 'freePlan',
DEFAULT_ROOMS: 'defaultRooms',
- BETA_EXPENSIFY_WALLET: 'expensifyWallet',
BETA_COMMENT_LINKING: 'commentLinking',
- INTERNATIONALIZATION: 'internationalization',
POLICY_ROOMS: 'policyRooms',
- PASSWORDLESS: 'passwordless',
- TASKS: 'tasks',
- THREADS: 'threads',
- CUSTOM_STATUS: 'customStatus',
- NEW_DOT_SAML: 'newDotSAML',
- PDF_META_STORE: 'pdfMetaStore',
- REPORT_ACTION_CONTEXT_MENU: 'reportActionContextMenu',
- SUBMIT_POLICY: 'submitPolicy',
- ATTENDEES: 'attendees',
- AUTO_EXPORT: 'autoExport',
- AUTO_EXPORT_INTACCT: 'autoExportIntacct',
- AUTO_EXPORT_QBO: 'autoExportQbo',
- AUTO_EXPORT_XERO: 'autoExportXero',
- AUTO_JOIN_POLICY: 'autoJoinPolicy',
- AUTOMATED_TAX_EXEMPTION: 'automatedTaxExemption',
- BILL_PAY: 'billPay',
- CATEGORY_DEFAULT_TAX: 'categoryDefaultTax',
- COLLECTABLE_DEPOSIT_ACCOUNTS: 'collectableDepositAccounts',
- CONCIERGE_TRAVEL: 'conciergeTravel',
- CONNECTED_CARDS: 'connectedCards',
- DISCREPANCY: 'discrepancy',
- DOMAIN_CONTACT_BILLING: 'domainContactBilling',
- DOMAIN_TWO_FACTOR_AUTH: 'domainTwoFactorAuth',
- DUPLICATE_DETECTION: 'duplicateDetection',
- EMAIL_SUPPRESSION_BETA: 'emailSuppressionBeta',
- EXPENSES_V2: 'expensesV2',
- EXPENSIFY_CARD: 'expensifyCard',
- EXPENSIFY_CARD_INTACCT_RECONCILIATION: 'expensifyCardIntacctReconciliation',
- EXPENSIFY_CARD_NETSUITE_RECONCILIATION: 'expensifyCardNetSuiteReconciliation',
- EXPENSIFY_CARD_QBO_RECONCILIATION: 'expensifyCardQBOReconciliation',
- EXPENSIFY_CARD_RAPID_INCREASE_FRAUD: 'expensifyCardRapidIncreaseFraud',
- EXPENSIFY_CARD_XERO_RECONCILIATION: 'expensifyCardXeroReconciliation',
- EXPENSIFY_ORG: 'expensifyOrg',
- FIX_VIOLATION_PUSH_NOTIFICATION: 'fixViolationPushNotification',
- FREE_PLAN_FULL_LAUNCH: 'freePlanFullLaunch',
- FREE_PLAN_SOFT_LAUNCH: 'freePlanSoftLaunch',
- GUSTO: 'gusto',
- INBOX_CACHE: 'inboxCache',
- INBOX_HIDDEN_TASKS: 'inboxHiddenTasks',
- INDIRECT_INTEGRATION_SETUP: 'indirectIntegrationSetup',
- IOU: 'IOU',
- JOIN_POLICY: 'joinPolicy',
- LOAD_POLICY_ASYNC: 'loadPolicyAsync',
- MAP_RECEIPT: 'mapReceipt',
- MERGE_API: 'mergeAPI',
- MOBILE_REALTIME_REPORT_COMMENTS: 'mobileRealtimeReportComments',
- MOBILE_SECURE_RECEIPTS: 'mobileSecureReceipts',
- MONTHLY_SETTLEMENT: 'monthlySettlement',
- NAMES_AND_AVATARS: 'namesAndAvatars',
- NATIVE_CHAT: 'nativeChat',
- NEW_PRICING: 'newPricing',
- NEWSLETTER_THREE: 'newsletterThree',
- NEXT_STEPS: 'nextSteps',
- OPEN_FACE_HAMBURGER: 'openFaceHamburger',
- PER_DIEM: 'perDiem',
- PER_DIEM_INTERNATIONAL: 'perDiemInternational',
- PRICING_COPY_CHANGES: 'pricingCopyChanges',
- QBO_INVOICES: 'qboInvoices',
- QUICKBOOKS_DESKTOP_V2: 'quickbooksDesktopV2',
- REALTIME_REPORT_COMMENTS: 'realtimeReportComments',
- S2W_ANNOUNCEMENT: 's2wAnnouncement',
- SCHEDULED_AUTO_REPORTING: 'scheduledAutoReporting',
- SECURE_RECEIPTS: 'secureReceipts',
- SECURE_RECEIPTS_REPORTS: 'secureReceiptsReports',
- SELF_SERVICE_HARD_LAUNCH: 'selfServiceHardLaunch',
- SEND_MONEY: 'sendMoney',
- SMART_SCAN_USER_DISPUTES: 'smartScanUserDisputes',
- SMS_SIGN_UP: 'smsSignUp',
- STRIPE_CONNECT: 'stripeConnect',
- SUMMARY_EMAIL: 'summaryEmail',
- SWIPE_TO_WIN: 'swipeToWin',
- TAX_FOR_MILEAGE: 'taxForMileage',
- TWO_FACTOR_AUTH: 'twoFactorAuth',
- VENMO_INTEGRATION: 'venmoIntegration',
- ZENEFITS_INTEGRATION: 'zenefitsIntegration',
VIOLATIONS: 'violations',
},
BUTTON_STATES: {
@@ -574,6 +495,7 @@ const CONST = {
CREATED: 'CREATED',
IOU: 'IOU',
MODIFIEDEXPENSE: 'MODIFIEDEXPENSE',
+ MOVED: 'MOVED',
REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED',
RENAMED: 'RENAMED',
REPORTPREVIEW: 'REPORTPREVIEW',
@@ -634,6 +556,7 @@ const CONST = {
UPDATE_REIMBURSEMENT_CHOICE: 'POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE',
UPDATE_REPORT_FIELD: 'POLICYCHANGELOG_UPDATE_REPORT_FIELD',
UPDATE_TAG: 'POLICYCHANGELOG_UPDATE_TAG',
+ UPDATE_TAG_ENABLED: 'POLICYCHANGELOG_UPDATE_TAG_ENABLED',
UPDATE_TAG_LIST_NAME: 'POLICYCHANGELOG_UPDATE_TAG_LIST_NAME',
UPDATE_TAG_NAME: 'POLICYCHANGELOG_UPDATE_TAG_NAME',
UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED',
@@ -677,6 +600,7 @@ const CONST = {
ADMINS: '#admins',
},
STATE: {
+ OPEN: 'OPEN',
SUBMITTED: 'SUBMITTED',
PROCESSING: 'PROCESSING',
},
@@ -1188,7 +1112,8 @@ const CONST = {
PAYMENT_METHODS: {
DEBIT_CARD: 'debitCard',
- BANK_ACCOUNT: 'bankAccount',
+ PERSONAL_BANK_ACCOUNT: 'bankAccount',
+ BUSINESS_BANK_ACCOUNT: 'businessBankAccount',
},
PAYMENT_METHOD_ID_KEYS: {
@@ -1231,6 +1156,7 @@ const CONST = {
DOCX: 'docx',
SVG: 'svg',
},
+ RECEIPT_ERROR: 'receiptError',
},
GROWL: {
@@ -1277,7 +1203,11 @@ const CONST = {
TYPE: {
FREE: 'free',
PERSONAL: 'personal',
+
+ // Often referred to as "control" workspaces
CORPORATE: 'corporate',
+
+ // Often referred to as "collect" workspaces
TEAM: 'team',
},
ROLE: {
@@ -1430,7 +1360,6 @@ const CONST = {
TIME_STARTS_01: /^01:\d{2} [AP]M$/,
TIME_FORMAT: /^\d{2}:\d{2} [AP]M$/,
DATE_TIME_FORMAT: /^\d{2}-\d{2} \d{2}:\d{2} [AP]M$/,
- ATTACHMENT_ROUTE: /\/r\/(\d*)\/attachment/,
ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g,
ENCODE_PERCENT_CHARACTER: /%(25)+/g,
@@ -2885,11 +2814,43 @@ const CONST = {
START_CHAT: 'startChat',
SEND_MONEY: 'sendMoney',
REFER_FRIEND: 'referralFriend',
+ SHARE_CODE: 'shareCode',
},
REVENUE: 250,
LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/billing-and-plan-types/Referral-Program',
LINK: 'https://join.my.expensify.com',
},
+
+ /**
+ * native IDs for close buttons in Overlay component
+ */
+ OVERLAY: {
+ TOP_BUTTON_NATIVE_ID: 'overLayTopButton',
+ BOTTOM_BUTTON_NATIVE_ID: 'overLayBottomButton',
+ },
+
+ BACK_BUTTON_NATIVE_ID: 'backButton',
+
+ /**
+ * The maximum count of items per page for OptionsSelector.
+ * When paginate, it multiplies by page number.
+ */
+ MAX_OPTIONS_SELECTOR_PAGE_LENGTH: 500,
+
+ /**
+ * Performance test setup - run the same test multiple times to get a more accurate result
+ */
+ PERFORMANCE_TESTS: {
+ RUNS: 20,
+ },
+
+ /**
+ * Constants for maxToRenderPerBatch parameter that is used for FlatList or SectionList. This controls the amount of items rendered per batch, which is the next chunk of items rendered on every scroll.
+ */
+ MAX_TO_RENDER_PER_BATCH: {
+ DEFAULT: 5,
+ CAROUSEL: 3,
+ },
} as const;
export default CONST;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 475f355c6a10..5576eb64736d 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -84,6 +84,9 @@ const ONYXKEYS = {
/** Contains all the users settings for the Settings page and sub pages */
USER: 'user',
+ /** Contains latitude and longitude of user's last known location */
+ USER_LOCATION: 'userLocation',
+
/** Contains metadata (partner, login, validation date) for all of the user's logins */
LOGIN_LIST: 'loginList',
@@ -246,6 +249,7 @@ const ONYXKEYS = {
POLICY_TAGS: 'policyTags_',
POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
+ WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_',
REPORT: 'report_',
// REPORT_METADATA is a perf optimization used to hold loading states (isLoadingInitialReportActions, isLoadingOlderReportActions, isLoadingNewerReportActions).
// A lot of components are connected to the Report entity and do not care about the actions. Setting the loading state
@@ -261,6 +265,9 @@ const ONYXKEYS = {
REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_',
SECURITY_GROUP: 'securityGroup_',
TRANSACTION: 'transactions_',
+
+ // Holds temporary transactions used during the creation and edit flow
+ TRANSACTION_DRAFT: 'transactionsDraft_',
SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_',
PRIVATE_NOTES_DRAFT: 'privateNotesDraft_',
NEXT_STEP: 'reportNextStep_',
@@ -368,6 +375,7 @@ type OnyxValues = {
[ONYXKEYS.COUNTRY_CODE]: number;
[ONYXKEYS.COUNTRY]: string;
[ONYXKEYS.USER]: OnyxTypes.User;
+ [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation;
[ONYXKEYS.LOGIN_LIST]: Record;
[ONYXKEYS.SESSION]: OnyxTypes.Session;
[ONYXKEYS.BETAS]: OnyxTypes.Beta[];
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 26589a3db0e0..24b698c24619 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -1,30 +1,28 @@
-import {ValueOf} from 'type-fest';
+import {IsEqual, ValueOf} from 'type-fest';
import CONST from './CONST';
-/**
- * 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 the routes we want to be able to go to
/**
* 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)}` : '';
- return url + backToParam;
+function getUrlWithBackToParam(url: TUrl, backTo?: string): `${TUrl}` | `${TUrl}?backTo=${string}` | `${TUrl}&backTo=${string}` {
+ const backToParam = backTo ? (`${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` as const) : '';
+ return `${url}${backToParam}` as const;
}
-export default {
+const ROUTES = {
HOME: '',
/** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */
CONCIERGE: 'concierge',
FLAG_COMMENT: {
route: 'flag/:reportID/:reportActionID',
- getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`,
+ getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}` as const,
},
SEARCH: 'search',
DETAILS: {
route: 'details',
- getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`,
+ getRoute: (login: string) => `details?login=${encodeURIComponent(login)}` as const,
},
PROFILE: {
route: 'a/:accountID',
@@ -35,7 +33,7 @@ export default {
VALIDATE_LOGIN: 'v/:accountID/:validateCode',
GET_ASSISTANCE: {
route: 'get-assistance/:taskID',
- getRoute: (taskID: string) => `get-assistance/${taskID}`,
+ getRoute: (taskID: string) => `get-assistance/${taskID}` as const,
},
UNLINK_LOGIN: 'u/:accountID/:validateCode',
APPLE_SIGN_IN: 'sign-in-with-apple',
@@ -54,7 +52,7 @@ export default {
BANK_ACCOUNT_PERSONAL: 'bank-account/personal',
BANK_ACCOUNT_WITH_STEP_TO_OPEN: {
route: 'bank-account/:stepToOpen?',
- getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo),
+ getRoute: (stepToOpen = '', policyID = '', backTo?: string) => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo),
},
SETTINGS: 'settings',
@@ -77,44 +75,44 @@ export default {
SETTINGS_WALLET: 'settings/wallet',
SETTINGS_WALLET_DOMAINCARD: {
route: '/settings/wallet/card/:domain',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const,
},
SETTINGS_REPORT_FRAUD: {
route: '/settings/wallet/card/:domain/report-virtual-fraud',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: {
route: '/settings/wallet/card/:domain/get-physical/name',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: {
route: '/settings/wallet/card/:domain/get-physical/phone',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: {
route: '/settings/wallet/card/:domain/get-physical/address',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: {
route: '/settings/wallet/card/:domain/get-physical/confirm',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const,
},
SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card',
SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account',
SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments',
SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: {
route: 'settings/wallet/card/:domain/digital-details/update-address',
- getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address`,
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address` as const,
},
SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance',
SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account',
SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: {
route: '/settings/wallet/card/:domain/report-card-lost-or-damaged',
- getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged`,
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const,
},
SETTINGS_WALLET_CARD_ACTIVATE: {
route: 'settings/wallet/card/:domain/activate',
- getRoute: (domain: string) => `settings/wallet/card/${domain}/activate`,
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/activate` as const,
},
SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details',
SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name',
@@ -130,10 +128,13 @@ export default {
},
SETTINGS_CONTACT_METHOD_DETAILS: {
route: 'settings/profile/contact-methods/:contactMethod/details',
- getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`,
+ getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details` as const,
},
SETTINGS_NEW_CONTACT_METHOD: 'settings/profile/contact-methods/new',
- SETTINGS_2FA: 'settings/security/two-factor-auth',
+ SETTINGS_2FA: {
+ route: 'settings/security/two-factor-auth',
+ getRoute: (backTo?: string) => getUrlWithBackToParam('settings/security/two-factor-auth', backTo),
+ },
SETTINGS_STATUS: 'settings/profile/status',
SETTINGS_STATUS_SET: 'settings/profile/status/set',
@@ -146,157 +147,158 @@ export default {
REPORT: 'r',
REPORT_WITH_ID: {
route: 'r/:reportID?/:reportActionID?',
- getRoute: (reportID: string) => `r/${reportID}`,
+ getRoute: (reportID: string) => `r/${reportID}` as const,
},
EDIT_REQUEST: {
route: 'r/:threadReportID/edit/:field',
- getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`,
+ getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}` as const,
},
EDIT_CURRENCY_REQUEST: {
route: 'r/:threadReportID/edit/currency',
- getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`,
+ getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}` as const,
},
REPORT_WITH_ID_DETAILS_SHARE_CODE: {
route: 'r/:reportID/details/shareCode',
- getRoute: (reportID: string) => `r/${reportID}/details/shareCode`,
+ getRoute: (reportID: string) => `r/${reportID}/details/shareCode` as const,
},
REPORT_ATTACHMENTS: {
route: 'r/:reportID/attachment',
- getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`,
+ getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const,
},
REPORT_PARTICIPANTS: {
route: 'r/:reportID/participants',
- getRoute: (reportID: string) => `r/${reportID}/participants`,
+ getRoute: (reportID: string) => `r/${reportID}/participants` as const,
},
REPORT_WITH_ID_DETAILS: {
route: 'r/:reportID/details',
- getRoute: (reportID: string) => `r/${reportID}/details`,
+ getRoute: (reportID: string) => `r/${reportID}/details` as const,
},
REPORT_SETTINGS: {
route: 'r/:reportID/settings',
- getRoute: (reportID: string) => `r/${reportID}/settings`,
+ getRoute: (reportID: string) => `r/${reportID}/settings` as const,
},
REPORT_SETTINGS_ROOM_NAME: {
route: 'r/:reportID/settings/room-name',
- getRoute: (reportID: string) => `r/${reportID}/settings/room-name`,
+ getRoute: (reportID: string) => `r/${reportID}/settings/room-name` as const,
},
REPORT_SETTINGS_NOTIFICATION_PREFERENCES: {
route: 'r/:reportID/settings/notification-preferences',
- getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`,
+ getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences` as const,
},
REPORT_SETTINGS_WRITE_CAPABILITY: {
route: 'r/:reportID/settings/who-can-post',
- getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`,
+ getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const,
},
REPORT_WELCOME_MESSAGE: {
route: 'r/:reportID/welcomeMessage',
- getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`,
+ getRoute: (reportID: string) => `r/${reportID}/welcomeMessage` as const,
},
SPLIT_BILL_DETAILS: {
route: 'r/:reportID/split/:reportActionID',
- getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`,
+ getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const,
},
EDIT_SPLIT_BILL: {
route: `r/:reportID/split/:reportActionID/edit/:field`,
- getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}`,
+ getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}` as const,
},
EDIT_SPLIT_BILL_CURRENCY: {
route: 'r/:reportID/split/:reportActionID/edit/currency',
- getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}`,
+ getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) =>
+ `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}` as const,
},
TASK_TITLE: {
route: 'r/:reportID/title',
- getRoute: (reportID: string) => `r/${reportID}/title`,
+ getRoute: (reportID: string) => `r/${reportID}/title` as const,
},
TASK_DESCRIPTION: {
route: 'r/:reportID/description',
- getRoute: (reportID: string) => `r/${reportID}/description`,
+ getRoute: (reportID: string) => `r/${reportID}/description` as const,
},
TASK_ASSIGNEE: {
route: 'r/:reportID/assignee',
- getRoute: (reportID: string) => `r/${reportID}/assignee`,
+ getRoute: (reportID: string) => `r/${reportID}/assignee` as const,
},
PRIVATE_NOTES_VIEW: {
route: 'r/:reportID/notes/:accountID',
- getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`,
+ getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}` as const,
},
PRIVATE_NOTES_LIST: {
route: 'r/:reportID/notes',
- getRoute: (reportID: string) => `r/${reportID}/notes`,
+ getRoute: (reportID: string) => `r/${reportID}/notes` as const,
},
PRIVATE_NOTES_EDIT: {
route: 'r/:reportID/notes/:accountID/edit',
- getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`,
+ getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit` as const,
},
ROOM_MEMBERS: {
route: 'r/:reportID/members',
- getRoute: (reportID: string) => `r/${reportID}/members`,
+ getRoute: (reportID: string) => `r/${reportID}/members` as const,
},
ROOM_INVITE: {
route: 'r/:reportID/invite',
- getRoute: (reportID: string) => `r/${reportID}/invite`,
+ getRoute: (reportID: string) => `r/${reportID}/invite` as const,
},
// To see the available iouType, please refer to CONST.IOU.TYPE
MONEY_REQUEST: {
route: ':iouType/new/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}` as const,
},
MONEY_REQUEST_AMOUNT: {
route: ':iouType/new/amount/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}` as const,
},
MONEY_REQUEST_PARTICIPANTS: {
route: ':iouType/new/participants/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}` as const,
},
MONEY_REQUEST_CONFIRMATION: {
route: ':iouType/new/confirmation/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const,
},
MONEY_REQUEST_DATE: {
route: ':iouType/new/date/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const,
},
MONEY_REQUEST_CURRENCY: {
route: ':iouType/new/currency/:reportID?',
- getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`,
+ getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const,
},
MONEY_REQUEST_DESCRIPTION: {
route: ':iouType/new/description/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}` as const,
},
MONEY_REQUEST_CATEGORY: {
route: ':iouType/new/category/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const,
},
MONEY_REQUEST_TAG: {
route: ':iouType/new/tag/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}` as const,
},
MONEY_REQUEST_MERCHANT: {
route: ':iouType/new/merchant/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}` as const,
},
MONEY_REQUEST_WAYPOINT: {
route: ':iouType/new/waypoint/:waypointIndex',
- getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`,
+ getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}` as const,
},
MONEY_REQUEST_RECEIPT: {
route: ':iouType/new/receipt/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const,
},
MONEY_REQUEST_DISTANCE: {
route: ':iouType/new/address/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const,
},
MONEY_REQUEST_EDIT_WAYPOINT: {
route: 'r/:threadReportID/edit/distance/:transactionID/waypoint/:waypointIndex',
- getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}`,
+ getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}` as const,
},
MONEY_REQUEST_DISTANCE_TAB: {
route: ':iouType/new/:reportID?/distance',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`,
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance` as const,
},
MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual',
MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan',
@@ -321,63 +323,63 @@ export default {
ERECEIPT: {
route: 'eReceipt/:transactionID',
- getRoute: (transactionID: string) => `eReceipt/${transactionID}`,
+ getRoute: (transactionID: string) => `eReceipt/${transactionID}` as const,
},
WORKSPACE_NEW: 'workspace/new',
WORKSPACE_NEW_ROOM: 'workspace/new-room',
WORKSPACE_INITIAL: {
route: 'workspace/:policyID',
- getRoute: (policyID: string) => `workspace/${policyID}`,
+ getRoute: (policyID: string) => `workspace/${policyID}` as const,
},
WORKSPACE_INVITE: {
route: 'workspace/:policyID/invite',
- getRoute: (policyID: string) => `workspace/${policyID}/invite`,
+ getRoute: (policyID: string) => `workspace/${policyID}/invite` as const,
},
WORKSPACE_INVITE_MESSAGE: {
route: 'workspace/:policyID/invite-message',
- getRoute: (policyID: string) => `workspace/${policyID}/invite-message`,
+ getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const,
},
WORKSPACE_SETTINGS: {
route: 'workspace/:policyID/settings',
- getRoute: (policyID: string) => `workspace/${policyID}/settings`,
+ getRoute: (policyID: string) => `workspace/${policyID}/settings` as const,
},
WORKSPACE_SETTINGS_CURRENCY: {
route: 'workspace/:policyID/settings/currency',
- getRoute: (policyID: string) => `workspace/${policyID}/settings/currency`,
+ getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const,
},
WORKSPACE_CARD: {
route: 'workspace/:policyID/card',
- getRoute: (policyID: string) => `workspace/${policyID}/card`,
+ getRoute: (policyID: string) => `workspace/${policyID}/card` as const,
},
WORKSPACE_REIMBURSE: {
route: 'workspace/:policyID/reimburse',
- getRoute: (policyID: string) => `workspace/${policyID}/reimburse`,
+ getRoute: (policyID: string) => `workspace/${policyID}/reimburse` as const,
},
WORKSPACE_RATE_AND_UNIT: {
route: 'workspace/:policyID/rateandunit',
- getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`,
+ getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const,
},
WORKSPACE_BILLS: {
route: 'workspace/:policyID/bills',
- getRoute: (policyID: string) => `workspace/${policyID}/bills`,
+ getRoute: (policyID: string) => `workspace/${policyID}/bills` as const,
},
WORKSPACE_INVOICES: {
route: 'workspace/:policyID/invoices',
- getRoute: (policyID: string) => `workspace/${policyID}/invoices`,
+ getRoute: (policyID: string) => `workspace/${policyID}/invoices` as const,
},
WORKSPACE_TRAVEL: {
route: 'workspace/:policyID/travel',
- getRoute: (policyID: string) => `workspace/${policyID}/travel`,
+ getRoute: (policyID: string) => `workspace/${policyID}/travel` as const,
},
WORKSPACE_MEMBERS: {
route: 'workspace/:policyID/members',
- getRoute: (policyID: string) => `workspace/${policyID}/members`,
+ getRoute: (policyID: string) => `workspace/${policyID}/members` as const,
},
// Referral program promotion
REFERRAL_DETAILS_MODAL: {
route: 'referral/:contentType',
- getRoute: (contentType: string) => `referral/${contentType}`,
+ getRoute: (contentType: string) => `referral/${contentType}` as const,
},
// These are some one-off routes that will be removed once they're no longer needed (see GH issues for details)
@@ -385,3 +387,24 @@ export default {
SBE: 'sbe',
MONEY2020: 'money2020',
} as const;
+
+export default ROUTES;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type ExtractRouteName = TRoute extends {getRoute: (...args: any[]) => infer TRouteName} ? TRouteName : TRoute;
+
+type AllRoutes = {
+ [K in keyof typeof ROUTES]: ExtractRouteName<(typeof ROUTES)[K]>;
+}[keyof typeof ROUTES];
+
+type RouteIsPlainString = IsEqual;
+
+/**
+ * Represents all routes in the app as a union of literal strings.
+ *
+ * If this type resolves to `never`, it implies that one or more routes defined within `ROUTES` have not correctly used
+ * `as const` in their `getRoute` function return value.
+ */
+type Route = RouteIsPlainString extends true ? never : AllRoutes;
+
+export type {Route};
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index f957a1dbb25e..f4cbcf4f2564 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -9,13 +9,13 @@ const PROTECTED_SCREENS = {
REPORT_ATTACHMENTS: 'ReportAttachments',
} as const;
-export default {
+const SCREENS = {
...PROTECTED_SCREENS,
- LOADING: 'Loading',
REPORT: 'Report',
NOT_FOUND: 'not-found',
TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps',
VALIDATE_LOGIN: 'ValidateLogin',
+ UNLINK_LOGIN: 'UnlinkLogin',
SETTINGS: {
ROOT: 'Settings_Root',
PREFERENCES: 'Settings_Preferences',
@@ -40,4 +40,5 @@ export default {
SAML_SIGN_IN: 'SAMLSignIn',
} as const;
+export default SCREENS;
export {PROTECTED_SCREENS};
diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js
index 252c8380b062..4d01fa108e2a 100644
--- a/src/components/AddPaymentMethodMenu.js
+++ b/src/components/AddPaymentMethodMenu.js
@@ -1,15 +1,18 @@
+import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import compose from '@libs/compose';
-import Permissions from '@libs/Permissions';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import iouReportPropTypes from '@pages/iouReportPropTypes';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as Expensicons from './Icon/Expensicons';
import PopoverMenu from './PopoverMenu';
import refPropTypes from './refPropTypes';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
import withWindowDimensions from './withWindowDimensions';
const propTypes = {
@@ -19,6 +22,12 @@ const propTypes = {
/** Callback to execute when the component closes. */
onClose: PropTypes.func.isRequired,
+ /** Callback to execute when the payment method is selected. */
+ onItemSelected: PropTypes.func.isRequired,
+
+ /** The IOU/Expense report we are paying */
+ iouReport: iouReportPropTypes,
+
/** Anchor position for the AddPaymentMenu. */
anchorPosition: PropTypes.shape({
horizontal: PropTypes.number,
@@ -31,51 +40,72 @@ const propTypes = {
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
}),
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** Popover anchor ref */
anchorRef: refPropTypes,
- ...withLocalizePropTypes,
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user accountID */
+ accountID: PropTypes.number,
+ }),
};
const defaultProps = {
+ iouReport: {},
anchorPosition: {},
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
},
- betas: [],
anchorRef: () => {},
+ session: {},
};
-function AddPaymentMethodMenu(props) {
+function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session}) {
+ const {translate} = useLocalize();
+
+ // Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report
+ // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee.
+ const canUseBusinessBankAccount =
+ ReportUtils.isExpenseReport(iouReport) ||
+ (ReportUtils.isIOUReport(iouReport) && !ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0)));
+
return (
{
- props.onItemSelected(CONST.PAYMENT_METHODS.BANK_ACCOUNT);
- },
- },
- ...(Permissions.canUseWallet(props.betas)
+ ...(ReportUtils.isIOUReport(iouReport)
? [
{
- text: props.translate('common.debitCard'),
- icon: Expensicons.CreditCard,
- onSelected: () => props.onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD),
+ text: translate('common.personalBankAccount'),
+ icon: Expensicons.Bank,
+ onSelected: () => {
+ onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT);
+ },
},
]
: []),
+ ...(canUseBusinessBankAccount
+ ? [
+ {
+ text: translate('common.businessBankAccount'),
+ icon: Expensicons.Building,
+ onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT),
+ },
+ ]
+ : []),
+ ...[
+ {
+ text: translate('common.debitCard'),
+ icon: Expensicons.CreditCard,
+ onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD),
+ },
+ ],
]}
withoutOverlay
/>
@@ -88,10 +118,9 @@ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu';
export default compose(
withWindowDimensions,
- withLocalize,
withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
+ session: {
+ key: ONYXKEYS.SESSION,
},
}),
)(AddPaymentMethodMenu);
diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js
index ec4ddd623929..c18b706e1acf 100644
--- a/src/components/AddPlaidBankAccount.js
+++ b/src/components/AddPlaidBankAccount.js
@@ -168,7 +168,7 @@ function AddPlaidBankAccount({
value: account.plaidAccountID,
label: `${account.addressName} ${account.mask}`,
}));
- const {icon, iconSize, iconStyles} = getBankIcon();
+ const {icon, iconSize, iconStyles} = getBankIcon({themeStyles: styles});
const plaidErrors = lodashGet(plaidData, 'errors');
const plaidDataErrorMessage = !_.isEmpty(plaidErrors) ? _.chain(plaidErrors).values().first().value() : '';
const bankName = lodashGet(plaidData, 'bankName');
@@ -209,7 +209,7 @@ function AddPlaidBankAccount({
// Handle Plaid login errors (will potentially reset plaid token and item depending on the error)
if (event === 'ERROR') {
Log.hmmm('[PlaidLink] Error: ', metadata);
- if (bankAccountID && metadata.error_code) {
+ if (bankAccountID && metadata && metadata.error_code) {
BankAccounts.handlePlaidError(bankAccountID, metadata.error_code, metadata.error_message, metadata.request_id);
}
}
diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js
index 19ab35f036c1..98650f94232b 100644
--- a/src/components/AddressForm.js
+++ b/src/components/AddressForm.js
@@ -7,7 +7,7 @@ import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import * as ValidationUtils from '@libs/ValidationUtils';
-import styles from '@styles/styles';
+import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import AddressSearch from './AddressSearch';
import CountrySelector from './CountrySelector';
@@ -63,6 +63,7 @@ const defaultProps = {
};
function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldSaveDraft, state, street1, street2, submitButtonText, zip}) {
+ const styles = useThemeStyles();
const {translate} = useLocalize();
const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], '');
const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat});
@@ -122,7 +123,6 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS
submitButtonText={submitButtonText}
enabledWhenOffline
>
- ({
- language: props.preferredLocale,
- types: props.resultTypes,
- components: props.isLimitedToUSA ? 'country:us' : undefined,
+ language: preferredLocale,
+ types: resultTypes,
+ components: isLimitedToUSA ? 'country:us' : undefined,
}),
- [props.preferredLocale, props.resultTypes, props.isLimitedToUSA],
+ [preferredLocale, resultTypes, isLimitedToUSA],
);
- const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
+ const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
const saveLocationDetails = (autocompleteData, details) => {
const addressComponents = details.address_components;
@@ -171,7 +190,7 @@ function AddressSearch(props) {
// 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)) {
- props.onPress({
+ onPress({
address: lodashGet(details, 'description'),
lat: lodashGet(details, 'geometry.location.lat', 0),
lng: lodashGet(details, 'geometry.location.lng', 0),
@@ -269,7 +288,7 @@ function AddressSearch(props) {
// 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 props.renamedInputKeys.street2 === 'undefined') {
+ if (subpremise && typeof renamedInputKeys.street2 === 'undefined') {
values.street += `, ${subpremise}`;
}
@@ -278,19 +297,19 @@ function AddressSearch(props) {
values.country = country;
}
- if (props.inputID) {
- _.each(values, (value, key) => {
- const inputKey = lodashGet(props.renamedInputKeys, key, key);
+ if (inputID) {
+ _.each(values, (inputValue, key) => {
+ const inputKey = lodashGet(renamedInputKeys, key, key);
if (!inputKey) {
return;
}
- props.onInputChange(value, inputKey);
+ onInputChange(inputValue, inputKey);
});
} else {
- props.onInputChange(values);
+ onInputChange(values);
}
- props.onPress(values);
+ onPress(values);
};
/** Gets the user's current location and registers success/error callbacks */
@@ -320,7 +339,7 @@ function AddressSearch(props) {
lng: successData.coords.longitude,
address: CONST.YOUR_LOCATION_TEXT,
};
- props.onPress(location);
+ onPress(location);
},
(errorData) => {
if (!shouldTriggerGeolocationCallbacks.current) {
@@ -338,16 +357,16 @@ function AddressSearch(props) {
};
const renderHeaderComponent = () =>
- props.predefinedPlaces.length > 0 && (
+ predefinedPlaces.length > 0 && (
<>
{/* This will show current location button in list if there are some recent destinations */}
{shouldShowCurrentLocationButton && (
)}
- {!props.value && {props.translate('common.recentDestinations')}}
+ {!value && {translate('common.recentDestinations')}}
>
);
@@ -359,6 +378,26 @@ function AddressSearch(props) {
};
}, []);
+ const listEmptyComponent = useCallback(
+ () =>
+ network.isOffline || !isTyping ? null : (
+ {translate('common.noResultsFound')}
+ ),
+ [network.isOffline, isTyping, styles, translate],
+ );
+
+ const listLoader = useCallback(
+ () => (
+
+
+
+ ),
+ [styles.pv4, theme.spinner],
+ );
+
return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
@@ -385,20 +424,10 @@ function AddressSearch(props) {
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
- predefinedPlaces={props.predefinedPlaces}
- listEmptyComponent={
- props.network.isOffline || !isTyping ? null : (
- {props.translate('common.noResultsFound')}
- )
- }
- listLoaderComponent={
-
-
-
- }
+ predefinedPlaces={predefinedPlaces}
+ listEmptyComponent={listEmptyComponent}
+ listLoaderComponent={listLoader}
+ renderHeaderComponent={renderHeaderComponent}
renderRow={(data) => {
const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text;
const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text;
@@ -409,7 +438,6 @@ function AddressSearch(props) {
);
}}
- renderHeaderComponent={renderHeaderComponent}
onPress={(data, details) => {
saveLocationDetails(data, details);
setIsTyping(false);
@@ -424,34 +452,31 @@ function AddressSearch(props) {
query={query}
requestUrl={{
useOnPlatform: 'all',
- url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
+ url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
}}
textInputProps={{
InputComp: TextInput,
ref: (node) => {
- if (!props.innerRef) {
+ if (!innerRef) {
return;
}
- if (_.isFunction(props.innerRef)) {
- props.innerRef(node);
+ if (_.isFunction(innerRef)) {
+ innerRef(node);
return;
}
// eslint-disable-next-line no-param-reassign
- props.innerRef.current = node;
+ innerRef.current = node;
},
- 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,
+ label,
+ containerStyles,
+ errorText,
+ hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint,
+ value,
+ defaultValue,
+ inputID,
+ shouldSaveDraft,
onFocus: () => {
setIsFocused(true);
},
@@ -461,24 +486,24 @@ function AddressSearch(props) {
setIsFocused(false);
setIsTyping(false);
}
- props.onBlur();
+ onBlur();
},
autoComplete: 'off',
onInputChange: (text) => {
setSearchValue(text);
setIsTyping(true);
- if (props.inputID) {
- props.onInputChange(text);
+ if (inputID) {
+ onInputChange(text);
} else {
- props.onInputChange({street: text});
+ 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(props.predefinedPlaces)) {
+ if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) {
setDisplayListViewBorder(false);
}
},
- maxLength: props.maxInputLength,
+ maxLength: maxInputLength,
spellCheck: false,
selectTextOnFocus: true,
}}
@@ -500,17 +525,18 @@ function AddressSearch(props) {
}}
inbetweenCompo={
// We want to show the current location button even if there are no recent destinations
- props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
+ predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
) : (
<>>
)
}
+ placeholder=""
/>
setLocationErrorCode(null)}
diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js
index dc3a722c2331..3fc90433f13e 100644
--- a/src/components/Alert/index.js
+++ b/src/components/Alert/index.js
@@ -5,7 +5,7 @@ import _ from 'underscore';
*
* @param {String} title The title of the alert
* @param {String} description The description of the alert
- * @param {Object[]} options An array of objects with `style` and `onPress` properties
+ * @param {Object[]} [options] An array of objects with `style` and `onPress` properties
*/
export default (title, description, options) => {
const result = _.filter(window.confirm([title, description], Boolean)).join('\n');
diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js
index 763ca4615c6e..33d6cb014df2 100644
--- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js
+++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js
@@ -51,7 +51,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '',
linkProps.href = href;
}
const defaultTextStyle = DeviceCapabilities.canUseTouchScreen() || isSmallScreenWidth ? {} : {...styles.userSelectText, ...styles.cursorPointer};
- const isEmail = Str.isValidEmailMarkdown(href.replace(/mailto:/i, ''));
+ const isEmail = Str.isValidEmail(href.replace(/mailto:/i, ''));
return (
void;
};
-function getAnimationStyle(direction: AnimationDirection) {
- let transitionValue;
+function AnimatedStep({onAnimationEnd, direction = CONST.ANIMATION_DIRECTION.IN, style, children}: AnimatedStepProps) {
+ const styles = useThemeStyles();
- if (direction === 'in') {
- transitionValue = CONST.ANIMATED_TRANSITION_FROM_VALUE;
- } else {
- transitionValue = -CONST.ANIMATED_TRANSITION_FROM_VALUE;
- }
- return styles.makeSlideInTranslation('translateX', transitionValue);
-}
+ const animationStyle = useMemo(() => {
+ const transitionValue = direction === 'in' ? CONST.ANIMATED_TRANSITION_FROM_VALUE : -CONST.ANIMATED_TRANSITION_FROM_VALUE;
+
+ return styles.makeSlideInTranslation('translateX', transitionValue);
+ }, [direction, styles]);
-function AnimatedStep({onAnimationEnd, direction = CONST.ANIMATION_DIRECTION.IN, style = [], children}: AnimatedStepProps) {
return (
{
@@ -39,7 +36,7 @@ function AnimatedStep({onAnimationEnd, direction = CONST.ANIMATION_DIRECTION.IN,
onAnimationEnd();
}}
duration={CONST.ANIMATED_TRANSITION}
- animation={getAnimationStyle(direction)}
+ animation={animationStyle}
useNativeDriver={useNativeDriver}
style={style}
>
diff --git a/src/components/ArchivedReportFooter.js b/src/components/ArchivedReportFooter.js
deleted file mode 100644
index b1fac827d273..000000000000
--- a/src/components/ArchivedReportFooter.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import _ from 'lodash';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {withOnyx} from 'react-native-onyx';
-import compose from '@libs/compose';
-import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
-import * as ReportActionsUtils from '@libs/ReportActionsUtils';
-import * as ReportUtils from '@libs/ReportUtils';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
-import reportPropTypes from '@pages/reportPropTypes';
-import useThemeStyles from '@styles/useThemeStyles';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import Banner from './Banner';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-
-const propTypes = {
- /** The reason this report was archived */
- reportClosedAction: PropTypes.shape({
- /** Message attached to the report closed action */
- originalMessage: PropTypes.shape({
- /** The reason the report was closed */
- reason: PropTypes.string.isRequired,
-
- /** (For accountMerged reason only), the accountID of the previous owner of this report. */
- oldAccountID: PropTypes.number,
-
- /** (For accountMerged reason only), the accountID of the account the previous owner was merged into */
- newAccountID: PropTypes.number,
- }).isRequired,
- }),
-
- /** The archived report */
- report: reportPropTypes.isRequired,
-
- /** Personal details of all users */
- personalDetails: PropTypes.objectOf(personalDetailsPropType),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- reportClosedAction: {
- originalMessage: {
- reason: CONST.REPORT.ARCHIVE_REASON.DEFAULT,
- },
- },
- personalDetails: {},
-};
-
-function ArchivedReportFooter(props) {
- const styles = useThemeStyles();
- const archiveReason = lodashGet(props.reportClosedAction, 'originalMessage.reason', CONST.REPORT.ARCHIVE_REASON.DEFAULT);
- let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [props.report.ownerAccountID, 'displayName']);
-
- let oldDisplayName;
- if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) {
- const newAccountID = props.reportClosedAction.originalMessage.newAccountID;
- const oldAccountID = props.reportClosedAction.originalMessage.oldAccountID;
- displayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [newAccountID, 'displayName']);
- oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [oldAccountID, 'displayName']);
- }
-
- const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT;
-
- let policyName = ReportUtils.getPolicyName(props.report);
-
- if (shouldRenderHTML) {
- oldDisplayName = _.escape(oldDisplayName);
- displayName = _.escape(displayName);
- policyName = _.escape(policyName);
- }
-
- return (
- ${displayName}`,
- oldDisplayName: `${oldDisplayName}`,
- policyName: `${policyName}`,
- })}
- shouldRenderHTML={shouldRenderHTML}
- shouldShowIcon
- />
- );
-}
-
-ArchivedReportFooter.propTypes = propTypes;
-ArchivedReportFooter.defaultProps = defaultProps;
-ArchivedReportFooter.displayName = 'ArchivedReportFooter';
-
-export default compose(
- withLocalize,
- withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- reportClosedAction: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- selector: ReportActionsUtils.getLastClosedReportAction,
- },
- }),
-)(ArchivedReportFooter);
diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx
new file mode 100644
index 000000000000..3187bf3604e8
--- /dev/null
+++ b/src/components/ArchivedReportFooter.tsx
@@ -0,0 +1,82 @@
+import lodashEscape from 'lodash/escape';
+import React from 'react';
+import {OnyxEntry, withOnyx} from 'react-native-onyx';
+import useLocalize from '@hooks/useLocalize';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {PersonalDetails, Report, ReportAction} from '@src/types/onyx';
+import Banner from './Banner';
+
+type ArchivedReportFooterOnyxProps = {
+ /** The reason this report was archived */
+ reportClosedAction: OnyxEntry;
+
+ /** Personal details of all users */
+ personalDetails: OnyxEntry>;
+};
+
+type ArchivedReportFooterProps = ArchivedReportFooterOnyxProps & {
+ /** The archived report */
+ report: Report;
+};
+
+function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}: ArchivedReportFooterProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const originalMessage = reportClosedAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ? reportClosedAction.originalMessage : null;
+ const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT;
+ let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [report.ownerAccountID, 'displayName']);
+
+ let oldDisplayName: string | undefined;
+ if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) {
+ const newAccountID = originalMessage?.newAccountID;
+ const oldAccountID = originalMessage?.oldAccountID;
+ displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [newAccountID, 'displayName']);
+ oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [oldAccountID, 'displayName']);
+ }
+
+ const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT;
+
+ let policyName = ReportUtils.getPolicyName(report);
+
+ if (shouldRenderHTML) {
+ oldDisplayName = lodashEscape(oldDisplayName);
+ displayName = lodashEscape(displayName);
+ policyName = lodashEscape(policyName);
+ }
+
+ const text = shouldRenderHTML
+ ? translate(`reportArchiveReasons.${archiveReason}`, {
+ displayName: `${displayName}`,
+ oldDisplayName: `${oldDisplayName}`,
+ policyName: `${policyName}`,
+ })
+ : translate(`reportArchiveReasons.${archiveReason}`);
+
+ return (
+
+ );
+}
+
+ArchivedReportFooter.displayName = 'ArchivedReportFooter';
+
+export default withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ reportClosedAction: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ canEvict: false,
+ selector: ReportActionsUtils.getLastClosedReportAction,
+ },
+})(ArchivedReportFooter);
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 4ab81ae462c9..356e75b1e1bb 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -128,6 +128,8 @@ function AttachmentModal(props) {
const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true);
const {windowWidth} = useWindowDimensions();
+ const isOverlayModalVisible = (isAttachmentReceipt && isDeleteReceiptConfirmModalVisible) || (!isAttachmentReceipt && isAttachmentInvalid);
+
const [file, setFile] = useState(
props.originalFileName
? {
@@ -361,7 +363,9 @@ function AttachmentModal(props) {
const menuItems = [];
const parentReportAction = props.parentReportActions[props.report.parentReportActionID];
- const canEdit = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, props.parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT);
+ const canEdit =
+ ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, props.parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT) &&
+ !TransactionUtils.isDistanceRequest(props.transaction);
if (canEdit) {
menuItems.push({
icon: Expensicons.Camera,
@@ -406,7 +410,7 @@ function AttachmentModal(props) {
{
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
index fa4ff50512d0..141e619e489e 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -12,6 +12,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import AttachmentCarouselCellRenderer from './AttachmentCarouselCellRenderer';
import {defaultProps, propTypes} from './attachmentCarouselPropTypes';
@@ -203,7 +204,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source,
initialScrollIndex={page}
initialNumToRender={3}
windowSize={5}
- maxToRenderPerBatch={3}
+ maxToRenderPerBatch={CONST.MAX_TO_RENDER_PER_BATCH.CAROUSEL}
data={attachments}
CellRendererComponent={AttachmentCarouselCellRenderer}
renderItem={renderItem}
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js
index 46afd23daa4c..b9dd65e2716b 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.js
@@ -3,11 +3,12 @@ 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 useThemeStyles from '@styles/useThemeStyles';
import BaseAttachmentViewPdf from './BaseAttachmentViewPdf';
import {attachmentViewPdfDefaultProps, attachmentViewPdfPropTypes} from './propTypes';
function AttachmentViewPdf(props) {
+ const styles = useThemeStyles();
const {onScaleChanged, ...restProps} = props;
const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
const scaleRef = useSharedValue(1);
@@ -41,7 +42,7 @@ function AttachmentViewPdf(props) {
return (
{
+const measureHeightOfSuggestionRows = (numRows: number, isSuggestionPickerLarge: boolean): number => {
if (isSuggestionPickerLarge) {
if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) {
// On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available
@@ -29,28 +26,26 @@ const measureHeightOfSuggestionRows = (numRows, isSuggestionPickerLarge) => {
return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT;
};
-function BaseAutoCompleteSuggestions({
- highlightedSuggestionIndex,
- onSelect,
- renderSuggestionMenuItem,
- suggestions,
- accessibilityLabelExtractor,
- keyExtractor,
- isSuggestionPickerLarge,
- forwardedRef,
-}) {
+function BaseAutoCompleteSuggestions(
+ {
+ highlightedSuggestionIndex,
+ onSelect,
+ accessibilityLabelExtractor,
+ renderSuggestionMenuItem,
+ suggestions,
+ isSuggestionPickerLarge,
+ keyExtractor,
+ }: AutoCompleteSuggestionsProps,
+ ref: ForwardedRef,
+) {
const styles = useThemeStyles();
const rowHeight = useSharedValue(0);
- const scrollRef = useRef(null);
+ const scrollRef = useRef>(null);
/**
* Render a suggestion menu item component.
- * @param {Object} params
- * @param {Object} params.item
- * @param {Number} params.index
- * @returns {JSX.Element}
*/
const renderItem = useCallback(
- ({item, index}) => (
+ ({item, index}: RenderSuggestionMenuItemProps): ReactElement => (
StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)}
hoverDimmingValue={1}
@@ -84,7 +79,7 @@ function BaseAutoCompleteSuggestions({
return (
@@ -104,17 +99,6 @@ function BaseAutoCompleteSuggestions({
);
}
-BaseAutoCompleteSuggestions.propTypes = propTypes;
BaseAutoCompleteSuggestions.displayName = 'BaseAutoCompleteSuggestions';
-const BaseAutoCompleteSuggestionsWithRef = React.forwardRef((props, ref) => (
-
-));
-
-BaseAutoCompleteSuggestionsWithRef.displayName = 'BaseAutoCompleteSuggestionsWithRef';
-
-export default BaseAutoCompleteSuggestionsWithRef;
+export default forwardRef(BaseAutoCompleteSuggestions);
diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
deleted file mode 100644
index 8c6dca1902c5..000000000000
--- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** Array of suggestions */
- // eslint-disable-next-line react/forbid-prop-types
- suggestions: PropTypes.arrayOf(PropTypes.object).isRequired,
-
- /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */
- renderSuggestionMenuItem: PropTypes.func.isRequired,
-
- /** Create unique keys for each suggestion item */
- keyExtractor: PropTypes.func.isRequired,
-
- /** The index of the highlighted suggestion */
- highlightedSuggestionIndex: PropTypes.number.isRequired,
-
- /** Fired when the user selects a suggestion */
- onSelect: PropTypes.func.isRequired,
-
- /** Show that we can use large auto-complete suggestion picker.
- * Depending on available space and whether the input is expanded, we can have a small or large mention suggester.
- * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */
- isSuggestionPickerLarge: PropTypes.bool.isRequired,
-
- /** create accessibility label for each item */
- accessibilityLabelExtractor: PropTypes.func.isRequired,
-
- /** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func,
-};
-
-const defaultProps = {
- measureParentContainer: () => {},
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.tsx
similarity index 61%
rename from src/components/AutoCompleteSuggestions/index.native.js
rename to src/components/AutoCompleteSuggestions/index.native.tsx
index 439fa45eae78..fbfa7d953581 100644
--- a/src/components/AutoCompleteSuggestions/index.native.js
+++ b/src/components/AutoCompleteSuggestions/index.native.tsx
@@ -1,18 +1,17 @@
import {Portal} from '@gorhom/portal';
import React from 'react';
-import {propTypes} from './autoCompleteSuggestionsPropTypes';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
+import type {AutoCompleteSuggestionsProps} from './types';
-function AutoCompleteSuggestions({measureParentContainer, ...props}) {
+function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) {
return (
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+ {...props} />
);
}
-AutoCompleteSuggestions.propTypes = propTypes;
AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions';
export default AutoCompleteSuggestions;
diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.tsx
similarity index 76%
rename from src/components/AutoCompleteSuggestions/index.js
rename to src/components/AutoCompleteSuggestions/index.tsx
index 30654caf5708..24b846c265a9 100644
--- a/src/components/AutoCompleteSuggestions/index.js
+++ b/src/components/AutoCompleteSuggestions/index.tsx
@@ -4,8 +4,8 @@ import {View} from 'react-native';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as StyleUtils from '@styles/StyleUtils';
-import {propTypes} from './autoCompleteSuggestionsPropTypes';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
+import type {AutoCompleteSuggestionsProps} from './types';
/**
* On the mobile-web platform, when long-pressing on auto-complete suggestions,
@@ -14,8 +14,8 @@ import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
* On the native platform, tapping on auto-complete suggestions will not blur the main input.
*/
-function AutoCompleteSuggestions({measureParentContainer, ...props}) {
- const containerRef = React.useRef(null);
+function AutoCompleteSuggestions({measureParentContainer = () => {}, ...props}: AutoCompleteSuggestionsProps) {
+ const containerRef = React.useRef(null);
const {windowHeight, windowWidth} = useWindowDimensions();
const [{width, left, bottom}, setContainerState] = React.useState({
width: 0,
@@ -25,7 +25,7 @@ function AutoCompleteSuggestions({measureParentContainer, ...props}) {
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
- return;
+ return () => {};
}
container.onpointerdown = (e) => {
if (DeviceCapabilities.hasHoverSupport()) {
@@ -44,20 +44,20 @@ function AutoCompleteSuggestions({measureParentContainer, ...props}) {
}, [measureParentContainer, windowHeight, windowWidth]);
const componentToRender = (
-
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={containerRef}
/>
);
+ const bodyElement = document.querySelector('body');
+
return (
- Boolean(width) &&
- ReactDOM.createPortal({componentToRender}, document.querySelector('body'))
+ !!width && bodyElement && ReactDOM.createPortal({componentToRender}, bodyElement)
);
}
-AutoCompleteSuggestions.propTypes = propTypes;
AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions';
export default AutoCompleteSuggestions;
diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts
new file mode 100644
index 000000000000..9130f5139d71
--- /dev/null
+++ b/src/components/AutoCompleteSuggestions/types.ts
@@ -0,0 +1,38 @@
+import {ReactElement} from 'react';
+
+type MeasureParentContainerCallback = (x: number, y: number, width: number) => void;
+
+type RenderSuggestionMenuItemProps = {
+ item: TSuggestion;
+ index: number;
+};
+
+type AutoCompleteSuggestionsProps = {
+ /** Array of suggestions */
+ suggestions: TSuggestion[];
+
+ /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */
+ renderSuggestionMenuItem: (item: TSuggestion, index: number) => ReactElement;
+
+ /** Create unique keys for each suggestion item */
+ keyExtractor: (item: TSuggestion, index: number) => string;
+
+ /** The index of the highlighted suggestion */
+ highlightedSuggestionIndex: number;
+
+ /** Fired when the user selects a suggestion */
+ onSelect: (index: number) => void;
+
+ /** Show that we can use large auto-complete suggestion picker.
+ * Depending on available space and whether the input is expanded, we can have a small or large mention suggester.
+ * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */
+ isSuggestionPickerLarge: boolean;
+
+ /** create accessibility label for each item */
+ accessibilityLabelExtractor: (item: TSuggestion, index: number) => string;
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer?: (callback: MeasureParentContainerCallback) => void;
+};
+
+export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps};
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js
index 893a02288e77..eabcd3aa85c5 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.js
@@ -1,14 +1,15 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
+import React, {useEffect, useRef, useState} from 'react';
+import {StyleSheet, View} from 'react-native';
import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import * as Browser from '@libs/Browser';
-import compose from '@libs/compose';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getImageResolution from '@libs/fileDownload/getImageResolution';
-import SpinningIndicatorAnimation from '@styles/animation/SpinningIndicatorAnimation';
import stylePropTypes from '@styles/stylePropTypes';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import AttachmentModal from './AttachmentModal';
@@ -21,11 +22,8 @@ import * as Expensicons from './Icon/Expensicons';
import OfflineWithFeedback from './OfflineWithFeedback';
import PopoverMenu from './PopoverMenu';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
-import Tooltip from './Tooltip/PopoverAnchorTooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import Tooltip from './Tooltip';
import withNavigationFocus from './withNavigationFocus';
-import withTheme, {withThemePropTypes} from './withTheme';
-import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles';
const propTypes = {
/** Avatar source to display */
@@ -54,9 +52,6 @@ const propTypes = {
left: PropTypes.number,
}).isRequired,
- /** Flag to see if image is being uploaded */
- isUploading: PropTypes.bool,
-
/** Size of Indicator */
size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]),
@@ -94,9 +89,11 @@ const propTypes = {
/** Whether navigation is focused */
isFocused: PropTypes.bool.isRequired,
- ...withLocalizePropTypes,
- ...withThemeStylesPropTypes,
- ...withThemePropTypes,
+ /** Where the popover should be positioned relative to the anchor points. */
+ anchorAlignment: PropTypes.shape({
+ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
+ vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
+ }),
};
const defaultProps = {
@@ -106,7 +103,6 @@ const defaultProps = {
style: [],
DefaultAvatar: () => {},
isUsingDefaultAvatar: false,
- isUploading: false,
size: CONST.AVATAR_SIZE.DEFAULT,
fallbackIcon: Expensicons.FallbackAvatar,
type: CONST.ICON_TYPE_AVATAR,
@@ -118,58 +114,69 @@ const defaultProps = {
headerTitle: '',
previewSource: '',
originalFileName: '',
+ anchorAlignment: {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+ },
};
-class AvatarWithImagePicker extends React.Component {
- constructor(props) {
- super(props);
- this.animation = new SpinningIndicatorAnimation();
- this.setError = this.setError.bind(this);
- this.isValidSize = this.isValidSize.bind(this);
- this.showAvatarCropModal = this.showAvatarCropModal.bind(this);
- this.hideAvatarCropModal = this.hideAvatarCropModal.bind(this);
- this.state = {
- isMenuVisible: false,
- validationError: null,
- phraseParam: {},
- isAvatarCropModalOpen: false,
- imageName: '',
- imageUri: '',
- imageType: '',
- };
- this.anchorRef = React.createRef();
- }
-
- componentDidMount() {
- if (!this.props.isUploading) {
- return;
- }
-
- this.animation.start();
- }
-
- componentDidUpdate(prevProps) {
- if (!prevProps.isFocused && this.props.isFocused) {
- this.setError(null, {});
- }
- if (!prevProps.isUploading && this.props.isUploading) {
- this.animation.start();
- } else if (prevProps.isUploading && !this.props.isUploading) {
- this.animation.stop();
- }
- }
-
- componentWillUnmount() {
- this.animation.stop();
- }
+function AvatarWithImagePicker({
+ isFocused,
+ DefaultAvatar,
+ style,
+ pendingAction,
+ errors,
+ errorRowStyles,
+ onErrorClose,
+ source,
+ fallbackIcon,
+ size,
+ type,
+ headerTitle,
+ previewSource,
+ originalFileName,
+ isUsingDefaultAvatar,
+ onImageRemoved,
+ anchorPosition,
+ anchorAlignment,
+ onImageSelected,
+ editorMaskImage,
+}) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const [isMenuVisible, setIsMenuVisible] = useState(false);
+ const [errorData, setErrorData] = useState({
+ validationError: null,
+ phraseParam: {},
+ });
+ const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
+ const [imageData, setImageData] = useState({
+ uri: '',
+ name: '',
+ type: '',
+ });
+ const anchorRef = useRef();
+ const {translate} = useLocalize();
/**
* @param {String} error
* @param {Object} phraseParam
*/
- setError(error, phraseParam) {
- this.setState({validationError: error, phraseParam});
- }
+ const setError = (error, phraseParam) => {
+ setErrorData({
+ validationError: error,
+ phraseParam,
+ });
+ };
+
+ useEffect(() => {
+ if (isFocused) {
+ return;
+ }
+
+ // Reset the error if the component is no longer focused.
+ setError(null, {});
+ }, [isFocused]);
/**
* Check if the attachment extension is allowed.
@@ -177,10 +184,10 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Boolean}
*/
- isValidExtension(image) {
+ const isValidExtension = (image) => {
const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', ''));
return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase());
- }
+ };
/**
* Check if the attachment size is less than allowed size.
@@ -188,9 +195,7 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Boolean}
*/
- isValidSize(image) {
- return image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
- }
+ const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
/**
* Check if the attachment resolution matches constraints.
@@ -198,34 +203,29 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Promise}
*/
- isValidResolution(image) {
- return getImageResolution(image).then(
- (resolution) =>
- resolution.height >= CONST.AVATAR_MIN_HEIGHT_PX &&
- resolution.width >= CONST.AVATAR_MIN_WIDTH_PX &&
- resolution.height <= CONST.AVATAR_MAX_HEIGHT_PX &&
- resolution.width <= CONST.AVATAR_MAX_WIDTH_PX,
+ const isValidResolution = (image) =>
+ getImageResolution(image).then(
+ ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX,
);
- }
/**
* Validates if an image has a valid resolution and opens an avatar crop modal
*
* @param {Object} image
*/
- showAvatarCropModal(image) {
- if (!this.isValidExtension(image)) {
- this.setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
+ const showAvatarCropModal = (image) => {
+ if (!isValidExtension(image)) {
+ setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
return;
}
- if (!this.isValidSize(image)) {
- this.setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)});
+ if (!isValidSize(image)) {
+ setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)});
return;
}
- this.isValidResolution(image).then((isValidResolution) => {
- if (!isValidResolution) {
- this.setError('avatarWithImagePicker.resolutionConstraints', {
+ isValidResolution(image).then((isValid) => {
+ if (!isValid) {
+ setError('avatarWithImagePicker.resolutionConstraints', {
minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX,
minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX,
maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX,
@@ -234,158 +234,168 @@ class AvatarWithImagePicker extends React.Component {
return;
}
- this.setState({
- isAvatarCropModalOpen: true,
- validationError: null,
- phraseParam: {},
- isMenuVisible: false,
- imageUri: image.uri,
- imageName: image.name,
- imageType: image.type,
+ setIsAvatarCropModalOpen(true);
+ setError(null, {});
+ setIsMenuVisible(false);
+ setImageData({
+ uri: image.uri,
+ name: image.name,
+ type: image.type,
});
});
- }
-
- hideAvatarCropModal() {
- this.setState({isAvatarCropModalOpen: false});
- }
-
- render() {
- const DefaultAvatar = this.props.DefaultAvatar;
- const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style];
-
- return (
-
-
-
-
- this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))}
- role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
- accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')}
- disabled={this.state.isAvatarCropModalOpen}
- ref={this.anchorRef}
- >
-
- {this.props.source ? (
-
- ) : (
-
- )}
-
-
- {
+ setIsAvatarCropModalOpen(false);
+ };
+
+ /**
+ * Create menu items list for avatar menu
+ *
+ * @param {Function} openPicker
+ * @returns {Array}
+ */
+ const createMenuItems = (openPicker) => {
+ const menuItems = [
+ {
+ icon: Expensicons.Upload,
+ text: translate('avatarWithImagePicker.uploadPhoto'),
+ onSelected: () => {
+ if (Browser.isSafari()) {
+ return;
+ }
+ openPicker({
+ onPicked: showAvatarCropModal,
+ });
+ },
+ },
+ ];
+
+ // If current avatar isn't a default avatar, allow Remove Photo option
+ if (!isUsingDefaultAvatar) {
+ menuItems.push({
+ icon: Expensicons.Trashcan,
+ text: translate('avatarWithImagePicker.removePhoto'),
+ onSelected: () => {
+ setError(null, {});
+ onImageRemoved();
+ },
+ });
+ }
+ return menuItems;
+ };
+
+ return (
+
+
+
+
+ setIsMenuVisible((prev) => !prev)}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ accessibilityLabel={translate('avatarWithImagePicker.editImage')}
+ disabled={isAvatarCropModalOpen}
+ ref={anchorRef}
+ >
+
+ {source ? (
+
-
-
-
-
-
- {({show}) => (
-
- {({openPicker}) => {
- const menuItems = [
- {
- icon: Expensicons.Upload,
- text: this.props.translate('avatarWithImagePicker.uploadPhoto'),
- onSelected: () => {
- if (Browser.isSafari()) {
- return;
- }
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {({show}) => (
+
+ {({openPicker}) => {
+ const menuItems = createMenuItems(openPicker);
+
+ // If the current avatar isn't a default avatar, allow the "View Photo" option
+ if (!isUsingDefaultAvatar) {
+ menuItems.push({
+ icon: Expensicons.Eye,
+ text: translate('avatarWithImagePicker.viewPhoto'),
+ onSelected: show,
+ });
+ }
+
+ return (
+ setIsMenuVisible(false)}
+ onItemSelected={(item, index) => {
+ setIsMenuVisible(false);
+ // In order for the file picker to open dynamically, the click
+ // function must be called from within an event handler that was initiated
+ // by the user on Safari.
+ if (index === 0 && Browser.isSafari()) {
openPicker({
- onPicked: this.showAvatarCropModal,
+ onPicked: showAvatarCropModal,
});
- },
- },
- ];
-
- // If current avatar isn't a default avatar, allow Remove Photo option
- if (!this.props.isUsingDefaultAvatar) {
- menuItems.push({
- icon: Expensicons.Trashcan,
- text: this.props.translate('avatarWithImagePicker.removePhoto'),
- onSelected: () => {
- this.setError(null, {});
- this.props.onImageRemoved();
- },
- });
-
- menuItems.push({
- icon: Expensicons.Eye,
- text: this.props.translate('avatarWithImagePicker.viewPhoto'),
- onSelected: () => show(),
- });
- }
- return (
- this.setState({isMenuVisible: false})}
- onItemSelected={(item, index) => {
- this.setState({isMenuVisible: false});
- // In order for the file picker to open dynamically, the click
- // function must be called from within a event handler that was initiated
- // by the user on Safari.
- if (index === 0 && Browser.isSafari()) {
- openPicker({
- onPicked: this.showAvatarCropModal,
- });
- }
- }}
- menuItems={menuItems}
- anchorPosition={this.props.anchorPosition}
- withoutOverlay
- anchorRef={this.anchorRef}
- anchorAlignment={this.props.anchorAlignment}
- />
- );
- }}
-
- )}
-
-
- {this.state.validationError && (
-
- )}
-
+ }
+ }}
+ menuItems={menuItems}
+ anchorPosition={anchorPosition}
+ withoutOverlay
+ anchorRef={anchorRef}
+ anchorAlignment={anchorAlignment}
+ />
+ );
+ }}
+
+ )}
+
- );
- }
+ {errorData.validationError && (
+
+ )}
+
+
+ );
}
AvatarWithImagePicker.propTypes = propTypes;
AvatarWithImagePicker.defaultProps = defaultProps;
+AvatarWithImagePicker.displayName = 'AvatarWithImagePicker';
-export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(AvatarWithImagePicker);
+export default withNavigationFocus(AvatarWithImagePicker);
diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx
index 22c056dfdfc4..575646f7dd9c 100644
--- a/src/components/Badge.tsx
+++ b/src/components/Badge.tsx
@@ -29,7 +29,7 @@ type BadgeProps = {
textStyles?: StyleProp;
/** Callback to be called on onPress */
- onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void;
};
function Badge({success = false, error = false, pressable = false, text, environment = CONST.ENVIRONMENT.DEV, badgeStyles, textStyles, onPress = () => {}}: BadgeProps) {
diff --git a/src/components/Banner.js b/src/components/Banner.tsx
similarity index 61%
rename from src/components/Banner.js
rename to src/components/Banner.tsx
index 2fcb866334e0..1c92208a7aa2 100644
--- a/src/components/Banner.js
+++ b/src/components/Banner.tsx
@@ -1,7 +1,6 @@
-import PropTypes from 'prop-types';
import React, {memo} from 'react';
-import {View} from 'react-native';
-import compose from '@libs/compose';
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import getButtonState from '@libs/getButtonState';
import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
@@ -13,54 +12,41 @@ import PressableWithFeedback from './Pressable/PressableWithFeedback';
import RenderHTML from './RenderHTML';
import Text from './Text';
import Tooltip from './Tooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-const propTypes = {
+type BannerProps = {
/** Text to display in the banner. */
- text: PropTypes.string.isRequired,
+ text: string;
/** Should this component render the left-aligned exclamation icon? */
- shouldShowIcon: PropTypes.bool,
+ shouldShowIcon?: boolean;
/** Should this component render a close button? */
- shouldShowCloseButton: PropTypes.bool,
+ shouldShowCloseButton?: boolean;
/** Should this component render the text as HTML? */
- shouldRenderHTML: PropTypes.bool,
+ shouldRenderHTML?: boolean;
/** Callback called when the close button is pressed */
- onClose: PropTypes.func,
+ onClose?: () => void;
/** Callback called when the message is pressed */
- onPress: PropTypes.func,
+ onPress?: () => void;
/** Styles to be assigned to the Banner container */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
+ containerStyles?: StyleProp;
/** Styles to be assigned to the Banner text */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- shouldRenderHTML: false,
- shouldShowIcon: false,
- shouldShowCloseButton: false,
- onClose: undefined,
- onPress: undefined,
- containerStyles: [],
- textStyles: [],
+ textStyles?: StyleProp;
};
-function Banner(props) {
+function Banner({text, onClose, onPress, containerStyles, textStyles, shouldRenderHTML = false, shouldShowIcon = false, shouldShowCloseButton = false}: BannerProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
return (
{(isHovered) => {
- const isClickable = props.onClose || props.onPress;
+ const isClickable = onClose ?? onPress;
const shouldHighlight = isClickable && isHovered;
return (
- {props.shouldShowIcon && (
+ {shouldShowIcon && (
)}
- {props.shouldRenderHTML ? (
-
+ {shouldRenderHTML ? (
+
) : (
- {props.text}
+ {text}
)}
- {props.shouldShowCloseButton && (
-
+ {shouldShowCloseButton && !!onClose && (
+
@@ -113,8 +99,6 @@ function Banner(props) {
);
}
-Banner.propTypes = propTypes;
-Banner.defaultProps = defaultProps;
Banner.displayName = 'Banner';
-export default compose(withLocalize, memo)(Banner);
+export default memo(Banner);
diff --git a/src/components/Button/index.js b/src/components/Button/index.tsx
similarity index 62%
rename from src/components/Button/index.js
rename to src/components/Button/index.tsx
index b9aaf8868924..8d5833a121ca 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.tsx
@@ -1,212 +1,166 @@
import {useIsFocused} from '@react-navigation/native';
-import PropTypes from 'prop-types';
-import React, {useCallback} from 'react';
-import {ActivityIndicator, View} from 'react-native';
+import React, {ForwardedRef, useCallback} from 'react';
+import {ActivityIndicator, GestureResponderEvent, 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 PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import refPropTypes from '@components/refPropTypes';
import Text from '@components/Text';
import withNavigationFallback from '@components/withNavigationFallback';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import HapticFeedback from '@libs/HapticFeedback';
-import * as StyleUtils from '@styles/StyleUtils';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
import validateSubmitShortcut from './validateSubmitShortcut';
-const propTypes = {
- /** Should the press event bubble across multiple instances when Enter key triggers it. */
- allowBubble: PropTypes.bool,
-
+type ButtonWithText = {
/** The text for the button label */
- text: PropTypes.string,
+ text: string;
/** Boolean whether to display the right icon */
- shouldShowRightIcon: PropTypes.bool,
+ shouldShowRightIcon?: boolean;
/** The icon asset to display to the left of the text */
- icon: PropTypes.func,
+ icon?: React.FC | null;
+};
+
+type ButtonProps = (ButtonWithText | ChildrenProps) & {
+ /** Should the press event bubble across multiple instances when Enter key triggers it. */
+ allowBubble?: boolean;
/** The icon asset to display to the right of the text */
- iconRight: PropTypes.func,
+ iconRight?: React.FC;
/** The fill color to pass into the icon. */
- iconFill: PropTypes.string,
+ iconFill?: string;
/** Any additional styles to pass to the left icon container. */
- // eslint-disable-next-line react/forbid-prop-types
- iconStyles: PropTypes.arrayOf(PropTypes.object),
+ iconStyles?: StyleProp;
/** Any additional styles to pass to the right icon container. */
- // eslint-disable-next-line react/forbid-prop-types
- iconRightStyles: PropTypes.arrayOf(PropTypes.object),
+ iconRightStyles?: StyleProp;
/** Small sized button */
- small: PropTypes.bool,
+ small?: boolean;
/** Large sized button */
- large: PropTypes.bool,
+ large?: boolean;
- /** medium sized button */
- medium: PropTypes.bool,
+ /** Medium sized button */
+ medium?: boolean;
/** Indicates whether the button should be disabled and in the loading state */
- isLoading: PropTypes.bool,
+ isLoading?: boolean;
/** Indicates whether the button should be disabled */
- isDisabled: PropTypes.bool,
+ isDisabled?: boolean;
/** A function that is called when the button is clicked on */
- onPress: PropTypes.func,
+ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void;
/** A function that is called when the button is long pressed */
- onLongPress: PropTypes.func,
+ onLongPress?: (event?: GestureResponderEvent) => void;
/** A function that is called when the button is pressed */
- onPressIn: PropTypes.func,
+ onPressIn?: () => void;
/** A function that is called when the button is released */
- onPressOut: PropTypes.func,
+ onPressOut?: () => void;
/** Callback that is called when mousedown is triggered. */
- onMouseDown: PropTypes.func,
+ onMouseDown?: () => void;
/** Call the onPress function when Enter key is pressed */
- pressOnEnter: PropTypes.bool,
+ pressOnEnter?: boolean;
/** The priority to assign the enter key event listener. 0 is the highest priority. */
- enterKeyEventListenerPriority: PropTypes.number,
+ enterKeyEventListenerPriority?: number;
/** Additional styles to add after local styles. Applied to Pressable portion of button */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ style?: StyleProp;
- /** Additional button styles. Specific to the OpacityView of button */
- // eslint-disable-next-line react/forbid-prop-types
- innerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Additional button styles. Specific to the OpacityView of the button */
+ innerStyles?: StyleProp;
/** Additional text styles */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
+ textStyles?: StyleProp;
/** Whether we should use the default hover style */
- shouldUseDefaultHover: PropTypes.bool,
+ shouldUseDefaultHover?: boolean;
/** Whether we should use the success theme color */
- success: PropTypes.bool,
+ success?: boolean;
/** Whether we should use the danger theme color */
- danger: PropTypes.bool,
-
- /** Children to replace all inner contents of button */
- children: PropTypes.node,
+ danger?: boolean;
/** Should we remove the right border radius top + bottom? */
- shouldRemoveRightBorderRadius: PropTypes.bool,
+ shouldRemoveRightBorderRadius?: boolean;
/** Should we remove the left border radius top + bottom? */
- shouldRemoveLeftBorderRadius: PropTypes.bool,
+ shouldRemoveLeftBorderRadius?: boolean;
/** Should enable the haptic feedback? */
- shouldEnableHapticFeedback: PropTypes.bool,
+ shouldEnableHapticFeedback?: boolean;
/** Id to use for this button */
- id: PropTypes.string,
+ id?: string;
/** Accessibility label for the component */
- accessibilityLabel: PropTypes.string,
-
- /** A ref to forward the button */
- forwardedRef: refPropTypes,
-};
-
-const defaultProps = {
- allowBubble: false,
- text: '',
- shouldShowRightIcon: false,
- icon: null,
- iconRight: Expensicons.ArrowRight,
- iconFill: undefined,
- iconStyles: [],
- iconRightStyles: [],
- isLoading: false,
- isDisabled: false,
- small: false,
- large: false,
- medium: false,
- onPress: () => {},
- onLongPress: () => {},
- onPressIn: () => {},
- onPressOut: () => {},
- onMouseDown: undefined,
- pressOnEnter: false,
- enterKeyEventListenerPriority: 0,
- style: [],
- innerStyles: [],
- textStyles: [],
- shouldUseDefaultHover: true,
- success: false,
- danger: false,
- children: null,
- shouldRemoveRightBorderRadius: false,
- shouldRemoveLeftBorderRadius: false,
- shouldEnableHapticFeedback: false,
- id: '',
- accessibilityLabel: '',
- forwardedRef: undefined,
+ accessibilityLabel?: string;
};
-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,
-}) {
+function Button(
+ {
+ allowBubble = false,
+
+ iconRight = Expensicons.ArrowRight,
+ iconFill,
+ iconStyles = [],
+ iconRightStyles = [],
+
+ small = false,
+ large = false,
+ medium = false,
+
+ isLoading = false,
+ isDisabled = false,
+
+ onPress = () => {},
+ onLongPress = () => {},
+ onPressIn = () => {},
+ onPressOut = () => {},
+ onMouseDown = undefined,
+
+ pressOnEnter = false,
+ enterKeyEventListenerPriority = 0,
+
+ style = [],
+ innerStyles = [],
+ textStyles = [],
+
+ shouldUseDefaultHover = true,
+ success = false,
+ danger = false,
+
+ shouldRemoveRightBorderRadius = false,
+ shouldRemoveLeftBorderRadius = false,
+ shouldEnableHapticFeedback = false,
+
+ id = '',
+ accessibilityLabel = '',
+ ...rest
+ }: ButtonProps,
+ ref: ForwardedRef,
+) {
const theme = useTheme();
const styles = useThemeStyles();
const isFocused = useIsFocused();
const keyboardShortcutCallback = useCallback(
- (event) => {
+ (event?: GestureResponderEvent | KeyboardEvent) => {
if (!validateSubmitShortcut(isFocused, isDisabled, isLoading, event)) {
return;
}
@@ -223,10 +177,12 @@ function Button({
});
const renderContent = () => {
- if (children) {
- return children;
+ if ('children' in rest) {
+ return rest.children;
}
+ const {text = '', icon = null, shouldShowRightIcon = false} = rest;
+
const textComponent = (
@@ -248,15 +204,16 @@ function Button({
);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (icon || shouldShowRightIcon) {
return (
{icon && (
-
+
@@ -264,10 +221,10 @@ function Button({
{textComponent}
{shouldShowRightIcon && (
-
+
@@ -281,10 +238,11 @@ function Button({
return (
{
- if (event && event.type === 'click') {
- event.currentTarget.blur();
+ if (event?.type === 'click') {
+ const currentTarget = event?.currentTarget as HTMLElement;
+ currentTarget?.blur();
}
if (shouldEnableHapticFeedback) {
@@ -307,7 +265,7 @@ function Button({
styles.buttonContainer,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- ...StyleUtils.parseStyleAsArray(style),
+ style,
]}
style={[
styles.button,
@@ -320,8 +278,9 @@ function Button({
isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- icon || shouldShowRightIcon ? styles.alignItemsStretch : undefined,
- ...innerStyles,
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined,
+ innerStyles,
]}
hoverStyle={[
shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
@@ -344,18 +303,6 @@ function Button({
);
}
-Button.propTypes = propTypes;
-Button.defaultProps = defaultProps;
Button.displayName = 'Button';
-const ButtonWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ButtonWithRef.displayName = 'ButtonWithRef';
-
-export default withNavigationFallback(ButtonWithRef);
+export default withNavigationFallback(React.forwardRef(Button));
diff --git a/src/components/Button/validateSubmitShortcut/index.js b/src/components/Button/validateSubmitShortcut/index.js
deleted file mode 100644
index bfe5c79483fa..000000000000
--- a/src/components/Button/validateSubmitShortcut/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Validate if the submit shortcut should be triggered depending on the button state
- *
- * @param {boolean} isFocused Whether Button is on active screen
- * @param {boolean} isDisabled Indicates whether the button should be disabled
- * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state
- * @param {Object} event Focused input event
- * @returns {boolean} Returns `true` if the shortcut should be triggered
- */
-function validateSubmitShortcut(isFocused, isDisabled, isLoading, event) {
- if (!isFocused || isDisabled || isLoading || (event && event.target.nodeName === 'TEXTAREA')) {
- return false;
- }
-
- event.preventDefault();
- return true;
-}
-
-export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.native.js b/src/components/Button/validateSubmitShortcut/index.native.js
deleted file mode 100644
index 2822fa56d590..000000000000
--- a/src/components/Button/validateSubmitShortcut/index.native.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Validate if the submit shortcut should be triggered depending on the button state
- *
- * @param {boolean} isFocused Whether Button is on active screen
- * @param {boolean} isDisabled Indicates whether the button should be disabled
- * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state
- * @returns {boolean} Returns `true` if the shortcut should be triggered
- */
-function validateSubmitShortcut(isFocused, isDisabled, isLoading) {
- if (!isFocused || isDisabled || isLoading) {
- return false;
- }
-
- return true;
-}
-
-export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.native.ts b/src/components/Button/validateSubmitShortcut/index.native.ts
new file mode 100644
index 000000000000..7687855f109b
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/index.native.ts
@@ -0,0 +1,20 @@
+import ValidateSubmitShortcut from './types';
+
+/**
+ * Validate if the submit shortcut should be triggered depending on the button state
+ *
+ * @param isFocused Whether Button is on active screen
+ * @param isDisabled Indicates whether the button should be disabled
+ * @param isLoading Indicates whether the button should be disabled and in the loading state
+ * @return Returns `true` if the shortcut should be triggered
+ */
+
+const validateSubmitShortcut: ValidateSubmitShortcut = (isFocused, isDisabled, isLoading) => {
+ if (!isFocused || isDisabled || isLoading) {
+ return false;
+ }
+
+ return true;
+};
+
+export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.ts b/src/components/Button/validateSubmitShortcut/index.ts
new file mode 100644
index 000000000000..55b3e44192e4
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/index.ts
@@ -0,0 +1,23 @@
+import ValidateSubmitShortcut from './types';
+
+/**
+ * Validate if the submit shortcut should be triggered depending on the button state
+ *
+ * @param isFocused Whether Button is on active screen
+ * @param isDisabled Indicates whether the button should be disabled
+ * @param isLoading Indicates whether the button should be disabled and in the loading state
+ * @param event Focused input event
+ * @returns Returns `true` if the shortcut should be triggered
+ */
+
+const validateSubmitShortcut: ValidateSubmitShortcut = (isFocused, isDisabled, isLoading, event) => {
+ const eventTarget = event?.target as HTMLElement;
+ if (!isFocused || isDisabled || isLoading || eventTarget.nodeName === 'TEXTAREA') {
+ return false;
+ }
+
+ event?.preventDefault();
+ return true;
+};
+
+export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/types.ts b/src/components/Button/validateSubmitShortcut/types.ts
new file mode 100644
index 000000000000..9970e1478a4c
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/types.ts
@@ -0,0 +1,5 @@
+import {GestureResponderEvent} from 'react-native';
+
+type ValidateSubmitShortcut = (isFocused: boolean, isDisabled: boolean, isLoading: boolean, event?: GestureResponderEvent | KeyboardEvent) => boolean;
+
+export default ValidateSubmitShortcut;
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
index ff7087df91dd..36cf9b1deadc 100644
--- a/src/components/CategoryPicker/index.js
+++ b/src/components/CategoryPicker/index.js
@@ -52,20 +52,9 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
return categoryOptions;
}, [policyCategories, policyRecentlyUsedCategories, searchValue, selectedOptions]);
- const initialFocusedIndex = useMemo(() => {
- let categoryInitialFocusedIndex = 0;
-
- if (!_.isEmpty(searchValue) || isCategoriesCountBelowThreshold) {
- const index = _.findIndex(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory);
-
- categoryInitialFocusedIndex = index === -1 ? 0 : index;
- }
-
- return categoryInitialFocusedIndex;
- }, [selectedCategory, searchValue, isCategoriesCountBelowThreshold, sections]);
-
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
const shouldShowTextInput = !isCategoriesCountBelowThreshold;
+ const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory)[0], 'keyForList');
return (
{
- if (event.code !== 'Space') {
- return;
- }
-
- props.onPress();
- };
-
- const firePressHandlerOnClick = (event) => {
- // Pressable can be triggered with Enter key and by a click. As this is a checkbox,
- // We do not want to toggle it, when Enter key is pressed.
- if (event.type && event.type !== 'click') {
- return;
- }
-
- props.onPress();
- };
-
- return (
-
- {props.children ? (
- props.children
- ) : (
-
- {props.isChecked && (
-
- )}
-
- )}
-
- );
-}
-
-Checkbox.propTypes = propTypes;
-Checkbox.defaultProps = defaultProps;
-Checkbox.displayName = 'Checkbox';
-
-export default Checkbox;
diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx
new file mode 100644
index 000000000000..6ee5ed1c558f
--- /dev/null
+++ b/src/components/Checkbox.tsx
@@ -0,0 +1,126 @@
+import React, {ForwardedRef, forwardRef, KeyboardEvent as ReactKeyboardEvent} from 'react';
+import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
+import * as StyleUtils from '@styles/StyleUtils';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+
+type CheckboxProps = ChildrenProps & {
+ /** Whether checkbox is checked */
+ isChecked?: boolean;
+
+ /** A function that is called when the box/label is pressed */
+ onPress: () => void;
+
+ /** Should the input be styled for errors */
+ hasError?: boolean;
+
+ /** Should the input be disabled */
+ disabled?: boolean;
+
+ /** Additional styles to add to checkbox button */
+ style?: StyleProp;
+
+ /** Additional styles to add to checkbox container */
+ containerStyle?: StyleProp;
+
+ /** Callback that is called when mousedown is triggered. */
+ onMouseDown?: () => void;
+
+ /** The size of the checkbox container */
+ containerSize?: number;
+
+ /** The border radius of the checkbox container */
+ containerBorderRadius?: number;
+
+ /** The size of the caret (checkmark) */
+ caretSize?: number;
+
+ /** An accessibility label for the checkbox */
+ accessibilityLabel: string;
+};
+
+function Checkbox(
+ {
+ isChecked = false,
+ hasError = false,
+ disabled = false,
+ style,
+ containerStyle,
+ children = null,
+ onMouseDown,
+ containerSize = 20,
+ containerBorderRadius = 4,
+ caretSize = 14,
+ onPress,
+ accessibilityLabel,
+ }: CheckboxProps,
+ ref: ForwardedRef,
+) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
+ const handleSpaceKey = (event?: ReactKeyboardEvent) => {
+ if (event?.code !== 'Space') {
+ return;
+ }
+
+ onPress();
+ };
+
+ const firePressHandlerOnClick = (event?: GestureResponderEvent | KeyboardEvent) => {
+ // Pressable can be triggered with Enter key and by a click. As this is a checkbox,
+ // We do not want to toggle it, when Enter key is pressed.
+ if (event?.type && event.type !== 'click') {
+ return;
+ }
+
+ onPress();
+ };
+
+ return (
+
+ {children ?? (
+
+ {isChecked && (
+
+ )}
+
+ )}
+
+ );
+}
+
+Checkbox.displayName = 'Checkbox';
+
+export default forwardRef(Checkbox);
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
index 146e37ceb730..92cd7ea38eea 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -34,7 +34,7 @@ const propTypes = {
isChecked: PropTypes.bool,
/** Called when the checkbox or label is pressed */
- onInputChange: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func,
/** Container styles */
style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
@@ -77,10 +77,11 @@ const defaultProps = {
errorText: '',
shouldSaveDraft: false,
isChecked: false,
- value: false,
+ value: undefined,
defaultValue: false,
forwardedRef: () => {},
accessibilityLabel: undefined,
+ onInputChange: () => {},
};
function CheckboxWithLabel(props) {
@@ -106,7 +107,7 @@ function CheckboxWithLabel(props) {
label={props.label}
style={[styles.checkboxWithLabelCheckboxStyle]}
hasError={Boolean(props.errorText)}
- forwardedRef={props.forwardedRef}
+ ref={props.forwardedRef}
accessibilityLabel={props.accessibilityLabel || props.label}
/>
ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e)}
+ onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)}
rejectResponderTermination={false}
// 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)
diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js
index a1b8c1a4ffe6..eb227de36a54 100644
--- a/src/components/Composer/index.ios.js
+++ b/src/components/Composer/index.ios.js
@@ -4,8 +4,8 @@ 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';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
const propTypes = {
/** If the input should clear, it actually gets intercepted instead of .clear() */
@@ -66,6 +66,8 @@ const defaultProps = {
function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) {
const textInput = useRef(null);
+ const styles = useThemeStyles();
+ const theme = useTheme();
/**
* Set the TextInput Ref
@@ -115,9 +117,9 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC
return (
ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e)}
+ onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)}
rejectResponderTermination={false}
smartInsertDelete={false}
maxNumberOfLines={maxNumberOfLines}
diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js
index 7c720c4bd681..3fe3838c8c81 100755
--- a/src/components/ConfirmModal.js
+++ b/src/components/ConfirmModal.js
@@ -98,7 +98,6 @@ function ConfirmModal(props) {
shouldSetModalVisibility={props.shouldSetModalVisibility}
onModalHide={props.onModalHide}
type={props.isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
- shouldEnableFocusTrap
>
{
- const numberOfWaypoints = _.size(waypoints);
- const lastWaypointIndex = numberOfWaypoints - 1;
- return _.filter(
- _.map(waypoints, (waypoint, key) => {
- if (!waypoint || lodashIsNil(waypoint.lat) || lodashIsNil(waypoint.lng)) {
- return;
- }
-
- const index = TransactionUtils.getWaypointIndex(key);
- let MarkerComponent;
- if (index === 0) {
- MarkerComponent = Expensicons.DotIndicatorUnfilled;
- } else if (index === lastWaypointIndex) {
- MarkerComponent = Expensicons.Location;
- } else {
- MarkerComponent = Expensicons.DotIndicator;
- }
-
- return {
- id: `${waypoint.lng},${waypoint.lat},${index}`,
- coordinate: [waypoint.lng, waypoint.lat],
- markerComponent: () => (
-
- ),
- };
- }),
- (waypoint) => waypoint,
- );
-};
-
function ConfirmedRoute({mapboxAccessToken, transaction}) {
const {isOffline} = useNetwork();
const {route0: route} = transaction.routes || {};
const waypoints = lodashGet(transaction, 'comment.waypoints', {});
const coordinates = lodashGet(route, 'geometry.coordinates', []);
+ const styles = useThemeStyles();
+ const theme = useTheme();
+
+ const getWaypointMarkers = useCallback(
+ (waypointsData) => {
+ const numberOfWaypoints = _.size(waypointsData);
+ const lastWaypointIndex = numberOfWaypoints - 1;
+
+ return _.filter(
+ _.map(waypointsData, (waypoint, key) => {
+ if (!waypoint || lodashIsNil(waypoint.lat) || lodashIsNil(waypoint.lng)) {
+ return;
+ }
+
+ const index = TransactionUtils.getWaypointIndex(key);
+ let MarkerComponent;
+ if (index === 0) {
+ MarkerComponent = Expensicons.DotIndicatorUnfilled;
+ } else if (index === lastWaypointIndex) {
+ MarkerComponent = Expensicons.Location;
+ } else {
+ MarkerComponent = Expensicons.DotIndicator;
+ }
+
+ return {
+ id: `${waypoint.lng},${waypoint.lat},${index}`,
+ coordinate: [waypoint.lng, waypoint.lat],
+ markerComponent: () => (
+
+ ),
+ };
+ }),
+ (waypoint) => waypoint,
+ );
+ },
+ [theme],
+ );
+
const waypointMarkers = getWaypointMarkers(waypoints);
useEffect(() => {
diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js
index d0a43badc5e3..a8f7e3172c81 100644
--- a/src/components/ContextMenuItem.js
+++ b/src/components/ContextMenuItem.js
@@ -98,7 +98,7 @@ function ContextMenuItem({onPress, successIcon, successText, icon, text, isMini,
success={!isThrottledButtonActive}
description={description}
descriptionTextStyle={styles.breakAll}
- style={getContextMenuItemStyles(windowWidth)}
+ style={getContextMenuItemStyles(styles, windowWidth)}
isAnonymousAction={isAnonymousAction}
focused={isFocused}
interactive={isThrottledButtonActive}
diff --git a/src/components/CustomDevMenu/index.js b/src/components/CustomDevMenu/index.js
deleted file mode 100644
index b8944c185d13..000000000000
--- a/src/components/CustomDevMenu/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const CustomDevMenu = () => {};
-
-CustomDevMenu.displayName = 'CustomDevMenu';
-
-export default CustomDevMenu;
diff --git a/src/components/CustomDevMenu/index.native.js b/src/components/CustomDevMenu/index.native.js
deleted file mode 100644
index c8d0e1e099d4..000000000000
--- a/src/components/CustomDevMenu/index.native.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import {useEffect} from 'react';
-import DevMenu from 'react-native-dev-menu';
-import toggleTestToolsModal from '@userActions/TestTool';
-
-function CustomDevMenu() {
- useEffect(() => {
- DevMenu.addItem('Open Test Preferences', toggleTestToolsModal);
- }, []);
-
- return null;
-}
-
-CustomDevMenu.displayName = 'CustomDevMenu';
-
-export default CustomDevMenu;
diff --git a/src/components/CustomDevMenu/index.native.tsx b/src/components/CustomDevMenu/index.native.tsx
new file mode 100644
index 000000000000..d8a0ea987171
--- /dev/null
+++ b/src/components/CustomDevMenu/index.native.tsx
@@ -0,0 +1,18 @@
+import {useEffect} from 'react';
+import DevMenu from 'react-native-dev-menu';
+import toggleTestToolsModal from '@userActions/TestTool';
+import CustomDevMenuElement from './types';
+
+const CustomDevMenu: CustomDevMenuElement = Object.assign(
+ () => {
+ useEffect(() => {
+ DevMenu.addItem('Open Test Preferences', toggleTestToolsModal);
+ }, []);
+ return <>>;
+ },
+ {
+ displayName: 'CustomDevMenu',
+ },
+);
+
+export default CustomDevMenu;
diff --git a/src/components/CustomDevMenu/index.tsx b/src/components/CustomDevMenu/index.tsx
new file mode 100644
index 000000000000..c8eae861b676
--- /dev/null
+++ b/src/components/CustomDevMenu/index.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import CustomDevMenuElement from './types';
+
+const CustomDevMenu: CustomDevMenuElement = Object.assign(() => <>>, {displayName: 'CustomDevMenu'});
+
+export default CustomDevMenu;
diff --git a/src/components/CustomDevMenu/types.ts b/src/components/CustomDevMenu/types.ts
new file mode 100644
index 000000000000..bdfc800a17f0
--- /dev/null
+++ b/src/components/CustomDevMenu/types.ts
@@ -0,0 +1,8 @@
+import {ReactElement} from 'react';
+
+type CustomDevMenuElement = {
+ (): ReactElement;
+ displayName: string;
+};
+
+export default CustomDevMenuElement;
diff --git a/src/components/CustomStatusBar/index.android.js b/src/components/CustomStatusBar/index.android.tsx
similarity index 50%
rename from src/components/CustomStatusBar/index.android.js
rename to src/components/CustomStatusBar/index.android.tsx
index a7bf509114e6..81b4f1d25f67 100644
--- a/src/components/CustomStatusBar/index.android.js
+++ b/src/components/CustomStatusBar/index.android.tsx
@@ -1,10 +1,15 @@
/**
* On Android we setup the status bar in native code.
*/
+import type CustomStatusBarType from './types';
-export default function CustomStatusBar() {
+// eslint-disable-next-line react/function-component-definition
+const CustomStatusBar: CustomStatusBarType = () =>
// Prefer to not render the StatusBar component in Android as it can cause
// issues with edge to edge display. We setup the status bar appearance in
// MainActivity.java and styles.xml.
- return null;
-}
+ null;
+
+CustomStatusBar.displayName = 'CustomStatusBar';
+
+export default CustomStatusBar;
diff --git a/src/components/CustomStatusBar/index.js b/src/components/CustomStatusBar/index.tsx
similarity index 87%
rename from src/components/CustomStatusBar/index.js
rename to src/components/CustomStatusBar/index.tsx
index a724c71059ef..c5c013c2bef9 100644
--- a/src/components/CustomStatusBar/index.js
+++ b/src/components/CustomStatusBar/index.tsx
@@ -2,8 +2,10 @@ import React, {useEffect} from 'react';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import StatusBar from '@libs/StatusBar';
import useTheme from '@styles/themes/useTheme';
+import type CustomStatusBarType from './types';
-function CustomStatusBar() {
+// eslint-disable-next-line react/function-component-definition
+const CustomStatusBar: CustomStatusBarType = () => {
const theme = useTheme();
useEffect(() => {
Navigation.isNavigationReady().then(() => {
@@ -20,7 +22,7 @@ function CustomStatusBar() {
});
}, [theme.PAGE_BACKGROUND_COLORS, theme.appBG]);
return ;
-}
+};
CustomStatusBar.displayName = 'CustomStatusBar';
diff --git a/src/components/CustomStatusBar/types.ts b/src/components/CustomStatusBar/types.ts
new file mode 100644
index 000000000000..7fecd02beba0
--- /dev/null
+++ b/src/components/CustomStatusBar/types.ts
@@ -0,0 +1,6 @@
+type CustomStatusBar = {
+ (): React.ReactNode;
+ displayName: string;
+};
+
+export default CustomStatusBar;
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.js
index f53fadb8ab87..b212dae615e4 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.js
+++ b/src/components/DistanceRequest/DistanceRequestFooter.js
@@ -8,10 +8,8 @@ import _ from 'underscore';
import Button from '@components/Button';
import DistanceMapView from '@components/DistanceMapView';
import * as Expensicons from '@components/Icon/Expensicons';
-import PendingMapView from '@components/MapView/PendingMapView';
import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
import * as TransactionUtils from '@libs/TransactionUtils';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
@@ -57,7 +55,6 @@ const defaultProps = {
function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navigateToWaypointEditPage}) {
const theme = useTheme();
const styles = useThemeStyles();
- const {isOffline} = useNetwork();
const {translate} = useLocalize();
const numberOfWaypoints = _.size(waypoints);
@@ -114,28 +111,20 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
)}
- {!isOffline && Boolean(mapboxAccessToken.token) ? (
-
- ) : (
-
- )}
+
>
);
diff --git a/src/components/DotIndicatorMessage.js b/src/components/DotIndicatorMessage.js
deleted file mode 100644
index ac0ac15f437d..000000000000
--- a/src/components/DotIndicatorMessage.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import * as Localize from '@libs/Localize';
-import stylePropTypes from '@styles/stylePropTypes';
-import * as StyleUtils from '@styles/StyleUtils';
-import useTheme from '@styles/themes/useTheme';
-import useThemeStyles from '@styles/useThemeStyles';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import Text from './Text';
-
-const propTypes = {
- /**
- * In most cases this should just be errors from onxyData
- * if you are not passing that data then this needs to be in a similar shape like
- * {
- * timestamp: 'message',
- * }
- */
- messages: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))])),
-
- // The type of message, 'error' shows a red dot, 'success' shows a green dot
- type: PropTypes.oneOf(['error', 'success']).isRequired,
-
- // Additional styles to apply to the container */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.arrayOf(PropTypes.object),
-
- // Additional styles to apply to the text
- textStyles: stylePropTypes,
-};
-
-const defaultProps = {
- messages: {},
- style: [],
- textStyles: [],
-};
-
-function DotIndicatorMessage(props) {
- const theme = useTheme();
- const styles = useThemeStyles();
- if (_.isEmpty(props.messages)) {
- return null;
- }
-
- // To ensure messages are presented in order we are sort of destroying the data we are given
- // and rebuilding as an array so we can render the messages in order. We don't really care about
- // the microtime timestamps anyways so isn't the end of the world that we sort of lose them here.
- // BEWARE: if you decide to refactor this and keep the microtime keys it could cause performance issues
- const sortedMessages = _.chain(props.messages)
- .keys()
- .sortBy()
- .map((key) => props.messages[key])
-
- // Using uniq here since some fields are wrapped by the same OfflineWithFeedback component (e.g. WorkspaceReimburseView)
- // and can potentially pass the same error.
- .uniq()
- .map((message) => Localize.translateIfPhraseKey(message))
- .value();
-
- const isErrorMessage = props.type === 'error';
-
- return (
-
-
-
-
-
- {_.map(sortedMessages, (message, i) => (
-
- {message}
-
- ))}
-
-
- );
-}
-
-DotIndicatorMessage.propTypes = propTypes;
-DotIndicatorMessage.defaultProps = defaultProps;
-DotIndicatorMessage.displayName = 'DotIndicatorMessage';
-
-export default DotIndicatorMessage;
diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx
new file mode 100644
index 000000000000..112feab166cf
--- /dev/null
+++ b/src/components/DotIndicatorMessage.tsx
@@ -0,0 +1,108 @@
+/* eslint-disable react/no-array-index-key */
+import React from 'react';
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import fileDownload from '@libs/fileDownload';
+import * as Localize from '@libs/Localize';
+import * as StyleUtils from '@styles/StyleUtils';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import {PressableWithoutFeedback} from './Pressable';
+import Text from './Text';
+
+type ReceiptError = {error?: string; source: string; filename: string};
+
+type DotIndicatorMessageProps = {
+ /**
+ * In most cases this should just be errors from onxyData
+ * if you are not passing that data then this needs to be in a similar shape like
+ * {
+ * timestamp: 'message',
+ * }
+ */
+ messages: Record;
+
+ /** The type of message, 'error' shows a red dot, 'success' shows a green dot */
+ type: 'error' | 'success';
+
+ /** Additional styles to apply to the container */
+ style?: StyleProp;
+
+ /** Additional styles to apply to the text */
+ textStyles?: StyleProp;
+};
+
+/** Check if the error includes a receipt. */
+function isReceiptError(message: string | ReceiptError): message is ReceiptError {
+ if (typeof message === 'string') {
+ return false;
+ }
+ return (message?.error ?? '') === CONST.IOU.RECEIPT_ERROR;
+}
+
+function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndicatorMessageProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
+ if (Object.keys(messages).length === 0) {
+ return null;
+ }
+
+ // Fetch the keys, sort them, and map through each key to get the corresponding message
+ const sortedMessages = Object.keys(messages)
+ .sort()
+ .map((key) => messages[key]);
+
+ // Removing duplicates using Set and transforming the result into an array
+ const uniqueMessages = [...new Set(sortedMessages)].map((message) => Localize.translateIfPhraseKey(message));
+
+ const isErrorMessage = type === 'error';
+
+ return (
+
+
+
+
+
+ {uniqueMessages.map((message, i) =>
+ isReceiptError(message) ? (
+ {
+ fileDownload(message.source, message.filename);
+ }}
+ >
+
+ {Localize.translateLocal('iou.error.receiptFailureMessage')}
+ {Localize.translateLocal('iou.error.saveFileMessage')}
+ {Localize.translateLocal('iou.error.loseFileMessage')}
+
+
+ ) : (
+
+ {message}
+
+ ),
+ )}
+
+
+ );
+}
+
+DotIndicatorMessage.displayName = 'DotIndicatorMessage';
+
+export default DotIndicatorMessage;
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 0ee12579733d..25a534b94758 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -18,8 +18,8 @@ import compose from '@libs/compose';
import * as EmojiUtils from '@libs/EmojiUtils';
import getOperatingSystem from '@libs/getOperatingSystem';
import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
-import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
+import useThemeStyles from '@styles/useThemeStyles';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -51,6 +51,8 @@ const throttleTime = Browser.isMobile() ? 200 : 50;
function EmojiPickerMenu(props) {
const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, translate} = props;
+ const styles = useThemeStyles();
+
const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
// Ref for the emoji search input
@@ -462,7 +464,7 @@ function EmojiPickerMenu(props) {
/>
);
},
- [isUsingKeyboardMovement, highlightedIndex, onEmojiSelected, preferredSkinTone, translate, highlightFirstEmoji],
+ [preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, styles, translate, onEmojiSelected],
);
const isFiltered = emojis.current.length !== filteredEmojis.length;
diff --git a/src/components/EnvironmentBadge.js b/src/components/EnvironmentBadge.tsx
similarity index 92%
rename from src/components/EnvironmentBadge.js
rename to src/components/EnvironmentBadge.tsx
index f32946f8bc25..a3f321072988 100644
--- a/src/components/EnvironmentBadge.js
+++ b/src/components/EnvironmentBadge.tsx
@@ -15,10 +15,10 @@ const ENVIRONMENT_SHORT_FORM = {
function EnvironmentBadge() {
const styles = useThemeStyles();
- const {environment} = useEnvironment();
+ const {environment, isProduction} = useEnvironment();
// If we are on production, don't show any badge
- if (environment === CONST.ENVIRONMENT.PRODUCTION) {
+ if (isProduction) {
return null;
}
diff --git a/src/components/ExpensifyWordmark.tsx b/src/components/ExpensifyWordmark.tsx
index 45c0c9bcef1e..1402b48df0d9 100644
--- a/src/components/ExpensifyWordmark.tsx
+++ b/src/components/ExpensifyWordmark.tsx
@@ -30,7 +30,7 @@ function ExpensifyWordmark({isSmallScreenWidth, style}: ExpensifyWordmarkProps)
const styles = useThemeStyles();
const {environment} = useEnvironment();
// PascalCase is required for React components, so capitalize the const here
- const LogoComponent = environment ? logoComponents[environment] : AdHocLogo;
+ const LogoComponent = logoComponents[environment];
return (
<>
diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx
index 34bce2133a89..475de82fac35 100644
--- a/src/components/FixedFooter.tsx
+++ b/src/components/FixedFooter.tsx
@@ -7,12 +7,12 @@ type FixedFooterProps = {
children: ReactNode;
/** Styles to be assigned to Container */
- style: Array>;
+ style?: StyleProp;
};
function FixedFooter({style = [], children}: FixedFooterProps) {
const styles = useThemeStyles();
- return {children};
+ return {children};
}
FixedFooter.displayName = 'FixedFooter';
diff --git a/src/components/FlatList/index.android.js b/src/components/FlatList/index.android.js
index fdb3a24667aa..f7c3da39ed84 100644
--- a/src/components/FlatList/index.android.js
+++ b/src/components/FlatList/index.android.js
@@ -1,7 +1,8 @@
import {useFocusEffect} from '@react-navigation/native';
import PropTypes from 'prop-types';
-import React, {forwardRef, useCallback, useState} from 'react';
+import React, {forwardRef, useCallback, useContext} from 'react';
import {FlatList} from 'react-native';
+import {ActionListContext} from '@pages/home/ReportScreenContext';
const propTypes = {
/** Same as for FlatList */
@@ -32,7 +33,7 @@ const defaultProps = {
// FlatList wrapped with the freeze component will lose its scroll state when frozen (only for Android).
// CustomFlatList saves the offset and use it for scrollToOffset() when unfrozen.
function CustomFlatList(props) {
- const [scrollPosition, setScrollPosition] = useState({});
+ const {scrollPosition, setScrollPosition} = useContext(ActionListContext);
const onScreenFocus = useCallback(() => {
if (!props.innerRef.current || !scrollPosition.offset) {
diff --git a/src/components/FocusTrapView/index.native.tsx b/src/components/FocusTrapView/index.native.tsx
deleted file mode 100644
index 1190cfda4156..000000000000
--- a/src/components/FocusTrapView/index.native.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * The FocusTrap is only used on web and desktop
- */
-import FocusTrapViewProps from './types';
-
-function FocusTrapView({children}: FocusTrapViewProps) {
- return children;
-}
-
-FocusTrapView.displayName = 'FocusTrapView';
-
-export default FocusTrapView;
diff --git a/src/components/FocusTrapView/index.tsx b/src/components/FocusTrapView/index.tsx
deleted file mode 100644
index 6b52512c2e63..000000000000
--- a/src/components/FocusTrapView/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * The FocusTrap is only used on web and desktop
- */
-import FocusTrap from 'focus-trap-react';
-import React, {useRef} from 'react';
-import {View} from 'react-native';
-import viewRef from '@src/types/utils/viewRef';
-import FocusTrapViewProps from './types';
-
-function FocusTrapView({isEnabled = true, isActive = true, shouldEnableAutoFocus = false, ...props}: FocusTrapViewProps) {
- /**
- * Focus trap always needs a focusable element.
- * In case that we don't have any focusable elements in the modal,
- * the FocusTrap will use fallback View element using this ref.
- */
- const ref = useRef(null);
-
- return isEnabled ? (
- (shouldEnableAutoFocus && ref.current) ?? false,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- fallbackFocus: () => ref.current!,
- clickOutsideDeactivates: true,
- }}
- >
-
-
- ) : (
- props.children
- );
-}
-
-FocusTrapView.displayName = 'FocusTrapView';
-
-export default FocusTrapView;
diff --git a/src/components/FocusTrapView/types.ts b/src/components/FocusTrapView/types.ts
deleted file mode 100644
index 500b4b4315d9..000000000000
--- a/src/components/FocusTrapView/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import {ViewProps} from 'react-native';
-import ChildrenProps from '@src/types/utils/ChildrenProps';
-
-type FocusTrapViewProps = ChildrenProps & {
- /**
- * Whether to enable the FocusTrap.
- * If the FocusTrap is disabled, we just pass the children through.
- */
- isEnabled?: boolean;
-
- /**
- * Whether to disable auto focus
- * It is used when the component inside the FocusTrap have their own auto focus logic
- */
- shouldEnableAutoFocus?: boolean;
-
- /** Whether the FocusTrap is active (listening for events) */
- isActive?: boolean;
-} & ViewProps;
-
-export default FocusTrapViewProps;
diff --git a/src/components/Form.js b/src/components/Form.js
index d5865dab44b8..ad5fcf611e9b 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -350,10 +350,18 @@ function Form(props) {
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// We delay the validation in order to prevent Checkbox loss of focus when
// the user are focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.
+
setTimeout(() => {
+ if (
+ relatedTargetId &&
+ _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)
+ ) {
+ return;
+ }
setTouchedInput(inputID);
if (props.shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 92c76da5936d..af2511fc9f74 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -47,15 +47,16 @@ const propTypes = {
errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
}),
+ /** Contains draft values for each input in the form */
+ draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date)])),
+
/** Should the button be enabled when offline */
enabledWhenOffline: PropTypes.bool,
/** Whether the form submit action is dangerous */
isSubmitActionDangerous: PropTypes.bool,
- /** Whether ScrollWithContext should be used instead of regular ScrollView.
- * Set to true when there's a nested Picker component in Form.
- */
+ /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */
scrollContextEnabled: PropTypes.bool,
/** Container styles */
@@ -67,16 +68,24 @@ const propTypes = {
/** Information about the network */
network: networkPropTypes.isRequired,
+ /** Should validate function be called when input loose focus */
shouldValidateOnBlur: PropTypes.bool,
+ /** Should validate function be called when the value of the input is changed */
shouldValidateOnChange: PropTypes.bool,
};
+// In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web.
+// 200ms delay was chosen as a result of empirical testing.
+// More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426
+const VALIDATE_DELAY = 200;
+
const defaultProps = {
isSubmitButtonVisible: true,
formState: {
isLoading: false,
},
+ draftValues: {},
enabledWhenOffline: false,
isSubmitActionDangerous: false,
scrollContextEnabled: false,
@@ -100,7 +109,7 @@ function getInitialValueByType(valueType) {
}
}
-function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) {
+function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}) {
const inputRefs = useRef({});
const touchedInputs = useRef({});
const [inputValues, setInputValues] = useState({});
@@ -208,7 +217,9 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (!_.isUndefined(propsToParse.value)) {
inputValues[inputID] = propsToParse.value;
- } else if (propsToParse.shouldUseDefaultValue) {
+ } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) {
+ inputValues[inputID] = draftValues[inputID];
+ } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) {
// We force the form to set the input value from the defaultValue props if there is a saved valid value
inputValues[inputID] = propsToParse.defaultValue;
} else if (_.isUndefined(inputValues[inputID])) {
@@ -243,19 +254,34 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
// as this is already happening by the value prop.
defaultValue: undefined,
onTouched: (event) => {
- setTouchedInput(inputID);
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onTouched)) {
propsToParse.onTouched(event);
}
},
onPress: (event) => {
- setTouchedInput(inputID);
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onPress)) {
propsToParse.onPress(event);
}
},
- onPressIn: (event) => {
- setTouchedInput(inputID);
+ 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
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
if (_.isFunction(propsToParse.onPressIn)) {
propsToParse.onPressIn(event);
}
@@ -263,15 +289,20 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// We delay the validation in order to prevent Checkbox loss of focus when
// the user is focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.
+
setTimeout(() => {
+ if (relatedTargetId && _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)) {
+ return;
+ }
setTouchedInput(inputID);
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
- }, 200);
+ }, VALIDATE_DELAY);
}
if (_.isFunction(propsToParse.onBlur)) {
@@ -293,7 +324,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
});
if (propsToParse.shouldSaveDraft) {
- FormActions.setDraftValues(propsToParse.formID, {[inputKey]: value});
+ FormActions.setDraftValues(formID, {[inputKey]: value});
}
if (_.isFunction(propsToParse.onValueChange)) {
@@ -302,7 +333,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
},
};
},
- [errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
+ [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
);
const value = useMemo(() => ({registerInput}), [registerInput]);
@@ -333,5 +364,8 @@ export default compose(
formState: {
key: (props) => props.formID,
},
+ draftValues: {
+ key: (props) => `${props.formID}Draft`,
+ },
}),
)(FormProvider);
diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js
index 4f7346a94a2d..638b6e5f8d19 100644
--- a/src/components/Form/FormWrapper.js
+++ b/src/components/Form/FormWrapper.js
@@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import FormSubmit from '@components/FormSubmit';
+import refPropTypes from '@components/refPropTypes';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import ScrollViewWithContext from '@components/ScrollViewWithContext';
import * as ErrorUtils from '@libs/ErrorUtils';
@@ -64,7 +65,7 @@ const propTypes = {
errors: errorsPropType.isRequired,
- inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])).isRequired,
+ inputRefs: PropTypes.objectOf(refPropTypes).isRequired,
};
const defaultProps = {
diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js
index 99237fd8db43..9a31210195c4 100644
--- a/src/components/Form/InputWrapper.js
+++ b/src/components/Form/InputWrapper.js
@@ -1,12 +1,14 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useContext} from 'react';
+import refPropTypes from '@components/refPropTypes';
+import TextInput from '@components/TextInput';
import FormContext from './FormContext';
const propTypes = {
InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired,
inputID: PropTypes.string.isRequired,
valueType: PropTypes.string,
- forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]),
+ forwardedRef: refPropTypes,
};
const defaultProps = {
@@ -17,8 +19,13 @@ const defaultProps = {
function InputWrapper(props) {
const {InputComponent, inputID, forwardedRef, ...rest} = props;
const {registerInput} = useContext(FormContext);
+ // There are inputs that dont have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to
+ // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were
+ // calling some methods too early or twice, so we had to add this check to prevent that side effect.
+ // For now this side effect happened only in `TextInput` components.
+ const shouldSetTouchedOnBlurOnly = InputComponent === TextInput;
// eslint-disable-next-line react/jsx-props-no-spreading
- return ;
+ return ;
}
InputWrapper.propTypes = propTypes;
diff --git a/src/components/GrowlNotification/index.js b/src/components/GrowlNotification/index.js
index 5fdbe205f87b..86850a8af96d 100644
--- a/src/components/GrowlNotification/index.js
+++ b/src/components/GrowlNotification/index.js
@@ -7,26 +7,11 @@ import * as Pressables from '@components/Pressable';
import Text from '@components/Text';
import * as Growl from '@libs/Growl';
import useNativeDriver from '@libs/useNativeDriver';
-import styles from '@styles/styles';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import GrowlNotificationContainer from './GrowlNotificationContainer';
-const types = {
- [CONST.GROWL.SUCCESS]: {
- icon: Expensicons.Checkmark,
- iconColor: themeColors.success,
- },
- [CONST.GROWL.ERROR]: {
- icon: Expensicons.Exclamation,
- iconColor: themeColors.danger,
- },
- [CONST.GROWL.WARNING]: {
- icon: Expensicons.Exclamation,
- iconColor: themeColors.warning,
- },
-};
-
const INACTIVE_POSITION_Y = -255;
const PressableWithoutFeedback = Pressables.PressableWithoutFeedback;
@@ -36,6 +21,23 @@ function GrowlNotification(_, ref) {
const [bodyText, setBodyText] = useState('');
const [type, setType] = useState('success');
const [duration, setDuration] = useState();
+ const styles = useThemeStyles();
+ const theme = useTheme();
+
+ const types = {
+ [CONST.GROWL.SUCCESS]: {
+ icon: Expensicons.Checkmark,
+ iconColor: theme.success,
+ },
+ [CONST.GROWL.ERROR]: {
+ icon: Expensicons.Exclamation,
+ iconColor: theme.danger,
+ },
+ [CONST.GROWL.WARNING]: {
+ icon: Expensicons.Exclamation,
+ iconColor: theme.warning,
+ },
+ };
/**
* Show the growl notification
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index c4eed0134008..8cddd3c017de 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -4,7 +4,7 @@ import {defaultHTMLElementModels, RenderHTMLConfigProvider, TRenderEngineProvide
import _ from 'underscore';
import convertToLTR from '@libs/convertToLTR';
import singleFontFamily from '@styles/fontFamily/singleFontFamily';
-import styles from '@styles/styles';
+import useThemeStyles from '@styles/useThemeStyles';
import * as HTMLEngineUtils from './htmlEngineUtils';
import htmlRenderers from './HTMLRenderers';
@@ -24,45 +24,49 @@ const defaultProps = {
enableExperimentalBRCollapsing: false,
};
-// Declare nonstandard tags and their content model here
-const customHTMLElementModels = {
- edited: defaultHTMLElementModels.span.extend({
- tagName: 'edited',
- }),
- 'alert-text': defaultHTMLElementModels.div.extend({
- tagName: 'alert-text',
- mixedUAStyles: {...styles.formError, ...styles.mb0},
- }),
- 'muted-text': defaultHTMLElementModels.div.extend({
- tagName: 'muted-text',
- mixedUAStyles: {...styles.colorMuted, ...styles.mb0},
- }),
- comment: defaultHTMLElementModels.div.extend({
- tagName: 'comment',
- mixedUAStyles: {whiteSpace: 'pre'},
- }),
- 'email-comment': defaultHTMLElementModels.div.extend({
- tagName: 'email-comment',
- mixedUAStyles: {whiteSpace: 'normal'},
- }),
- strong: defaultHTMLElementModels.span.extend({
- tagName: 'strong',
- mixedUAStyles: {whiteSpace: 'pre'},
- }),
- 'mention-user': defaultHTMLElementModels.span.extend({tagName: 'mention-user'}),
- 'mention-here': defaultHTMLElementModels.span.extend({tagName: 'mention-here'}),
-};
-
-const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]};
-
// We are using the explicit composite architecture for performance gains.
// Configuration for RenderHTML is handled in a top-level component providing
// context to RenderHTMLSource components. See https://git.io/JRcZb
// Beware that each prop should be referentialy stable between renders to avoid
// costly invalidations and commits.
function BaseHTMLEngineProvider(props) {
+ const styles = useThemeStyles();
+
+ // Declare nonstandard tags and their content model here
+ const customHTMLElementModels = useMemo(
+ () => ({
+ edited: defaultHTMLElementModels.span.extend({
+ tagName: 'edited',
+ }),
+ 'alert-text': defaultHTMLElementModels.div.extend({
+ tagName: 'alert-text',
+ mixedUAStyles: {...styles.formError, ...styles.mb0},
+ }),
+ 'muted-text': defaultHTMLElementModels.div.extend({
+ tagName: 'muted-text',
+ mixedUAStyles: {...styles.colorMuted, ...styles.mb0},
+ }),
+ comment: defaultHTMLElementModels.div.extend({
+ tagName: 'comment',
+ mixedUAStyles: {whiteSpace: 'pre'},
+ }),
+ 'email-comment': defaultHTMLElementModels.div.extend({
+ tagName: 'email-comment',
+ mixedUAStyles: {whiteSpace: 'normal'},
+ }),
+ strong: defaultHTMLElementModels.span.extend({
+ tagName: 'strong',
+ mixedUAStyles: {whiteSpace: 'pre'},
+ }),
+ 'mention-user': defaultHTMLElementModels.span.extend({tagName: 'mention-user'}),
+ 'mention-here': defaultHTMLElementModels.span.extend({tagName: 'mention-here'}),
+ }),
+ [styles.colorMuted, styles.formError, styles.mb0],
+ );
+
// We need to memoize this prop to make it referentially stable.
const defaultTextProps = useMemo(() => ({selectable: props.textSelectable, allowFontScaling: false, textBreakStrategy: 'simple'}), [props.textSelectable]);
+ const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText]};
return (
;
- }
+const defaultProps = {
+ styles: [],
+};
+
+function HeaderGap(props) {
+ return ;
}
+HeaderGap.displayName = 'HeaderGap';
HeaderGap.propTypes = propTypes;
-HeaderGap.defaultProps = {
- styles: [],
-};
-export default withThemeStyles(HeaderGap);
+HeaderGap.defaultProps = defaultProps;
+export default compose(memo, withThemeStyles)(HeaderGap);
diff --git a/src/components/HeaderGap/index.js b/src/components/HeaderGap/index.js
index ca81056d5f7a..35e6bf92fb5d 100644
--- a/src/components/HeaderGap/index.js
+++ b/src/components/HeaderGap/index.js
@@ -1,7 +1,6 @@
-import {PureComponent} from 'react';
-
-export default class HeaderGap extends PureComponent {
- render() {
- return null;
- }
+function HeaderGap() {
+ return null;
}
+
+HeaderGap.displayName = 'HeaderGap';
+export default HeaderGap;
diff --git a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js
index 1ad1f0961e38..109e60adf672 100644
--- a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js
+++ b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js
@@ -93,6 +93,9 @@ const propTypes = {
/** Single execution function to prevent concurrent navigation actions */
singleExecution: PropTypes.func,
+
+ /** Whether we should navigate to report page when the route have a topMostReport */
+ shouldNavigateToTopMostReport: PropTypes.bool,
};
export default propTypes;
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js
index 1371e6a36b97..051e18ed675e 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.js
@@ -53,6 +53,7 @@ function HeaderWithBackButton({
children = null,
shouldOverlay = false,
singleExecution = (func) => func,
+ shouldNavigateToTopMostReport = false,
}) {
const styles = useThemeStyles();
const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState();
@@ -74,11 +75,17 @@ function HeaderWithBackButton({
if (isKeyboardShown) {
Keyboard.dismiss();
}
- onBackButtonPress();
+ const topmostReportId = Navigation.getTopmostReportId();
+ if (shouldNavigateToTopMostReport && topmostReportId) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(topmostReportId));
+ } else {
+ onBackButtonPress();
+ }
}}
style={[styles.touchableButtonImage]}
role="button"
accessibilityLabel={translate('common.back')}
+ nativeID={CONST.BACK_BUTTON_NATIVE_ID}
>
;
};
+type BankIconParams = {
+ themeStyles: typeof styles;
+ bankName?: string;
+ isCard?: boolean;
+};
+
/**
* Returns matching asset icon for bankName
*/
@@ -140,7 +146,7 @@ function getAssetIcon(bankName: string, isCard: boolean): React.FC {
* Returns Bank Icon Object that matches to existing bank icons or default icons
*/
-export default function getBankIcon(bankName: string, isCard = false): BankIcon {
+export default function getBankIcon({themeStyles, bankName, isCard = false}: BankIconParams): BankIcon {
const bankIcon: BankIcon = {
icon: isCard ? GenericBankCard : GenericBank,
};
@@ -152,11 +158,11 @@ export default function getBankIcon(bankName: string, isCard = false): BankIcon
// For default Credit Card icon the icon size should not be set.
if (!isCard) {
bankIcon.iconSize = variables.iconSizeExtraLarge;
- bankIcon.iconStyles = [styles.bankIconContainer];
+ bankIcon.iconStyles = [themeStyles.bankIconContainer];
} else {
bankIcon.iconHeight = variables.bankCardHeight;
bankIcon.iconWidth = variables.bankCardWidth;
- bankIcon.iconStyles = [styles.assignedCardsIconContainer];
+ bankIcon.iconStyles = [themeStyles.assignedCardsIconContainer];
}
return bankIcon;
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
index 022c740907ea..98449c838b67 100644
--- a/src/components/Icon/index.tsx
+++ b/src/components/Icon/index.tsx
@@ -1,8 +1,8 @@
import React, {PureComponent} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
-import styles from '@styles/styles';
+import withTheme, {ThemeProps} from '@components/withTheme';
+import withThemeStyles, {ThemeStylesProps} from '@components/withThemeStyles';
import * as StyleUtils from '@styles/StyleUtils';
-import themeColors from '@styles/themes/default';
import variables from '@styles/variables';
import IconWrapperStyles from './IconWrapperStyles';
@@ -41,7 +41,8 @@ type IconProps = {
/** Additional styles to add to the Icon */
additionalStyles?: StyleProp;
-};
+} & ThemeStylesProps &
+ ThemeProps;
// We must use a class component to create an animatable component with the Animated API
// eslint-disable-next-line react/prefer-stateless-function
@@ -50,7 +51,7 @@ class Icon extends PureComponent {
public static defaultProps = {
width: variables.iconSizeNormal,
height: variables.iconSizeNormal,
- fill: themeColors.icon,
+ fill: undefined,
small: false,
inline: false,
additionalStyles: [],
@@ -61,19 +62,20 @@ class Icon extends PureComponent {
render() {
const width = this.props.small ? variables.iconSizeSmall : this.props.width;
const height = this.props.small ? variables.iconSizeSmall : this.props.height;
- const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, this.props.additionalStyles];
+ const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, this.props.themeStyles.pAbsolute, this.props.additionalStyles];
+ const fill = this.props.fill ?? this.props.theme.icon;
if (this.props.inline) {
return (
@@ -90,7 +92,7 @@ class Icon extends PureComponent {
@@ -99,4 +101,4 @@ class Icon extends PureComponent {
}
}
-export default Icon;
+export default withTheme(withThemeStyles(Icon));
diff --git a/src/components/InlineSystemMessage.tsx b/src/components/InlineSystemMessage.tsx
index e9de0111cd23..6e6423a19a35 100644
--- a/src/components/InlineSystemMessage.tsx
+++ b/src/components/InlineSystemMessage.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import {View} from 'react-native';
-import styles from '@styles/styles';
-import theme from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import Text from './Text';
@@ -12,6 +12,9 @@ type InlineSystemMessageProps = {
};
function InlineSystemMessage({message = ''}: InlineSystemMessageProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
if (!message) {
return null;
}
diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js
index d55417a6190a..9ba342ddda90 100644
--- a/src/components/KYCWall/BaseKYCWall.js
+++ b/src/components/KYCWall/BaseKYCWall.js
@@ -10,9 +10,11 @@ import Navigation from '@libs/Navigation/Navigation';
import * as PaymentUtils from '@libs/PaymentUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as PaymentMethods from '@userActions/PaymentMethods';
+import * as Policy from '@userActions/Policy';
import * as Wallet from '@userActions/Wallet';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
import {defaultProps, propTypes} from './kycWallPropTypes';
// This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow
@@ -93,10 +95,19 @@ class KYCWall extends React.Component {
*/
selectPaymentMethod(paymentMethod) {
this.props.onSelectPaymentMethod(paymentMethod);
- if (paymentMethod === CONST.PAYMENT_METHODS.BANK_ACCOUNT) {
+ if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) {
Navigation.navigate(this.props.addBankAccountRoute);
} else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) {
Navigation.navigate(this.props.addDebitCardRoute);
+ } else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) {
+ if (ReportUtils.isIOUReport(this.props.iouReport)) {
+ const policyID = Policy.createWorkspaceFromIOUPayment(this.props.iouReport);
+
+ // Navigate to the bank account set up flow for this specific policy
+ Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute('', policyID));
+ return;
+ }
+ Navigation.navigate(this.props.addBankAccountRoute);
}
}
@@ -135,7 +146,7 @@ class KYCWall extends React.Component {
) {
Log.info('[KYC Wallet] User does not have valid payment method');
if (!this.props.shouldIncludeDebitCard) {
- this.selectPaymentMethod(CONST.PAYMENT_METHODS.BANK_ACCOUNT);
+ this.selectPaymentMethod(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT);
return;
}
const clickedElementLocation = getClickedTargetLocation(targetElement);
@@ -164,6 +175,7 @@ class KYCWall extends React.Component {
<>
this.setState({shouldShowAddPaymentMenu: false})}
anchorRef={this.anchorRef}
anchorPosition={{
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index 8420f3db7a1e..3b2de574ba17 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -20,7 +20,6 @@ import DateUtils from '@libs/DateUtils';
import DomUtils from '@libs/DomUtils';
import {getGroupChatName} from '@libs/GroupChatUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import Permissions from '@libs/Permissions';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import * as ReportUtils from '@libs/ReportUtils';
import * as ContextMenuActions from '@pages/home/report/ContextMenu/ContextMenuActions';
@@ -36,9 +35,6 @@ const propTypes = {
// eslint-disable-next-line react/forbid-prop-types
hoverStyle: PropTypes.object,
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** The ID of the report that the option is for */
reportID: PropTypes.string.isRequired,
@@ -65,7 +61,6 @@ const defaultProps = {
style: null,
optionItem: null,
isFocused: false,
- betas: [],
};
function OptionRowLHN(props) {
@@ -108,7 +103,7 @@ function OptionRowLHN(props) {
props.style,
);
const contentContainerStyles =
- props.viewMode === CONST.OPTION_MODE.COMPACT ? [styles.flex1, styles.flexRow, styles.overflowHidden, optionRowStyles.compactContentContainerStyles] : [styles.flex1];
+ props.viewMode === CONST.OPTION_MODE.COMPACT ? [styles.flex1, styles.flexRow, styles.overflowHidden, optionRowStyles.compactContentContainerStyles(styles)] : [styles.flex1];
const sidebarInnerRowStyle = StyleSheet.flatten(
props.viewMode === CONST.OPTION_MODE.COMPACT
? [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRowCompact, styles.justifyContentCenter]
@@ -157,7 +152,7 @@ function OptionRowLHN(props) {
const statusClearAfterDate = lodashGet(optionItem, 'status.clearAfter', '');
const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate);
const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText;
- const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(ReportUtils.getReport(optionItem.reportID));
+ const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(ReportUtils.getReport(optionItem.reportID));
const isGroupChat =
optionItem.type === CONST.REPORT.TYPE.CHAT && _.isEmpty(optionItem.chatType) && !optionItem.isThread && lodashGet(optionItem, 'displayNamesWithTooltips.length', 0) > 2;
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index 677fe1c38827..1f3dd061a4bd 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -1,8 +1,10 @@
import PropTypes from 'prop-types';
import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {StyleSheet, View} from 'react-native';
+import {TapGestureHandler} from 'react-native-gesture-handler';
import _ from 'underscore';
import useNetwork from '@hooks/useNetwork';
+import * as Browser from '@libs/Browser';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
@@ -13,6 +15,8 @@ import {withNetwork} from './OnyxProvider';
import Text from './Text';
import TextInput from './TextInput';
+const TEXT_INPUT_EMPTY_STATE = '';
+
const propTypes = {
/** Information about the network */
network: networkPropTypes.isRequired,
@@ -104,23 +108,53 @@ const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys());
function MagicCodeInput(props) {
const styles = useThemeStyles();
- const inputRefs = useRef([]);
- const [input, setInput] = useState('');
+ const inputRefs = useRef();
+ const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE);
const [focusedIndex, setFocusedIndex] = useState(0);
const [editIndex, setEditIndex] = useState(0);
const [wasSubmitted, setWasSubmitted] = useState(false);
+ const shouldFocusLast = useRef(false);
+ const inputWidth = useRef(0);
+ const lastFocusedIndex = useRef(0);
+ const lastValue = useRef(TEXT_INPUT_EMPTY_STATE);
+
+ useEffect(() => {
+ lastValue.current = input.length;
+ }, [input]);
const blurMagicCodeInput = () => {
- inputRefs.current[editIndex].blur();
+ inputRefs.current.blur();
setFocusedIndex(undefined);
};
+ const focusMagicCodeInput = () => {
+ setFocusedIndex(0);
+ lastFocusedIndex.current = 0;
+ setEditIndex(0);
+ inputRefs.current.focus();
+ };
+
+ const setInputAndIndex = (index) => {
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ setFocusedIndex(index);
+ setEditIndex(index);
+ };
+
useImperativeHandle(props.innerRef, () => ({
focus() {
- inputRefs.current[0].focus();
+ focusMagicCodeInput();
+ },
+ focusLastSelected() {
+ inputRefs.current.focus();
+ },
+ resetFocus() {
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ focusMagicCodeInput();
},
clear() {
- inputRefs.current[0].focus();
+ lastFocusedIndex.current = 0;
+ setInputAndIndex(0);
+ inputRefs.current.focus();
props.onChangeText('');
},
blur() {
@@ -140,6 +174,7 @@ function MagicCodeInput(props) {
// on complete, it will call the onFulfill callback.
blurMagicCodeInput();
props.onFulfill(props.value);
+ lastValue.current = '';
};
useNetwork({onReconnect: validateAndSubmit});
@@ -154,17 +189,34 @@ function MagicCodeInput(props) {
}, [props.value, props.shouldSubmitOnComplete]);
/**
- * Callback for the onFocus event, updates the indexes
- * of the currently focused input.
+ * Focuses on the input when it is pressed.
*
* @param {Object} event
* @param {Number} index
*/
- const onFocus = (event, index) => {
+ const onFocus = (event) => {
+ if (shouldFocusLast.current) {
+ lastValue.current = TEXT_INPUT_EMPTY_STATE;
+ setInputAndIndex(lastFocusedIndex.current);
+ }
event.preventDefault();
- setInput('');
- setFocusedIndex(index);
- setEditIndex(index);
+ };
+
+ /**
+ * Callback for the onPress event, updates the indexes
+ * of the currently focused input.
+ *
+ * @param {Number} index
+ */
+ const onPress = (index) => {
+ shouldFocusLast.current = false;
+ // TapGestureHandler works differently on mobile web and native app
+ // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually
+ if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) {
+ inputRefs.current.focus();
+ }
+ setInputAndIndex(index);
+ lastFocusedIndex.current = index;
};
/**
@@ -181,9 +233,16 @@ function MagicCodeInput(props) {
return;
}
+ // Checks if one new character was added, or if the content was replaced
+ const hasToSlice = value.length - 1 === lastValue.current.length && value.slice(0, value.length - 1) === lastValue.current;
+
+ // Gets the new value added by the user
+ const addedValue = hasToSlice ? value.slice(lastValue.current.length, value.length) : value;
+
+ lastValue.current = value;
// Updates the focused input taking into consideration the last input
// edited and the number of digits added by the user.
- const numbersArr = value
+ const numbersArr = addedValue
.trim()
.split('')
.slice(0, props.maxLength - editIndex);
@@ -192,7 +251,7 @@ function MagicCodeInput(props) {
let numbers = decomposeString(props.value, props.maxLength);
numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)];
- inputRefs.current[updatedFocusedIndex].focus();
+ setInputAndIndex(updatedFocusedIndex);
const finalInput = composeToString(numbers);
props.onChangeText(finalInput);
@@ -225,7 +284,7 @@ function MagicCodeInput(props) {
// If the currently focused index already has a value, it will delete
// that value but maintain the focus on the same input.
if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) {
- setInput('');
+ setInput(TEXT_INPUT_EMPTY_STATE);
numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)];
setEditIndex(focusedIndex);
props.onChangeText(composeToString(numbers));
@@ -244,24 +303,31 @@ function MagicCodeInput(props) {
}
const newFocusedIndex = Math.max(0, focusedIndex - 1);
+
+ // Saves the input string so that it can compare to the change text
+ // event that will be triggered, this is a workaround for mobile that
+ // triggers the change text on the event after the key press.
+ setInputAndIndex(newFocusedIndex);
props.onChangeText(composeToString(numbers));
if (!_.isUndefined(newFocusedIndex)) {
- inputRefs.current[newFocusedIndex].focus();
+ inputRefs.current.focus();
}
}
if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.max(0, focusedIndex - 1);
- inputRefs.current[newFocusedIndex].focus();
+ setInputAndIndex(newFocusedIndex);
+ inputRefs.current.focus();
} else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1);
- inputRefs.current[newFocusedIndex].focus();
+ setInputAndIndex(newFocusedIndex);
+ inputRefs.current.focus();
} else if (keyValue === 'Enter') {
// We should prevent users from submitting when it's offline.
if (props.network.isOffline) {
return;
}
- setInput('');
+ setInput(TEXT_INPUT_EMPTY_STATE);
props.onFulfill(props.value);
}
};
@@ -290,6 +356,49 @@ function MagicCodeInput(props) {
return (
<>
+ {
+ onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength)));
+ }}
+ >
+ {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */}
+
+ {
+ inputWidth.current = e.nativeEvent.layout.width;
+ }}
+ ref={(ref) => (inputRefs.current = ref)}
+ autoFocus={props.autoFocus}
+ inputMode="numeric"
+ textContentType="oneTimeCode"
+ name={props.name}
+ maxLength={props.maxLength}
+ value={input}
+ hideFocusedState
+ autoComplete={input.length === 0 && props.autoComplete}
+ shouldDelayFocus={input.length === 0 && props.shouldDelayFocus}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ onChangeText={(value) => {
+ onChangeText(value);
+ }}
+ onKeyPress={onKeyPress}
+ onFocus={onFocus}
+ onBlur={() => {
+ shouldFocusLast.current = true;
+ lastFocusedIndex.current = focusedIndex;
+ setFocusedIndex(undefined);
+ }}
+ selectionColor="transparent"
+ inputStyle={[styles.inputTransparent]}
+ role={CONST.ACCESSIBILITY_ROLE.TEXT}
+ style={[styles.inputTransparent]}
+ textInputContainerStyles={[styles.borderNone]}
+ />
+
+
{_.map(getInputPlaceholderSlots(props.maxLength), (index) => (
{decomposeString(props.value, props.maxLength)[index] || ''}
- {/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */}
-
- {
- inputRefs.current[index] = ref;
- // Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome
- if (ref && ref.setAttribute) {
- ref.setAttribute('type', 'search');
- }
- }}
- disableKeyboard={props.isDisableKeyboard}
- autoFocus={index === 0 && props.autoFocus}
- shouldDelayFocus={index === 0 && props.shouldDelayFocus}
- inputMode={props.isDisableKeyboard ? 'none' : 'numeric'}
- textContentType="oneTimeCode"
- name={props.name}
- maxLength={props.maxLength}
- value={input}
- hideFocusedState
- autoComplete={index === 0 ? props.autoComplete : 'off'}
- onChangeText={(value) => {
- // Do not run when the event comes from an input that is
- // not currently being responsible for the input, this is
- // necessary to avoid calls when the input changes due to
- // deleted characters. Only happens in mobile.
- if (index !== editIndex || _.isUndefined(focusedIndex)) {
- return;
- }
- onChangeText(value);
- }}
- onKeyPress={onKeyPress}
- onFocus={(event) => onFocus(event, index)}
- // Manually set selectionColor to make caret transparent.
- // We cannot use caretHidden as it breaks the pasting function on Android.
- selectionColor="transparent"
- textInputContainerStyles={[styles.borderNone]}
- inputStyle={[styles.inputTransparent]}
- role={CONST.ACCESSIBILITY_ROLE.TEXT}
- />
-
))}
diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx
index c91dc63a3bd1..91f9d9930079 100644
--- a/src/components/MapView/MapView.tsx
+++ b/src/components/MapView/MapView.tsx
@@ -2,36 +2,100 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native';
import Mapbox, {MapState, MarkerView, setAccessToken} from '@rnmapbox/maps';
import {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';
-import styles from '@styles/styles';
+import {withOnyx} from 'react-native-onyx';
+import setUserLocation from '@libs/actions/UserLocation';
+import compose from '@libs/compose';
+import getCurrentPosition from '@libs/getCurrentPosition';
+import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
+import useLocalize from '@src/hooks/useLocalize';
+import useNetwork from '@src/hooks/useNetwork';
+import ONYXKEYS from '@src/ONYXKEYS';
import Direction from './Direction';
-import {MapViewHandle, MapViewProps} from './MapViewTypes';
+import {MapViewHandle} from './MapViewTypes';
+import PendingMapView from './PendingMapView';
import responder from './responder';
+import {ComponentProps, MapViewOnyxProps} from './types';
import utils from './utils';
-const MapView = forwardRef(({accessToken, style, mapPadding, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady}, ref) => {
- const cameraRef = useRef(null);
- const [isIdle, setIsIdle] = useState(false);
- const navigation = useNavigation();
-
- useImperativeHandle(
- ref,
- () => ({
- flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) =>
- cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}),
- fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) =>
- cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration),
- }),
- [],
- );
-
- // When the page loses focus, we temporarily set the "idled" state to false.
- // When the page regains focus, the onIdled method of the map will set the actual "idled" state,
- // which in turn triggers the callback.
- useFocusEffect(
- // eslint-disable-next-line rulesdir/prefer-early-return
- useCallback(() => {
- if (waypoints?.length && isIdle) {
+const MapView = forwardRef(
+ ({accessToken, style, mapPadding, userLocation: cachedUserLocation, styleURL, pitchEnabled, initialState, waypoints, directionCoordinates, onMapReady}, ref) => {
+ const navigation = useNavigation();
+ const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const cameraRef = useRef(null);
+ const [isIdle, setIsIdle] = useState(false);
+ const [currentPosition, setCurrentPosition] = useState(cachedUserLocation);
+ const [userInteractedWithMap, setUserInteractedWithMap] = useState(false);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (isOffline) {
+ return;
+ }
+
+ getCurrentPosition(
+ (params) => {
+ const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude};
+ setCurrentPosition(currentCoords);
+ setUserLocation(currentCoords);
+ },
+ () => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ if (cachedUserLocation || !initialState) {
+ return;
+ }
+
+ setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]});
+ },
+ );
+ }, [cachedUserLocation, initialState, isOffline]),
+ );
+
+ // Determines if map can be panned to user's detected
+ // location without bothering the user. It will return
+ // false if user has already started dragging the map or
+ // if there are one or more waypoints present.
+ const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]);
+
+ useEffect(() => {
+ if (!currentPosition || !cameraRef.current) {
+ return;
+ }
+
+ if (!shouldPanMapToCurrentPosition()) {
+ return;
+ }
+
+ cameraRef.current.setCamera({
+ zoomLevel: CONST.MAPBOX.DEFAULT_ZOOM,
+ animationDuration: 1500,
+ centerCoordinate: [currentPosition.longitude, currentPosition.latitude],
+ });
+ }, [currentPosition, shouldPanMapToCurrentPosition]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ flyTo: (location: [number, number], zoomLevel: number = CONST.MAPBOX.DEFAULT_ZOOM, animationDuration?: number) =>
+ cameraRef.current?.setCamera({zoomLevel, centerCoordinate: location, animationDuration}),
+ fitBounds: (northEast: [number, number], southWest: [number, number], paddingConfig?: number | number[] | undefined, animationDuration?: number | undefined) =>
+ cameraRef.current?.fitBounds(northEast, southWest, paddingConfig, animationDuration),
+ }),
+ [],
+ );
+
+ // When the page loses focus, we temporarily set the "idled" state to false.
+ // When the page regains focus, the onIdled method of the map will set the actual "idled" state,
+ // which in turn triggers the callback.
+ useFocusEffect(
+ useCallback(() => {
+ if (!waypoints || waypoints.length === 0 || !isIdle) {
+ return;
+ }
+
if (waypoints.length === 1) {
cameraRef.current?.setCamera({
zoomLevel: 15,
@@ -45,69 +109,87 @@ const MapView = forwardRef(({accessToken, style, ma
);
cameraRef.current?.fitBounds(northEast, southWest, mapPadding, 1000);
}
+ }, [mapPadding, waypoints, isIdle, directionCoordinates]),
+ );
+
+ useEffect(() => {
+ const unsubscribe = navigation.addListener('blur', () => {
+ setIsIdle(false);
+ });
+ return unsubscribe;
+ }, [navigation]);
+
+ useEffect(() => {
+ setAccessToken(accessToken);
+ }, [accessToken]);
+
+ const setMapIdle = (e: MapState) => {
+ if (e.gestures.isGestureActive) {
+ return;
+ }
+ setIsIdle(true);
+ if (onMapReady) {
+ onMapReady();
}
- }, [mapPadding, waypoints, isIdle, directionCoordinates]),
- );
-
- useEffect(() => {
- const unsubscribe = navigation.addListener('blur', () => {
- setIsIdle(false);
- });
- return unsubscribe;
- }, [navigation]);
-
- useEffect(() => {
- setAccessToken(accessToken);
- }, [accessToken]);
-
- const setMapIdle = (e: MapState) => {
- if (e.gestures.isGestureActive) {
- return;
- }
- setIsIdle(true);
- if (onMapReady) {
- onMapReady();
- }
- };
-
- return (
-
-
-
-
- {waypoints?.map(({coordinate, markerComponent, id}) => {
- const MarkerComponent = markerComponent;
- return (
-
+ {!isOffline && Boolean(accessToken) && Boolean(currentPosition) ? (
+
+ setUserInteractedWithMap(true)}
+ pitchEnabled={pitchEnabled}
+ attributionPosition={{...styles.r2, ...styles.b2}}
+ scaleBarEnabled={false}
+ logoPosition={{...styles.l2, ...styles.b2}}
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {...responder.panHandlers}
>
-
-
- );
- })}
+
+
+ {waypoints?.map(({coordinate, markerComponent, id}) => {
+ const MarkerComponent = markerComponent;
+ return (
+
+
+
+ );
+ })}
- {directionCoordinates && }
-
-
- );
-});
+ {directionCoordinates && }
+
+
+ ) : (
+
+ )}
+ >
+ );
+ },
+);
-export default memo(MapView);
+export default compose(
+ withOnyx({
+ userLocation: {
+ key: ONYXKEYS.USER_LOCATION,
+ },
+ }),
+ memo,
+)(MapView);
diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx
index 110d24f0c087..f32413cbc15d 100644
--- a/src/components/MapView/MapView.web.tsx
+++ b/src/components/MapView/MapView.web.tsx
@@ -2,26 +2,100 @@
// This is why we have separate components for web and native to handle the specific implementations.
// For the web version, we use the Mapbox Web library called react-map-gl, while for the native mobile version,
// we utilize a different Mapbox library @rnmapbox/maps tailored for mobile development.
+import {useFocusEffect} from '@react-navigation/native';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useState} from 'react';
import Map, {MapRef, Marker} from 'react-map-gl';
import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
import * as StyleUtils from '@styles/StyleUtils';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
+import setUserLocation from '@userActions/UserLocation';
import CONST from '@src/CONST';
+import useLocalize from '@src/hooks/useLocalize';
+import useNetwork from '@src/hooks/useNetwork';
+import getCurrentPosition from '@src/libs/getCurrentPosition';
+import ONYXKEYS from '@src/ONYXKEYS';
import Direction from './Direction';
import './mapbox.css';
-import {MapViewHandle, MapViewProps} from './MapViewTypes';
+import {MapViewHandle} from './MapViewTypes';
+import PendingMapView from './PendingMapView';
import responder from './responder';
+import {ComponentProps, MapViewOnyxProps} from './types';
import utils from './utils';
-const MapView = forwardRef(
- ({style, styleURL, waypoints, mapPadding, accessToken, directionCoordinates, initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM}}, ref) => {
+const MapView = forwardRef(
+ (
+ {
+ style,
+ styleURL,
+ waypoints,
+ mapPadding,
+ accessToken,
+ userLocation: cachedUserLocation,
+ directionCoordinates,
+ initialState = {location: CONST.MAPBOX.DEFAULT_COORDINATE, zoom: CONST.MAPBOX.DEFAULT_ZOOM},
+ },
+ ref,
+ ) => {
+ const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
const [mapRef, setMapRef] = useState(null);
+ const [currentPosition, setCurrentPosition] = useState(cachedUserLocation);
+ const [userInteractedWithMap, setUserInteractedWithMap] = useState(false);
const [shouldResetBoundaries, setShouldResetBoundaries] = useState(false);
const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []);
+ useFocusEffect(
+ useCallback(() => {
+ if (isOffline) {
+ return;
+ }
+
+ getCurrentPosition(
+ (params) => {
+ const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude};
+ setCurrentPosition(currentCoords);
+ setUserLocation(currentCoords);
+ },
+ () => {
+ if (cachedUserLocation) {
+ return;
+ }
+
+ setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]});
+ },
+ );
+ }, [cachedUserLocation, isOffline, initialState.location]),
+ );
+
+ // Determines if map can be panned to user's detected
+ // location without bothering the user. It will return
+ // false if user has already started dragging the map or
+ // if there are one or more waypoints present.
+ const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]);
+
+ useEffect(() => {
+ if (!currentPosition || !mapRef) {
+ return;
+ }
+
+ if (!shouldPanMapToCurrentPosition()) {
+ return;
+ }
+
+ mapRef.flyTo({
+ center: [currentPosition.longitude, currentPosition.latitude],
+ zoom: CONST.MAPBOX.DEFAULT_ZOOM,
+ });
+ }, [currentPosition, userInteractedWithMap, mapRef, shouldPanMapToCurrentPosition]);
+
const resetBoundaries = useCallback(() => {
if (!waypoints || waypoints.length === 0) {
return;
@@ -34,7 +108,7 @@ const MapView = forwardRef(
if (waypoints.length === 1) {
mapRef.flyTo({
center: waypoints[0].coordinate,
- zoom: 15,
+ zoom: CONST.MAPBOX.DEFAULT_ZOOM,
});
return;
}
@@ -91,40 +165,55 @@ const MapView = forwardRef(
);
return (
-
-
-
+ <>
+ {!isOffline && Boolean(accessToken) && Boolean(currentPosition) ? (
+
+
+
+ ) : (
+
+ )}
+ >
);
},
);
-export default MapView;
+export default withOnyx({
+ userLocation: {
+ key: ONYXKEYS.USER_LOCATION,
+ },
+})(MapView);
diff --git a/src/components/MapView/PendingMapView.tsx b/src/components/MapView/PendingMapView.tsx
index eed879596888..2acdb59d3782 100644
--- a/src/components/MapView/PendingMapView.tsx
+++ b/src/components/MapView/PendingMapView.tsx
@@ -4,13 +4,13 @@ import {View} from 'react-native';
import BlockingView from '@components/BlockingViews/BlockingView';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
-import styles from '@styles/styles';
+import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import {PendingMapViewProps} from './MapViewTypes';
function PendingMapView({title = '', subtitle = '', style}: PendingMapViewProps) {
const hasTextContent = !_.isEmpty(title) || !_.isEmpty(subtitle);
-
+ const styles = useThemeStyles();
return (
{hasTextContent ? (
diff --git a/src/components/MapView/types.ts b/src/components/MapView/types.ts
new file mode 100644
index 000000000000..2c8b9240c445
--- /dev/null
+++ b/src/components/MapView/types.ts
@@ -0,0 +1,11 @@
+import {OnyxEntry} from 'react-native-onyx';
+import * as OnyxTypes from '@src/types/onyx';
+import {MapViewProps} from './MapViewTypes';
+
+type MapViewOnyxProps = {
+ userLocation: OnyxEntry;
+};
+
+type ComponentProps = MapViewProps & MapViewOnyxProps;
+
+export type {MapViewOnyxProps, ComponentProps};
diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx
index f1ac13f58d16..f04212ae113b 100644
--- a/src/components/MentionSuggestions.tsx
+++ b/src/components/MentionSuggestions.tsx
@@ -14,9 +14,12 @@ type Mention = {
/** Display name of the user */
text: string;
- /** Email/phone number of the user */
+ /** The formatted email/phone number of the user */
alternateText: string;
+ /** Email/phone number of the user */
+ login: string;
+
/** Array of icons of the user. We use the first element of this array */
icons: Icon[];
};
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 9883672976e8..ed6ea1274dfc 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -7,9 +7,9 @@ import ControlSelection from '@libs/ControlSelection';
import convertToLTR from '@libs/convertToLTR';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import getButtonState from '@libs/getButtonState';
-import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import * as Session from '@userActions/Session';
import CONST from '@src/CONST';
@@ -38,11 +38,11 @@ const defaultProps = {
shouldShowHeaderTitle: false,
shouldParseTitle: false,
wrapperStyle: [],
- style: styles.popoverMenuItem,
+ style: undefined,
titleStyle: {},
shouldShowTitleIcon: false,
titleIcon: () => {},
- descriptionTextStyle: styles.breakWord,
+ descriptionTextStyle: undefined,
success: false,
icon: undefined,
secondaryIcon: undefined,
@@ -86,10 +86,13 @@ const defaultProps = {
};
const MenuItem = React.forwardRef((props, ref) => {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const style = StyleUtils.combineStyles(props.style, styles.popoverMenuItem);
const {isSmallScreenWidth} = useWindowDimensions();
const [html, setHtml] = React.useState('');
- const isDeleted = _.contains(props.style, styles.offlineFeedback.deleted);
+ const isDeleted = _.contains(style, styles.offlineFeedback.deleted);
const descriptionVerticalMargin = props.shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1;
const titleTextStyle = StyleUtils.combineStyles(
[
@@ -109,7 +112,7 @@ const MenuItem = React.forwardRef((props, ref) => {
styles.textLabelSupporting,
props.icon && !_.isArray(props.icon) ? styles.ml3 : undefined,
props.title ? descriptionVerticalMargin : StyleUtils.getFontSizeStyle(variables.fontSizeNormal),
- props.descriptionTextStyle,
+ props.descriptionTextStyle || styles.breakWord,
isDeleted ? styles.offlineFeedback.deleted : undefined,
]);
@@ -176,7 +179,7 @@ const MenuItem = React.forwardRef((props, ref) => {
onPressOut={ControlSelection.unblock}
onSecondaryInteraction={props.onSecondaryInteraction}
style={({pressed}) => [
- props.style,
+ style,
!props.interactive && styles.cursorDefault,
StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || isHovered, pressed, props.success, props.disabled, props.interactive), true),
(isHovered || pressed) && props.hoverAndPressStyle,
@@ -206,9 +209,9 @@ const MenuItem = React.forwardRef((props, ref) => {
icons={props.icon}
size={props.avatarSize}
secondAvatarStyle={[
- StyleUtils.getBackgroundAndBorderStyle(themeColors.sidebar),
- pressed && props.interactive ? StyleUtils.getBackgroundAndBorderStyle(themeColors.buttonPressedBG) : undefined,
- isHovered && !pressed && props.interactive ? StyleUtils.getBackgroundAndBorderStyle(themeColors.border) : undefined,
+ StyleUtils.getBackgroundAndBorderStyle(theme.sidebar),
+ pressed && props.interactive ? StyleUtils.getBackgroundAndBorderStyle(theme.buttonPressedBG) : undefined,
+ isHovered && !pressed && props.interactive ? StyleUtils.getBackgroundAndBorderStyle(theme.border) : undefined,
]}
/>
)}
@@ -291,7 +294,7 @@ const MenuItem = React.forwardRef((props, ref) => {
)}
@@ -342,7 +345,7 @@ const MenuItem = React.forwardRef((props, ref) => {
{/* Since subtitle can be of type number, we should allow 0 to be shown */}
{(props.subtitle || props.subtitle === 0) && (
- {props.subtitle}
+ {props.subtitle}
)}
{!_.isEmpty(props.floatRightAvatars) && (
@@ -361,7 +364,7 @@ const MenuItem = React.forwardRef((props, ref) => {
)}
diff --git a/src/components/MessagesRow.js b/src/components/MessagesRow.js
index d9568834cbe3..e4d6240ba0fd 100644
--- a/src/components/MessagesRow.js
+++ b/src/components/MessagesRow.js
@@ -15,7 +15,9 @@ import Tooltip from './Tooltip';
const propTypes = {
/** The messages to display */
- messages: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))])),
+ messages: PropTypes.objectOf(
+ PropTypes.oneOfType([PropTypes.oneOfType([PropTypes.string, PropTypes.object]), PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]),
+ ),
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: PropTypes.oneOf(['error', 'success']).isRequired,
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index d0e309d06766..4945b4b62ad6 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -56,6 +56,7 @@ function BaseModal(
*/
const hideModal = useCallback(
(callHideCallback = true) => {
+ Modal.willAlertModalBecomeVisible(false);
if (shouldSetModalVisibility) {
Modal.setModalVisibility(false);
}
@@ -72,14 +73,19 @@ function BaseModal(
useEffect(() => {
isVisibleRef.current = isVisible;
+ let removeOnCloseListener: () => void;
if (isVisible) {
Modal.willAlertModalBecomeVisible(true);
// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
- Modal.setCloseModal(onClose);
- } else if (wasVisible && !isVisible) {
- Modal.willAlertModalBecomeVisible(false);
- Modal.setCloseModal(null);
+ removeOnCloseListener = Modal.setCloseModal(onClose);
}
+
+ return () => {
+ if (!removeOnCloseListener) {
+ return;
+ }
+ removeOnCloseListener();
+ };
}, [isVisible, wasVisible, onClose]);
useEffect(
@@ -89,9 +95,6 @@ function BaseModal(
return;
}
hideModal(true);
- Modal.willAlertModalBecomeVisible(false);
- // To prevent closing any modal already unmounted when this modal still remains as visible state
- Modal.setCloseModal(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -136,11 +139,12 @@ function BaseModal(
windowHeight,
isSmallScreenWidth,
},
+ theme,
popoverAnchorPosition,
innerContainerStyle,
outerStyle,
),
- [innerContainerStyle, isSmallScreenWidth, outerStyle, popoverAnchorPosition, type, windowHeight, windowWidth],
+ [innerContainerStyle, isSmallScreenWidth, outerStyle, popoverAnchorPosition, theme, type, windowHeight, windowWidth],
);
const {
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
index 710ecd79b375..f760d3c0244e 100644
--- a/src/components/Modal/index.tsx
+++ b/src/components/Modal/index.tsx
@@ -1,16 +1,13 @@
import React, {useState} from 'react';
-import FocusTrapView from '@components/FocusTrapView';
import withWindowDimensions from '@components/withWindowDimensions';
import StatusBar from '@libs/StatusBar';
import * as StyleUtils from '@styles/StyleUtils';
import useTheme from '@styles/themes/useTheme';
-import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import BaseModalProps from './types';
-function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldEnableFocusTrap = false, ...rest}: BaseModalProps) {
- const styles = useThemeStyles();
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
const theme = useTheme();
const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
@@ -51,13 +48,7 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
fullscreen={fullscreen}
type={type}
>
-
- {children}
-
+ {children}
);
}
diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js
index 7465e28b28ad..84e610b694e4 100644
--- a/src/components/Modal/modalPropTypes.js
+++ b/src/components/Modal/modalPropTypes.js
@@ -66,9 +66,6 @@ const propTypes = {
* */
hideModalContentWhileAnimating: PropTypes.bool,
- /** Should the modal use custom focus trap logic */
- shouldEnableFocusTrap: PropTypes.bool,
-
...windowDimensionsPropTypes,
};
@@ -87,7 +84,6 @@ const defaultProps = {
statusBarTranslucent: true,
avoidKeyboard: false,
hideModalContentWhileAnimating: false,
- shouldEnableFocusTrap: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index ddb51a68ba1b..ebe55d9b7469 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -61,9 +61,7 @@ type BaseModalProps = WindowDimensionsProps &
* See: https://github.com/react-native-modal/react-native-modal/pull/116
* */
hideModalContentWhileAnimating?: boolean;
-
- /** Whether the modal should use focus trap */
- shouldEnableFocusTrap?: boolean;
};
export default BaseModalProps;
+export type {PopoverAnchorPosition};
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index df41abea30a3..880e46b2592a 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -77,7 +77,7 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
const isPolicyAdmin = policyType !== CONST.POLICY.TYPE.PERSONAL && lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN;
const isManager = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === moneyRequestReport.managerID;
const isPayer = policyType === CONST.POLICY.TYPE.CORPORATE ? isPolicyAdmin && isApproved : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager);
- const isDraft = ReportUtils.isReportDraft(moneyRequestReport);
+ const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport);
const shouldShowSettlementButton = useMemo(
() => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
[isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport],
@@ -89,7 +89,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
return isManager && !isDraft && !isApproved && !isSettled;
}, [policyType, isManager, isDraft, isApproved, isSettled]);
const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0;
- const shouldShowNextSteps = isDraft && nextStep && (!_.isEmpty(nextStep.message) || !_.isEmpty(nextStep.expenseMessage));
+ const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE;
+ const shouldShowNextSteps = isFromPaidPolicy && nextStep && !_.isEmpty(nextStep.message);
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextSteps;
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableTotal, moneyRequestReport.currency);
@@ -119,7 +120,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
style={[styles.pv2]}
formattedAmount={formattedAmount}
/>
@@ -164,7 +164,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
formattedAmount={formattedAmount}
/>
diff --git a/src/components/MoneyReportHeaderStatusBar.js b/src/components/MoneyReportHeaderStatusBar.js
index 362a2dc22b9f..3f2c06be78cf 100644
--- a/src/components/MoneyReportHeaderStatusBar.js
+++ b/src/components/MoneyReportHeaderStatusBar.js
@@ -1,6 +1,5 @@
import React, {useMemo} from 'react';
import {Text, View} from 'react-native';
-import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import * as NextStepUtils from '@libs/NextStepUtils';
import nextStepPropTypes from '@pages/nextStepPropTypes';
@@ -21,9 +20,9 @@ function MoneyReportHeaderStatusBar({nextStep}) {
const {translate} = useLocalize();
const messageContent = useMemo(() => {
- const messageArray = _.isEmpty(nextStep.expenseMessage) ? nextStep.message : nextStep.expenseMessage;
+ const messageArray = nextStep.message;
return NextStepUtils.parseMessage(messageArray);
- }, [nextStep.expenseMessage, nextStep.message]);
+ }, [nextStep.message]);
return (
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 6cf1b7e6cef1..f203154ab3db 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -28,17 +28,16 @@ import * as IOU from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import Button from './Button';
import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
import categoryPropTypes from './categoryPropTypes';
import ConfirmedRoute from './ConfirmedRoute';
import FormHelpMessage from './FormHelpMessage';
-import * as Expensicons from './Icon/Expensicons';
import Image from './Image';
import MenuItemWithTopDescription from './MenuItemWithTopDescription';
import optionPropTypes from './optionPropTypes';
import OptionsSelector from './OptionsSelector';
import SettlementButton from './SettlementButton';
+import ShowMoreButton from './ShowMoreButton';
import Switch from './Switch';
import tagPropTypes from './tagPropTypes';
import Text from './Text';
@@ -509,7 +508,6 @@ function MoneyRequestConfirmationList(props) {
addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD}
currency={props.iouCurrencyCode}
policyID={props.policyID}
- shouldShowPaymentOptions
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
@@ -636,20 +634,10 @@ function MoneyRequestConfirmationList(props) {
numberOfLinesTitle={2}
/>
{!shouldShowAllFields && (
-
-
-
-
-
-
+
)}
{shouldShowAllFields && (
<>
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index febe18f30c7d..6f15612736ef 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -2,9 +2,9 @@ import React, {memo, useMemo} from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import {ValueOf} from 'type-fest';
import {AvatarSource} from '@libs/UserUtils';
-import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {Icon} from '@src/types/onyx/OnyxCommon';
@@ -63,26 +63,11 @@ type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_S
type AvatarSizeToStylesMap = Record;
-const avatarSizeToStylesMap: AvatarSizeToStylesMap = {
- [CONST.AVATAR_SIZE.SMALL]: {
- singleAvatarStyle: styles.singleAvatarSmall,
- secondAvatarStyles: styles.secondAvatarSmall,
- },
- [CONST.AVATAR_SIZE.LARGE]: {
- singleAvatarStyle: styles.singleAvatarMedium,
- secondAvatarStyles: styles.secondAvatarMedium,
- },
- [CONST.AVATAR_SIZE.DEFAULT]: {
- singleAvatarStyle: styles.singleAvatar,
- secondAvatarStyles: styles.secondAvatar,
- },
-};
-
function MultipleAvatars({
fallbackIcon,
icons = [],
size = CONST.AVATAR_SIZE.DEFAULT,
- secondAvatarStyle = [StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)],
+ secondAvatarStyle: secondAvatarStyleProp,
shouldStackHorizontally = false,
shouldDisplayAvatarsInRows = false,
isHovered = false,
@@ -93,8 +78,31 @@ function MultipleAvatars({
shouldUseCardBackground = false,
maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT,
}: MultipleAvatarsProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
+ const avatarSizeToStylesMap: AvatarSizeToStylesMap = useMemo(
+ () => ({
+ [CONST.AVATAR_SIZE.SMALL]: {
+ singleAvatarStyle: styles.singleAvatarSmall,
+ secondAvatarStyles: styles.secondAvatarSmall,
+ },
+ [CONST.AVATAR_SIZE.LARGE]: {
+ singleAvatarStyle: styles.singleAvatarMedium,
+ secondAvatarStyles: styles.secondAvatarMedium,
+ },
+ [CONST.AVATAR_SIZE.DEFAULT]: {
+ singleAvatarStyle: styles.singleAvatar,
+ secondAvatarStyles: styles.secondAvatar,
+ },
+ }),
+ [styles],
+ );
+
+ const secondAvatarStyle = secondAvatarStyleProp ?? [StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)];
+
let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction);
- const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size]);
+ const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]);
const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => icon.name) : ['']), [shouldShowTooltip, icons]);
const avatarSize = useMemo(() => {
@@ -143,7 +151,7 @@ function MultipleAvatars({
addMonths(new Date(this.state.currentDateView), 1);
- const hasAvailableDatesPrevMonth = startOfDay(new Date(this.props.minDate)) < endOfMonth(subMonths(new Date(this.state.currentDateView), 1));
+ const hasAvailableDatesNextMonth = startOfDay(new Date(this.props.maxDate)) > endOfMonth(new Date(this.state.currentDateView));
+ const hasAvailableDatesPrevMonth = endOfDay(new Date(this.props.minDate)) < startOfMonth(new Date(this.state.currentDateView));
return (
@@ -219,7 +219,7 @@ class CalendarPicker extends React.PureComponent {
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 = isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day));
+ const isSelected = !!day && isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day));
return (
{
- if (containerStyles.length) {
- return containerStyles;
- }
- return isSmallScreenWidth ? styles.offlineIndicatorMobile : styles.offlineIndicator;
-};
-
function OfflineIndicator(props) {
+ const styles = useThemeStyles();
+
+ const computedStyles = useMemo(() => {
+ if (props.containerStyles.length) {
+ return props.containerStyles;
+ }
+ return props.isSmallScreenWidth ? styles.offlineIndicatorMobile : styles.offlineIndicator;
+ }, [props.containerStyles, props.isSmallScreenWidth, styles.offlineIndicatorMobile, styles.offlineIndicator]);
+
if (!props.network.isOffline) {
return null;
}
return (
-
+ {
if (!React.isValidElement(child)) {
return child;
}
const props = {style: StyleUtils.combineStyles(child.props.style, styles.offlineFeedback.deleted, styles.userSelectNone)};
if (child.props.children) {
- props.children = applyStrikeThrough(child.props.children);
+ props.children = applyStrikeThrough(child.props.children, styles);
}
return React.cloneElement(child, props);
});
}
function OfflineWithFeedback(props) {
+ const styles = useThemeStyles();
const {isOffline} = useNetwork();
const hasErrors = !_.isEmpty(props.errors);
@@ -109,7 +111,7 @@ function OfflineWithFeedback(props) {
// Apply strikethrough to children if needed, but skip it if we are not going to render them
if (needsStrikeThrough && !hideChildren) {
- children = applyStrikeThrough(children);
+ children = applyStrikeThrough(children, styles);
}
return (
diff --git a/src/components/Onfido/BaseOnfidoWeb.js b/src/components/Onfido/BaseOnfidoWeb.js
index 5c0f83902e55..7a3b90945a5c 100644
--- a/src/components/Onfido/BaseOnfidoWeb.js
+++ b/src/components/Onfido/BaseOnfidoWeb.js
@@ -6,13 +6,13 @@ import useLocalize from '@hooks/useLocalize';
import Log from '@libs/Log';
import fontFamily from '@styles/fontFamily';
import fontWeightBold from '@styles/fontWeight/bold';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import './index.css';
import onfidoPropTypes from './onfidoPropTypes';
-function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLocale, translate}) {
+function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLocale, translate, theme}) {
OnfidoSDK.init({
token: sdkToken,
containerId: CONST.ONFIDO.CONTAINER_ID,
@@ -25,35 +25,35 @@ function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLo
fontWeightTitle: fontWeightBold,
fontWeightSubtitle: 400,
fontSizeSubtitle: `${variables.fontSizeNormal}px`,
- colorContentTitle: themeColors.text,
- colorContentSubtitle: themeColors.text,
- colorContentBody: themeColors.text,
+ colorContentTitle: theme.text,
+ colorContentSubtitle: theme.text,
+ colorContentBody: theme.text,
borderRadiusButton: `${variables.buttonBorderRadius}px`,
- colorBackgroundSurfaceModal: themeColors.appBG,
- colorBorderDocTypeButton: themeColors.border,
- colorBorderDocTypeButtonHover: themeColors.transparent,
- colorBorderButtonPrimaryHover: themeColors.transparent,
- colorBackgroundButtonPrimary: themeColors.success,
- colorBackgroundButtonPrimaryHover: themeColors.successHover,
- colorBackgroundButtonPrimaryActive: themeColors.successHover,
- colorBorderButtonPrimary: themeColors.success,
- colorContentButtonSecondaryText: themeColors.text,
- colorBackgroundButtonSecondary: themeColors.border,
- colorBackgroundButtonSecondaryHover: themeColors.icon,
- colorBackgroundButtonSecondaryActive: themeColors.icon,
- colorBorderButtonSecondary: themeColors.border,
- colorBackgroundIcon: themeColors.transparent,
- colorContentLinkTextHover: themeColors.appBG,
- colorBorderLinkUnderline: themeColors.link,
- colorBackgroundLinkHover: themeColors.link,
- colorBackgroundLinkActive: themeColors.link,
- authAccentColor: themeColors.link,
- colorBackgroundInfoPill: themeColors.link,
- colorBackgroundSelector: themeColors.appBG,
- colorBackgroundDocTypeButton: themeColors.success,
- colorBackgroundDocTypeButtonHover: themeColors.successHover,
- colorBackgroundButtonIconHover: themeColors.transparent,
- colorBackgroundButtonIconActive: themeColors.transparent,
+ colorBackgroundSurfaceModal: theme.appBG,
+ colorBorderDocTypeButton: theme.border,
+ colorBorderDocTypeButtonHover: theme.transparent,
+ colorBorderButtonPrimaryHover: theme.transparent,
+ colorBackgroundButtonPrimary: theme.success,
+ colorBackgroundButtonPrimaryHover: theme.successHover,
+ colorBackgroundButtonPrimaryActive: theme.successHover,
+ colorBorderButtonPrimary: theme.success,
+ colorContentButtonSecondaryText: theme.text,
+ colorBackgroundButtonSecondary: theme.border,
+ colorBackgroundButtonSecondaryHover: theme.icon,
+ colorBackgroundButtonSecondaryActive: theme.icon,
+ colorBorderButtonSecondary: theme.border,
+ colorBackgroundIcon: theme.transparent,
+ colorContentLinkTextHover: theme.appBG,
+ colorBorderLinkUnderline: theme.link,
+ colorBackgroundLinkHover: theme.link,
+ colorBackgroundLinkActive: theme.link,
+ authAccentColor: theme.link,
+ colorBackgroundInfoPill: theme.link,
+ colorBackgroundSelector: theme.appBG,
+ colorBackgroundDocTypeButton: theme.success,
+ colorBackgroundDocTypeButtonHover: theme.successHover,
+ colorBackgroundButtonIconHover: theme.transparent,
+ colorBackgroundButtonIconActive: theme.transparent,
},
steps: [
{
@@ -120,6 +120,7 @@ function logOnFidoEvent(event) {
const Onfido = forwardRef((props, ref) => {
const {preferredLocale, translate} = useLocalize();
+ const theme = useTheme();
useEffect(() => {
initializeOnfido({
@@ -129,6 +130,7 @@ const Onfido = forwardRef((props, ref) => {
onUserExit: props.onUserExit,
preferredLocale,
translate,
+ theme,
});
window.addEventListener('userAnalyticsEvent', logOnFidoEvent);
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index e1c554dc1d37..cb670f3cf6ce 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -186,11 +186,11 @@ function OptionRow(props) {
styles.alignItemsCenter,
styles.justifyContentBetween,
styles.sidebarLink,
+ !props.isDisabled && styles.cursorPointer,
props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner,
props.optionIsFocused ? styles.sidebarLinkActive : null,
props.shouldHaveOptionSeparator && styles.borderTop,
!props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null,
- props.isSelected && props.highlightSelected && styles.optionRowSelected,
]}
accessibilityLabel={props.option.text}
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js
index 2f81e6d80e7d..b2bc4b182a60 100644
--- a/src/components/OptionsList/BaseOptionsList.js
+++ b/src/components/OptionsList/BaseOptionsList.js
@@ -9,6 +9,7 @@ import Text from '@components/Text';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import {defaultProps as optionsListDefaultProps, propTypes as optionsListPropTypes} from './optionsListPropTypes';
const propTypes = {
@@ -70,6 +71,7 @@ function BaseOptionsList({
isLoadingNewOptions,
nestedScrollEnabled,
bounces,
+ renderFooterContent,
}) {
const styles = useThemeStyles();
const flattenedData = useRef();
@@ -281,11 +283,12 @@ function BaseOptionsList({
renderSectionHeader={renderSectionHeader}
extraData={focusedIndex}
initialNumToRender={12}
- maxToRenderPerBatch={5}
+ maxToRenderPerBatch={CONST.MAX_TO_RENDER_PER_BATCH.DEFAULT}
windowSize={5}
viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
onViewableItemsChanged={onViewableItemsChanged}
bounces={bounces}
+ ListFooterComponent={renderFooterContent}
/>
>
)}
diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js
index b841943f2402..2485ad1558ac 100644
--- a/src/components/OptionsList/optionsListPropTypes.js
+++ b/src/components/OptionsList/optionsListPropTypes.js
@@ -100,6 +100,9 @@ const propTypes = {
/** Whether the list should have a bounce effect on iOS */
bounces: PropTypes.bool,
+
+ /** Custom content to display in the floating footer */
+ renderFooterContent: PropTypes.func,
};
const defaultProps = {
@@ -130,6 +133,7 @@ const defaultProps = {
isLoadingNewOptions: false,
nestedScrollEnabled: true,
bounces: true,
+ renderFooterContent: undefined,
};
export {propTypes, defaultProps};
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index c2f3e2b47330..ba6a3067284f 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -11,17 +11,18 @@ import Icon from '@components/Icon';
import {Info} from '@components/Icon/Expensicons';
import OptionsList from '@components/OptionsList';
import {PressableWithoutFeedback} from '@components/Pressable';
+import ShowMoreButton from '@components/ShowMoreButton';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import withNavigationFocus from '@components/withNavigationFocus';
+import withTheme, {withThemePropTypes} from '@components/withTheme';
+import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles';
import compose from '@libs/compose';
import getPlatform from '@libs/getPlatform';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import Navigation from '@libs/Navigation/Navigation';
import setSelection from '@libs/setSelection';
-import colors from '@styles/colors';
-import styles from '@styles/styles';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import {defaultProps as optionsSelectorDefaultProps, propTypes as optionsSelectorPropTypes} from './optionsSelectorPropTypes';
@@ -50,6 +51,8 @@ const propTypes = {
...optionsSelectorPropTypes,
...withLocalizePropTypes,
+ ...withThemeStylesPropTypes,
+ ...withThemePropTypes,
};
const defaultProps = {
@@ -58,7 +61,7 @@ const defaultProps = {
referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND,
safeAreaPaddingBottomStyle: {},
contentContainerStyles: [],
- listContainerStyles: [styles.flex1],
+ listContainerStyles: undefined,
listStyles: [],
...optionsSelectorDefaultProps,
};
@@ -74,17 +77,23 @@ class BaseOptionsSelector extends Component {
this.selectFocusedOption = this.selectFocusedOption.bind(this);
this.addToSelection = this.addToSelection.bind(this);
this.updateSearchValue = this.updateSearchValue.bind(this);
+ this.incrementPage = this.incrementPage.bind(this);
+ this.sliceSections = this.sliceSections.bind(this);
+ this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this);
this.relatedTarget = null;
const allOptions = this.flattenSections();
+ const sections = this.sliceSections();
const focusedIndex = this.getInitiallyFocusedIndex(allOptions);
this.state = {
+ sections,
allOptions,
focusedIndex,
shouldDisableRowSelection: false,
shouldShowReferralModal: false,
errorMessage: '',
+ paginationPage: 1,
};
}
@@ -100,7 +109,7 @@ class BaseOptionsSelector extends Component {
this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false);
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps, prevState) {
if (prevProps.isFocused !== this.props.isFocused) {
if (this.props.isFocused) {
this.subscribeToKeyboardShortcut();
@@ -118,14 +127,24 @@ class BaseOptionsSelector extends Component {
}, CONST.ANIMATED_TRANSITION);
}
+ if (prevState.paginationPage !== this.state.paginationPage) {
+ const newSections = this.sliceSections();
+
+ this.setState({
+ sections: newSections,
+ });
+ }
+
if (_.isEqual(this.props.sections, prevProps.sections)) {
return;
}
+ const newSections = this.sliceSections();
const newOptions = this.flattenSections();
if (prevProps.preferredLocale !== this.props.preferredLocale) {
this.setState({
+ sections: newSections,
allOptions: newOptions,
});
return;
@@ -136,8 +155,9 @@ class BaseOptionsSelector extends Component {
// eslint-disable-next-line react/no-did-update-set-state
this.setState(
{
+ sections: newSections,
allOptions: newOptions,
- focusedIndex: _.isNumber(this.props.initialFocusedIndex) ? this.props.initialFocusedIndex : newFocusedIndex,
+ focusedIndex: _.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex,
},
() => {
// If we just toggled an option on a multi-selection page or cleared the search input, scroll to top
@@ -168,14 +188,14 @@ class BaseOptionsSelector extends Component {
* @returns {Number}
*/
getInitiallyFocusedIndex(allOptions) {
- if (_.isNumber(this.props.initialFocusedIndex)) {
- return this.props.initialFocusedIndex;
+ let defaultIndex;
+ if (this.props.shouldTextInputAppearBelowOptions) {
+ defaultIndex = allOptions.length;
+ } else if (this.props.focusedIndex >= 0) {
+ defaultIndex = this.props.focusedIndex;
+ } else {
+ defaultIndex = this.props.selectedOptions.length;
}
-
- if (this.props.selectedOptions.length > 0) {
- return this.props.selectedOptions.length;
- }
- const defaultIndex = this.props.shouldTextInputAppearBelowOptions ? allOptions.length : 0;
if (_.isUndefined(this.props.initiallyFocusedOptionKey)) {
return defaultIndex;
}
@@ -189,8 +209,43 @@ class BaseOptionsSelector extends Component {
return defaultIndex;
}
+ /**
+ * Maps sections to render only allowed count of them per section.
+ *
+ * @returns {Objects[]}
+ */
+ sliceSections() {
+ return _.map(this.props.sections, (section) => {
+ if (_.isEmpty(section.data)) {
+ return section;
+ }
+
+ return {
+ ...section,
+ data: section.data.slice(0, CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * lodashGet(this.state, 'paginationPage', 1)),
+ };
+ });
+ }
+
+ /**
+ * Calculates all currently visible options based on the sections that are currently being shown
+ * and the number of items of those sections.
+ *
+ * @returns {Number}
+ */
+ calculateAllVisibleOptionsCount() {
+ let count = 0;
+
+ _.forEach(this.state.sections, (section) => {
+ count += lodashGet(section, 'data.length', 0);
+ });
+
+ return count;
+ }
+
updateSearchValue(value) {
this.setState({
+ paginationPage: 1,
errorMessage: value.length > this.props.maxLength ? this.props.translate('common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}) : '',
});
@@ -328,12 +383,16 @@ class BaseOptionsSelector extends Component {
const itemIndex = option.index;
const sectionIndex = option.sectionIndex;
+ if (!lodashGet(this.state.sections, `[${sectionIndex}].data[${itemIndex}]`, null)) {
+ return;
+ }
+
// Note: react-native's SectionList automatically strips out any empty sections.
// So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to.
// Otherwise, it will cause an index-out-of-bounds error and crash the app.
let adjustedSectionIndex = sectionIndex;
for (let i = 0; i < sectionIndex; i++) {
- if (_.isEmpty(lodashGet(this.props.sections, `[${i}].data`))) {
+ if (_.isEmpty(lodashGet(this.state.sections, `[${i}].data`))) {
adjustedSectionIndex--;
}
}
@@ -387,12 +446,24 @@ class BaseOptionsSelector extends Component {
this.props.onAddToSelection(option);
}
+ /**
+ * Increments a pagination page to show more items
+ */
+ incrementPage() {
+ this.setState((prev) => ({
+ paginationPage: prev.paginationPage + 1,
+ }));
+ }
+
render() {
+ const shouldShowShowMoreButton = this.state.allOptions.length > CONST.MAX_OPTIONS_SELECTOR_PAGE_LENGTH * this.state.paginationPage;
const shouldShowFooter =
!this.props.isReadOnly && (this.props.shouldShowConfirmButton || this.props.footerContent) && !(this.props.canSelectMultipleOptions && _.isEmpty(this.props.selectedOptions));
const defaultConfirmButtonText = _.isUndefined(this.props.confirmButtonText) ? this.props.translate('common.confirm') : this.props.confirmButtonText;
const shouldShowDefaultConfirmButton = !this.props.footerContent && defaultConfirmButtonText;
const safeAreaPaddingBottomStyle = shouldShowFooter ? undefined : this.props.safeAreaPaddingBottomStyle;
+ const listContainerStyles = this.props.listContainerStyles || [this.props.themeStyles.flex1];
+
const textInput = (
(this.textInput = el)}
@@ -424,7 +495,7 @@ class BaseOptionsSelector extends Component {
ref={(el) => (this.list = el)}
optionHoveredStyle={this.props.optionHoveredStyle}
onSelectRow={this.props.onSelectRow ? this.selectRow : undefined}
- sections={this.props.sections}
+ sections={this.state.sections}
focusedIndex={this.state.focusedIndex}
selectedOptions={this.props.selectedOptions}
canSelectMultipleOptions={this.props.canSelectMultipleOptions}
@@ -449,7 +520,7 @@ class BaseOptionsSelector extends Component {
}}
contentContainerStyles={[safeAreaPaddingBottomStyle, ...this.props.contentContainerStyles]}
sectionHeaderStyle={this.props.sectionHeaderStyle}
- listContainerStyles={this.props.listContainerStyles}
+ listContainerStyles={listContainerStyles}
listStyles={this.props.listStyles}
isLoading={!this.props.shouldShowOptions}
showScrollIndicator={this.props.showScrollIndicator}
@@ -458,13 +529,39 @@ class BaseOptionsSelector extends Component {
shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow}
nestedScrollEnabled={this.props.nestedScrollEnabled}
bounces={!this.props.shouldTextInputAppearBelowOptions || !this.props.shouldAllowScrollingChildren}
+ renderFooterContent={() =>
+ shouldShowShowMoreButton && (
+
+ )
+ }
/>
);
const optionsAndInputsBelowThem = (
<>
- {optionsList}
-
+
+ {optionsList}
+
+
{this.props.children}
{this.props.shouldShowTextInput && textInput}
@@ -475,22 +572,22 @@ class BaseOptionsSelector extends Component {
{} : this.updateFocusedIndex}
shouldResetIndexOnEndReached={false}
>
-
+
{/*
* The OptionsList component uses a SectionList which uses a VirtualizedList internally.
* VirtualizedList cannot be directly nested within ScrollViews of the same orientation.
* To work around this, we wrap the OptionsList component with a horizontal ScrollView.
*/}
{this.props.shouldTextInputAppearBelowOptions && this.props.shouldAllowScrollingChildren && (
-
+
{optionsAndInputsBelowThem}
@@ -501,13 +598,13 @@ class BaseOptionsSelector extends Component {
{!this.props.shouldTextInputAppearBelowOptions && (
<>
-
+
{this.props.children}
{this.props.shouldShowTextInput && textInput}
{Boolean(this.props.textInputAlert) && (
)}
@@ -517,20 +614,29 @@ class BaseOptionsSelector extends Component {
)}
{this.props.shouldShowReferralCTA && (
-
+ {
Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(this.props.referralContentType));
}}
- style={[styles.p5, styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10}]}
+ style={[
+ this.props.themeStyles.p5,
+ this.props.themeStyles.w100,
+ this.props.themeStyles.br2,
+ this.props.themeStyles.highlightBG,
+ this.props.themeStyles.flexRow,
+ this.props.themeStyles.justifyContentBetween,
+ this.props.themeStyles.alignItemsCenter,
+ {gap: 10},
+ ]}
accessibilityLabel="referral"
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{this.props.translate(`referralProgram.${this.props.referralContentType}.buttonText1`)}
{this.props.translate(`referralProgram.${this.props.referralContentType}.buttonText2`)}
@@ -549,7 +655,7 @@ class BaseOptionsSelector extends Component {
{shouldShowDefaultConfirmButton && (
diff --git a/src/components/TabSelector/TabSelector.js b/src/components/TabSelector/TabSelector.js
index b6ee359dc4ca..cdec2a7e91e1 100644
--- a/src/components/TabSelector/TabSelector.js
+++ b/src/components/TabSelector/TabSelector.js
@@ -1,11 +1,11 @@
import PropTypes from 'prop-types';
-import React, {useMemo, useState} from 'react';
+import React, {useCallback, useMemo, useState} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import * as Expensicons from '@components/Icon/Expensicons';
import useLocalize from '@hooks/useLocalize';
-import styles from '@styles/styles';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import TabSelectorItem from './TabSelectorItem';
@@ -68,24 +68,28 @@ const getOpacity = (position, routesLength, tabIndex, active, affectedTabs) => {
return activeValue;
};
-const getBackgroundColor = (position, routesLength, tabIndex, affectedTabs) => {
- if (routesLength > 1) {
- const inputRange = Array.from({length: routesLength}, (v, i) => i);
-
- return position.interpolate({
- inputRange,
- outputRange: _.map(inputRange, (i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? themeColors.border : themeColors.appBG)),
- });
- }
- return themeColors.border;
-};
-
function TabSelector({state, navigation, onTabPress, position}) {
const {translate} = useLocalize();
-
+ const styles = useThemeStyles();
+ const theme = useTheme();
const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: state.routes.length}, (v, i) => i), [state.routes.length]);
const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs);
+ const getBackgroundColor = useCallback(
+ (routesLength, tabIndex, affectedTabs) => {
+ if (routesLength > 1) {
+ const inputRange = Array.from({length: routesLength}, (v, i) => i);
+
+ return position.interpolate({
+ inputRange,
+ outputRange: _.map(inputRange, (i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? theme.border : theme.appBG)),
+ });
+ }
+ return theme.border;
+ },
+ [theme, position],
+ );
+
React.useEffect(() => {
// It is required to wait transition end to reset affectedAnimatedTabs because tabs style is still animating during transition.
setTimeout(() => {
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index d6d49e3fe288..78724718b2af 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -6,12 +6,13 @@ import OptionsSelector from '@components/OptionsSelector';
import useLocalize from '@hooks/useLocalize';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
+import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {defaultProps, propTypes} from './tagPickerPropTypes';
-function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit, shouldShowDisabledAndSelectedOption}) {
+function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, shouldShowDisabledAndSelectedOption, insets, onSubmit}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [searchValue, setSearchValue] = useState('');
@@ -37,17 +38,6 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
];
}, [selectedTag]);
- const initialFocusedIndex = useMemo(() => {
- if (isTagsCountBelowThreshold && selectedOptions.length > 0) {
- return _.chain(policyTagList)
- .values()
- .findIndex((policyTag) => policyTag.name === selectedOptions[0].name, true)
- .value();
- }
-
- return 0;
- }, [policyTagList, selectedOptions, isTagsCountBelowThreshold]);
-
const enabledTags = useMemo(() => {
if (!shouldShowDisabledAndSelectedOption) {
return policyTagList;
@@ -64,8 +54,11 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
+ const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (policyTag) => policyTag.searchText === selectedTag)[0], 'keyForList');
+
return (
diff --git a/src/components/TagPicker/tagPickerPropTypes.js b/src/components/TagPicker/tagPickerPropTypes.js
index 1221c939b940..b98f7f6ef8e9 100644
--- a/src/components/TagPicker/tagPickerPropTypes.js
+++ b/src/components/TagPicker/tagPickerPropTypes.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import tagPropTypes from '@components/tagPropTypes';
+import safeAreaInsetPropTypes from '@pages/safeAreaInsetPropTypes';
const propTypes = {
/** The policyID we are getting tags for */
@@ -14,6 +15,12 @@ const propTypes = {
/** Callback to submit the selected tag */
onSubmit: PropTypes.func.isRequired,
+ /**
+ * Safe area insets required for reflecting the portion of the view,
+ * that is not covered by navigation bars, tab bars, toolbars, and other ancestor views.
+ */
+ insets: safeAreaInsetPropTypes.isRequired,
+
/* Onyx Props */
/** Collection of tags attached to a policy */
policyTags: tagPropTypes,
diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js
index 3e2a835e0722..09ca427b8e56 100644
--- a/src/components/TaskHeaderActionButton.js
+++ b/src/components/TaskHeaderActionButton.js
@@ -6,6 +6,7 @@ import compose from '@libs/compose';
import * as ReportUtils from '@libs/ReportUtils';
import reportPropTypes from '@pages/reportPropTypes';
import useThemeStyles from '@styles/useThemeStyles';
+import * as Session from '@userActions/Session';
import * as Task from '@userActions/Task';
import ONYXKEYS from '@src/ONYXKEYS';
import Button from './Button';
@@ -38,7 +39,7 @@ function TaskHeaderActionButton(props) {
isDisabled={!Task.canModifyTask(props.report, props.session.accountID)}
medium
text={props.translate(ReportUtils.isCompletedTaskReport(props.report) ? 'task.markAsIncomplete' : 'task.markAsComplete')}
- onPress={() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report))}
+ onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report)))}
style={[styles.flex1]}
/>
diff --git a/src/components/TextInput/BaseTextInput/index.native.js b/src/components/TextInput/BaseTextInput/index.native.js
index 60863cfb5771..c30f932fb3a6 100644
--- a/src/components/TextInput/BaseTextInput/index.native.js
+++ b/src/components/TextInput/BaseTextInput/index.native.js
@@ -28,6 +28,7 @@ function BaseTextInput(props) {
const styles = useThemeStyles();
const initialValue = props.value || props.defaultValue || '';
const initialActiveLabel = props.forceActiveLabel || initialValue.length > 0 || Boolean(props.prefixCharacter);
+ const isMultiline = props.multiline || props.autoGrowHeight;
const [isFocused, setIsFocused] = useState(false);
const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry);
@@ -172,10 +173,12 @@ function BaseTextInput(props) {
/**
* Set Value & activateLabel
*
- * @param {String} value
+ * @param {String} val
* @memberof BaseTextInput
*/
- const setValue = (value) => {
+ const setValue = (val) => {
+ const value = isMultiline ? val : val.replace(/\n/g, ' ');
+
if (props.onInputChange) {
props.onInputChange(value);
}
@@ -184,7 +187,7 @@ function BaseTextInput(props) {
if (value && value.length > 0) {
hasValueRef.current = true;
- // When the componment is uncontrolled, we need to manually activate the label:
+ // When the component is uncontrolled, we need to manually activate the label
if (props.value === undefined) {
activateLabel();
}
@@ -227,7 +230,6 @@ function BaseTextInput(props) {
(props.hasError || props.errorText) && styles.borderColorDanger,
props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight},
]);
- const isMultiline = props.multiline || props.autoGrowHeight;
return (
<>
diff --git a/src/components/ThumbnailImage.js b/src/components/ThumbnailImage.tsx
similarity index 60%
rename from src/components/ThumbnailImage.js
rename to src/components/ThumbnailImage.tsx
index 30bbbe525100..0fdd626a1517 100644
--- a/src/components/ThumbnailImage.js
+++ b/src/components/ThumbnailImage.tsx
@@ -1,51 +1,52 @@
import lodashClamp from 'lodash/clamp';
-import PropTypes from 'prop-types';
import React, {useCallback, useState} from 'react';
-import {Dimensions, View} from 'react-native';
+import {Dimensions, StyleProp, View, ViewStyle} from 'react-native';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import ImageWithSizeCalculation from './ImageWithSizeCalculation';
-const propTypes = {
+type ThumbnailImageProps = {
/** Source URL for the preview image */
- previewSourceURL: PropTypes.string.isRequired,
+ previewSourceURL: string;
/** Any additional styles to apply */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.any,
+ style?: StyleProp;
/** Whether the image requires an authToken */
- isAuthTokenRequired: PropTypes.bool.isRequired,
+ isAuthTokenRequired: boolean;
/** Width of the thumbnail image */
- imageWidth: PropTypes.number,
+ imageWidth?: number;
/** Height of the thumbnail image */
- imageHeight: PropTypes.number,
+ imageHeight?: number;
/** Should the image be resized on load or just fit container */
- shouldDynamicallyResize: PropTypes.bool,
+ shouldDynamicallyResize?: boolean;
};
-const defaultProps = {
- style: {},
- imageWidth: 200,
- imageHeight: 200,
- shouldDynamicallyResize: true,
+type UpdateImageSizeParams = {
+ width: number;
+ height: number;
+};
+
+type CalculateThumbnailImageSizeResult = {
+ thumbnailWidth?: number;
+ thumbnailHeight?: number;
};
/**
* Compute the thumbnails width and height given original image dimensions.
*
- * @param {Number} width - Width of the original image.
- * @param {Number} height - Height of the original image.
- * @param {Number} windowHeight - Height of the device/browser window.
- * @returns {Object} - Object containing thumbnails width and height.
+ * @param width - Width of the original image.
+ * @param height - Height of the original image.
+ * @param windowHeight - Height of the device/browser window.
+ * @returns - Object containing thumbnails width and height.
*/
-function calculateThumbnailImageSize(width, height, windowHeight) {
+function calculateThumbnailImageSize(width: number, height: number, windowHeight: number): CalculateThumbnailImageSizeResult {
if (!width || !height) {
return {};
}
@@ -69,44 +70,42 @@ function calculateThumbnailImageSize(width, height, windowHeight) {
return {thumbnailWidth: Math.max(40, thumbnailScreenWidth), thumbnailHeight: Math.max(40, thumbnailScreenHeight)};
}
-function ThumbnailImage(props) {
+function ThumbnailImage({previewSourceURL, style, isAuthTokenRequired, imageWidth = 200, imageHeight = 200, shouldDynamicallyResize = true}: ThumbnailImageProps) {
const styles = useThemeStyles();
const {windowHeight} = useWindowDimensions();
- const initialDimensions = calculateThumbnailImageSize(props.imageWidth, props.imageHeight, windowHeight);
- const [imageWidth, setImageWidth] = useState(initialDimensions.thumbnailWidth);
- const [imageHeight, setImageHeight] = useState(initialDimensions.thumbnailHeight);
+ const initialDimensions = calculateThumbnailImageSize(imageWidth, imageHeight, windowHeight);
+ const [currentImageWidth, setCurrentImageWidth] = useState(initialDimensions.thumbnailWidth);
+ const [currentImageHeight, setCurrentImageHeight] = useState(initialDimensions.thumbnailHeight);
/**
* Update the state with the computed thumbnail sizes.
- *
- * @param {{ width: number, height: number }} Params - width and height of the original image.
+ * @param Params - width and height of the original image.
*/
const updateImageSize = useCallback(
- ({width, height}) => {
+ ({width, height}: UpdateImageSizeParams) => {
const {thumbnailWidth, thumbnailHeight} = calculateThumbnailImageSize(width, height, windowHeight);
- setImageWidth(thumbnailWidth);
- setImageHeight(thumbnailHeight);
+
+ setCurrentImageWidth(thumbnailWidth);
+ setCurrentImageHeight(thumbnailHeight);
},
[windowHeight],
);
- const sizeStyles = props.shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(imageWidth, imageHeight)] : [styles.w100, styles.h100];
+ const sizeStyles = shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(currentImageWidth ?? 0, currentImageHeight)] : [styles.w100, styles.h100];
return (
-
+
);
}
-ThumbnailImage.propTypes = propTypes;
-ThumbnailImage.defaultProps = defaultProps;
ThumbnailImage.displayName = 'ThumbnailImage';
export default React.memo(ThumbnailImage);
diff --git a/src/components/UnorderedList.js b/src/components/UnorderedList.js
deleted file mode 100644
index c3300c11aae0..000000000000
--- a/src/components/UnorderedList.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import useThemeStyles from '@styles/useThemeStyles';
-import Text from './Text';
-
-const propTypes = {
- /** An array of strings to display as an unordered list */
- items: PropTypes.arrayOf(PropTypes.string),
-};
-const defaultProps = {
- items: [],
-};
-
-function UnorderedList(props) {
- const styles = useThemeStyles();
- return (
- <>
- {_.map(props.items, (itemText) => (
-
- {'\u2022'}
- {itemText}
-
- ))}
- >
- );
-}
-
-UnorderedList.displayName = 'UnorderedList';
-UnorderedList.propTypes = propTypes;
-UnorderedList.defaultProps = defaultProps;
-
-export default UnorderedList;
diff --git a/src/components/UnorderedList.tsx b/src/components/UnorderedList.tsx
new file mode 100644
index 000000000000..a51cefce9ce6
--- /dev/null
+++ b/src/components/UnorderedList.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import {View} from 'react-native';
+import useThemeStyles from '@styles/useThemeStyles';
+import Text from './Text';
+
+type UnorderedListProps = {
+ /** An array of strings to display as an unordered list */
+ items?: string[];
+};
+
+function UnorderedList({items = []}: UnorderedListProps) {
+ const styles = useThemeStyles();
+
+ return items.map((itemText) => (
+
+ {'\u2022'}
+ {itemText}
+
+ ));
+}
+
+UnorderedList.displayName = 'UnorderedList';
+export default UnorderedList;
diff --git a/src/components/UnreadActionIndicator.js b/src/components/UnreadActionIndicator.tsx
similarity index 53%
rename from src/components/UnreadActionIndicator.js
rename to src/components/UnreadActionIndicator.tsx
index 7555c93c2326..b34f962e57bd 100755
--- a/src/components/UnreadActionIndicator.js
+++ b/src/components/UnreadActionIndicator.tsx
@@ -1,26 +1,31 @@
import React from 'react';
import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-function UnreadActionIndicator(props) {
+type UnreadActionIndicatorProps = {
+ reportActionID: string;
+};
+
+function UnreadActionIndicator({reportActionID}: UnreadActionIndicatorProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
return (
- {props.translate('common.new')}
+ {translate('common.new')}
);
}
-UnreadActionIndicator.propTypes = {...withLocalizePropTypes};
-
UnreadActionIndicator.displayName = 'UnreadActionIndicator';
-export default withLocalize(UnreadActionIndicator);
+
+export default UnreadActionIndicator;
diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js
index 049fe60630e5..a26cf9a76cf5 100644
--- a/src/components/transactionPropTypes.js
+++ b/src/components/transactionPropTypes.js
@@ -31,25 +31,28 @@ export default PropTypes.shape({
modifiedMerchant: PropTypes.string,
/** The comment object on the transaction */
- comment: PropTypes.shape({
- /** The text of the comment */
- comment: PropTypes.string,
+ comment: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.shape({
+ /** The text of the comment */
+ comment: PropTypes.string,
- /** The waypoints defining the distance request */
- waypoints: PropTypes.shape({
- /** The latitude of the waypoint */
- lat: PropTypes.number,
+ /** The waypoints defining the distance request */
+ waypoints: PropTypes.shape({
+ /** The latitude of the waypoint */
+ lat: PropTypes.number,
- /** The longitude of the waypoint */
- lng: PropTypes.number,
+ /** The longitude of the waypoint */
+ lng: PropTypes.number,
- /** The address of the waypoint */
- address: PropTypes.string,
+ /** The address of the waypoint */
+ address: PropTypes.string,
- /** The name of the waypoint */
- name: PropTypes.string,
+ /** The name of the waypoint */
+ name: PropTypes.string,
+ }),
}),
- }),
+ ]),
/** The type of transaction */
type: PropTypes.oneOf(_.values(CONST.TRANSACTION.TYPE)),
diff --git a/src/components/withCurrentReportID.tsx b/src/components/withCurrentReportID.tsx
index 22da02159073..3ce9eeae37b5 100644
--- a/src/components/withCurrentReportID.tsx
+++ b/src/components/withCurrentReportID.tsx
@@ -8,6 +8,7 @@ type CurrentReportIDContextValue = {
updateCurrentReportID: (state: NavigationState) => void;
currentReportID: string;
};
+
type CurrentReportIDContextProviderProps = {
/** Actual content wrapped by this component */
children: React.ReactNode;
diff --git a/src/components/withEnvironment.tsx b/src/components/withEnvironment.tsx
index 1bfc147f0db3..6054b43f97f5 100644
--- a/src/components/withEnvironment.tsx
+++ b/src/components/withEnvironment.tsx
@@ -19,7 +19,10 @@ type EnvironmentContextValue = {
environmentURL: string;
};
-const EnvironmentContext = createContext(null);
+const EnvironmentContext = createContext({
+ environment: CONST.ENVIRONMENT.PRODUCTION,
+ environmentURL: CONST.NEW_EXPENSIFY_URL,
+});
function EnvironmentProvider({children}: EnvironmentProviderProps): ReactElement {
const [environment, setEnvironment] = useState(CONST.ENVIRONMENT.PRODUCTION);
@@ -47,7 +50,7 @@ export default function withEnvironment>,
): (props: Omit & React.RefAttributes) => ReactElement | null {
function WithEnvironment(props: Omit, ref: ForwardedRef): ReactElement {
- const {environment, environmentURL} = useContext(EnvironmentContext) ?? {};
+ const {environment, environmentURL} = useContext(EnvironmentContext);
return (
;
+ navigation: NavigationProp;
};
export default function withNavigation(
diff --git a/src/components/withTheme.tsx b/src/components/withTheme.tsx
index d78742b7036b..451292f1a66f 100644
--- a/src/components/withTheme.tsx
+++ b/src/components/withTheme.tsx
@@ -29,4 +29,4 @@ export default function withTheme(
return forwardRef(WithTheme);
}
-export {withThemePropTypes};
+export {withThemePropTypes, type ThemeProps};
diff --git a/src/components/withThemeStyles.tsx b/src/components/withThemeStyles.tsx
index d95122c3e2ba..8ea55c5fbef8 100644
--- a/src/components/withThemeStyles.tsx
+++ b/src/components/withThemeStyles.tsx
@@ -29,4 +29,4 @@ export default function withThemeStyles(
return forwardRef(WithThemeStyles);
}
-export {withThemeStylesPropTypes};
+export {withThemeStylesPropTypes, type ThemeStylesProps};
diff --git a/src/hooks/useEnvironment.ts b/src/hooks/useEnvironment.ts
index 60e0b4a12394..b8584d0f0a48 100644
--- a/src/hooks/useEnvironment.ts
+++ b/src/hooks/useEnvironment.ts
@@ -3,13 +3,13 @@ import {EnvironmentContext} from '@components/withEnvironment';
import type {EnvironmentContextValue} from '@components/withEnvironment';
import CONST from '@src/CONST';
-type UseEnvironment = Partial & {
+type UseEnvironment = EnvironmentContextValue & {
isProduction: boolean;
isDevelopment: boolean;
};
export default function useEnvironment(): UseEnvironment {
- const {environment, environmentURL} = useContext(EnvironmentContext) ?? {};
+ const {environment, environmentURL} = useContext(EnvironmentContext);
return {
environment,
environmentURL,
diff --git a/src/hooks/useFlipper/index.js b/src/hooks/useFlipper/index.js
deleted file mode 100644
index 2d1ec238274a..000000000000
--- a/src/hooks/useFlipper/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export default () => {};
diff --git a/src/hooks/useFlipper/index.native.js b/src/hooks/useFlipper/index.native.js
deleted file mode 100644
index 90779d5b8a14..000000000000
--- a/src/hooks/useFlipper/index.native.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import {useFlipper} from '@react-navigation/devtools';
-
-export default useFlipper;
diff --git a/src/hooks/useFlipper/index.native.ts b/src/hooks/useFlipper/index.native.ts
new file mode 100644
index 000000000000..df1aa3bf513b
--- /dev/null
+++ b/src/hooks/useFlipper/index.native.ts
@@ -0,0 +1,6 @@
+import {useFlipper as useFlipperRN} from '@react-navigation/devtools';
+import UseFlipper from './types';
+
+const useFlipper: UseFlipper = useFlipperRN;
+
+export default useFlipper;
diff --git a/src/hooks/useFlipper/index.ts b/src/hooks/useFlipper/index.ts
new file mode 100644
index 000000000000..26d4c9659ad8
--- /dev/null
+++ b/src/hooks/useFlipper/index.ts
@@ -0,0 +1,5 @@
+import UseFlipper from './types';
+
+const useFlipper: UseFlipper = () => {};
+
+export default useFlipper;
diff --git a/src/hooks/useFlipper/types.ts b/src/hooks/useFlipper/types.ts
new file mode 100644
index 000000000000..a995414e5dd1
--- /dev/null
+++ b/src/hooks/useFlipper/types.ts
@@ -0,0 +1,6 @@
+import {NavigationContainerRefWithCurrent} from '@react-navigation/core';
+import {RootStackParamList} from '@libs/Navigation/types';
+
+type UseFlipper = (ref: NavigationContainerRefWithCurrent) => void;
+
+export default UseFlipper;
diff --git a/src/hooks/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut.ts
index e4a7a16f4cfc..a5921ba37e4a 100644
--- a/src/hooks/useKeyboardShortcut.ts
+++ b/src/hooks/useKeyboardShortcut.ts
@@ -1,4 +1,5 @@
import {useEffect} from 'react';
+import {GestureResponderEvent} from 'react-native';
import {ValueOf} from 'type-fest';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import CONST from '@src/CONST';
@@ -23,7 +24,7 @@ type KeyboardShortcutConfig = {
* Register a keyboard shortcut handler.
* Recommendation: To ensure stability, wrap the `callback` function with the useCallback hook before using it with this hook.
*/
-export default function useKeyboardShortcut(shortcut: Shortcut, callback: () => void, config: KeyboardShortcutConfig | Record = {}) {
+export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: GestureResponderEvent | KeyboardEvent) => void, config: KeyboardShortcutConfig | Record = {}) {
const {
captureOnInputs = true,
shouldBubble = false,
diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts
index 0d4c956b7bd8..24de5d8da63a 100644
--- a/src/hooks/useReportScrollManager/index.native.ts
+++ b/src/hooks/useReportScrollManager/index.native.ts
@@ -3,7 +3,7 @@ import {ActionListContext} from '@pages/home/ReportScreenContext';
import ReportScrollManagerData from './types';
function useReportScrollManager(): ReportScrollManagerData {
- const flatListRef = useContext(ActionListContext);
+ const {flatListRef, setScrollPosition} = useContext(ActionListContext);
/**
* Scroll to the provided index.
@@ -24,8 +24,10 @@ function useReportScrollManager(): ReportScrollManagerData {
return;
}
- flatListRef.current.scrollToOffset({animated: false, offset: 0});
- }, [flatListRef]);
+ setScrollPosition({offset: 0});
+
+ flatListRef.current?.scrollToOffset({animated: false, offset: 0});
+ }, [flatListRef, setScrollPosition]);
return {ref: flatListRef, scrollToIndex, scrollToBottom};
}
diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts
index 081844fc8c79..032417ed9fde 100644
--- a/src/hooks/useReportScrollManager/index.ts
+++ b/src/hooks/useReportScrollManager/index.ts
@@ -3,7 +3,7 @@ import {ActionListContext} from '@pages/home/ReportScreenContext';
import ReportScrollManagerData from './types';
function useReportScrollManager(): ReportScrollManagerData {
- const flatListRef = useContext(ActionListContext);
+ const {flatListRef} = useContext(ActionListContext);
/**
* Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because
diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts
index a10238d51f74..a0d7f14f7b84 100644
--- a/src/hooks/useReportScrollManager/types.ts
+++ b/src/hooks/useReportScrollManager/types.ts
@@ -1,7 +1,7 @@
-import {ActionListContextType} from '@pages/home/ReportScreenContext';
+import {FlatListRefType} from '@pages/home/ReportScreenContext';
type ReportScrollManagerData = {
- ref: ActionListContextType;
+ ref: FlatListRefType;
scrollToIndex: (index: number, isEditing?: boolean) => void;
scrollToBottom: () => void;
};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 4c6ea25eb2c8..7deb38b47e28 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -215,6 +215,8 @@ export default {
more: 'More',
debitCard: 'Debit card',
bankAccount: 'Bank account',
+ personalBankAccount: 'Personal bank account',
+ businessBankAccount: 'Business bank account',
join: 'Join',
leave: 'Leave',
decline: 'Decline',
@@ -268,6 +270,8 @@ export default {
selectCurrency: 'Select a currency',
card: 'Card',
required: 'Required',
+ showing: 'Showing',
+ of: 'of',
},
location: {
useCurrent: 'Use current location',
@@ -584,13 +588,16 @@ export default {
`changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
- tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`,
- categorySelection: 'Select a category to add additional organization to your money',
+ tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money.`,
+ categorySelection: 'Select a category to add additional organization to your money.',
error: {
invalidAmount: 'Please enter a valid amount before continuing.',
invalidSplit: 'Split amounts do not equal total amount',
other: 'Unexpected error, please try again later',
genericCreateFailureMessage: 'Unexpected error requesting money, please try again later',
+ receiptFailureMessage: "The receipt didn't upload. ",
+ saveFileMessage: 'Download the file ',
+ loseFileMessage: 'or dismiss this error and lose it',
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',
@@ -903,6 +910,8 @@ export default {
activatePhysicalCard: 'Activate physical card',
error: {
thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.",
+ throttled:
+ "You've incorrectly entered the last 4 digits of your Expensify Card too many times. If you're sure the numbers are correct, please reach out to Concierge to resolve. Otherwise, try again later.",
},
},
getPhysicalCard: {
@@ -1182,7 +1191,7 @@ export default {
noBankAccountAvailable: 'Sorry, no bank account is available',
noBankAccountSelected: 'Please choose an account',
taxID: 'Please enter a valid tax ID number',
- website: 'Please enter a valid website',
+ website: 'Please enter a valid website. The website should be in lowercase.',
zipCode: `Incorrect zip code format. Acceptable format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`,
phoneNumber: 'Please enter a valid phone number',
companyName: 'Please enter a valid legal business name',
@@ -1664,6 +1673,7 @@ export default {
genericCreateTaskFailureMessage: 'Unexpected error create task, please try again later.',
},
statementPage: {
+ title: (year, monthName) => `${monthName} ${year} statement`,
generatingPDF: "We're generating your PDF right now. Please come back later!",
},
keyboardShortcutsPage: {
@@ -1937,25 +1947,31 @@ export default {
buttonText1: 'Start a chat, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Start a chat, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Start a chat with a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
+ body1: `Start a chat with a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: {
buttonText1: 'Request money, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Request money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
+ body1: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: {
buttonText1: 'Send money, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Send money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Send money to a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
+ body1: `Send money to a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: {
buttonText1: 'Refer a friend, ',
buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
header: `Refer a friend, get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Send your Expensify referral link to a friend or anyone else you know who spends too much time on expenses. When they start an annual subscription, you'll get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ body1: `Send your Expensify referral link to a friend or anyone else you know who spends too much time on expenses. When they start an annual subscription, you'll get $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ },
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]: {
+ buttonText1: `Get $${CONST.REFERRAL_PROGRAM.REVENUE}`,
+ header: `Get $${CONST.REFERRAL_PROGRAM.REVENUE} for every referral`,
+ body1: 'If you know anyone who’s spending too much time on expenses (literally anyone – your neighbor, your boss, your friend in accounting), send them your Expensify referral link:',
+ body2: `When they start an annual subscription, you’ll get $${CONST.REFERRAL_PROGRAM.REVENUE}. Easy as that.`,
},
copyReferralLink: 'Copy referral link',
},
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 85eab5c3f14d..b52975496e08 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -205,6 +205,8 @@ export default {
more: 'Más',
debitCard: 'Tarjeta de débito',
bankAccount: 'Cuenta bancaria',
+ personalBankAccount: 'Cuenta bancaria personal',
+ businessBankAccount: 'Cuenta bancaria comercial',
join: 'Unirse',
leave: 'Salir',
decline: 'Rechazar',
@@ -258,6 +260,8 @@ export default {
selectCurrency: 'Selecciona una moneda',
card: 'Tarjeta',
required: 'Obligatorio',
+ showing: 'Mostrando',
+ of: 'de',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -578,13 +582,16 @@ export default {
`cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`,
threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
- tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`,
- categorySelection: 'Seleccione una categoría para organizar mejor tu dinero',
+ tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero.`,
+ categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.',
error: {
invalidAmount: 'Por favor ingresa un monto válido antes de continuar.',
invalidSplit: 'La suma de las partes no equivale al monto total',
other: 'Error inesperado, por favor inténtalo más tarde',
genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde',
+ receiptFailureMessage: 'El recibo no se subió. ',
+ saveFileMessage: 'Guarda el archivo ',
+ loseFileMessage: 'o descarta este error y piérdelo',
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',
@@ -899,6 +906,8 @@ export default {
activatePhysicalCard: 'Activar tarjeta física',
error: {
thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.',
+ throttled:
+ 'Has introducido incorrectamente los 4 últimos dígitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con Conserjería para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.',
},
},
// TODO: add translation
@@ -1199,7 +1208,7 @@ export default {
noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible',
noBankAccountSelected: 'Por favor, elige una cuenta bancaria',
taxID: 'Por favor, introduce un número de identificación fiscal válido',
- website: 'Por favor, introduce un sitio web válido',
+ website: 'Por favor, introduce un sitio web válido. El sitio web debe estar en minúsculas.',
zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`,
phoneNumber: 'Por favor, introduce un teléfono válido',
companyName: 'Por favor, introduce un nombre comercial legal válido',
@@ -1688,6 +1697,7 @@ export default {
genericCreateTaskFailureMessage: 'Error inesperado al crear el tarea, por favor, inténtalo más tarde.',
},
statementPage: {
+ title: (year, monthName) => `Estado de cuenta de ${monthName} ${year}`,
generatingPDF: 'Estamos generando tu PDF ahora mismo. ¡Por favor, vuelve más tarde!',
},
keyboardShortcutsPage: {
@@ -2422,25 +2432,31 @@ export default {
buttonText1: 'Inicia un chat y ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Inicia un chat y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Inicia un chat con una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
+ body1: `Inicia un chat con una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: {
buttonText1: 'Pide dinero, ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Pide dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Pide dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
+ body1: `Pide dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: {
buttonText1: 'Envía dinero, ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Envía dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Envía dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
+ body1: `Envía dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`,
},
[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: {
buttonText1: 'Recomienda a un amigo y ',
buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
header: `Recomienda a un amigo y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
- body: `Envía tu enlace de invitación de Expensify a un amigo o a cualquier otra persona que conozcas que dedique demasiado tiempo a los gastos. Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ body1: `Envía tu enlace de invitación de Expensify a un amigo o a cualquier otra persona que conozcas que dedique demasiado tiempo a los gastos. Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}.`,
+ },
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE]: {
+ buttonText1: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`,
+ header: `Recibe $${CONST.REFERRAL_PROGRAM.REVENUE} por cada recomendación`,
+ body1: 'Si conoces a alguien que dedique demasiado tiempo a los gastos (literalmente cualquiera: tu vecino, tu jefe, tu amigo de contabilidad), envíale tu enlace de invitación de Expensify:',
+ body2: `Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}. Así de fácil.`,
},
copyReferralLink: 'Copiar enlace de invitación',
},
diff --git a/src/languages/types.ts b/src/languages/types.ts
index e2af3222a98f..a012ebdfb95b 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -111,17 +111,17 @@ type DidSplitAmountMessageParams = {formattedAmount: string; comment: string};
type AmountEachParams = {amount: number};
-type PayerOwesAmountParams = {payer: string; amount: number};
+type PayerOwesAmountParams = {payer: string; amount: number | string};
type PayerOwesParams = {payer: string};
-type PayerPaidAmountParams = {payer: string; amount: number};
+type PayerPaidAmountParams = {payer: string; amount: number | string};
type ManagerApprovedParams = {manager: string};
type PayerPaidParams = {payer: string};
-type PayerSettledParams = {amount: number};
+type PayerSettledParams = {amount: number | string};
type WaitingOnBankAccountParams = {submitterDisplayName: string};
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 0dc483aff50e..b0d426c9774a 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -37,6 +37,14 @@ function isExpensifyCard(cardID: number) {
return card.bank === CONST.EXPENSIFY_CARD.BANK;
}
+/**
+ * @param cardID
+ * @returns boolean if the cardID is in the cardList from ONYX. Includes Expensify Cards.
+ */
+function isCorporateCard(cardID: number) {
+ return !!allCards[cardID];
+}
+
/**
* @param cardID
* @returns string in format % - %.
@@ -99,4 +107,4 @@ function findPhysicalCard(cards: Card[]) {
return cards.find((card) => !card.isVirtual);
}
-export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription, findPhysicalCard};
+export {isExpensifyCard, isCorporateCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription, findPhysicalCard};
diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts
index 5a7da7ca08cf..32ebca9afee8 100644
--- a/src/libs/ComposerUtils/index.ts
+++ b/src/libs/ComposerUtils/index.ts
@@ -23,19 +23,4 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo
return (isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen()) || isKeyboardShown;
}
-/**
- * Returns the length of the common suffix between two input strings.
- * The common suffix is the number of characters shared by both strings
- * at the end (suffix) until a mismatch is encountered.
- *
- * @returns The length of the common suffix between the strings.
- */
-function getCommonSuffixLength(str1: string, str2: string): number {
- let i = 0;
- while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
- i++;
- }
- return i;
-}
-
-export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys, getCommonSuffixLength};
+export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys};
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/index.native.ts b/src/libs/ComposerUtils/updateNumberOfLines/index.native.ts
index df9292ecd690..6f87edaf2475 100644
--- a/src/libs/ComposerUtils/updateNumberOfLines/index.native.ts
+++ b/src/libs/ComposerUtils/updateNumberOfLines/index.native.ts
@@ -1,13 +1,12 @@
import getNumberOfLines from '@libs/ComposerUtils/getNumberOfLines';
import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
-import styles from '@styles/styles';
import UpdateNumberOfLines from './types';
/**
* Check the current scrollHeight of the textarea (minus any padding) and
* divide by line height to get the total number of rows for the textarea.
*/
-const updateNumberOfLines: UpdateNumberOfLines = (props, event) => {
+const updateNumberOfLines: UpdateNumberOfLines = (props, event, styles) => {
const lineHeight = styles.textInputCompose.lineHeight ?? 0;
const paddingTopAndBottom = styles.textInputComposeSpacing.paddingVertical * 2;
const inputHeight = event?.nativeEvent?.contentSize?.height ?? null;
diff --git a/src/libs/ComposerUtils/updateNumberOfLines/types.ts b/src/libs/ComposerUtils/updateNumberOfLines/types.ts
index b0f9ba48ddc2..cef7d7ef6a80 100644
--- a/src/libs/ComposerUtils/updateNumberOfLines/types.ts
+++ b/src/libs/ComposerUtils/updateNumberOfLines/types.ts
@@ -1,6 +1,7 @@
import {NativeSyntheticEvent, TextInputContentSizeChangeEventData} from 'react-native';
import ComposerProps from '@libs/ComposerUtils/types';
+import themeStyles from '@styles/styles';
-type UpdateNumberOfLines = (props: ComposerProps, event: NativeSyntheticEvent) => void;
+type UpdateNumberOfLines = (props: ComposerProps, event: NativeSyntheticEvent, styles: typeof themeStyles) => void;
export default UpdateNumberOfLines;
diff --git a/src/libs/E2E/apiMocks/beginSignin.ts b/src/libs/E2E/apiMocks/beginSignin.ts
index 298846250a12..c5002f1f3dd8 100644
--- a/src/libs/E2E/apiMocks/beginSignin.ts
+++ b/src/libs/E2E/apiMocks/beginSignin.ts
@@ -17,11 +17,6 @@ const beginSignin = ({email}: SigninParams): Response => ({
validated: true,
},
},
- {
- onyxMethod: 'set',
- key: 'betas',
- value: ['passwordless'],
- },
],
jsonCode: 200,
requestID: '783e54ef4b38cff5-SJC',
diff --git a/src/libs/E2E/apiMocks/openApp.ts b/src/libs/E2E/apiMocks/openApp.ts
index 13fc9f1f6784..a0583d8439c0 100644
--- a/src/libs/E2E/apiMocks/openApp.ts
+++ b/src/libs/E2E/apiMocks/openApp.ts
@@ -1457,82 +1457,7 @@ const openApp = (): Response => ({
{
onyxMethod: 'set',
key: 'betas',
- value: [
- 'all',
- 'pdfMetaStore',
- 'reportActionContextMenu',
- 'submitPolicy',
- 'attendees',
- 'autoExport',
- 'autoExportIntacct',
- 'autoExportQbo',
- 'autoExportXero',
- 'autoJoinPolicy',
- 'automatedTaxExemption',
- 'billPay',
- 'categoryDefaultTax',
- 'collectableDepositAccounts',
- 'conciergeTravel',
- 'connectedCards',
- 'discrepancy',
- 'domainContactBilling',
- 'domainTwoFactorAuth',
- 'duplicateDetection',
- 'emailSuppressionBeta',
- 'expensesV2',
- 'expensifyCard',
- 'expensifyCardIntacctReconciliation',
- 'expensifyCardNetSuiteReconciliation',
- 'expensifyCardQBOReconciliation',
- 'expensifyCardRapidIncreaseFraud',
- 'expensifyCardXeroReconciliation',
- 'expensifyOrg',
- 'fixViolationPushNotification',
- 'freePlan',
- 'freePlanFullLaunch',
- 'freePlanSoftLaunch',
- 'gusto',
- 'inboxCache',
- 'inboxHiddenTasks',
- 'indirectIntegrationSetup',
- 'IOU',
- 'joinPolicy',
- 'loadPolicyAsync',
- 'mapReceipt',
- 'mergeAPI',
- 'mobileRealtimeReportComments',
- 'mobileSecureReceipts',
- 'monthlySettlement',
- 'namesAndAvatars',
- 'nativeChat',
- 'newPricing',
- 'newsletterThree',
- 'nextSteps',
- 'openFaceHamburger',
- 'pdfMetaStore',
- 'perDiem',
- 'perDiemInternational',
- 'pricingCopyChanges',
- 'qboInvoices',
- 'quickbooksDesktopV2',
- 'realtimeReportComments',
- 's2wAnnouncement',
- 'scheduledAutoReporting',
- 'secureReceipts',
- 'secureReceiptsReports',
- 'selfServiceHardLaunch',
- 'sendMoney',
- 'smartScanUserDisputes',
- 'smsSignUp',
- 'stripeConnect',
- 'submitPolicy',
- 'summaryEmail',
- 'swipeToWin',
- 'taxForMileage',
- 'twoFactorAuth',
- 'venmoIntegration',
- 'zenefitsIntegration',
- ],
+ value: ['all'],
},
{
onyxMethod: 'merge',
diff --git a/src/libs/E2E/apiMocks/signinUser.ts b/src/libs/E2E/apiMocks/signinUser.ts
index d94d2fda9016..a7d841196c48 100644
--- a/src/libs/E2E/apiMocks/signinUser.ts
+++ b/src/libs/E2E/apiMocks/signinUser.ts
@@ -36,82 +36,7 @@ const signinUser = ({email}: SigninParams): Response => ({
{
onyxMethod: 'set',
key: 'betas',
- value: [
- 'all',
- 'pdfMetaStore',
- 'reportActionContextMenu',
- 'submitPolicy',
- 'attendees',
- 'autoExport',
- 'autoExportIntacct',
- 'autoExportQbo',
- 'autoExportXero',
- 'autoJoinPolicy',
- 'automatedTaxExemption',
- 'billPay',
- 'categoryDefaultTax',
- 'collectableDepositAccounts',
- 'conciergeTravel',
- 'connectedCards',
- 'discrepancy',
- 'domainContactBilling',
- 'domainTwoFactorAuth',
- 'duplicateDetection',
- 'emailSuppressionBeta',
- 'expensesV2',
- 'expensifyCard',
- 'expensifyCardIntacctReconciliation',
- 'expensifyCardNetSuiteReconciliation',
- 'expensifyCardQBOReconciliation',
- 'expensifyCardRapidIncreaseFraud',
- 'expensifyCardXeroReconciliation',
- 'expensifyOrg',
- 'fixViolationPushNotification',
- 'freePlan',
- 'freePlanFullLaunch',
- 'freePlanSoftLaunch',
- 'gusto',
- 'inboxCache',
- 'inboxHiddenTasks',
- 'indirectIntegrationSetup',
- 'IOU',
- 'joinPolicy',
- 'loadPolicyAsync',
- 'mapReceipt',
- 'mergeAPI',
- 'mobileRealtimeReportComments',
- 'mobileSecureReceipts',
- 'monthlySettlement',
- 'namesAndAvatars',
- 'nativeChat',
- 'newPricing',
- 'newsletterThree',
- 'nextSteps',
- 'openFaceHamburger',
- 'pdfMetaStore',
- 'perDiem',
- 'perDiemInternational',
- 'pricingCopyChanges',
- 'qboInvoices',
- 'quickbooksDesktopV2',
- 'realtimeReportComments',
- 's2wAnnouncement',
- 'scheduledAutoReporting',
- 'secureReceipts',
- 'secureReceiptsReports',
- 'selfServiceHardLaunch',
- 'sendMoney',
- 'smartScanUserDisputes',
- 'smsSignUp',
- 'stripeConnect',
- 'submitPolicy',
- 'summaryEmail',
- 'swipeToWin',
- 'taxForMileage',
- 'twoFactorAuth',
- 'venmoIntegration',
- 'zenefitsIntegration',
- ],
+ value: ['all'],
},
{
onyxMethod: 'merge',
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 1308faa65d20..22e84921b1ee 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -13,7 +13,7 @@ import emojisTrie from './EmojiTrie';
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 ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number};
type UserReactions = {
id: string;
skinTones: Record;
@@ -333,8 +333,11 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI
if (!emojiData || emojiData.length === 0) {
return {text: newText, emojis};
}
- for (let i = 0; i < emojiData.length; i++) {
- const name = emojiData[i].slice(1, -1);
+
+ let cursorPosition;
+
+ for (const emoji of emojiData) {
+ const name = emoji.slice(1, -1);
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.
@@ -346,35 +349,46 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI
}
}
if (checkEmoji?.metaData?.code && checkEmoji?.metaData?.name) {
- let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
+ const emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
emojis.push({
name,
code: checkEmoji.metaData?.code,
types: checkEmoji.metaData.types,
});
- // If this is the last emoji in the message and it's the end of the message so far,
- // add a space after it so the user can keep typing easily.
- if (i === emojiData.length - 1) {
- emojiReplacement += ' ';
- }
+ // Set the cursor to the end of the last replaced Emoji. Note that we position after
+ // the extra space, if we added one.
+ cursorPosition = newText.indexOf(emoji) + emojiReplacement.length;
+
+ newText = newText.replace(emoji, emojiReplacement);
+ }
+ }
+
+ // cursorPosition, when not undefined, points to the end of the last emoji that was replaced.
+ // In that case we want to append a space at the cursor position, but only if the next character
+ // is not already a space (to avoid double spaces).
+ if (cursorPosition && cursorPosition > 0) {
+ const space = ' ';
- newText = newText.replace(emojiData[i], emojiReplacement);
+ if (newText.charAt(cursorPosition) !== space) {
+ newText = newText.slice(0, cursorPosition) + space + newText.slice(cursorPosition);
}
+ cursorPosition += space.length;
}
- return {text: newText, emojis};
+ return {text: newText, emojis, cursorPosition};
}
/**
* Find all emojis in a text and replace them with their code.
*/
function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji {
- const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang);
+ const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang);
return {
text: convertedText,
emojis: emojis.concat(extractEmojis(text)),
+ cursorPosition,
};
}
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
index 5bc8ea1d3508..46bdd510f5c4 100644
--- a/src/libs/ErrorUtils.ts
+++ b/src/libs/ErrorUtils.ts
@@ -42,6 +42,14 @@ function getMicroSecondOnyxError(error: string): Record {
return {[DateUtils.getMicroseconds()]: error};
}
+/**
+ * Method used to get an error object with microsecond as the key and an object as the value.
+ * @param error - error key or message to be saved
+ */
+function getMicroSecondOnyxErrorObject(error: Record): Record> {
+ return {[DateUtils.getMicroseconds()]: error};
+}
+
type OnyxDataWithErrors = {
errors?: Errors;
};
@@ -111,4 +119,4 @@ function addErrorMessage(errors: ErrorsList, inpu
}
}
-export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
+export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
diff --git a/src/libs/GroupChatUtils.ts b/src/libs/GroupChatUtils.ts
index dcb2b13f092c..db64f6574824 100644
--- a/src/libs/GroupChatUtils.ts
+++ b/src/libs/GroupChatUtils.ts
@@ -13,10 +13,11 @@ Onyx.connect({
/**
* Returns the report name if the report is a group chat
*/
-function getGroupChatName(report: Report): string {
+function getGroupChatName(report: Report): string | undefined {
const participants = report.participantAccountIDs ?? [];
const isMultipleParticipantReport = participants.length > 1;
const participantPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, allPersonalDetails ?? {});
+ // @ts-expect-error Error will gone when OptionsListUtils will be migrated to Typescript
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipantReport);
return ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips);
}
diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.ts
similarity index 65%
rename from src/libs/HttpUtils.js
rename to src/libs/HttpUtils.ts
index 2df7421ea91c..859c8624833c 100644
--- a/src/libs/HttpUtils.js
+++ b/src/libs/HttpUtils.ts
@@ -1,13 +1,16 @@
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
+import {ValueOf} from 'type-fest';
import alert from '@components/Alert';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import {RequestType} from '@src/types/onyx/Request';
+import type Response from '@src/types/onyx/Response';
import * as ApiUtils from './ApiUtils';
import HttpsError from './Errors/HttpsError';
let shouldFailAllRequests = false;
let shouldForceOffline = false;
+
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
@@ -25,14 +28,8 @@ let cancellationController = new AbortController();
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
- *
- * @param {String} url
- * @param {String} [method]
- * @param {Object} [body]
- * @param {Boolean} [canCancel]
- * @returns {Promise}
*/
-function processHTTPRequest(url, method = 'get', body = null, canCancel = true) {
+function processHTTPRequest(url: string, method: RequestType = 'get', body: FormData | null = null, canCancel = true): Promise {
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
signal: canCancel ? cancellationController.signal : undefined,
@@ -49,40 +46,41 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
if (!response.ok) {
// Expensify site is down or there was an internal server error, or something temporary like a Bad Gateway, or unknown error occurred
- const serviceInterruptedStatuses = [
+ const serviceInterruptedStatuses: Array> = [
CONST.HTTP_STATUS.INTERNAL_SERVER_ERROR,
CONST.HTTP_STATUS.BAD_GATEWAY,
CONST.HTTP_STATUS.GATEWAY_TIMEOUT,
CONST.HTTP_STATUS.UNKNOWN_ERROR,
];
- if (_.contains(serviceInterruptedStatuses, response.status)) {
+ if (serviceInterruptedStatuses.indexOf(response.status as ValueOf) > -1) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
- status: response.status,
+ status: response.status.toString(),
title: 'Issue connecting to Expensify site',
});
- } else if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
+ }
+ if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
throw new HttpsError({
message: CONST.ERROR.THROTTLED,
- status: response.status,
+ status: response.status.toString(),
title: 'API request throttled',
});
}
throw new HttpsError({
message: response.statusText,
- status: response.status,
+ status: response.status.toString(),
});
}
- return response.json();
+ return response.json() as Promise;
})
.then((response) => {
// Some retried requests will result in a "Unique Constraints Violation" error from the server, which just means the record already exists
if (response.jsonCode === CONST.JSON_CODE.BAD_REQUEST && response.message === CONST.ERROR_TITLE.DUPLICATE_RECORD) {
throw new HttpsError({
message: CONST.ERROR.DUPLICATE_RECORD,
- status: CONST.JSON_CODE.BAD_REQUEST,
+ status: CONST.JSON_CODE.BAD_REQUEST.toString(),
title: CONST.ERROR_TITLE.DUPLICATE_RECORD,
});
}
@@ -91,43 +89,42 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
- status: CONST.JSON_CODE.EXP_ERROR,
+ status: CONST.JSON_CODE.EXP_ERROR.toString(),
title: CONST.ERROR_TITLE.SOCKET,
});
}
if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) {
- const {phpCommandName, authWriteCommands} = response.data;
- // eslint-disable-next-line max-len
- const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
- ', ',
- )}. Check the APIWriteCommands class in Web-Expensify`;
- alert('Too many auth writes', message);
+ if (response.data) {
+ const {phpCommandName, authWriteCommands} = response.data;
+ // eslint-disable-next-line max-len
+ const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
+ ', ',
+ )}. Check the APIWriteCommands class in Web-Expensify`;
+ alert('Too many auth writes', message);
+ }
}
- return response;
+ return response as Promise;
});
}
/**
* Makes XHR request
- * @param {String} command the name of the API command
- * @param {Object} data parameters for the API command
- * @param {String} type HTTP request type (get/post)
- * @param {Boolean} shouldUseSecure should we use the secure server
- * @returns {Promise}
+ * @param command the name of the API command
+ * @param data parameters for the API command
+ * @param type HTTP request type (get/post)
+ * @param shouldUseSecure should we use the secure server
*/
-function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) {
+function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise {
const formData = new FormData();
- _.each(data, (val, key) => {
- // Do not send undefined request parameters to our API. They will be processed as strings of 'undefined'.
- if (_.isUndefined(val)) {
+ Object.keys(data).forEach((key) => {
+ if (typeof data[key] === 'undefined') {
return;
}
-
- formData.append(key, val);
+ formData.append(key, data[key] as string | Blob);
});
const url = ApiUtils.getCommandURL({shouldUseSecure, command});
- return processHTTPRequest(url, type, formData, data.canCancel);
+ return processHTTPRequest(url, type, formData, Boolean(data.canCancel));
}
function cancelPendingRequests() {
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index ff4f2aafc8a8..afbbcc2684a0 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -1,3 +1,4 @@
+import {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import {Report, Transaction} from '@src/types/onyx';
import * as CurrencyUtils from './CurrencyUtils';
@@ -35,8 +36,8 @@ function calculateAmount(numberOfParticipants: number, total: number, currency:
*
* @param isDeleting - whether the user is deleting the request
*/
-function updateIOUOwnerAndTotal(iouReport: Report, actorAccountID: number, amount: number, currency: string, isDeleting = false): Report {
- if (currency !== iouReport.currency) {
+function updateIOUOwnerAndTotal(iouReport: OnyxEntry, actorAccountID: number, amount: number, currency: string, isDeleting = false): OnyxEntry {
+ if (currency !== iouReport?.currency) {
return iouReport;
}
diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts
index 6910bc7e9bdb..488ff0d9b98a 100644
--- a/src/libs/Localize/index.ts
+++ b/src/libs/Localize/index.ts
@@ -96,10 +96,12 @@ function translateLocal(phrase: TKey, ...variable
return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables);
}
+type MaybePhraseKey = string | [string, Record & {isTranslated?: true}] | [];
+
/**
* Return translated string for given error.
*/
-function translateIfPhraseKey(message: string | [string, Record & {isTranslated?: true}] | []): string {
+function translateIfPhraseKey(message: MaybePhraseKey): string {
if (!message || (Array.isArray(message) && message.length === 0)) {
return '';
}
@@ -138,4 +140,4 @@ function getDevicePreferredLocale(): string {
}
export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale};
-export type {PhraseParameters, Phrase};
+export type {PhraseParameters, Phrase, MaybePhraseKey};
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index aedb2fa8d741..4c610bc12099 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -144,7 +144,7 @@ const defaultProps = {
function AuthScreens({isUsingMemoryOnlyKeys, lastUpdateIDAppliedToClient, session, lastOpenedPublicRoomID, demoInfo}) {
const styles = useThemeStyles();
const {isSmallScreenWidth} = useWindowDimensions();
- const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth);
+ const screenOptions = getRootNavigatorScreenOptions(isSmallScreenWidth, styles);
const isInitialRender = useRef(true);
if (isInitialRender.current) {
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 01573cb434b4..be803e62a98b 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -1,15 +1,9 @@
import {CardStyleInterpolators, createStackNavigator} from '@react-navigation/stack';
-import React from 'react';
+import React, {useMemo} from 'react';
import _ from 'underscore';
-import styles from '@styles/styles';
+import useThemeStyles from '@styles/useThemeStyles';
import SCREENS from '@src/SCREENS';
-const defaultSubRouteOptions = {
- cardStyle: styles.navigationScreenCardStyle,
- headerShown: false,
- cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
-};
-
/**
* Create a modal stack navigator with an array of sub-screens.
*
@@ -20,6 +14,17 @@ function createModalStackNavigator(screens) {
const ModalStackNavigator = createStackNavigator();
function ModalStack() {
+ const styles = useThemeStyles();
+
+ const defaultSubRouteOptions = useMemo(
+ () => ({
+ cardStyle: styles.navigationScreenCardStyle,
+ headerShown: false,
+ cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
+ }),
+ [styles],
+ );
+
return (
{_.map(screens, (getComponent, name) => (
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js
index a1646011e560..d23b03c8c73e 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.js
@@ -2,7 +2,7 @@ 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 useThemeStyles from '@styles/useThemeStyles';
import SCREENS from '@src/SCREENS';
const Stack = createStackNavigator();
@@ -11,6 +11,7 @@ const url = getCurrentUrl();
const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined;
function BaseCentralPaneNavigator() {
+ const styles = useThemeStyles();
return (
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
index b8e04a6ff9e8..c5786a0fcfe4 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
@@ -1,6 +1,6 @@
import {createStackNavigator} from '@react-navigation/stack';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, {useMemo} from 'react';
import {View} from 'react-native';
import NoDropZone from '@components/DragAndDrop/NoDropZone';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -21,12 +21,13 @@ const propTypes = {
function RightModalNavigator(props) {
const styles = useThemeStyles();
const {isSmallScreenWidth} = useWindowDimensions();
+ const screenOptions = useMemo(() => RHPScreenOptions(styles), [styles]);
return (
{!isSmallScreenWidth && }
-
+
diff --git a/src/libs/Navigation/AppNavigator/RHPScreenOptions.js b/src/libs/Navigation/AppNavigator/RHPScreenOptions.js
index 6adf5bd2b507..02354b90591f 100644
--- a/src/libs/Navigation/AppNavigator/RHPScreenOptions.js
+++ b/src/libs/Navigation/AppNavigator/RHPScreenOptions.js
@@ -1,12 +1,17 @@
import {CardStyleInterpolators} from '@react-navigation/stack';
-import styles from '@styles/styles';
-const RHPScreenOptions = {
+/**
+ * RHP stack navigator screen options generator function
+ * @function
+ * @param {Object} styles - The styles object
+ * @returns {Object} - The screen options object
+ */
+const RHPScreenOptions = (styles) => ({
headerShown: false,
animationEnabled: true,
gestureDirection: 'horizontal',
cardStyle: styles.navigationScreenCardStyle,
cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS,
-};
+});
export default RHPScreenOptions;
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js
index 70a33ee34a67..bb7acddb188c 100644
--- a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js
+++ b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import {useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import usePermissions from '@hooks/usePermissions';
-import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import reportPropTypes from '@pages/reportPropTypes';
import * as App from '@userActions/App';
@@ -61,12 +60,6 @@ const defaultProps = {
* @returns {Number}
*/
const getLastAccessedReportID = (reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom) => {
- // If deeplink url contains reportID params, we should show the report that has this reportID.
- const currentRoute = Navigation.getActiveRoute();
- const {reportID} = ReportUtils.parseReportRouteParams(currentRoute);
- if (reportID) {
- return reportID;
- }
const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom);
return lodashGet(lastReport, 'reportID');
};
diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.js b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.js
index 04784fb9d0e1..44fa7b6c0b09 100644
--- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.js
+++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.js
@@ -1,5 +1,4 @@
import getNavigationModalCardStyle from '@styles/getNavigationModalCardStyles';
-import styles from '@styles/styles';
import variables from '@styles/variables';
import CONFIG from '@src/CONFIG';
import modalCardStyleInterpolator from './modalCardStyleInterpolator';
@@ -12,7 +11,7 @@ const commonScreenOptions = {
animationTypeForReplace: 'push',
};
-export default (isSmallScreenWidth) => ({
+export default (isSmallScreenWidth, styles) => ({
rightModalNavigator: {
...commonScreenOptions,
cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props),
diff --git a/src/libs/Navigation/FreezeWrapper.js b/src/libs/Navigation/FreezeWrapper.tsx
similarity index 65%
rename from src/libs/Navigation/FreezeWrapper.js
rename to src/libs/Navigation/FreezeWrapper.tsx
index 16a353ebddea..df3f117c9a2e 100644
--- a/src/libs/Navigation/FreezeWrapper.js
+++ b/src/libs/Navigation/FreezeWrapper.tsx
@@ -1,31 +1,24 @@
import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native';
-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';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
-const propTypes = {
+type FreezeWrapperProps = ChildrenProps & {
/** Prop to disable freeze */
- keepVisible: PropTypes.bool,
- /** Children to wrap in FreezeWrapper. */
- children: PropTypes.node.isRequired,
+ keepVisible?: boolean;
};
-const defaultProps = {
- keepVisible: false,
-};
-
-function FreezeWrapper(props) {
+function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) {
const [isScreenBlurred, setIsScreenBlurred] = useState(false);
// we need to know the screen index to determine if the screen can be frozen
- const screenIndexRef = useRef(null);
+ const screenIndexRef = useRef(null);
const isFocused = useIsFocused();
const navigation = useNavigation();
const currentRoute = useRoute();
useEffect(() => {
- const index = lodashFindIndex(navigation.getState().routes, (route) => route.key === currentRoute.key);
+ const index = navigation.getState().routes.findIndex((route) => route.key === currentRoute.key);
screenIndexRef.current = index;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -35,7 +28,7 @@ function FreezeWrapper(props) {
// if the screen is more than 1 screen away from the current screen, freeze it,
// 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) {
+ if (navigation.getState().index - (screenIndexRef.current ?? 0) > 1) {
InteractionManager.runAfterInteractions(() => setIsScreenBlurred(true));
} else {
setIsScreenBlurred(false);
@@ -44,11 +37,9 @@ function FreezeWrapper(props) {
return () => unsubscribe();
}, [isFocused, isScreenBlurred, navigation]);
- return {props.children};
+ return {children};
}
-FreezeWrapper.propTypes = propTypes;
-FreezeWrapper.defaultProps = defaultProps;
FreezeWrapper.displayName = 'FreezeWrapper';
export default FreezeWrapper;
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.ts
similarity index 61%
rename from src/libs/Navigation/Navigation.js
rename to src/libs/Navigation/Navigation.ts
index 2629d36999bf..c2dd3e76e7ad 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.ts
@@ -1,11 +1,10 @@
import {findFocusedRoute, getActionFromState} from '@react-navigation/core';
-import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native';
-import _ from 'lodash';
-import lodashGet from 'lodash/get';
+import {CommonActions, EventMapCore, getPathFromState, NavigationState, PartialState, StackActions} from '@react-navigation/native';
+import findLastIndex from 'lodash/findLastIndex';
import Log from '@libs/Log';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
-import ROUTES from '@src/ROUTES';
+import ROUTES, {Route} from '@src/ROUTES';
import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS';
import getStateFromPath from './getStateFromPath';
import originalGetTopmostReportActionId from './getTopmostReportActionID';
@@ -13,13 +12,14 @@ import originalGetTopmostReportId from './getTopmostReportId';
import linkingConfig from './linkingConfig';
import linkTo from './linkTo';
import navigationRef from './navigationRef';
+import {StackNavigationAction, StateOrRoute} from './types';
-let resolveNavigationIsReadyPromise;
-const navigationIsReadyPromise = new Promise((resolve) => {
+let resolveNavigationIsReadyPromise: () => void;
+const navigationIsReadyPromise = new Promise((resolve) => {
resolveNavigationIsReadyPromise = resolve;
});
-let pendingRoute = null;
+let pendingRoute: Route | null = null;
let shouldPopAllStateOnUP = false;
@@ -30,12 +30,7 @@ function setShouldPopAllStateOnUP() {
shouldPopAllStateOnUP = true;
}
-/**
- * @param {String} methodName
- * @param {Object} params
- * @returns {Boolean}
- */
-function canNavigate(methodName, params = {}) {
+function canNavigate(methodName: string, params: Record = {}): boolean {
if (navigationRef.isReady()) {
return true;
}
@@ -49,37 +44,32 @@ const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopm
// Re-exporting the getTopmostReportActionID here to fill in default value for state. The getTopmostReportActionID isn't defined in this file to avoid cyclic dependencies.
const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state);
-/**
- * Method for finding on which index in stack we are.
- * @param {Object} route
- * @param {Number} index
- * @returns {Number}
- */
-const getActiveRouteIndex = function (route, index) {
- if (route.routes) {
- const childActiveRoute = route.routes[route.index || 0];
- return getActiveRouteIndex(childActiveRoute, route.index || 0);
+/** Method for finding on which index in stack we are. */
+function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number | undefined {
+ if ('routes' in stateOrRoute && stateOrRoute.routes) {
+ const childActiveRoute = stateOrRoute.routes[stateOrRoute.index ?? 0];
+ return getActiveRouteIndex(childActiveRoute, stateOrRoute.index ?? 0);
}
- if (route.state && route.state.routes) {
- const childActiveRoute = route.state.routes[route.state.index || 0];
- return getActiveRouteIndex(childActiveRoute, route.state.index || 0);
+ if ('state' in stateOrRoute && stateOrRoute.state?.routes) {
+ const childActiveRoute = stateOrRoute.state.routes[stateOrRoute.state.index ?? 0];
+ return getActiveRouteIndex(childActiveRoute, stateOrRoute.state.index ?? 0);
}
- if (route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
+ if ('name' in stateOrRoute && stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
return 0;
}
return index;
-};
+}
/**
* Gets distance from the path in root navigator. In other words how much screen you have to pop to get to the route with this path.
* The search is limited to 5 screens from the top for performance reasons.
- * @param {String} path - Path that you are looking for.
- * @return {Number} - Returns distance to path or -1 if the path is not found in root navigator.
+ * @param path - Path that you are looking for.
+ * @return - Returns distance to path or -1 if the path is not found in root navigator.
*/
-function getDistanceFromPathInRootNavigator(path) {
+function getDistanceFromPathInRootNavigator(path: string): number {
let currentState = navigationRef.getRootState();
for (let index = 0; index < 5; index++) {
@@ -98,12 +88,41 @@ function getDistanceFromPathInRootNavigator(path) {
return -1;
}
+/** Returns the current active route */
+function getActiveRoute(): string {
+ const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute();
+ if (!currentRoute?.name) {
+ return '';
+ }
+
+ const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
+
+ if (routeFromState) {
+ return routeFromState;
+ }
+
+ return '';
+}
+
+/**
+ * Check whether the passed route is currently Active or not.
+ *
+ * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path
+ * is undefined in the first navigation.
+ *
+ * @param routePath Path to check
+ * @return is active
+ */
+function isActiveRoute(routePath: Route): boolean {
+ // We remove First forward slash from the URL before matching
+ return getActiveRoute().substring(1) === routePath;
+}
+
/**
* Main navigation method for redirecting to a route.
- * @param {String} route
- * @param {String} [type] - Type of action to perform. Currently UP is supported.
+ * @param [type] - Type of action to perform. Currently UP is supported.
*/
-function navigate(route = ROUTES.HOME, type) {
+function navigate(route: Route = ROUTES.HOME, type?: string) {
if (!canNavigate('navigate', {route})) {
// Store intended route if the navigator is not yet available,
// we will try again after the NavigationContainer is ready
@@ -111,15 +130,15 @@ function navigate(route = ROUTES.HOME, type) {
pendingRoute = route;
return;
}
- linkTo(navigationRef.current, route, type);
+ linkTo(navigationRef.current, route, type, isActiveRoute(route));
}
/**
- * @param {String} fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP
- * @param {Boolean} shouldEnforceFallback - Enforces navigation to fallback route
- * @param {Boolean} shouldPopToTop - Should we navigate to LHN on back press
+ * @param fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP
+ * @param shouldEnforceFallback - Enforces navigation to fallback route
+ * @param shouldPopToTop - Should we navigate to LHN on back press
*/
-function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = false) {
+function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopToTop = false) {
if (!canNavigate('goBack')) {
return;
}
@@ -127,12 +146,12 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f
if (shouldPopToTop) {
if (shouldPopAllStateOnUP) {
shouldPopAllStateOnUP = false;
- navigationRef.current.dispatch(StackActions.popToTop());
+ navigationRef.current?.dispatch(StackActions.popToTop());
return;
}
}
- if (!navigationRef.current.canGoBack()) {
+ if (!navigationRef.current?.canGoBack()) {
Log.hmmm('[Navigation] Unable to go back');
return;
}
@@ -140,9 +159,9 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f
const isFirstRouteInNavigator = !getActiveRouteIndex(navigationRef.current.getState());
if (isFirstRouteInNavigator) {
const rootState = navigationRef.getRootState();
- const lastRoute = _.last(rootState.routes);
+ const lastRoute = rootState.routes.at(-1);
// If the user comes from a different flow (there is more than one route in RHP) we should go back to the previous flow on UP button press instead of using the fallbackRoute.
- if (lastRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && lastRoute.state.index > 0) {
+ if (lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && (lastRoute.state?.index ?? 0) > 0) {
navigationRef.current.goBack();
return;
}
@@ -153,7 +172,7 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f
return;
}
- const isCentralPaneFocused = findFocusedRoute(navigationRef.current.getState()).name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR;
+ const isCentralPaneFocused = findFocusedRoute(navigationRef.current.getState())?.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR;
const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute);
// Allow CentralPane to use UP with fallback route if the path is not found in root navigator.
@@ -162,7 +181,7 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f
return;
}
- // Add posibility to go back more than one screen in root navigator if that screen is on the stack.
+ // Add possibility to go back more than one screen in root navigator if that screen is on the stack.
if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator > 0) {
navigationRef.current.dispatch(StackActions.pop(distanceFromPathInRootNavigator));
return;
@@ -173,12 +192,9 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f
/**
* Update route params for the specified route.
- *
- * @param {Object} params
- * @param {String} routeKey
*/
-function setParams(params, routeKey) {
- navigationRef.current.dispatch({
+function setParams(params: Record, routeKey: string) {
+ navigationRef.current?.dispatch({
...CommonActions.setParams(params),
source: routeKey,
});
@@ -187,15 +203,15 @@ function setParams(params, routeKey) {
/**
* Dismisses the last modal stack if there is any
*
- * @param {String | undefined} targetReportID - The reportID to navigate to after dismissing the modal
+ * @param targetReportID - The reportID to navigate to after dismissing the modal
*/
-function dismissModal(targetReportID) {
+function dismissModal(targetReportID?: string) {
if (!canNavigate('dismissModal')) {
return;
}
const rootState = navigationRef.getRootState();
- const lastRoute = _.last(rootState.routes);
- switch (lastRoute.name) {
+ const lastRoute = rootState.routes.at(-1);
+ switch (lastRoute?.name) {
case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
case SCREENS.NOT_FOUND:
case SCREENS.REPORT_ATTACHMENTS:
@@ -203,16 +219,18 @@ function dismissModal(targetReportID) {
if (targetReportID && targetReportID !== getTopmostReportId(rootState)) {
const state = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReportID));
- const action = getActionFromState(state, linkingConfig.config);
- action.type = 'REPLACE';
- navigationRef.current.dispatch(action);
+ const action: StackNavigationAction = getActionFromState(state, linkingConfig.config);
+ if (action) {
+ action.type = 'REPLACE';
+ navigationRef.current?.dispatch(action);
+ }
// If not-found page is in the route stack, we need to close it
- } else if (targetReportID && _.some(rootState.routes, (route) => route.name === SCREENS.NOT_FOUND)) {
+ } else if (targetReportID && rootState.routes.some((route) => route.name === SCREENS.NOT_FOUND)) {
const lastRouteIndex = rootState.routes.length - 1;
- const centralRouteIndex = _.findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
- navigationRef.current.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key});
+ const centralRouteIndex = findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR);
+ navigationRef.current?.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key});
} else {
- navigationRef.current.dispatch({...StackActions.pop(), target: rootState.key});
+ navigationRef.current?.dispatch({...StackActions.pop(), target: rootState.key});
}
break;
default: {
@@ -221,43 +239,19 @@ function dismissModal(targetReportID) {
}
}
-/**
- * Returns the current active route
- * @returns {String}
- */
-function getActiveRoute() {
- const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute();
- const currentRouteHasName = lodashGet(currentRoute, 'name', false);
- if (!currentRouteHasName) {
- return '';
- }
-
- const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
-
- if (routeFromState) {
- return routeFromState;
- }
-
- return '';
-}
-
/**
* Returns the current active route without the URL params
- * @returns {String}
*/
-function getActiveRouteWithoutParams() {
+function getActiveRouteWithoutParams(): string {
return getActiveRoute().replace(/\?.*/, '');
}
-/** Returns the active route name from a state event from the navigationRef
- * @param {Object} event
- * @returns {String | undefined}
- * */
-function getRouteNameFromStateEvent(event) {
+/** Returns the active route name from a state event from the navigationRef */
+function getRouteNameFromStateEvent(event: EventMapCore['state']): string | undefined {
if (!event.data.state) {
return;
}
- const currentRouteName = event.data.state.routes.slice(-1).name;
+ const currentRouteName = event.data.state.routes.at(-1)?.name;
// Check to make sure we have a route name
if (currentRouteName) {
@@ -265,20 +259,6 @@ function getRouteNameFromStateEvent(event) {
}
}
-/**
- * Check whether the passed route is currently Active or not.
- *
- * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path
- * is undefined in the first navigation.
- *
- * @param {String} routePath Path to check
- * @return {Boolean} is active
- */
-function isActiveRoute(routePath) {
- // We remove First forward slash from the URL before matching
- return getActiveRoute().substring(1) === routePath;
-}
-
/**
* Navigate to the route that we originally intended to go to
* but the NavigationContainer was not ready when navigate() was called
@@ -292,10 +272,7 @@ function goToPendingRoute() {
pendingRoute = null;
}
-/**
- * @returns {Promise}
- */
-function isNavigationReady() {
+function isNavigationReady(): Promise {
return navigationIsReadyPromise;
}
@@ -307,57 +284,50 @@ function setIsNavigationReady() {
/**
* Checks if the navigation state contains routes that are protected (over the auth wall).
*
- * @function
- * @param {Object} state - react-navigation state object
- *
- * @returns {Boolean}
+ * @param state - react-navigation state object
*/
-function navContainsProtectedRoutes(state) {
- if (!state || !state.routeNames || !_.isArray(state.routeNames)) {
+function navContainsProtectedRoutes(state: NavigationState | PartialState | undefined): boolean {
+ if (!state?.routeNames || !Array.isArray(state.routeNames)) {
return false;
}
- const protectedScreensName = _.values(PROTECTED_SCREENS);
- const difference = _.difference(protectedScreensName, state.routeNames);
-
- return !difference.length;
+ const protectedScreensName = Object.values(PROTECTED_SCREENS);
+ return !protectedScreensName.some((screen) => !state.routeNames?.includes(screen));
}
/**
- * Waits for the navitgation state to contain protected routes specified in PROTECTED_SCREENS constant.
- * If the navigation is in a state, where protected routes are avilable, the promise resolve immediately.
+ * Waits for the navigation state to contain protected routes specified in PROTECTED_SCREENS constant.
+ * If the navigation is in a state, where protected routes are available, the promise resolve immediately.
*
* @function
- * @returns {Promise} A promise that resolves when the one of the PROTECTED_SCREENS screen is available in the nav tree.
+ * @returns A promise that resolves when the one of the PROTECTED_SCREENS screen is available in the nav tree.
*
* @example
* waitForProtectedRoutes()
* .then(()=> console.log('Protected routes are present!'))
*/
function waitForProtectedRoutes() {
- return new Promise((resolve) => {
+ return new Promise((resolve) => {
isNavigationReady().then(() => {
- const currentState = navigationRef.current.getState();
+ const currentState = navigationRef.current?.getState();
if (navContainsProtectedRoutes(currentState)) {
resolve();
return;
}
- let unsubscribe;
- const handleStateChange = ({data}) => {
- const state = lodashGet(data, 'state');
+
+ const unsubscribe = navigationRef.current?.addListener('state', ({data}) => {
+ const state = data?.state;
if (navContainsProtectedRoutes(state)) {
- unsubscribe();
+ unsubscribe?.();
resolve();
}
- };
- unsubscribe = navigationRef.current.addListener('state', handleStateChange);
+ });
});
});
}
export default {
setShouldPopAllStateOnUP,
- canNavigate,
navigate,
setParams,
dismissModal,
@@ -371,7 +341,6 @@ export default {
getRouteNameFromStateEvent,
getTopmostReportActionId,
waitForProtectedRoutes,
- navContainsProtectedRoutes,
};
export {navigationRef};
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.tsx
similarity index 58%
rename from src/libs/Navigation/NavigationRoot.js
rename to src/libs/Navigation/NavigationRoot.tsx
index 2373066cf4bd..cbb2e62161f3 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -1,39 +1,29 @@
-import {DefaultTheme, getPathFromState, NavigationContainer} from '@react-navigation/native';
-import PropTypes from 'prop-types';
-import React, {useEffect, useRef} from 'react';
-import {Easing, interpolateColor, runOnJS, useAnimatedReaction, useSharedValue, withDelay, withTiming} from 'react-native-reanimated';
+import {DefaultTheme, getPathFromState, NavigationContainer, NavigationState} from '@react-navigation/native';
+import React, {useEffect, useMemo, useRef} from 'react';
+import {ColorValue} from 'react-native';
+import {interpolateColor, runOnJS, useAnimatedReaction, useSharedValue, withDelay, withTiming} from 'react-native-reanimated';
import useCurrentReportID from '@hooks/useCurrentReportID';
import useFlipper from '@hooks/useFlipper';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Log from '@libs/Log';
import StatusBar from '@libs/StatusBar';
-import themeColors from '@styles/themes/default';
+import useTheme from '@styles/themes/useTheme';
import AppNavigator from './AppNavigator';
import linkingConfig from './linkingConfig';
import Navigation, {navigationRef} from './Navigation';
-// https://reactnavigation.org/docs/themes
-const navigationTheme = {
- ...DefaultTheme,
- colors: {
- ...DefaultTheme.colors,
- background: themeColors.appBG,
- },
-};
-
-const propTypes = {
+type NavigationRootProps = {
/** Whether the current user is logged in with an authToken */
- authenticated: PropTypes.bool.isRequired,
+ authenticated: boolean;
/** Fired when react-navigation is ready */
- onReady: PropTypes.func.isRequired,
+ onReady: () => void;
};
/**
* Intercept navigation state changes and log it
- * @param {NavigationState} state
*/
-function parseAndLogRoute(state) {
+function parseAndLogRoute(state: NavigationState) {
if (!state) {
return;
}
@@ -50,13 +40,26 @@ function parseAndLogRoute(state) {
Navigation.setIsNavigationReady();
}
-function NavigationRoot(props) {
+function NavigationRoot({authenticated, onReady}: NavigationRootProps) {
useFlipper(navigationRef);
+ const theme = useTheme();
const firstRenderRef = useRef(true);
- const {updateCurrentReportID} = useCurrentReportID();
+ const currentReportIDValue = useCurrentReportID();
const {isSmallScreenWidth} = useWindowDimensions();
+ // https://reactnavigation.org/docs/themes
+ const navigationTheme = useMemo(
+ () => ({
+ ...DefaultTheme,
+ colors: {
+ ...DefaultTheme.colors,
+ background: theme.appBG,
+ },
+ }),
+ [theme],
+ );
+
useEffect(() => {
if (firstRenderRef.current) {
// we don't want to make the report back button go back to LHN if the user
@@ -72,24 +75,24 @@ function NavigationRoot(props) {
}, [isSmallScreenWidth]);
useEffect(() => {
- if (!navigationRef.isReady() || !props.authenticated) {
+ if (!navigationRef.isReady() || !authenticated) {
return;
}
// We need to force state rehydration so the CustomRouter can add the CentralPaneNavigator route if necessary.
navigationRef.resetRoot(navigationRef.getRootState());
- }, [isSmallScreenWidth, props.authenticated]);
+ }, [isSmallScreenWidth, authenticated]);
- const prevStatusBarBackgroundColor = useRef(themeColors.appBG);
- const statusBarBackgroundColor = useRef(themeColors.appBG);
+ const prevStatusBarBackgroundColor = useRef(theme.appBG);
+ const statusBarBackgroundColor = useRef(theme.appBG);
const statusBarAnimation = useSharedValue(0);
- const updateStatusBarBackgroundColor = (color) => StatusBar.setBackgroundColor(color);
+ const updateStatusBarBackgroundColor = (color: ColorValue) => StatusBar.setBackgroundColor(color);
useAnimatedReaction(
() => statusBarAnimation.value,
(current, previous) => {
// Do not run if either of the animated value is null
// or previous animated value is greater than or equal to the current one
- if ([current, previous].includes(null) || current <= previous) {
+ if (previous === null || current === null || current <= previous) {
return;
}
const color = interpolateColor(statusBarAnimation.value, [0, 1], [prevStatusBarBackgroundColor.current, statusBarBackgroundColor.current]);
@@ -99,32 +102,34 @@ function NavigationRoot(props) {
const animateStatusBarBackgroundColor = () => {
const currentRoute = navigationRef.getCurrentRoute();
- const currentScreenBackgroundColor = (currentRoute.params && currentRoute.params.backgroundColor) || themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG;
+
+ const backgroundColorFromRoute =
+ currentRoute?.params && 'backgroundColor' in currentRoute.params && typeof currentRoute.params.backgroundColor === 'string' && currentRoute.params.backgroundColor;
+ const backgroundColorFallback = currentRoute?.name ? theme.PAGE_BACKGROUND_COLORS[currentRoute.name] || theme.appBG : theme.appBG;
+
+ // It's possible for backgroundColorFromRoute to be empty string, so we must use "||" to fallback to backgroundColorFallback.
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const currentScreenBackgroundColor = backgroundColorFromRoute || backgroundColorFallback;
prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current;
statusBarBackgroundColor.current = currentScreenBackgroundColor;
- if (currentScreenBackgroundColor === themeColors.appBG && prevStatusBarBackgroundColor.current === themeColors.appBG) {
+ if (currentScreenBackgroundColor === theme.appBG && prevStatusBarBackgroundColor.current === theme.appBG) {
return;
}
statusBarAnimation.value = 0;
- statusBarAnimation.value = withDelay(
- 300,
- withTiming(1, {
- duration: 300,
- easing: Easing.in,
- }),
- );
+ statusBarAnimation.value = withDelay(300, withTiming(1));
};
- const handleStateChange = (state) => {
+ const handleStateChange = (state: NavigationState | undefined) => {
if (!state) {
return;
}
+
// Performance optimization to avoid context consumers to delay first render
setTimeout(() => {
- updateCurrentReportID(state);
+ currentReportIDValue?.updateCurrentReportID(state);
}, 0);
parseAndLogRoute(state);
animateStatusBarBackgroundColor();
@@ -133,7 +138,7 @@ function NavigationRoot(props) {
return (
-
+
);
}
NavigationRoot.displayName = 'NavigationRoot';
-NavigationRoot.propTypes = propTypes;
+
export default NavigationRoot;
diff --git a/src/libs/Navigation/OnyxTabNavigator.js b/src/libs/Navigation/OnyxTabNavigator.tsx
similarity index 54%
rename from src/libs/Navigation/OnyxTabNavigator.js
rename to src/libs/Navigation/OnyxTabNavigator.tsx
index eeed3e0cd270..1ea57e773323 100644
--- a/src/libs/Navigation/OnyxTabNavigator.js
+++ b/src/libs/Navigation/OnyxTabNavigator.tsx
@@ -1,31 +1,33 @@
-import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs';
-import PropTypes from 'prop-types';
+import {createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs';
+import {EventMapCore, NavigationState, ScreenListeners} from '@react-navigation/native';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
+import {OnyxEntry} from 'react-native-onyx/lib/types';
import Tab from '@userActions/Tab';
import ONYXKEYS from '@src/ONYXKEYS';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
-const propTypes = {
- /** ID of the tab component to be saved in onyx */
- id: PropTypes.string.isRequired,
+type OnyxTabNavigatorOnyxProps = {
+ selectedTab: OnyxEntry;
+};
- /** Name of the selected tab */
- selectedTab: PropTypes.string,
+type OnyxTabNavigatorProps = OnyxTabNavigatorOnyxProps &
+ ChildrenProps & {
+ /** ID of the tab component to be saved in onyx */
+ id: string;
- /** Children nodes */
- children: PropTypes.node.isRequired,
-};
+ /** Name of the selected tab */
+ selectedTab?: string;
-const defaultProps = {
- selectedTab: '',
-};
+ screenListeners?: ScreenListeners;
+ };
// eslint-disable-next-line rulesdir/no-inline-named-export
export const TopTab = createMaterialTopTabNavigator();
// This takes all the same props as MaterialTopTabsNavigator: https://reactnavigation.org/docs/material-top-tab-navigator/#props,
// except ID is now required, and it gets a `selectedTab` from Onyx
-function OnyxTabNavigator({id, selectedTab, children, ...rest}) {
+function OnyxTabNavigator({id, selectedTab = '', children, screenListeners, ...rest}: OnyxTabNavigatorProps) {
return (
{
+ state: (e) => {
+ const event = e as unknown as EventMapCore['state'];
const state = event.data.state;
const index = state.index;
const routeNames = state.routeNames;
Tab.setSelectedTab(id, routeNames[index]);
},
- ...(rest.screenListeners || {}),
+ ...(screenListeners ?? {}),
}}
>
{children}
@@ -49,11 +52,9 @@ function OnyxTabNavigator({id, selectedTab, children, ...rest}) {
);
}
-OnyxTabNavigator.defaultProps = defaultProps;
-OnyxTabNavigator.propTypes = propTypes;
OnyxTabNavigator.displayName = 'OnyxTabNavigator';
-export default withOnyx({
+export default withOnyx({
selectedTab: {
key: ({id}) => `${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`,
},
diff --git a/src/libs/Navigation/getStateFromPath.js b/src/libs/Navigation/getStateFromPath.ts
similarity index 57%
rename from src/libs/Navigation/getStateFromPath.js
rename to src/libs/Navigation/getStateFromPath.ts
index f2564c9d2512..3a53b02fc3c7 100644
--- a/src/libs/Navigation/getStateFromPath.js
+++ b/src/libs/Navigation/getStateFromPath.ts
@@ -1,11 +1,12 @@
-import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native';
+import {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native';
+import {Route} from '@src/ROUTES';
import linkingConfig from './linkingConfig';
/**
- * @param {String} path - The path to parse
- * @returns {Object | undefined} - It's possible that there is no navigation action for the given path
+ * @param path - The path to parse
+ * @returns - It's possible that there is no navigation action for the given path
*/
-function getStateFromPath(path) {
+function getStateFromPath(path: Route): PartialState {
const normalizedPath = !path.startsWith('/') ? `/${path}` : path;
const state = linkingConfig.getStateFromPath ? linkingConfig.getStateFromPath(normalizedPath, linkingConfig.config) : RNGetStateFromPath(normalizedPath, linkingConfig.config);
@@ -13,6 +14,7 @@ function getStateFromPath(path) {
if (!state) {
throw new Error('Failed to parse the path to a navigation state.');
}
+
return state;
}
diff --git a/src/libs/Navigation/getTopmostReportActionID.js b/src/libs/Navigation/getTopmostReportActionID.js
deleted file mode 100644
index a4480931cda0..000000000000
--- a/src/libs/Navigation/getTopmostReportActionID.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import lodashFindLast from 'lodash/findLast';
-import lodashGet from 'lodash/get';
-
-// This function is in a separate file than Navigation.js to avoid cyclic dependency.
-
-/**
- * Find the last visited report screen in the navigation state and get the linked reportActionID of it.
- *
- * @param {Object} state - The react-navigation state
- * @returns {String | undefined} - It's possible that there is no report screen
- */
-function getTopmostReportActionID(state) {
- if (!state) {
- return;
- }
- const topmostCentralPane = lodashFindLast(state.routes, (route) => route.name === 'CentralPaneNavigator');
-
- if (!topmostCentralPane) {
- return;
- }
-
- const directReportActionIDParam = lodashGet(topmostCentralPane, 'params.params.reportActionID');
-
- if (!topmostCentralPane.state && !directReportActionIDParam) {
- return;
- }
-
- if (directReportActionIDParam) {
- return directReportActionIDParam;
- }
-
- const topmostReport = lodashFindLast(topmostCentralPane.state.routes, (route) => route.name === 'Report');
- if (!topmostReport) {
- return;
- }
-
- const topmostReportActionID = lodashGet(topmostReport, 'params.reportActionID');
-
- return topmostReportActionID;
-}
-
-export default getTopmostReportActionID;
diff --git a/src/libs/Navigation/getTopmostReportActionID.ts b/src/libs/Navigation/getTopmostReportActionID.ts
new file mode 100644
index 000000000000..15ab1efef704
--- /dev/null
+++ b/src/libs/Navigation/getTopmostReportActionID.ts
@@ -0,0 +1,48 @@
+import {NavigationState, PartialState} from '@react-navigation/native';
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+import {RootStackParamList} from './types';
+
+// This function is in a separate file than Navigation.js to avoid cyclic dependency.
+
+/**
+ * Find the last visited report screen in the navigation state and get the linked reportActionID of it.
+ *
+ * @param state - The react-navigation state
+ * @returns - It's possible that there is no report screen
+ */
+function getTopmostReportActionID(state: NavigationState | NavigationState | PartialState): string | undefined {
+ if (!state) {
+ return;
+ }
+
+ const topmostCentralPane = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1);
+ if (!topmostCentralPane) {
+ return;
+ }
+
+ const directReportParams = topmostCentralPane.params && 'params' in topmostCentralPane.params && topmostCentralPane?.params?.params;
+ const directReportActionIDParam = directReportParams && 'reportActionID' in directReportParams && directReportParams?.reportActionID;
+
+ if (!topmostCentralPane.state && !directReportActionIDParam) {
+ return;
+ }
+
+ if (directReportActionIDParam) {
+ return directReportActionIDParam;
+ }
+
+ const topmostReport = topmostCentralPane.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1);
+ if (!topmostReport) {
+ return;
+ }
+
+ const topmostReportActionID = topmostReport.params && 'reportActionID' in topmostReport.params && topmostReport.params?.reportActionID;
+ if (typeof topmostReportActionID !== 'string') {
+ return;
+ }
+
+ return topmostReportActionID;
+}
+
+export default getTopmostReportActionID;
diff --git a/src/libs/Navigation/getTopmostReportId.js b/src/libs/Navigation/getTopmostReportId.js
deleted file mode 100644
index 8ca9c39baf6a..000000000000
--- a/src/libs/Navigation/getTopmostReportId.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import lodashFindLast from 'lodash/findLast';
-import lodashGet from 'lodash/get';
-
-// This function is in a separate file than Navigation.js to avoid cyclic dependency.
-
-/**
- * Find the last visited report screen in the navigation state and get the id of it.
- *
- * @param {Object} state - The react-navigation state
- * @returns {String | undefined} - It's possible that there is no report screen
- */
-function getTopmostReportId(state) {
- if (!state) {
- return;
- }
- const topmostCentralPane = lodashFindLast(state.routes, (route) => route.name === 'CentralPaneNavigator');
-
- if (!topmostCentralPane) {
- return;
- }
-
- const directReportIdParam = lodashGet(topmostCentralPane, 'params.params.reportID');
-
- if (!topmostCentralPane.state && !directReportIdParam) {
- return;
- }
-
- if (directReportIdParam) {
- return directReportIdParam;
- }
-
- const topmostReport = lodashFindLast(topmostCentralPane.state.routes, (route) => route.name === 'Report');
- if (!topmostReport) {
- return;
- }
-
- const topmostReportId = lodashGet(topmostReport, 'params.reportID');
-
- return topmostReportId;
-}
-
-export default getTopmostReportId;
diff --git a/src/libs/Navigation/getTopmostReportId.ts b/src/libs/Navigation/getTopmostReportId.ts
new file mode 100644
index 000000000000..3342761e7ccf
--- /dev/null
+++ b/src/libs/Navigation/getTopmostReportId.ts
@@ -0,0 +1,48 @@
+import {NavigationState, PartialState} from '@react-navigation/native';
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+import {RootStackParamList} from './types';
+
+// This function is in a separate file than Navigation.js to avoid cyclic dependency.
+
+/**
+ * Find the last visited report screen in the navigation state and get the id of it.
+ *
+ * @param state - The react-navigation state
+ * @returns - It's possible that there is no report screen
+ */
+function getTopmostReportId(state: NavigationState | NavigationState | PartialState): string | undefined {
+ if (!state) {
+ return;
+ }
+
+ const topmostCentralPane = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1);
+ if (!topmostCentralPane) {
+ return;
+ }
+
+ const directReportParams = topmostCentralPane.params && 'params' in topmostCentralPane.params && topmostCentralPane?.params?.params;
+ const directReportIdParam = directReportParams && 'reportID' in directReportParams && directReportParams?.reportID;
+
+ if (!topmostCentralPane.state && !directReportIdParam) {
+ return;
+ }
+
+ if (directReportIdParam) {
+ return directReportIdParam;
+ }
+
+ const topmostReport = topmostCentralPane.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1);
+ if (!topmostReport) {
+ return;
+ }
+
+ const topmostReportId = topmostReport.params && 'reportID' in topmostReport.params && topmostReport.params?.reportID;
+ if (typeof topmostReportId !== 'string') {
+ return;
+ }
+
+ return topmostReportId;
+}
+
+export default getTopmostReportId;
diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.ts
similarity index 58%
rename from src/libs/Navigation/linkTo.js
rename to src/libs/Navigation/linkTo.ts
index 55bd4b31dbdf..1a4aa2d0cfb7 100644
--- a/src/libs/Navigation/linkTo.js
+++ b/src/libs/Navigation/linkTo.ts
@@ -1,39 +1,53 @@
import {getActionFromState} from '@react-navigation/core';
-import _ from 'lodash';
+import {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native';
+import {Writable} from 'type-fest';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
+import {Route} from '@src/ROUTES';
import getStateFromPath from './getStateFromPath';
import getTopmostReportId from './getTopmostReportId';
import linkingConfig from './linkingConfig';
+import {NavigationRoot, RootStackParamList, StackNavigationAction} from './types';
+
+type ActionPayloadParams = {
+ screen?: string;
+ params?: unknown;
+ path?: string;
+};
+
+type ActionPayload = {
+ params?: ActionPayloadParams;
+};
/**
* Motivation for this function is described in NAVIGATION.md
*
- * @param {Object} action action generated by getActionFromState
- * @param {Object} state The root state
- * @returns {Object} minimalAction minimal action is the action that we should dispatch
+ * @param action action generated by getActionFromState
+ * @param state The root state
+ * @returns minimalAction minimal action is the action that we should dispatch
*/
-function getMinimalAction(action, state) {
- let currentAction = action;
- let currentState = state;
- let currentTargetKey = null;
+function getMinimalAction(action: NavigationAction, state: NavigationState): Writable {
+ let currentAction: NavigationAction = action;
+ let currentState: NavigationState | PartialState | undefined = state;
+ let currentTargetKey: string | undefined;
- while (currentState.routes[currentState.index].name === currentAction.payload.name) {
- if (!currentState.routes[currentState.index].state) {
+ while (currentAction.payload && 'name' in currentAction.payload && currentState?.routes[currentState.index ?? -1].name === currentAction.payload.name) {
+ if (!currentState?.routes[currentState.index ?? -1].state) {
break;
}
- currentState = currentState.routes[currentState.index].state;
+ currentState = currentState?.routes[currentState.index ?? -1].state;
+ currentTargetKey = currentState?.key;
- currentTargetKey = currentState.key;
+ const payload = currentAction.payload as ActionPayload;
// Creating new smaller action
currentAction = {
type: currentAction.type,
payload: {
- name: currentAction.payload.params.screen,
- params: currentAction.payload.params.params,
- path: currentAction.payload.params.path,
+ name: payload?.params?.screen,
+ params: payload?.params?.params,
+ path: payload?.params?.path,
},
target: currentTargetKey,
};
@@ -41,13 +55,13 @@ function getMinimalAction(action, state) {
return currentAction;
}
-export default function linkTo(navigation, path, type) {
- if (navigation === undefined) {
+export default function linkTo(navigation: NavigationContainerRef | null, path: Route, type?: string, isActiveRoute?: boolean) {
+ if (!navigation) {
throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?");
}
- let root = navigation;
- let current;
+ let root: NavigationRoot = navigation;
+ let current: NavigationRoot | undefined;
// Traverse up to get the root navigation
// eslint-disable-next-line no-cond-assign
@@ -55,18 +69,18 @@ export default function linkTo(navigation, path, type) {
root = current;
}
+ const rootState = root.getState();
const state = getStateFromPath(path);
-
- const action = getActionFromState(state, linkingConfig.config);
+ const action: StackNavigationAction = getActionFromState(state, linkingConfig.config);
// If action type is different than NAVIGATE we can't change it to the PUSH safely
- if (action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
+ if (action?.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
// In case if type is 'FORCED_UP' we replace current screen with the provided. This means the current screen no longer exists in the stack
if (type === CONST.NAVIGATION.TYPE.FORCED_UP) {
action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
// If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack
- } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(root.getState()) !== getTopmostReportId(state)) {
+ } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(rootState) !== getTopmostReportId(state)) {
action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
// If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow
@@ -75,18 +89,18 @@ export default function linkTo(navigation, path, type) {
action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
// If this action is navigating to the RightModalNavigator and the last route on the root navigator is not RightModalNavigator then push
- } else if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && _.last(root.getState().routes).name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
+ } else if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && rootState.routes.at(-1)?.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
}
}
- if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
+ if (action && 'payload' in action && action.payload && 'name' in action.payload && action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
const minimalAction = getMinimalAction(action, navigation.getRootState());
if (minimalAction) {
// There are situations where a route already exists on the current navigation stack
// But we want to push the same route instead of going back in the stack
// Which would break the user navigation history
- if (type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
+ if (!isActiveRoute && type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
}
// There are situations when the user is trying to access a route which he has no access to
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.ts
similarity index 97%
rename from src/libs/Navigation/linkingConfig.js
rename to src/libs/Navigation/linkingConfig.ts
index e0ac35c957a3..bb06bf7e4528 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.ts
@@ -1,21 +1,24 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import {LinkingOptions} from '@react-navigation/native';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
+import {RootStackParamList} from './types';
-export default {
+const linkingConfig: LinkingOptions = {
prefixes: ['new-expensify://', 'https://www.expensify.cash', 'https://staging.expensify.cash', 'https://dev.new.expensify.com', CONST.NEW_EXPENSIFY_URL, CONST.STAGING_NEW_EXPENSIFY_URL],
config: {
initialRouteName: SCREENS.HOME,
screens: {
// Main Routes
- ValidateLogin: ROUTES.VALIDATE_LOGIN,
- UnlinkLogin: ROUTES.UNLINK_LOGIN,
+ [SCREENS.VALIDATE_LOGIN]: ROUTES.VALIDATE_LOGIN,
+ [SCREENS.UNLINK_LOGIN]: ROUTES.UNLINK_LOGIN,
[SCREENS.TRANSITION_BETWEEN_APPS]: ROUTES.TRANSITION_BETWEEN_APPS,
[SCREENS.CONCIERGE]: ROUTES.CONCIERGE,
- AppleSignInDesktop: ROUTES.APPLE_SIGN_IN,
- GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN,
- SAMLSignIn: ROUTES.SAML_SIGN_IN,
+ [SCREENS.SIGN_IN_WITH_APPLE_DESKTOP]: ROUTES.APPLE_SIGN_IN,
+ [SCREENS.SIGN_IN_WITH_GOOGLE_DESKTOP]: ROUTES.GOOGLE_SIGN_IN,
+ [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN,
[SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
[SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route,
@@ -192,7 +195,7 @@ export default {
exact: true,
},
Settings_TwoFactorAuth: {
- path: ROUTES.SETTINGS_2FA,
+ path: ROUTES.SETTINGS_2FA.route,
exact: true,
},
Settings_Share_Code: {
@@ -448,3 +451,5 @@ export default {
},
},
};
+
+export default linkingConfig;
diff --git a/src/libs/Navigation/navigationRef.js b/src/libs/Navigation/navigationRef.js
deleted file mode 100644
index 00c98d178f7e..000000000000
--- a/src/libs/Navigation/navigationRef.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import {createNavigationContainerRef} from '@react-navigation/native';
-
-const navigationRef = createNavigationContainerRef();
-export default navigationRef;
diff --git a/src/libs/Navigation/navigationRef.ts b/src/libs/Navigation/navigationRef.ts
new file mode 100644
index 000000000000..032d9f9f3d9a
--- /dev/null
+++ b/src/libs/Navigation/navigationRef.ts
@@ -0,0 +1,6 @@
+import {createNavigationContainerRef} from '@react-navigation/native';
+import {NavigationRef} from './types';
+
+const navigationRef: NavigationRef = createNavigationContainerRef();
+
+export default navigationRef;
diff --git a/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js b/src/libs/Navigation/shouldPreventDeeplinkPrompt.ts
similarity index 57%
rename from src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js
rename to src/libs/Navigation/shouldPreventDeeplinkPrompt.ts
index 23f46cb9808f..2b19da1f5224 100644
--- a/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js
+++ b/src/libs/Navigation/shouldPreventDeeplinkPrompt.ts
@@ -2,12 +2,9 @@ import CONST from '@src/CONST';
/**
* Determines if the deeplink prompt should be shown on the current screen
- * @param {String} screenName
- * @param {Boolean} isAuthenticated
- * @returns {Boolean}
*/
-export default function shouldPreventDeeplinkPrompt(screenName) {
+export default function shouldPreventDeeplinkPrompt(screenName: string): boolean {
// We don't want to show the deeplink prompt on screens where a user is in the
// authentication process, so we are blocking the prompt on the following screens (Denylist)
- return CONST.DEEPLINK_PROMPT_DENYLIST.includes(screenName);
+ return CONST.DEEPLINK_PROMPT_DENYLIST.some((name) => name === screenName);
}
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
new file mode 100644
index 000000000000..41df21d8e237
--- /dev/null
+++ b/src/libs/Navigation/types.ts
@@ -0,0 +1,401 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import {CommonActions, NavigationContainerRefWithCurrent, NavigationHelpers, NavigationState, NavigatorScreenParams, PartialRoute, Route} from '@react-navigation/native';
+import {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+import NAVIGATORS from '@src/NAVIGATORS';
+import SCREENS from '@src/SCREENS';
+
+type NavigationRef = NavigationContainerRefWithCurrent;
+
+type NavigationRoot = NavigationHelpers;
+
+type GoBackAction = Extract;
+type ResetAction = Extract;
+type SetParamsAction = Extract;
+
+type ActionNavigate = {
+ type: ValueOf;
+ payload: {
+ name?: string;
+ key?: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ params?: any;
+ path?: string;
+ merge?: boolean;
+ };
+ source?: string;
+ target?: string;
+};
+
+type StackNavigationAction = GoBackAction | ResetAction | SetParamsAction | ActionNavigate | undefined;
+
+type NavigationStateRoute = NavigationState['routes'][number];
+type NavigationPartialRoute = PartialRoute>;
+type StateOrRoute = NavigationState | NavigationStateRoute | NavigationPartialRoute;
+
+type CentralPaneNavigatorParamList = {
+ [SCREENS.REPORT]: {
+ reportActionID: string;
+ reportID: string;
+ };
+};
+
+type SettingsNavigatorParamList = {
+ [SCREENS.SETTINGS.ROOT]: undefined;
+ Settings_Share_Code: undefined;
+ [SCREENS.SETTINGS.WORKSPACES]: undefined;
+ Settings_Profile: undefined;
+ Settings_Pronouns: undefined;
+ Settings_Display_Name: undefined;
+ Settings_Timezone: undefined;
+ Settings_Timezone_Select: undefined;
+ Settings_PersonalDetails_Initial: undefined;
+ Settings_PersonalDetails_LegalName: undefined;
+ Settings_PersonalDetails_DateOfBirth: undefined;
+ Settings_PersonalDetails_Address: undefined;
+ Settings_PersonalDetails_Address_Country: undefined;
+ Settings_ContactMethods: undefined;
+ Settings_ContactMethodDetails: undefined;
+ Settings_NewContactMethod: undefined;
+ [SCREENS.SETTINGS.PREFERENCES]: undefined;
+ Settings_Preferences_PriorityMode: undefined;
+ Settings_Preferences_Language: undefined;
+ Settings_Preferences_Theme: undefined;
+ Settings_Close: undefined;
+ [SCREENS.SETTINGS.SECURITY]: undefined;
+ Settings_About: undefined;
+ Settings_App_Download_Links: undefined;
+ Settings_Lounge_Access: undefined;
+ Settings_Wallet: undefined;
+ Settings_Wallet_Cards_Digital_Details_Update_Address: undefined;
+ Settings_Wallet_DomainCard: undefined;
+ Settings_Wallet_ReportVirtualCardFraud: undefined;
+ Settings_Wallet_Card_Activate: undefined;
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: undefined;
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: undefined;
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: undefined;
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: undefined;
+ Settings_Wallet_Transfer_Balance: undefined;
+ Settings_Wallet_Choose_Transfer_Account: undefined;
+ Settings_Wallet_EnablePayments: undefined;
+ Settings_Add_Debit_Card: undefined;
+ Settings_Add_Bank_Account: undefined;
+ [SCREENS.SETTINGS.STATUS]: undefined;
+ Settings_Status_Set: undefined;
+ Workspace_Initial: undefined;
+ Workspace_Settings: undefined;
+ Workspace_Settings_Currency: undefined;
+ Workspace_Card: {
+ policyID: string;
+ };
+ Workspace_Reimburse: {
+ policyID: string;
+ };
+ Workspace_RateAndUnit: undefined;
+ Workspace_Bills: {
+ policyID: string;
+ };
+ Workspace_Invoices: {
+ policyID: string;
+ };
+ Workspace_Travel: {
+ policyID: string;
+ };
+ Workspace_Members: {
+ policyID: string;
+ };
+ Workspace_Invite: {
+ policyID: string;
+ };
+ Workspace_Invite_Message: {
+ policyID: string;
+ };
+ ReimbursementAccount: {
+ stepToOpen: string;
+ policyID: string;
+ };
+ GetAssistance: {
+ taskID: string;
+ };
+ Settings_TwoFactorAuth: undefined;
+ Settings_ReportCardLostOrDamaged: undefined;
+ KeyboardShortcuts: undefined;
+};
+
+type NewChatNavigatorParamList = {
+ NewChat_Root: undefined;
+};
+
+type SearchNavigatorParamList = {
+ Search_Root: undefined;
+};
+
+type DetailsNavigatorParamList = {
+ Details_Root: {
+ login: string;
+ reportID: string;
+ };
+};
+
+type ProfileNavigatorParamList = {
+ Profile_Root: {
+ accountID: string;
+ reportID: string;
+ };
+};
+
+type ReportDetailsNavigatorParamList = {
+ Report_Details_Root: undefined;
+ Report_Details_Share_Code: {
+ reportID: string;
+ };
+};
+
+type ReportSettingsNavigatorParamList = {
+ Report_Settings_Root: undefined;
+ Report_Settings_Room_Name: undefined;
+ Report_Settings_Notification_Preferences: undefined;
+ Report_Settings_Write_Capability: undefined;
+};
+
+type ReportWelcomeMessageNavigatorParamList = {
+ Report_WelcomeMessage_Root: {reportID: string};
+};
+
+type ParticipantsNavigatorParamList = {
+ ReportParticipants_Root: {reportID: string};
+};
+
+type RoomMembersNavigatorParamList = {
+ RoomMembers_Root: undefined;
+};
+
+type RoomInviteNavigatorParamList = {
+ RoomInvite_Root: undefined;
+};
+
+type MoneyRequestNavigatorParamList = {
+ Money_Request: undefined;
+ Money_Request_Amount: undefined;
+ Money_Request_Participants: {
+ iouType: string;
+ reportID: string;
+ };
+ Money_Request_Confirmation: {
+ iouType: string;
+ reportID: string;
+ };
+ Money_Request_Currency: {
+ iouType: string;
+ reportID: string;
+ currency: string;
+ backTo: string;
+ };
+ Money_Request_Date: {
+ iouType: string;
+ reportID: string;
+ field: string;
+ threadReportID: string;
+ };
+ Money_Request_Description: {
+ iouType: string;
+ reportID: string;
+ field: string;
+ threadReportID: string;
+ };
+ Money_Request_Category: {
+ iouType: string;
+ reportID: string;
+ };
+ Money_Request_Tag: {
+ iouType: string;
+ reportID: string;
+ };
+ Money_Request_Merchant: {
+ iouType: string;
+ reportID: string;
+ field: string;
+ threadReportID: string;
+ };
+ IOU_Send_Enable_Payments: undefined;
+ IOU_Send_Add_Bank_Account: undefined;
+ IOU_Send_Add_Debit_Card: undefined;
+ Money_Request_Waypoint: {
+ iouType: string;
+ transactionID: string;
+ waypointIndex: string;
+ threadReportID: number;
+ };
+ Money_Request_Edit_Waypoint: {
+ iouType: string;
+ transactionID: string;
+ waypointIndex: string;
+ threadReportID: number;
+ };
+ Money_Request_Distance: {
+ iouType: ValueOf;
+ reportID: string;
+ };
+ Money_Request_Receipt: {
+ iouType: string;
+ reportID: string;
+ };
+};
+
+type NewTaskNavigatorParamList = {
+ NewTask_Root: undefined;
+ NewTask_TaskAssigneeSelector: undefined;
+ NewTask_TaskShareDestinationSelector: undefined;
+ NewTask_Details: undefined;
+ NewTask_Title: undefined;
+ NewTask_Description: undefined;
+};
+
+type TeachersUniteNavigatorParamList = {
+ [SCREENS.SAVE_THE_WORLD.ROOT]: undefined;
+ I_Know_A_Teacher: undefined;
+ Intro_School_Principal: undefined;
+ I_Am_A_Teacher: undefined;
+};
+
+type TaskDetailsNavigatorParamList = {
+ Task_Title: undefined;
+ Task_Description: undefined;
+ Task_Assignee: {
+ reportID: string;
+ };
+};
+
+type EnablePaymentsNavigatorParamList = {
+ EnablePayments_Root: undefined;
+};
+
+type SplitDetailsNavigatorParamList = {
+ SplitDetails_Root: {
+ reportActionID: string;
+ };
+ SplitDetails_Edit_Request: undefined;
+ SplitDetails_Edit_Currency: undefined;
+};
+
+type AddPersonalBankAccountNavigatorParamList = {
+ AddPersonalBankAccount_Root: undefined;
+};
+
+type WalletStatementNavigatorParamList = {
+ WalletStatement_Root: undefined;
+};
+
+type FlagCommentNavigatorParamList = {
+ FlagComment_Root: {
+ reportID: string;
+ reportActionID: string;
+ };
+};
+
+type EditRequestNavigatorParamList = {
+ EditRequest_Root: {
+ field: string;
+ threadReportID: string;
+ };
+ EditRequest_Currency: undefined;
+};
+
+type SignInNavigatorParamList = {
+ SignIn_Root: undefined;
+};
+
+type ReferralDetailsNavigatorParamList = {
+ Referral_Details: undefined;
+};
+
+type PrivateNotesNavigatorParamList = {
+ PrivateNotes_View: {
+ reportID: string;
+ accountID: string;
+ };
+ PrivateNotes_List: {
+ reportID: string;
+ accountID: string;
+ };
+ PrivateNotes_Edit: {
+ reportID: string;
+ accountID: string;
+ };
+};
+
+type RightModalNavigatorParamList = {
+ Settings: NavigatorScreenParams;
+ NewChat: NavigatorScreenParams;
+ Search: NavigatorScreenParams;
+ Details: NavigatorScreenParams;
+ Profile: NavigatorScreenParams;
+ Report_Details: NavigatorScreenParams;
+ Report_Settings: NavigatorScreenParams;
+ Report_WelcomeMessage: NavigatorScreenParams;
+ Participants: NavigatorScreenParams;
+ RoomMembers: NavigatorScreenParams;
+ RoomInvite: NavigatorScreenParams;
+ MoneyRequest: NavigatorScreenParams;
+ NewTask: NavigatorScreenParams;
+ TeachersUnite: NavigatorScreenParams;
+ Task_Details: NavigatorScreenParams;
+ EnablePayments: NavigatorScreenParams;
+ SplitDetails: NavigatorScreenParams;
+ AddPersonalBankAccount: NavigatorScreenParams;
+ Wallet_Statement: NavigatorScreenParams;
+ Flag_Comment: NavigatorScreenParams;
+ EditRequest: NavigatorScreenParams;
+ SignIn: NavigatorScreenParams;
+ Referral: NavigatorScreenParams;
+ Private_Notes: NavigatorScreenParams;
+};
+
+type PublicScreensParamList = {
+ [SCREENS.HOME]: undefined;
+ [SCREENS.TRANSITION_BETWEEN_APPS]: {
+ shouldForceLogin: string;
+ email: string;
+ shortLivedAuthToken: string;
+ exitTo: string;
+ };
+ [SCREENS.VALIDATE_LOGIN]: {
+ accountID: string;
+ validateCode: string;
+ };
+ [SCREENS.UNLINK_LOGIN]: {
+ accountID: string;
+ validateCode: string;
+ };
+ [SCREENS.SIGN_IN_WITH_APPLE_DESKTOP]: undefined;
+ [SCREENS.SIGN_IN_WITH_GOOGLE_DESKTOP]: undefined;
+ [SCREENS.SAML_SIGN_IN]: undefined;
+};
+
+type AuthScreensParamList = {
+ [SCREENS.HOME]: undefined;
+ [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: NavigatorScreenParams;
+ [SCREENS.VALIDATE_LOGIN]: {
+ accountID: string;
+ validateCode: string;
+ };
+ [SCREENS.TRANSITION_BETWEEN_APPS]: {
+ shouldForceLogin: string;
+ email: string;
+ shortLivedAuthToken: string;
+ exitTo: string;
+ };
+ [SCREENS.CONCIERGE]: undefined;
+ [SCREENS.REPORT_ATTACHMENTS]: {
+ reportID: string;
+ source: string;
+ };
+ [SCREENS.NOT_FOUND]: undefined;
+ [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams;
+ [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined;
+ [CONST.DEMO_PAGES.MONEY2020]: undefined;
+};
+
+type RootStackParamList = PublicScreensParamList & AuthScreensParamList;
+
+export type {NavigationRef, StackNavigationAction, CentralPaneNavigatorParamList, RootStackParamList, StateOrRoute, NavigationStateRoute, NavigationRoot};
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index 5da032baaf45..d4aee4a221e5 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -160,7 +160,7 @@ NetworkStore.onReconnection(flush);
function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
- PersistedRequests.save(request);
+ PersistedRequests.save([request]);
// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts
index 3fadeea7447c..6ff54f94bc88 100644
--- a/src/libs/Network/enhanceParameters.ts
+++ b/src/libs/Network/enhanceParameters.ts
@@ -37,8 +37,5 @@ export default function enhanceParameters(command: string, parameters: Record {
const connectionID = Onyx.connect({
- key: ONYXKEYS.ONYX_UPDATES.LAST_UPDATE_ID,
- callback: (lastUpdateID) => {
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
+ callback: (lastUpdateIDAppliedToClient) => {
Onyx.disconnect(connectionID);
- resolve(lastUpdateID);
+ resolve(lastUpdateIDAppliedToClient);
},
});
});
@@ -26,15 +28,19 @@ export default function backgroundRefresh() {
return;
}
- getLastOnyxUpdateID().then((lastUpdateID) => {
- /**
- * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered.
- * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI,
- * so we must manually run confirmReadyToOpenApp here instead.
- *
- * See more here: https://reactnative.dev/docs/headless-js-android
- */
- App.confirmReadyToOpenApp();
- App.reconnectApp(lastUpdateID);
- });
+ getLastOnyxUpdateID()
+ .then((lastUpdateIDAppliedToClient) => {
+ /**
+ * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered.
+ * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI,
+ * so we must manually run confirmReadyToOpenApp here instead.
+ *
+ * See more here: https://reactnative.dev/docs/headless-js-android
+ */
+ App.confirmReadyToOpenApp();
+ App.reconnectApp(lastUpdateIDAppliedToClient);
+ })
+ .catch((error) => {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PushNotification] backgroundRefresh failed. This should never happen.`, {error});
+ });
}
diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js
index 04fd34bf6075..ede873f79c6e 100644
--- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js
+++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js
@@ -23,18 +23,20 @@ export default function subscribeToReportCommentPushNotifications() {
}
Log.info('[PushNotification] onSelected() - called', false, {reportID, reportActionID});
- Navigation.isNavigationReady().then(() => {
- try {
- // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back
- if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) {
- Navigation.goBack(ROUTES.HOME);
- }
+ Navigation.isNavigationReady()
+ .then(Navigation.waitForProtectedRoutes)
+ .then(() => {
+ try {
+ // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back
+ if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) {
+ Navigation.goBack(ROUTES.HOME);
+ }
- Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID});
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
- } catch (error) {
- Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message});
- }
- });
+ Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID});
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
+ } catch (error) {
+ Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message});
+ }
+ });
});
}
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index b97ae6daed11..c616587c3983 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -910,24 +910,19 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt
* @returns {Array