diff --git a/.eslintrc.js b/.eslintrc.js
index 75a74ed371c4..4909a24fe797 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -37,10 +37,12 @@ module.exports = {
overrides: [
{
files: ['*.js', '*.jsx', '*.ts', '*.tsx'],
+ plugins: ['react'],
rules: {
'rulesdir/no-multiple-onyx-in-file': 'off',
'rulesdir/onyx-props-must-have-default': 'off',
'react-native-a11y/has-accessibility-hint': ['off'],
+ 'react/jsx-no-constructed-context-values': 'error',
'react-native-a11y/has-valid-accessibility-descriptors': [
'error',
{
@@ -77,6 +79,7 @@ module.exports = {
},
],
curly: 'error',
+ 'react/display-name': 'error',
},
},
{
@@ -116,7 +119,7 @@ module.exports = {
},
{
selector: ['parameter', 'method'],
- format: ['camelCase'],
+ format: ['camelCase', 'PascalCase'],
},
],
'@typescript-eslint/ban-types': [
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index 9a314079362c..f3a5c8dc4314 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -2,4 +2,4 @@
self-hosted-runner:
labels:
- ubuntu-latest-xl
- - macos-12-xl
+ - macos-13-xlarge
diff --git a/.github/actions/composite/buildAndroidAPK/action.yml b/.github/actions/composite/buildAndroidAPK/action.yml
index fc280ab2a223..4f466be84a68 100644
--- a/.github/actions/composite/buildAndroidAPK/action.yml
+++ b/.github/actions/composite/buildAndroidAPK/action.yml
@@ -11,7 +11,7 @@ runs:
steps:
- uses: Expensify/App/.github/actions/composite/setupNode@main
- - uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
+ - uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
ruby-version: "2.7"
bundler-cache: true
diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml
index bd5b5139bc6b..328bc7f9b50c 100644
--- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml
+++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml
@@ -47,7 +47,7 @@ runs:
- name: Generate a token
id: generateToken
- uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
+ uses: actions/create-github-app-token@9d97a4282b2c51a2f4f0465b9326399f53c890d4
with:
- app_id: ${{ inputs.OS_BOTIFY_APP_ID }}
- private_key: ${{ inputs.OS_BOTIFY_PRIVATE_KEY }}
+ app-id: ${{ inputs.OS_BOTIFY_APP_ID }}
+ private-key: ${{ inputs.OS_BOTIFY_PRIVATE_KEY }}
diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js
index da08d1a060b6..830dbf626548 100644
--- a/.github/actions/javascript/bumpVersion/index.js
+++ b/.github/actions/javascript/bumpVersion/index.js
@@ -298,9 +298,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js
index 22ad59ed9588..561b8e61bc21 100644
--- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js
+++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js
@@ -998,9 +998,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js
index 3aafda798c54..e42f97508bc5 100644
--- a/.github/actions/javascript/getDeployPullRequestList/index.js
+++ b/.github/actions/javascript/getDeployPullRequestList/index.js
@@ -961,9 +961,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js
index 6770ba99ba69..37db08db93e9 100644
--- a/.github/actions/javascript/getPreviousVersion/index.js
+++ b/.github/actions/javascript/getPreviousVersion/index.js
@@ -148,9 +148,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/libs/versionUpdater.js b/.github/libs/versionUpdater.js
index b78178f443e6..78e8085621bd 100644
--- a/.github/libs/versionUpdater.js
+++ b/.github/libs/versionUpdater.js
@@ -118,9 +118,6 @@ function getPreviousVersion(currentVersion, level) {
if (patch === 0) {
return getPreviousVersion(currentVersion, SEMANTIC_VERSION_LEVELS.MINOR);
}
- if (major === 1 && minor === 3 && patch === 83) {
- return getVersionStringFromNumber(major, minor, patch - 2, 0);
- }
return getVersionStringFromNumber(major, minor, patch - 1, 0);
}
diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml
index ca7345ef9462..4a53e75354c6 100644
--- a/.github/workflows/deployExpensifyHelp.yml
+++ b/.github/workflows/deployExpensifyHelp.yml
@@ -1,20 +1,23 @@
-# Deploying the ExpensifyHelp Jekyll site by dynamically generating routes file
name: Deploy ExpensifyHelp
on:
- # Runs on pushes targeting the default branch
+ # Run on any push to main that has changes to the docs directory
push:
- branches: ["main"]
-
- # Allows you to run this workflow manually from the Actions tab
+ branches:
+ - main
+ paths:
+ - 'docs/**'
+
+ # Run on any pull request (except PRs against staging or production) that has changes to the docs directory
+ pull_request:
+ types: [opened, synchronize]
+ branches-ignore: [staging, production]
+ paths:
+ - 'docs/**'
+
+ # Run on any manual trigger
workflow_dispatch:
-# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
-permissions:
- contents: read
- pages: write
- id-token: write
-
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
@@ -22,7 +25,6 @@ concurrency:
cancel-in-progress: false
jobs:
- # Build job
build:
runs-on: ubuntu-latest
steps:
@@ -32,9 +34,6 @@ jobs:
- name: Setup NodeJS
uses: Expensify/App/.github/actions/composite/setupNode@main
- - name: Setup Pages
- uses: actions/configure-pages@f156874f8191504dae5b037505266ed5dda6c382
-
- name: Create docs routes file
run: ./.github/scripts/createDocsRoutes.sh
@@ -44,19 +43,18 @@ jobs:
source: ./docs/
destination: ./docs/_site
- - name: Upload artifact
- uses: actions/upload-pages-artifact@64bcae551a7b18bcb9a09042ddf1960979799187
+ - name: Deploy to Cloudflare Pages
+ uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca
+ id: deploy
with:
- path: ./docs/_site
-
- # Deployment job
- deploy:
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
- needs: build
- steps:
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@af48cf94a42f2c634308b1c9dc0151830b6f190a
+ apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ projectName: helpdot
+ directory: ./docs/_site
+
+ - name: Leave a comment on the PR
+ uses: actions-cool/maintain-one-comment@de04bd2a3750d86b324829a3ff34d47e48e16f4b
+ if: ${{ github.event_name == 'pull_request' }}
+ with:
+ token: ${{ secrets.OS_BOTIFY_TOKEN }}
+ body: ${{ format('A preview of your ExpensifyHelp changes have been deployed to {0} ⚡️', steps.deploy.outputs.alias) }}
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index 308404b74bc0..ff888c135be9 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -125,6 +125,9 @@ jobs:
steps:
- uses: actions/checkout@v3
+ - name: Setup Node
+ uses: Expensify/App/.github/actions/composite/setupNode@main
+
- name: Make zip directory for everything to send to AWS Device Farm
run: mkdir zip
@@ -137,7 +140,7 @@ jobs:
# The downloaded artifact will be a file named "app-e2e-release.apk" so we have to rename it
- name: Rename baseline APK
- run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk"
+ run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-main.apk"
- name: Download delta APK
uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b
@@ -147,7 +150,7 @@ jobs:
path: zip
- name: Rename delta APK
- run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-compare.apk"
+ run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-delta.apk"
- name: Copy e2e code into zip folder
run: cp -r tests/e2e zip
@@ -162,44 +165,72 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-west-2
- - name: Schedule AWS Device Farm test run
+ - name: Schedule AWS Device Farm test run on main branch
uses: realm/aws-devicefarm/test-application@7b9a91236c456c97e28d384c9e476035d5ea686b
+ id: schedule-awsdf-main
with:
name: App E2E Performance Regression Tests
project_arn: ${{ secrets.AWS_PROJECT_ARN }}
device_pool_arn: ${{ secrets.AWS_DEVICE_POOL_ARN }}
- app_file: zip/app-e2eRelease-baseline.apk
+ app_file: zip/app-e2eRelease-main.apk
app_type: ANDROID_APP
test_type: APPIUM_NODE
test_package_file: App.zip
test_package_type: APPIUM_NODE_TEST_PACKAGE
- test_spec_file: tests/e2e/TestSpec.yml
+ test_spec_file: tests/e2e/TestSpecMain.yml
test_spec_type: APPIUM_NODE_TEST_SPEC
remote_src: false
file_artifacts: Customer Artifacts.zip
+ log_artifacts: debug.log
cleanup: true
- - name: Unzip AWS Device Farm results
- if: ${{ always() }}
- run: unzip "Customer Artifacts.zip"
-
- - name: Print AWS Device Farm run results
- if: ${{ always() }}
- run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md"
-
- - name: Print AWS Device Farm verbose run results
- if: ${{ always() && runner.debug != null && fromJSON(runner.debug) }}
- run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/debug.log"
-
-# TODO: Once tests are more reliable we should uncomment this
-# - name: Check if test failed, if so post the results and add the DeployBlocker label
-# run: |
-# if grep -q '🔴' ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md; then
-# gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash
-# gh pr comment ${{ inputs.PR_NUMBER }} -F ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md
-# gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker."
-# else
-# echo '✅ no performance regression detected'
-# fi
-# env:
-# GITHUB_TOKEN: ${{ github.token }}
+ - name: Print logs if run failed
+ if: failure()
+ run: |
+ echo ${{ steps.schedule-awsdf-main.outputs.data }}
+ unzip "Customer Artifacts.zip" -d mainResults
+ cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log
+
+ - name: Unzip AWS Device Farm main results
+ run: unzip "Customer Artifacts.zip" -d mainResults
+
+ - name: Delete Customer Artifacts.zip
+ run: rm "Customer Artifacts.zip"
+
+ - name: Schedule AWS Device Farm test run on delta branch
+ uses: realm/aws-devicefarm/test-application@7b9a91236c456c97e28d384c9e476035d5ea686b
+ with:
+ name: App E2E Performance Regression Tests
+ project_arn: ${{ secrets.AWS_PROJECT_ARN }}
+ device_pool_arn: ${{ secrets.AWS_DEVICE_POOL_ARN }}
+ app_file: zip/app-e2eRelease-delta.apk
+ app_type: ANDROID_APP
+ test_type: APPIUM_NODE
+ test_package_file: App.zip
+ test_package_type: APPIUM_NODE_TEST_PACKAGE
+ test_spec_file: tests/e2e/TestSpecDelta.yml
+ test_spec_type: APPIUM_NODE_TEST_SPEC
+ remote_src: false
+ file_artifacts: Customer Artifacts.zip
+ cleanup: true
+
+ - name: Unzip AWS Device Farm delta results
+ run: unzip "Customer Artifacts.zip" -d deltaResults
+
+ - name: Compare results
+ run: node tests/e2e/merge.js --mainPath ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/main.json --deltaPath ./deltaResults//Host_Machine_Files/\$WORKING_DIRECTORY/delta.json --outputPath ./output.md
+
+ - name: Print results
+ run: cat "./output.md"
+
+ - name: Check if test failed, if so post the results and add the DeployBlocker label
+ run: |
+ if grep -q '🔴' ./output.md; then
+ gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash
+ gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md
+ gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker."
+ else
+ echo '✅ no performance regression detected'
+ fi
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index f5a5dc5e1616..a18961b24389 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -67,7 +67,7 @@ jobs:
uses: Expensify/App/.github/actions/composite/setupNode@main
- name: Setup Ruby
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
+ uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
ruby-version: '2.7'
bundler-cache: true
@@ -104,6 +104,13 @@ jobs:
name: android-sourcemap
path: android/app/build/generated/sourcemaps/react/release/*.map
+ - name: Upload Android version to GitHub artifacts
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v3
+ with:
+ name: app-production-release.aab
+ path: android/app/build/outputs/bundle/productionRelease/app-production-release.aab
+
- name: Upload Android version to Browser Stack
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab"
@@ -132,7 +139,7 @@ jobs:
name: Build and deploy Desktop
needs: validateActor
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }}
- runs-on: macos-12-xl
+ runs-on: macos-13-xlarge
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -171,7 +178,7 @@ jobs:
name: Build and deploy iOS
needs: validateActor
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }}
- runs-on: macos-12-xl
+ runs-on: macos-13-xlarge
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -183,7 +190,7 @@ jobs:
uses: Expensify/App/.github/actions/composite/setupNode@main
- name: Setup Ruby
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
+ uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
ruby-version: '2.7'
bundler-cache: true
@@ -238,6 +245,13 @@ jobs:
name: ios-sourcemap
path: main.jsbundle.map
+ - name: Upload iOS version to GitHub artifacts
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v3
+ with:
+ name: New Expensify.ipa
+ path: /Users/runner/work/App/App/New Expensify.ipa
+
- name: Upload iOS version to Browser Stack
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa"
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index 6ded44d7059f..7bd7e13bc82b 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -86,7 +86,7 @@ jobs:
uses: Expensify/App/.github/actions/composite/setupNode@main
- name: Setup Ruby
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
+ uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
ruby-version: '2.7'
bundler-cache: true
@@ -133,7 +133,7 @@ jobs:
if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
env:
PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }}
- runs-on: macos-12-xl
+ runs-on: macos-13-xlarge
steps:
# This action checks-out the repository, so the workflow can access it.
- name: Checkout
@@ -157,7 +157,7 @@ jobs:
run: sudo xcode-select -switch /Applications/Xcode_14.2.app
- name: Setup Ruby
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
+ uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
ruby-version: '2.7'
bundler-cache: true
@@ -218,7 +218,7 @@ jobs:
if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
env:
PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }}
- runs-on: macos-12-xl
+ runs-on: macos-13-xlarge
steps:
- name: Checkout
uses: actions/checkout@v3
diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index 1e63fdcb2d52..b3adf0f59b9c 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -80,6 +80,10 @@
"/": "/search/*",
"comment": "Search"
},
+ {
+ "/": "/send/*",
+ "comment": "Send money"
+ },
{
"/": "/money2020/*",
"comment": "Money 2020"
diff --git a/README.md b/README.md
index 9aad797ebb51..998f185939fa 100644
--- a/README.md
+++ b/README.md
@@ -59,8 +59,7 @@ For an M1 Mac, read this [SO](https://stackoverflow.com/questions/64901180/how-t
## Running the Android app 🤖
* Before installing Android dependencies, you need to obtain a token from Mapbox to download their SDKs. Please run `npm run configure-mapbox` and follow the instructions. If you already did this step for iOS, there is no need to repeat this step.
-* Go through the instructions on [this SO post](https://stackoverflow.com/c/expensify/questions/13283/13284#13284) to start running the app on android.
-* For more information, go through the official React-Native instructions on [this page](https://reactnative.dev/docs/environment-setup#development-os) for "React Native CLI Quickstart" > Mac OS > Android
+* Go through the official React-Native instructions on [this page](https://reactnative.dev/docs/environment-setup?guide=native&platform=android) to start running the app on android.
* If you are an Expensify employee and want to point the emulator to your local VM, follow [this](https://stackoverflow.com/c/expensify/questions/7699)
* To run a on a **Development Emulator**: `npm run android`
* Changes applied to Javascript will be applied automatically, any changes to native code will require a recompile
diff --git a/__mocks__/react-native-safe-area-context.js b/__mocks__/react-native-safe-area-context.js
index 4b4af7841c2c..b31ed670b81c 100644
--- a/__mocks__/react-native-safe-area-context.js
+++ b/__mocks__/react-native-safe-area-context.js
@@ -20,13 +20,18 @@ function withSafeAreaInsets(WrappedComponent) {
/>
);
}
- return forwardRef((props, ref) => (
+
+ const WithSafeAreaInsetsWithRef = forwardRef((props, ref) => (
));
+
+ WithSafeAreaInsetsWithRef.displayName = 'WithSafeAreaInsetsWithRef';
+
+ return WithSafeAreaInsetsWithRef;
}
const SafeAreaView = View;
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 52ae868c514a..1959dbbdc0dd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001038403
- versionName "1.3.84-3"
+ versionCode 1001039002
+ versionName "1.3.90-2"
}
flavorDimensions "default"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7419d5b1e1a7..74e91caa91d5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -70,6 +70,7 @@
+
@@ -88,6 +89,7 @@
+
diff --git a/assets/css/pdf.css b/assets/css/pdf.css
index 9cbbf31b074c..26c80a5baf27 100644
--- a/assets/css/pdf.css
+++ b/assets/css/pdf.css
@@ -11,12 +11,7 @@
border-image: url(../images/shadow.png) 9 9 repeat;
background-color: rgba(255, 255, 255, 1);
}
-.react-pdf__message {
- position: absolute;
- left: 50%;
- top: 50%;
- transform: translate(-50%, -50%);
-}
+
.react-pdf__Page__annotations {
height: 0;
}
diff --git a/assets/images/bankicons/american-express.svg b/assets/images/bankicons/american-express.svg
index b22ccbb4169a..0ab8383d46ed 100644
--- a/assets/images/bankicons/american-express.svg
+++ b/assets/images/bankicons/american-express.svg
@@ -1,38 +1,23 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/bank-of-america.svg b/assets/images/bankicons/bank-of-america.svg
index 0d962a914cfd..e4f87be611fc 100644
--- a/assets/images/bankicons/bank-of-america.svg
+++ b/assets/images/bankicons/bank-of-america.svg
@@ -1,22 +1,22 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/bb-t.svg b/assets/images/bankicons/bb-t.svg
index 13dba55f68f4..7e7bf1f29ee4 100644
--- a/assets/images/bankicons/bb-t.svg
+++ b/assets/images/bankicons/bb-t.svg
@@ -1,27 +1,25 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/capital-one.svg b/assets/images/bankicons/capital-one.svg
index 116543884e52..c37c8e3ca582 100644
--- a/assets/images/bankicons/capital-one.svg
+++ b/assets/images/bankicons/capital-one.svg
@@ -1,55 +1,53 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/charles-schwab.svg b/assets/images/bankicons/charles-schwab.svg
index 4ba4ca4f9488..181a668965da 100644
--- a/assets/images/bankicons/charles-schwab.svg
+++ b/assets/images/bankicons/charles-schwab.svg
@@ -1,59 +1,58 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/chase.svg b/assets/images/bankicons/chase.svg
index 1df546e9785b..70f0b911f147 100644
--- a/assets/images/bankicons/chase.svg
+++ b/assets/images/bankicons/chase.svg
@@ -1,12 +1,13 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/citibank.svg b/assets/images/bankicons/citibank.svg
index 482f33c8b9c9..b03e1efe9bb6 100644
--- a/assets/images/bankicons/citibank.svg
+++ b/assets/images/bankicons/citibank.svg
@@ -1,18 +1,18 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/citizens-bank.svg b/assets/images/bankicons/citizens-bank.svg
index 19160a747490..a0cdc6c1df2b 100644
--- a/assets/images/bankicons/citizens-bank.svg
+++ b/assets/images/bankicons/citizens-bank.svg
@@ -1,49 +1,47 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/discover.svg b/assets/images/bankicons/discover.svg
index 60396e16d29e..75db16e4d1c1 100644
--- a/assets/images/bankicons/discover.svg
+++ b/assets/images/bankicons/discover.svg
@@ -1 +1,47 @@
-
\ No newline at end of file
+
+
+
diff --git a/assets/images/bankicons/expensify-background.png b/assets/images/bankicons/expensify-background.png
new file mode 100644
index 000000000000..ab7b71d34e11
Binary files /dev/null and b/assets/images/bankicons/expensify-background.png differ
diff --git a/assets/images/bankicons/expensify.svg b/assets/images/bankicons/expensify.svg
new file mode 100644
index 000000000000..b61773e8d838
--- /dev/null
+++ b/assets/images/bankicons/expensify.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/assets/images/bankicons/fidelity.svg b/assets/images/bankicons/fidelity.svg
index ac0a05babc95..d49eca17c12d 100644
--- a/assets/images/bankicons/fidelity.svg
+++ b/assets/images/bankicons/fidelity.svg
@@ -1,17 +1,17 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/generic-bank-account.svg b/assets/images/bankicons/generic-bank-account.svg
index 8912413c668d..493f06b335d8 100644
--- a/assets/images/bankicons/generic-bank-account.svg
+++ b/assets/images/bankicons/generic-bank-account.svg
@@ -1,14 +1,14 @@
-
+
diff --git a/assets/images/bankicons/huntington-bank.svg b/assets/images/bankicons/huntington-bank.svg
index e6b43b78daaa..40909a273e19 100644
--- a/assets/images/bankicons/huntington-bank.svg
+++ b/assets/images/bankicons/huntington-bank.svg
@@ -1,24 +1,22 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/navy-federal-credit-union.svg b/assets/images/bankicons/navy-federal-credit-union.svg
index 5541daa9f49a..898cd03768f0 100644
--- a/assets/images/bankicons/navy-federal-credit-union.svg
+++ b/assets/images/bankicons/navy-federal-credit-union.svg
@@ -1,89 +1,85 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/pnc.svg b/assets/images/bankicons/pnc.svg
index 104abb28ba05..3f78dbe94f47 100644
--- a/assets/images/bankicons/pnc.svg
+++ b/assets/images/bankicons/pnc.svg
@@ -1,19 +1,17 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/regions-bank.svg b/assets/images/bankicons/regions-bank.svg
index 2de53c116064..bff045f0eb5a 100644
--- a/assets/images/bankicons/regions-bank.svg
+++ b/assets/images/bankicons/regions-bank.svg
@@ -1,40 +1,38 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/suntrust.svg b/assets/images/bankicons/suntrust.svg
index 256b8157600f..b5b94c105b14 100644
--- a/assets/images/bankicons/suntrust.svg
+++ b/assets/images/bankicons/suntrust.svg
@@ -1,220 +1,217 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/td-bank.svg b/assets/images/bankicons/td-bank.svg
index 03f100171f67..84675de5f2bf 100644
--- a/assets/images/bankicons/td-bank.svg
+++ b/assets/images/bankicons/td-bank.svg
@@ -1,16 +1,14 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/us-bank.svg b/assets/images/bankicons/us-bank.svg
index d1364e253e62..e091ba0a6f50 100644
--- a/assets/images/bankicons/us-bank.svg
+++ b/assets/images/bankicons/us-bank.svg
@@ -1,29 +1,27 @@
-
-
-
+
+
+
diff --git a/assets/images/bankicons/usaa.svg b/assets/images/bankicons/usaa.svg
index 2552db28eca3..1e137fab626f 100644
--- a/assets/images/bankicons/usaa.svg
+++ b/assets/images/bankicons/usaa.svg
@@ -1,38 +1,36 @@
-
-
-
+
+
+
diff --git a/assets/images/cardicons/american-express.svg b/assets/images/cardicons/american-express.svg
new file mode 100644
index 000000000000..9e31f7c8a08e
--- /dev/null
+++ b/assets/images/cardicons/american-express.svg
@@ -0,0 +1,25 @@
+
+
+
diff --git a/assets/images/cardicons/bank-of-america.svg b/assets/images/cardicons/bank-of-america.svg
new file mode 100644
index 000000000000..62dd510b0649
--- /dev/null
+++ b/assets/images/cardicons/bank-of-america.svg
@@ -0,0 +1,25 @@
+
+
+
diff --git a/assets/images/cardicons/bb-t.svg b/assets/images/cardicons/bb-t.svg
new file mode 100644
index 000000000000..ad3676458d21
--- /dev/null
+++ b/assets/images/cardicons/bb-t.svg
@@ -0,0 +1,33 @@
+
+
+
diff --git a/assets/images/cardicons/capital-one.svg b/assets/images/cardicons/capital-one.svg
new file mode 100644
index 000000000000..ee4f756e2600
--- /dev/null
+++ b/assets/images/cardicons/capital-one.svg
@@ -0,0 +1,67 @@
+
+
+
diff --git a/assets/images/cardicons/charles-schwab.svg b/assets/images/cardicons/charles-schwab.svg
new file mode 100644
index 000000000000..39c894042cd3
--- /dev/null
+++ b/assets/images/cardicons/charles-schwab.svg
@@ -0,0 +1,76 @@
+
+
+
diff --git a/assets/images/cardicons/chase.svg b/assets/images/cardicons/chase.svg
new file mode 100644
index 000000000000..8e8ddb6d5378
--- /dev/null
+++ b/assets/images/cardicons/chase.svg
@@ -0,0 +1,15 @@
+
+
+
diff --git a/assets/images/cardicons/citibank.svg b/assets/images/cardicons/citibank.svg
new file mode 100644
index 000000000000..f9869aee7146
--- /dev/null
+++ b/assets/images/cardicons/citibank.svg
@@ -0,0 +1,22 @@
+
+
+
diff --git a/assets/images/cardicons/citizens.svg b/assets/images/cardicons/citizens.svg
new file mode 100644
index 000000000000..3b4bf9ea1af3
--- /dev/null
+++ b/assets/images/cardicons/citizens.svg
@@ -0,0 +1,57 @@
+
+
+
diff --git a/assets/images/cardicons/discover.svg b/assets/images/cardicons/discover.svg
new file mode 100644
index 000000000000..668e5634339d
--- /dev/null
+++ b/assets/images/cardicons/discover.svg
@@ -0,0 +1,53 @@
+
+
+
diff --git a/assets/images/cardicons/expensify-card-dark.svg b/assets/images/cardicons/expensify-card-dark.svg
new file mode 100644
index 000000000000..4a65afeeda9d
--- /dev/null
+++ b/assets/images/cardicons/expensify-card-dark.svg
@@ -0,0 +1,78 @@
+
+
+
diff --git a/assets/images/cardicons/fidelity.svg b/assets/images/cardicons/fidelity.svg
new file mode 100644
index 000000000000..c87f9c4aa56c
--- /dev/null
+++ b/assets/images/cardicons/fidelity.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/assets/images/cardicons/generic-bank-card.svg b/assets/images/cardicons/generic-bank-card.svg
new file mode 100644
index 000000000000..f700691ac29b
--- /dev/null
+++ b/assets/images/cardicons/generic-bank-card.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/assets/images/cardicons/huntington-bank.svg b/assets/images/cardicons/huntington-bank.svg
new file mode 100644
index 000000000000..c108c7039898
--- /dev/null
+++ b/assets/images/cardicons/huntington-bank.svg
@@ -0,0 +1,26 @@
+
+
+
diff --git a/assets/images/cardicons/navy-federal-credit-union.svg b/assets/images/cardicons/navy-federal-credit-union.svg
new file mode 100644
index 000000000000..5abc1103cce1
--- /dev/null
+++ b/assets/images/cardicons/navy-federal-credit-union.svg
@@ -0,0 +1,105 @@
+
+
+
diff --git a/assets/images/cardicons/pnc.svg b/assets/images/cardicons/pnc.svg
new file mode 100644
index 000000000000..ae4d4aac8e41
--- /dev/null
+++ b/assets/images/cardicons/pnc.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/assets/images/cardicons/regions-bank.svg b/assets/images/cardicons/regions-bank.svg
new file mode 100644
index 000000000000..1837ad2be41b
--- /dev/null
+++ b/assets/images/cardicons/regions-bank.svg
@@ -0,0 +1,45 @@
+
+
+
diff --git a/assets/images/cardicons/suntrust.svg b/assets/images/cardicons/suntrust.svg
new file mode 100644
index 000000000000..32ea5096f876
--- /dev/null
+++ b/assets/images/cardicons/suntrust.svg
@@ -0,0 +1,237 @@
+
+
+
diff --git a/assets/images/cardicons/td-bank.svg b/assets/images/cardicons/td-bank.svg
new file mode 100644
index 000000000000..19988e35bbbe
--- /dev/null
+++ b/assets/images/cardicons/td-bank.svg
@@ -0,0 +1,17 @@
+
+
+
diff --git a/assets/images/cardicons/us-bank.svg b/assets/images/cardicons/us-bank.svg
new file mode 100644
index 000000000000..321b4cb755b0
--- /dev/null
+++ b/assets/images/cardicons/us-bank.svg
@@ -0,0 +1,32 @@
+
+
+
diff --git a/assets/images/cardicons/usaa.svg b/assets/images/cardicons/usaa.svg
new file mode 100644
index 000000000000..bb634f64e658
--- /dev/null
+++ b/assets/images/cardicons/usaa.svg
@@ -0,0 +1,40 @@
+
+
+
diff --git a/assets/images/product-illustrations/simple-illustration__smartscan.svg b/assets/images/product-illustrations/simple-illustration__smartscan.svg
new file mode 100644
index 000000000000..34d1fadfaa3b
--- /dev/null
+++ b/assets/images/product-illustrations/simple-illustration__smartscan.svg
@@ -0,0 +1,21 @@
+
diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js
index 7dc851c95c9e..d8a24adefdc3 100644
--- a/config/webpack/webpack.common.js
+++ b/config/webpack/webpack.common.js
@@ -83,6 +83,8 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({
{from: 'web/favicon-unread.png'},
{from: 'web/og-preview-image.png'},
{from: 'web/apple-touch-icon.png'},
+ {from: 'assets/images/expensify-app-icon.svg'},
+ {from: 'web/manifest.json'},
{from: 'assets/css', to: 'css'},
{from: 'assets/fonts/web', to: 'fonts'},
{from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'},
diff --git a/contributingGuides/OFFLINE_UX.md b/contributingGuides/OFFLINE_UX.md
index a678a0b5b042..cca5c6286f73 100644
--- a/contributingGuides/OFFLINE_UX.md
+++ b/contributingGuides/OFFLINE_UX.md
@@ -104,7 +104,7 @@ This pattern greys out the submit button on a form and does not allow the form t
**How to implement:** Use the `` component. This pattern should use the `API.write()` method.
-**Example:** Inviting new memebers to a workspace.
+**Example:** Inviting new members to a workspace.
### D - Full Page Blocking UI Pattern
This pattern blocks the user from interacting with an entire page.
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index 27656eeb68f0..de99bbcb48ef 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -256,6 +256,7 @@ GEM
PLATFORMS
arm64-darwin-22
+ arm64-darwin-23
x86_64-darwin-20
x86_64-darwin-21
diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml
index c6733ac11715..84735e95e0e9 100644
--- a/docs/_data/_routes.yml
+++ b/docs/_data/_routes.yml
@@ -44,16 +44,21 @@ platforms:
icon: /assets/images/hand-card.svg
description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here.
- - href: exports
- title: Exports
- icon: /assets/images/monitor.svg
- description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options.
+ - href: expensify-partner-program
+ title: Expensify Partner Program
+ icon: /assets/images/handshake.svg
+ description: Discover how to get the most out of Expensify as an ExpensifyApproved! accountant partner. Learn how to set up your clients, receive CPE credits, and take advantage of your partner discount.
- href: get-paid-back
title: Get Paid Back
icon: /assets/images/money-into-wallet.svg
description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time.
+ - href: insights-and-custom-reporting
+ title: Insights & Custom Reporting
+ icon: /assets/images/monitor.svg
+ description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options.
+
- href: integrations
title: Integrations
icon: /assets/images/workflow.svg
@@ -64,15 +69,15 @@ platforms:
icon: /assets/images/envelope-receipt.svg
description: Master the art of overseeing employees and reports by utilizing Expensify’s automation features and approval workflows.
- - href: policy-and-domain-settings
- title: Policy & Domain Settings
- icon: /assets/images/shield.svg
- description: Discover how to set up and manage policies, define user permissions, and implement compliance rules to maintain a secure and compliant financial management landscape.
-
- href: send-payments
title: Send Payments
icon: /assets/images/money-wings.svg
description: Uncover step-by-step guidance on sending direct reimbursements to employees, paying an invoice to a vendor, and utilizing third-party payment options.
+
+ - href: workspace-and-domain-settings
+ title: Workspace & Domain Settings
+ icon: /assets/images/shield.svg
+ description: Discover how to set up and manage workspace, define user permissions, and implement compliance rules to maintain a secure and compliant financial management landscape.
- href: new-expensify
title: New Expensify
@@ -113,16 +118,21 @@ platforms:
icon: /assets/images/hand-card.svg
description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here.
- - href: exports
- title: Exports
- icon: /assets/images/monitor.svg
- description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options.
+ - href: expensify-partner-program
+ title: Expensify Partner Program
+ icon: /assets/images/handshake.svg
+ description: Discover how to get the most out of Expensify as an ExpensifyApproved! accountant partner. Learn how to set up your clients, receive CPE credits, and take advantage of your partner discount.
- href: get-paid-back
title: Get Paid Back
icon: /assets/images/money-into-wallet.svg
description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time.
+ - href: insights-and-custom-reporting
+ title: Insights & Custom Reporting
+ icon: /assets/images/monitor.svg
+ description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options.
+
- href: integrations
title: Integrations
icon: /assets/images/workflow.svg
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index 3ad2276713da..825b681c8871 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -350,7 +350,7 @@ button {
img {
display: block;
margin: 20px auto;
- border-radius: 10px;
+ border-radius: 16px;
@include maxBreakpoint($breakpoint-tablet) {
width: 100%;
@@ -371,9 +371,26 @@ button {
flex-wrap: wrap;
}
+ h1 {
+ font-size: 1.5em;
+ padding: 20px 0 12px 0;
+ }
+
+ h2 {
+ font-size: 1.125em;
+ font-weight: 500;
+ font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ }
+
+ h3 {
+ font-size: 1em;
+ font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ }
+
h2,
h3 {
- font-family: "ExpensifyNewKansas", "Helvetica Neue", "Helvetica", Arial, sans-serif;
+ margin: 0;
+ padding: 12px 0 12px 0;
}
blockquote {
@@ -529,7 +546,7 @@ button {
align-items: center;
img {
- border-radius: 12px;
+ border-radius: 16px;
width: 100%;
}
}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Business-Bank-Account-(AUD).md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Business-Bank-Account-(AUD).md
deleted file mode 100644
index 1fa5734293ac..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Business-Bank-Account-(AUD).md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-title: Add-a-Business-Bank-Account-(AUD).md
-description: This article provides insight on setting up and using an Australian Business Bank account in Expensify.
----
-
-# How to add an Australian business bank account (for admins)
-A withdrawal account is the business bank account that you want to use to pay your employee reimbursements.
-
-_Your policy currency must be set to AUD and reimbursement setting set to Indirect to continue. If your main policy is used for something other than AUD, then you will need to create a new one and set that policy to AUD._
-
-To set this up, you’ll run through the following steps:
-
-1. Go to **Settings > Your Account > Payments** and click **Add Verified Bank Account**
-![Click the Verified Bank Account button in the bottom right-hand corner of the screen](https://help.expensify.com/assets/images/add-vba-australian-account.png){:width="100%"}
-
-2. Enter the required information to connect to your business bank account. If you don't know your Bank User ID/Direct Entry ID/APCA Number, please contact your bank and they will be able to provide this.
-![Enter your information in each of the required fields](https://help.expensify.com/assets/images/add-vba-australian-account-modal.png){:width="100%"}
-
-3. Link the withdrawal account to your policy by heading to **Settings > Policies > Group > [Policy name] > Reimbursement**
-4. Click **Direct reimbursement**
-5. Set the default withdrawal account for processing reimbursements
-6. Tell your employees to add their deposit accounts and start reimbursing.
-
-# How to delete a bank account
-If you’re no longer using a bank account you previously connected to Expensify, you can delete it by doing the following:
-
-1. Navigate to Settings > Accounts > Payments
-2. Click **Delete**
-![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
-
-You can complete this process either via the web app (on a computer), or via the mobile app.
-
-# Deep Dive
-## Bank-specific batch payment support
-
-If you are new to using Batch Payments in Australia, to reimburse your staff or process payroll, you may want to check out these bank-specific instructions for how to upload your .aba file:
-
-- ANZ Bank - [Import a file for payroll payments](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
-- CommBank - [Importing and using Direct Entry (EFT) files](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
-- Westpac - [Importing Payment Files](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
-- NAB - [Quick Reference Guide - Upload a payment file](https://www.nab.com.au/business/online-banking/nab-connect/help)
-- Bendigo Bank - [Bulk payments user guide](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
-- Bank of Queensland - [Payments file upload facility FAQ](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
-
-**Note:** Some financial institutions require an ABA file to include a *self-balancing transaction*. If you are unsure, please check with your bank to ensure whether to tick this option or not, as selecting an incorrect option will result in the ABA file not working with your bank's internet banking platform.
-
-## Enable Global Reimbursement
-
-If you have employees in other countries outside of Australia, you can now reimburse them directly using Global Reimbursement.
-
-To do this, you’ll first need to delete any existing Australian business bank accounts. Then, you’ll want to follow the instructions to enable Global Reimbursements
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
index 7c789942a2b3..b59f68a65ce6 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD.md
@@ -1,5 +1,51 @@
---
-title: Business Bank Accounts - AUD
-description: Business Bank Accounts - AUD
+title: Add a Business Bank Account
+description: This article provides insight on setting up and using an Australian Business Bank account in Expensify.
---
-## Resource Coming Soon!
+
+# How to add an Australian business bank account (for admins)
+A withdrawal account is the business bank account that you want to use to pay your employee reimbursements.
+
+_Your policy currency must be set to AUD and reimbursement setting set to Indirect to continue. If your main policy is used for something other than AUD, then you will need to create a new one and set that policy to AUD._
+
+To set this up, you’ll run through the following steps:
+
+1. Go to **Settings > Your Account > Payments** and click **Add Verified Bank Account**
+![Click the Verified Bank Account button in the bottom right-hand corner of the screen](https://help.expensify.com/assets/images/add-vba-australian-account.png){:width="100%"}
+
+2. Enter the required information to connect to your business bank account. If you don't know your Bank User ID/Direct Entry ID/APCA Number, please contact your bank and they will be able to provide this.
+![Enter your information in each of the required fields](https://help.expensify.com/assets/images/add-vba-australian-account-modal.png){:width="100%"}
+
+3. Link the withdrawal account to your policy by heading to **Settings > Policies > Group > [Policy name] > Reimbursement**
+4. Click **Direct reimbursement**
+5. Set the default withdrawal account for processing reimbursements
+6. Tell your employees to add their deposit accounts and start reimbursing.
+
+# How to delete a bank account
+If you’re no longer using a bank account you previously connected to Expensify, you can delete it by doing the following:
+
+1. Navigate to Settings > Accounts > Payments
+2. Click **Delete**
+![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
+
+You can complete this process either via the web app (on a computer), or via the mobile app.
+
+# Deep Dive
+## Bank-specific batch payment support
+
+If you are new to using Batch Payments in Australia, to reimburse your staff or process payroll, you may want to check out these bank-specific instructions for how to upload your .aba file:
+
+- ANZ Bank - [Import a file for payroll payments](https://www.anz.com.au/support/internet-banking/pay-transfer-business/payroll/import-file/)
+- CommBank - [Importing and using Direct Entry (EFT) files](https://www.commbank.com.au/business/pds/003-279-importing-a-de-file.pdf)
+- Westpac - [Importing Payment Files](https://www.westpac.com.au/business-banking/online-banking/support-faqs/import-files/)
+- NAB - [Quick Reference Guide - Upload a payment file](https://www.nab.com.au/business/online-banking/nab-connect/help)
+- Bendigo Bank - [Bulk payments user guide](https://www.bendigobank.com.au/globalassets/documents/business/bulk-payments-user-guide.pdf)
+- Bank of Queensland - [Payments file upload facility FAQ](https://www.boq.com.au/help-and-support/online-banking/ob-faqs-and-support/faq-pfuf)
+
+**Note:** Some financial institutions require an ABA file to include a *self-balancing transaction*. If you are unsure, please check with your bank to ensure whether to tick this option or not, as selecting an incorrect option will result in the ABA file not working with your bank's internet banking platform.
+
+## Enable Global Reimbursement
+
+If you have employees in other countries outside of Australia, you can now reimburse them directly using Global Reimbursement.
+
+To do this, you’ll first need to delete any existing Australian business bank accounts. Then, you’ll want to follow the instructions to enable Global Reimbursements
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
similarity index 83%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
rename to docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
index 7273e5ece879..6114e98883e0 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Add-a-Deposit-Account-(AUD).md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUD.md
@@ -1,12 +1,12 @@
---
-title: Add a Deposit Account (AUD)
+title: Deposit Accounts (AUD)
description: Expensify allows you to add a personal bank account to receive reimbursements for your expenses. We never take money out of this account — it is only a place for us to deposit funds from your employer. This article covers deposit accounts for Australian banks.
---
## How-to add your Australian personal deposit account information
1. Confirm with your Policy Admin that they’ve set up Global Reimbursment
2. Set your default policy (by selecting the correct policy after clicking on your profile picture) before adding your deposit account.
-3. Go to *Settings > Account > Payments* and click *Add Deposit-Only Bank Account*
+3. Go to **Settings > Account > Payments** and click **Add Deposit-Only Bank Account**
![Click the Add Deposit-Only Bank Account button](https://help.expensify.com/assets/images/add-australian-deposit-only-account.png){:width="100%"}
4. Enter your BSB, account number and name. If your screen looks different than the image below, that means your company hasn't enabled reimbursements through Expensify. Please contact your administrator and ask them to enable reimbursements.
@@ -14,7 +14,7 @@ description: Expensify allows you to add a personal bank account to receive reim
![Fill in the required fields](https://help.expensify.com/assets/images/add-australian-deposit-only-account-modal.png){:width="100%"}
# How-to delete a bank account
-Bank accounts are easy to delete! Simply click the red “Delete” button in the bank account under *Settings > Account > Payments*.
+Bank accounts are easy to delete! Simply click the red **Delete** button in the bank account under **Settings > Account > Payments**.
![Click the Delete button](https://help.expensify.com/assets/images/delete-australian-bank-account.png){:width="100%"}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md
deleted file mode 100644
index 61e6dfd95e38..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-AUS.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Deposit Accounts - AUD
-description: Deposit Accounts - AUD
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md
index 19010be95980..a4ff7503f7bb 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/deposit-accounts/Deposit-Accounts-USD.md
@@ -1,5 +1,75 @@
---
title: Deposit Accounts - USD
-description: Deposit Accounts - USD
+description: How to add a deposit account to receive payments for yourself or your business (US)
---
-## Resource Coming Soon!
+# Overview
+
+There are two types of deposit-only accounts:
+
+1. If you're an employee seeking reimbursement for expenses you’ve incurred, you’ll add a **Personal deposit-only bank account**.
+2. If you're a vendor seeking payment for goods or services, you’ll add a **Business deposit-only account**.
+
+# How to connect a personal deposit-only bank account
+
+**Connect a personal deposit-only bank account if you are:**
+
+- An employee based in the US who gets reimbursed by their employer
+- An employee based in Australia who gets reimbursed by their company via batch payments
+- An international (non-US) employee whose US-based employers send international reimbursements
+
+**To establish the connection to a personal bank account, follow these steps:**
+
+1. Navigate to your **Settings > Account > Payments** and click the **Add Deposit-Only Bank Account** button.
+2. Click **Log into your bank** button and click **Continue** on the Plaid connection pop-up window.
+3. Search for your bank account in the list of banks and follow the prompts to sign-in to your bank account.
+4. Enter your bank login credentials when prompted.
+ - If your bank doesn't appear, click the 'x' in the upper right corner of the Plaid pop-up window and click **Connect Manually**.
+ - Enter your account information, then click **Save & Continue**.
+
+You should be all set! You’ll receive reimbursement for your expense reports directly to this bank account.
+
+# How to connect a business deposit-only bank account
+
+**Connect a business deposit-only bank account if you are:**
+
+- A US-based vendor who wants to be paid directly for bills sent to customers/clients
+- A US-based vendor who want to pay invoices directly via Expensify
+
+**To establish the connection to a business bank account, follow these steps:**
+
+1. Navigate to your **Settings > Account > Payments and click the Add Deposit-Only Bank Account** button.
+2. Click **Log into your bank** button and click **Continue** on the Plaid connection pop-up window.
+3. Search for your bank account in the list of banks and follow the prompts to sign-in to your bank account.
+4. Enter your bank login credentials when prompted.
+ - If your bank doesn't appear, click the 'x' in the upper right corner of the Plaid pop-up window and click **Connect Manually**.
+ - Enter your account information, then click **Save & Continue**.
+5. If you see the option to “Switch to Business” after entering the account owner information, click that link.
+6. Enter your Company Name and FEIN or TIN information.
+7. Enter your company’s website formatted as https://www.domain.com.
+
+You should be all set! The bank account will display as a deposit-only business account, and you’ll be paid directly for any invoices you submit for payment.
+
+# How to delete a deposit-only bank account
+
+**To delete a deposit-only bank account, do the following:**
+
+1. Navigate to **Settings > Account > Payments > Bank Accounts**
+2. Click the **Delete** next to the bank account you want to remove
+
+# FAQ
+
+## **What happens if my bank requires an additional security check before adding it to a third-party?**
+
+If your bank account has 2FA enabled or another security step, you should be prompted to complete this when adding the account. If not, and you encounter an error, you can always select the option to “Connect Manually”. Either way, please double check that you are entering the correct bank account details to ensure successful payments.
+
+## **What if I also want to pay employees with my business bank account?**
+
+If you’ve added a business deposit-only account and also wish to also pay employees, vendors, or utilize the Expensify Card with this bank account, select “Verify” on the listed bank account. This will take you through the additional verification steps to use this account to issue payments.
+
+## **I connected my deposit-only bank account – Why haven’t I received my reimbursement?**
+
+There are a few reasons a reimbursement may be unsuccessful. The first step is to review the estimated deposit date on the report. If it’s after that date and you still haven’t seen the funds, it could have been unsuccessful because:
+ - The incorrect account was added. If you believe you may have entered the wrong account, please reach out to Concierge and provide the Report ID for the missing reimbursement.
+ - Your account wasn’t set up for Direct Deposit/ACH. You may want to contact your bank to confirm.
+
+If you aren’t sure, please reach out to Concierge and we can assist!
diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md b/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md
index 77aca2a01678..1d689f5b0355 100644
--- a/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md
+++ b/docs/articles/expensify-classic/billing-and-subscriptions/Pay-Per-Use-Subscription.md
@@ -1,5 +1,29 @@
---
title: Pay-per-use Subscription
-description: Pay-per-use Subscription
+description: Learn more about your pay-per-use subscription.
---
-## Resource Coming Soon!
+# Overview
+Pay-per-use is a billing option for people who prefer to use Expensify month to month or on an as-needed basis. On a pay-per-use subscription, you will only pay for active users in that given month.
+
+**We recommend this billing setup for companies that use Expensify a few months out of the year**. If you have expenses to manage for more than 6 out of 12 months, an [**Annual Subscription**](https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription#gsc.tab=0) may better suit your needs.
+
+# How to start a pay-per-use subscription
+1. Create a Group Workspace if you haven’t already by going to **Settings > Workspaces > Group > New Workspace**
+2. Once you’ve created your Workspace, under the “Subscription” section on the Group Workspace page, select “Pay-per-use”.
+
+# FAQ
+
+## What is considered an active user?
+An active user is anyone who chats, creates, modifies, submits, approves, reimburses, or exports a report in Expensify. This includes actions taken by a Copilot and Workspace automation (such as Scheduled Submit and automated reimbursement). If no one on your Group Workspace uses Expensify in a given month, you will not be billed for that month.
+
+You can review the number of Active Users by selecting “View Activity” next to your billing receipt (**Settings > Account > Payments > Billing History**).
+
+## Why do I have pay-per-use users in addition to my Annual Subscription on my Expensify bill?
+If you have an Annual Subscription, but go above your set user count, we will charge at the pay-per-use rate for these ad-hoc users.
+
+If you expect to have an increased number of users for more than 3 out of 12 months, the most cost-effective approach is to increase your Annual Subscription size.
+
+## Will billing only be in USD currency?
+While USD is the default billing currency, we also have GBP, AUD, and NZD billing currencies. You can see the rates on our [pricing](https://www.expensify.com/pricing) page.
+
+
diff --git a/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md b/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md
index 304c93d1da6d..ae6a9ca77db1 100644
--- a/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md
+++ b/docs/articles/expensify-classic/expense-and-report-features/Expense-Rules.md
@@ -1,5 +1,55 @@
---
title: Expense Rules
-description: Expense Rules
+description: Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant's name.
+
---
-## Resource Coming Soon!
+# Overview
+Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant’s name.
+
+# How to use Expense Rules
+**To create an expense rule, follow these steps:**
+1. Navigate to **Settings > Account > Expense Rules**
+2. Click on **New Rule**
+3. Fill in the required information to set up your rule
+
+When creating an expense rule, you will be able to apply the following rules to expenses:
+
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_01.png){:width="100%"}
+
+- **Merchant:** Updates the merchant name, e.g., “Starbucks #238” could be changed to “Starbucks”
+- **Category:** Applies a workspace category to the expense
+- **Tag:** Applies a tag to the expense, e.g., a Department or Location
+- **Description:** Adds a description to the description field on the expense
+- **Reimbursability:** Determines whether the expense will be marked as reimbursable or non-reimbursable
+- **Billable**: Determines whether the expense is billable
+- **Add to a report named:** Adds the expense to a report with the name you type into the field. If no report with that name exists, a new report will be created
+
+## Tips on using Expense Rules
+- If you'd like to apply a rule to all expenses (“Universal Rule”) rather than just one merchant, simply enter a period [.] and nothing else into the **“When the merchant name contains:”** field. **Note:** Universal Rules will always take precedence over all other rules for category (more on this below).
+- You can apply a rule to previously entered expenses by checking the **Apply to existing matching expenses** checkbox. Click “Preview Matching Expenses” to see if your rule matches the intended expenses.
+- You can create expense rules while editing an expense. To do this, simply check the box **“Create a rule based on your changes"** at the time of editing. Note that the expense must be saved, reopened, and edited for this option to appear.
+
+
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_02.png){:width="100%"}
+
+
+To delete an expense rule, go to **Settings > Account > Expense Rules**, scroll down to the rule you’d like to remove, and then click the trash can icon in the upper right corner of the rule:
+
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_03.png){:width="100%"}
+
+# Deep Dive
+In general, your expense rules will be applied in order, from **top to bottom**, i.e., from the first rule. However, other settings can impact how expense rules are applied. Here is the hierarchy that determines how these are applied:
+1. A Universal Rule will **always** precede over any other expense category rules. Rules that would otherwise change the expense category will **not** override the Universal Rule.
+2. If Scheduled Submit and the setting “Enforce Default Report Title” are enabled on the workspace, this will take precedence over any rules trying to add the expense to a report.
+3. If the expense is from a Company Card that is forced to a workspace with strict rule enforcement, those rules will take precedence over individual expense rules.
+4. If you belong to a workspace that is tied to an accounting integration, the configuration settings for this connection may update your expense details upon export, even if the expense rules were successfully applied to the expense.
+
+
+# FAQ
+## How can I use Expense Rules to vendor match when exporting to an accounting package?
+When exporting non-reimbursable expenses to your connected accounting package, the payee field will list "Credit Card Misc." if the merchant name on the expense in Expensify is not an exact match to a vendor in the accounting package.
+When an exact match is unavailable, "Credit Card Misc." prevents multiple variations of the same vendor (e.g., Starbucks and Starbucks #1234, as is often seen in credit card statements) from being created in your accounting package.
+For repeated expenses, the best practice is to use Expense Rules, which will automatically update the merchant name without having to do it manually each time.
+This only works for connections to QuickBooks Online, Desktop, and Xero. Vendor matching cannot be performed in this manner for NetSuite or Sage Intacct due to limitations in the API of the accounting package.
+
+
diff --git a/docs/articles/expensify-classic/expensify-card/Card-Settings.md b/docs/articles/expensify-classic/expensify-card/Card-Settings.md
index ab212354974a..35708b6fbb1e 100644
--- a/docs/articles/expensify-classic/expensify-card/Card-Settings.md
+++ b/docs/articles/expensify-classic/expensify-card/Card-Settings.md
@@ -1,5 +1,169 @@
---
-title: Card Settings
-description: Card Settings
+title: Expensify Card Settings
+description: Admin Card Settings and Features
---
-## Resource Coming Soon!
+## Expensify Card - admin settings and features
+
+# Overview
+
+The Expensify Card offers a range of settings and functionality to customize how admins manage expenses and card usage in Expensify. To start, we'll lay out the best way to make these options work for you.
+
+Set Smart Limits to control card spend. Smart Limits are spend limits that can be set for individual cards or specific groups. Once a given Smart Limit is reached, the card is temporarily disabled until expenses are approved.
+
+Monitor spend using your Domain Limit and the Reconciliation Dashboard.
+Your Domain Limit is the total Expensify Card limit across your entire organization. No member can spend more than what's available here, no matter what their individual Smart Limit is. A Domain Limit is dynamic and depends on a number of factors, which we'll explain below.
+
+Decide the settlement model that works best for your business
+Monthly settlement is when your Expensify Card balance is paid in full on a certain day each month. Though the Expensify Card is set to settle daily by default, any Domain Admin can change this setting to monthly.
+
+Now, let's get into the mechanics of each piece mentioned above.
+
+# How to set Smart Limits
+Smart Limits allow you to set a custom spend limit for each Expensify cardholder, or default limits for groups. Setting a Smart Limit is the step that activates an Expensify card for your user (and issues a virtual card for immediate use).
+
+## Set limits for individual cardholders
+As a Domain Admin, you can set or edit Custom Smart Limits for a card by going to Settings > Domains > Domain Name > Company Cards. Simply click Edit Limit to set the limit. This limit will restrict the amount of unapproved (unsubmitted and Processing) expenses that a cardholder can incur. After the limit is reached, the cardholder won't be able to use their card until they submit outstanding expenses and have their card spend approved. If you set the Smart Limit to $0, the user's card can't be used.
+## Set default group limits
+Domain Admins can set or edit custom Smart Limits for a domain group by going to Settings > Domains > Domain Name > Groups. Just click on the limit in-line for your chosen group and amend the value.
+
+This limit will apply to all members of the Domain Group who do not have an individual limit set via Settings > Domains > Domain Name > Company Cards.
+## Refreshing Smart Limits
+To let cardholders keep spending, you can approve their pending expenses via the Reconciliation tab. This will free up their limit, allowing them to use their card again.
+
+To check an unapproved card balance and approve expenses, click on Reconciliation and enter a date range, then click though the Unapproved total to see what needs approving. You can add to a new report or approve an existing report from here.
+
+You can also increase a Smart Limit at any time by clicking Edit Limit.
+
+# Understanding your Domain Limit
+
+To get the most accurate Domain Limit for your company, connect your bank account via Plaid under Settings > Account > Payments > Add Verified Bank Account.
+
+If your bank isn't supported or you're having connection issues, you can request a custom limit under Settings > Domains > Domain Name > Company Cards > Request Limit Increase. As a note, you'll need to provide three months of unredacted bank statements for review by our risk management team.
+
+Your Domain Limit may fluctuate from time to time based on various factors, including:
+
+- Available funds in your Verified Business Bank Account: We regularly check bank balances via Plaid. A sudden drop in balance within the last 24 hours may affect your limit. For 'sweep' accounts, be sure to maintain a substantial balance even if you're sweeping daily.
+- Pending expenses: Review the Reconciliation Dashboard to check for large pending expenses that may impact your available balance. Your Domain Limit will adjust automatically to include pending expenses.
+- Processing settlements: Settlements need about three business days to process and clear. Several large settlements over consecutive days may impact your Domain Limit, which will dynamically update when settlements have cleared.
+
+As a note, if your Domain Limit is reduced to $0, your cardholders can't make purchases even if they have a larger Smart Limit set on their individual cards.
+# How to reconcile Expensify Cards
+## How to reconcile expenses
+Reconciling expenses is essential to ensuring your financial records are accurate and up-to-date.
+
+Follow the steps below to quickly review and reconcile expenses associated with your Expensify Cards:
+
+1. Go to Settings > Domains > Domain Name > Company Cards > Reconciliation > Expenses
+2. Enter your start and end dates, then click Run
+3. The Imported Total will show all Expensify Card transactions for the period
+4. You'll also see a list of all Expensify Cards, the total spend on each card, and a snapshot of expenses that have and have not been approved (Approved Total and Unapproved Total, respectively)
+By clicking on the amounts, you can view the associated expenses
+
+## How to reconcile settlements
+A settlement is the payment to Expensify for the purchases made using the Expensify Cards.
+
+The Expensify Card program can settle on either a daily or monthly basis. One thing to note is that not all transactions in a settlement will be approved when running reconciliation.
+
+You can view the Expensify Card settlements under Settings > Domains > Domain Name > Company Cards > Reconciliation > Settlements.
+
+By clicking each settlement amount, you can see the transactions contained in that specific payment amount.
+
+Follow the below steps to run reconciliation on the Expensify Card settlements:
+
+1. Log into the Expensify web app
+2. Click Settings > Domains > Domain Name > Company Cards > Reconciliation tab > Settlements
+3. Use the Search function to generate a statement for the specific period you need
+4. The search results will include the following info for each entry:
+ - Date: when a purchase was made or funds were debited for payments
+ - Posted Date: when the purchase transaction posted
+ - Entry ID: a unique number grouping card payments and transactions settled by those payments
+ - Amount: the amount debited from the Business Bank Account for payments
+ - Merchant: the business where a purchase was made
+ - Card: refers to the Expensify credit card number and cardholder's email address
+ - Business Account: the business bank account connected to Expensify that the settlement is paid from
+ - Transaction ID: a special ID that helps Expensify support locate transactions if there's an issue
+
+5. Review the individual transactions (debits) and the payments (credits) that settled them
+6. Every cardholder will have a virtual and a physical card listed. They're handled the same way for settlements, reconciliation, and exporting.
+7. Click Download CSV for reconciliation
+8. This will list everything that you see on screen
+9. To reconcile pre-authorizations, you can use the Transaction ID column in the CSV file to locate the original purchase
+10. Review account payments
+11. You'll see payments made from the accounts listed under Settings > Account > Payments > Bank Accounts. Payment data won't show for deleted accounts.
+
+You can use the Reconciliation Dashboard to confirm the status of expenses that are missing from your accounting system. It allows you to view both approved and unapproved expenses within your selected date range that haven't been exported yet.
+
+# Deep dive
+## Set a preferred workspace
+Some customers choose to split their company card expenses from other expense types for coding purposes. Most commonly this is done by creating a separate workspace for card expenses.
+
+You can use the preferred workspace feature in conjunction with Scheduled Submit to make sure all newly imported card expenses are automatically added to reports connected to your card-specific workspace.
+## How to change your settlement account
+You can change your settlement account to any other verified business bank account in Expensify. If your bank account is closing, make sure you set up the replacement bank account in Expensify as early as possible.
+
+To select a different settlement account:
+
+1. Go to Settings > Domains > Domain Name > Company Cards > Settings tab
+2. Use the Expensify Card settlement account dropdown to select a new account
+3. Click Save
+
+## Change the settlement frequency
+
+By default, the Expensify Cards settle on a daily cadence. However, you can choose to have the cards settle on a monthly basis.
+
+1. Monthly settlement is only available if the settlement account hasn't had a negative balance in the last 90 days
+2. There will be an initial settlement to settle any outstanding spend that happened before switching the settlement frequency
+3. The date that the settlement is changed to monthly is the settlement date going forward (e.g. If you switch to monthly settlement on September 15th, Expensify Cards will settle on the 15th of each month going forward)
+
+To change the settlement frequency:
+1. Go to Settings > Domains > Domain Name > Company Cards > Settings tab
+2. Click the Settlement Frequency dropdown and select Monthly
+3. Click Save to confirm the change
+
+
+## Declined Expensify Card transactions
+As long as you have 'Receive realtime alerts' enabled, you'll get a notification explaining the decline reason. You can enable alerts in the mobile app by clicking on three-bar icon in the upper-left corner > Settings > toggle Receive realtime alerts on.
+
+If you ever notice any unfamiliar purchases or need a new card, go to Settings > Account > Credit Card Import and click on Request a New Card right away.
+
+Here are some reasons an Expensify Card transaction might be declined:
+
+1. You have an insufficient card limit
+ - If a transaction amount exceeds the available limit on your Expensify Card, the transaction will be declined. It's essential to be aware of the available balance before making a purchase to avoid this - you can see the balance under Settings > Account > Credit Card Import on the web app or mobile app. Submitting expenses and having them approved will free up your limit for more spend.
+
+2. Your card hasn't been activated yet, or has been canceled
+ - If the card has been canceled or not yet activated, it won't process any transactions.
+
+3. Your card information was entered incorrectly. Entering incorrect card information, such as the CVC, ZIP or expiration date will also lead to declines.
+
+4. There was suspicious activity
+ - If Expensify detects unusual or suspicious activity, we may block transactions as a security measure. This could happen due to irregular spending patterns, attempted purchases from risky vendors, or multiple rapid transactions. Check your Expensify Home page to approve unsual merchants and try again.
+ If the spending looks suspicious, we may do a manual due diligence check, and our team will do this as quickly as possible - your cards will all be locked while this happens.
+
+5. The merchant is located in a restricted country
+ - Some countries may be off-limits for transactions. If a merchant or their headquarters (billing address) are physically located in one of these countries, Expensify Card purchases will be declined. This list may change at any time, so be sure to check back frequently: Belarus, Burundi, Cambodia, Central African Republic, Democratic Republic of the Congo, Cuba, Iran, Iraq, North Korea, Lebanon, Libya, Russia, Somalia, South Sudan, Syrian Arab Republic, Tanzania, Ukraine, Venezuela, Yemen, and Zimbabwe.
+
+# FAQ
+## What happens when I reject an Expensify Card expense?
+
+
+Rejecting an Expensify Card expense from an Expensify report will simply allow it to be reported on a different report. You cannot undo a credit card charge.
+
+If an Expensify Card expense needs to be rejected, you can reject the report or the specific expense so it can be added to a different report. The rejected expense will become Unreported and return to the submitter's Expenses page.
+
+If you want to dispute a card charge, please message Concierge to start the dispute process.
+
+If your employee has accidentally made an unauthorised purchase, you will need to work that out with the employee to determine how they will pay back your company.
+
+
+## What happens when an Expensify Card transaction is refunded?
+
+
+The way a refund is displayed in Expensify depends on the status of the expense (pending or posted) and whether or not the employee also submitted an accompanying SmartScanned receipt. Remember, a SmartScanned receipt will auto-merge with the Expensify Card expense.
+
+- Full refunds:
+If a transaction is pending and doesn't have a receipt attached (except for eReceipts), getting a full refund will make the transaction disappear.
+If a transaction is pending and has a receipt attached (excluding eReceipts), a full refund will zero-out the transaction (amount becomes zero).
+- Partial refunds:
+If a transaction is pending, a partial refund will reduce the amount of the transaction.
+- If a transaction is posted, a partial refund will create a negative transaction for the refund amount.
diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
new file mode 100644
index 000000000000..5c9761b7ff1d
--- /dev/null
+++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
@@ -0,0 +1,247 @@
+---
+title: Expensify Card Perks
+description: Get the most out of your Expensify Card with exclusive perks!
+---
+
+
+# Overview
+The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. The Expensify Card’s primary perks include:
+- Access to our premiere Expensify Lounge (with more locations coming soon)
+- Swipe to Win, where every swipe has a chance to win fun personalized gifts for you and your closest friends and family members
+- And unbeatable cash back incentive with each swipe
+Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners.
+
+# Expensify Card Perks
+
+## Access to the Expensify Lounge
+Our [world-class lounge](https://use.expensify.com/lounge) is now open for Expensify members and guests to enjoy!
+
+We invite you to visit our sleek San Francisco lounge, where sweeping city views provide the perfect backdrop for a morning coffee to start your day.
+
+Enjoy complimentary cocktails and snacks in a vibrant atmosphere with blazing-fast WiFi. Whether you want a place to focus on work, socialize with other members, or simply kick back and relax – our lounge is ready and waiting to welcome you.
+
+You can sign up for free [here](https://use.expensify.com) if you’re not an Expensify member. If you have any questions, reach out to concierge@expensify.com and [check this out](https://use.expensify.com/lounge) for more info.
+
+## Swipe to Win
+Swipe to Win is a new [Expensify Card](https://use.expensify.com/company-credit-card) perk that gives cardholders the chance to send a gift to a friend, family member, or essential worker on the frontlines!
+
+Winners can choose to _Send a Smile_ or _Send a Laugh_. To start, we’re offering one gift per option:
+
+- **Send A Smile:** Champagne by Expensify
+- **Send a Laugh:** Jenga Set
+
+**How to Participate**
+It’s easy! Once you have an Expensify Card, you just need to start using it. With each swipe, you're automatically entered to win and have a 1 in 250 chance of getting a prize!
+
+**How will I know if I’ve won?**
+Winners will be notified immediately via the Expensify app, and receive additional instructions on how to choose and send their desired gift.
+
+If you don't have Expensify notifications turned on yet, here are some helpful guides:
+- [Apple Notification Preferences](https://support.apple.com/en-us/HT201925)
+- [Android Notification Preferences](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fsupport.google.com%2Fandroid%2Fanswer%2F9079661%3Fhl%3Den)
+
+# Partner Specific Perks
+
+## Amazon AWS
+Whether you are a two-person startup launching a new company or a venture-backed startup, we all could use a little relief in these difficult times. AWS Activate provides you with access to the resources you need to quickly get started on AWS - including free credits, technical support, and training.
+
+All Expensify customers that have adopted The Expensify Card qualify when they add their Expensify Card for billing with AWS!
+
+**Apply now by going [to this link](https://aws.amazon.com/startups/credits) and using the OrgID: 0qyIA (Case Sensitive)**
+
+The full details on the AWS Activate program can be found in AWS's [terms & conditions](https://aws.amazon.com/activate/terms/) and the [Activate FAQs](https://aws.amazon.com/startups/faq).
+
+## Stripe
+Whether you’re creating a subscription service, an on-demand marketplace, or an e-commerce store, Stripe’s integrated payments platform helps you build and scale your business globally.
+
+**Receive waived Stripe fees, if you’re new to Stripe, for your first $5,000 in processed payments.**
+
+**How to redeem:** Sign up for Stripe using your Expensify Card.
+
+## Lamar Advertising
+Lamar provides out-of-home advertising space for clients on billboards, digital, airport displays, transit, and highway logo signs.
+
+**Receive at minimum a 10% discount on your first campaign.**
+
+**How do redeem:** Contact Expensify’s dedicated account manager, Lisa Kane, and mention you’re an Expensify cardholder.
+
+Email: lkane@lamar.com
+
+## Carta
+Simplify equity management with Carta.
+
+**Receive a 20% first-year discount and waived implementation fees for Carta.**
+
+**How to redeem:** Sign up using your Expensify Card
+
+## Pilot
+Pilot specializes in bookkeeping and tax prep for startups and e-commerce providers. When you work with Pilot, you’re paired with a dedicated finance expert who takes the work off your plate and is on hand to answer your questions.
+
+**20% off the first 6-months of Pilot Core**
+
+**How to redeem:** Sign-up using your Expensify Card.
+
+## Spotlight Reporting
+The integrated cloud reporting and forecasting tool that allows you to create insights for better business decisions. Designed by Accountants, for Accountants
+
+**20% discount off your subscription for the first 6 months, plus one free seat to Spotlight Certification.**
+
+**How to redeem:** Sign up using your Expensify Card.
+
+## Guideline
+Guideline's full-service 401(k) plans make it easier and more affordable to offer your employees the retirement benefits they deserve.
+
+**Receive 3 months free.**
+
+**How to redeem:** Sign up using your Expensify Card.
+
+## Gusto
+Gusto's people platform helps businesses like yours onboard, pay, insure, and support your hardworking team. Payroll, benefits, and more
+
+**3 months free service**
+
+**How to redeem:** Sign-up using your Expensify Card.
+
+## QuickBooks Online
+QuickBooks accounting software helps keep your books accurate and up to date, automatically such as: invoicing, cashflow, expense tracking, and more.
+
+**Receive 30% off QuickBooks Online for the first 12 months.**
+
+**How to redeem:** Sign up using your Expensify Card.
+
+## Highfive
+Highfive improves the ease and quality of intelligent in-room video conferencing.
+
+**Receive 50% off the Highfive Select starter package. 10% off the Highfive Premium Package.**
+
+**How to redeem:** Sign-up with your Expensify Card.
+
+## Zendesk
+**$436 in credits for Zendesk Suite products per month for the first year**
+
+How to redeem:
+1. Reach out to startups@zendesk.com with the following: "Expensify asked me to send an email regarding the Zendesk promotion”. You'll receive a code you use in step 5 below.
+2. Start a Zendesk Trial (can be a suite trial or something different) in USD. If your trial is not in USD, contact Zendesk. If you already have a current trial, the code applies and can be used.
+3. From inside your Zendesk trial, click the Buy Now button.
+4. Select your chosen plan with monthly billing. The $436 monthly credit works for up to 4 licenses of the Suite, but the code can also apply $436 to any alternative monthly plan selection.
+5. Enter the promo code that was provided to you in step 1 after emailing Zendesk.
+6. Complete the checkout process and note that once your free credit runs out after 12 monthly billing periods, you will be charged for your next month with Zendesk.
+
+## Xero
+Accounting Software With Everything You Need To Run Your Business Beautifully. Smart Online Accounting. Bank Connections
+
+**U.S. residents get 50% off Xero for six months.**
+
+Head to [this](https://apps.xero.com/us/app/expensify?xtid=x30expensify&utm_source=expensify&utm_medium=web&utm_campaign=cardoffer) page and sign-up for Xero using your Expensify Card!
+
+## Freshworks
+Boost your startup journey with leading customer and employee engagement solutions from Freshworks including CRM, livechat, support, marketing automation, ITSM and HRMS.
+
+How to receive $4,000 in credits on Freshworks products:
+
+[Click here](https://www.freshworks.com/partners/startup-program/expensify-card/) and fill out the form and enter your details, Freshbooks will recognize your company as an Expensify Card customer automatically.
+
+## Slack
+**Receive 25% off for the first year:** You’ll enjoy premium features like unlimited messaging and apps, Slack Connect channels, group video calls, priority support, and much more. It’s all just a click away.
+
+**How to redeem with your Expensify Card:** [Click here](https://slack.com/promo/partner?remote_promo=ead919f5) to redeem the offer by using your Expensify Card to manage the billing.
+
+## Deel.com
+Deel makes onboarding international team members in 150 different countries painless. Quickly bring on contractors or hire employees in seconds with Deel as your employer of record (EOR). It’s one simple, powerful dashboard that houses everything you need. Finalize contracts, pay employees, and manage all your payroll data in one place seamlessly.
+
+**How to redeem 3 months free, then 30% off the rest of the year with Deel.com:** Click [here](https://www.deel.com/partners/expensify) and sign up using your Expensify Card.
+
+## Snap
+**$1,000 in Snap credits**
+Whether you're looking to increase online sales, drive app installs, or get more leads, Snapchat can connect you with a unique mobile audience primed to take action. For a limited time, spend $1000 in Snapchat's Ads Manager and receive $1000 in ad credit to use towards your next campaign!
+
+**How to redeem with your Expensify Card:** Click on `create ad` or `request a call` by clicking here. Enter your details to set up your account if you don't already have one.Add the Expensify Card as your payment option for your Snap Business account.Credits will be automatically placed in your account once you've reached $1,000 in spend.
+
+## Aircall
+Aircall is the cloud-based phone system of choice for modern brands. Aircall allows sales and support teams to have meaningful and efficient phone conversations, and integrates with the most popular CRMs, Help desks, and business tools. Pricing is dependent on the number of users within the account. Discount could range from $270-$9,000+
+
+**2 Months Free**
+
+**How to redeem with your Expensify Card:**
+1. Click [here])(http://pages.aircall.io/Expensify-RewardsPartnerReferral.html)
+2. Sign up for a demo
+3. Let our team know you're an Expensify customer
+
+## NetSuite
+NetSuite helps companies manage core business processes with a cloud-based ERP and accounting software. Expensify has a direct integration with NetSuite so that expenses are coded to your exact preference and data is always synchronized across the two systems.
+
+**10% OFF for the First Year**
+
+**How to redeem:**
+1. Fill out this [Google form](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fdocs.google.com%2Fforms%2Fd%2Fe%2F1FAIpQLSeiOzLrdO-MgqeEMwEqgdQoh4SJBi42MZME9ycHp4SQjlk3bQ%2Fviewform%3Fusp%3Dsf_link).
+2. An Expensify rep will make an introduction to a NetSuite sales rep to get the process started. This offer is only for prospective NetSuite customers. If you are currently a NetSuite customer, this promotion does not apply.
+3. Once you are set up and pay for your first year with NetSuite, we will send you a payment equal to 10% of your first year contract within three months of paying your first NetSuite invoice.
+
+## PagerDuty
+PagerDuty's Platform for Real-Time Operations integrates machine data & human intelligence to improve visibility & agility across organizations.
+
+**25% OFF**
+
+**How to redeem:**
+1. Sign-up using your Expensify Card
+2. Use the discount code EXPENSIFYPDTEAM for a 25% discount on the Team plan or EXPENSIFYPDBUSINESS for a 25% discount on the Business plan within the Cost Summary section upon checkout.
+
+## Typeform
+Typeform makes collecting and sharing information comfortable and conversational. It's a web-based platform you can use to create anything from surveys to apps, without needing to write a single line of code.
+
+**30% off annual premium and professional plans**
+
+**How to redeem with your Expensify Card:**
+1. Click on the 'Get Typeform` by [clicking here](https://try.typeform.com/expensify/?utm_source=expensify&utm_medium=referral&utm_campaign=expensify_integration&utm_content=directory)
+2. Enter your details and setup your free account
+3. Verify your email by clicking on the link that Typeform sends you
+4. Go through the on boarding flow within Tyepform
+5. Click on the 'Upgrade' button from within your workspace
+6. Select your plan
+7. Enter the coupon 'EXPENSIFY30' on the checkout page
+8. Click on 'Upgrade now' once you've filled out all of your payment details with your Expensify Card
+
+## Intercom
+Intercom builds a suite of messaging-first products for businesses to accelerate growth across the customer lifecycle.
+
+**3-months free service**
+
+**How to redeem:** Sign-up using your Expensify Card.
+
+## Talkspace
+Prescription management and personalized treatment from a network of licensed prescribers trained in mental healthcare. Therapists are licensed, verified and background-checked. Working with a Talkspace therapist will give you an unbiased, trained perspective and provide you with the guidance and tools to help you feel better. When it comes to your mental health, the right therapist makes all the difference.
+
+**$125 OFF Talkspace purchases**
+
+**How to redeem with your Expensify Card:** Use the code at EXPENSIFY at the time of checkout.
+
+## Stripe Atlas
+Stripe Atlas helps removes obstacles typically associated with starting a business so you can build your startup from anywhere in the world.
+
+**Receive $100 off Stripe Atlas and get access to a startup toolkit and special offers on additional Strip Atlas services.**
+
+**How to redeem:** Sign up with your Expensify Card.
+
+# FAQ
+
+## Where is the Expensify Lounge?
+The Expensify Lounge is located on the 16th floor of 88 Kearny Street in San Francisco, California, 94108. This is currently our only lounge location, but keep an eye out for more work lounges popping up soon!
+
+## When is the Expensify Lounge open?
+The lounge is open 8 a.m. to 6 p.m. from Monday through Friday, except for national holidays. Capacity is limited, and we are admitting loungers on a first-come, first-served basis, so make sure to get there early!
+
+## Who can use the lounge workplace?
+Customers with an Expensify subscription can use Expensify’s lounge workplace, and any partner who has completed [ExpensifyApproved! University!](https://university.expensify.com/users/sign_in?next=%2Fdashboard)
+
+
+
+
+# FAQ
+This section covers the useful but not as vital information, it should capture commonly queried elements which do not organically form part of the About or How-to sections.
+
+- What's idiosyncratic or potentially confusing about this feature?
+- Is there anything unique about how this feature relates to billing/activity?
+- If this feature is released, are there any common confusions that can't be solved by improvements to the product itself?
+- Similarly, if this feature hasn't been released, can you predict and pre-empt any potential confusion?
+- Is there any general troubleshooting for this feature?
+ - Note: troubleshooting should generally go in the FAQ, but if there is extensive troubleshooting, such as with integrations, that will be housed in a separate page, stored with and linked from the main page for that feature.
diff --git a/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md b/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md
new file mode 100644
index 000000000000..8f87b36ef3d9
--- /dev/null
+++ b/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md
@@ -0,0 +1,67 @@
+---
+title: Set Up the Card for your Company
+description: Details on setting up the Expensify Card for your company as an admin
+---
+# Overview
+
+If you’re an admin interested in rolling out the Expensify Card for your organization, you’re in the right place. This article will cover how to qualify and apply for the Expensify Card program and begin issuing cards to your employees.
+
+# How to qualify for the Expensify Card program
+
+There are three prerequisites to consider before applying for the Expensify Card:
+
+1. The email address associated with your account must be on a private domain
+2. You must claim your private domain in Expensify
+3. You must add and verify a US business bank account to your Expensify account
+
+To claim a domain, you must be a workspace admin with a company email address matching the domain you want to claim. After you create an account and set up a workspace, head to **Settings > Domains** to claim your domain.
+
+You can add a business bank account by navigating to **Settings > Account > Payments** and clicking Add Verified Bank Account. Follow the setup steps and complete the verification process as required.
+
+# How to apply for the Expensify Card
+
+Once you’ve claimed your domain and added a verified US business bank account, you can apply for the Expensify Card. There are multiple ways to apply for the card from the web:
+
+## From the home page
+
+1. Log into your Expensify account using your preferred web browser
+2. Head to your account’s home page
+3. On the task that says “Introducing the Expensify Card,” click **Enable my Expensify Cards** to get started
+
+## From the Company Cards page
+
+1. Log into your Expensify account using your preferred web browser
+2. Head to **Settings > Domains > _Domain Name_ > Company Cards**
+3. Click **Get the Card**
+
+After we receive your application, we’ll review it ASAP and send you a confirmation email with the next steps once we have them.
+
+# How to issue cards
+
+After you’ve been approved, it’s time to set limits for your employees. Setting a limit triggers an email and task on the home page requesting the employee’s shipping address. Once they enter their details, a card will be shipped to them. We’ll also create a virtual card for the employee that can be used immediately.
+
+To set a limit, head over to the Company Cards UI via **Settings > Domains > _Domain Name_ > Company Cards**. Click the **Edit Limit** button next to members who need a card assigned, and set a non-$0 to issue them a card.
+
+If you have a validated domain, you can set a limit for multiple members by setting a limit for an entire domain group via **Settings > Domains > _Domain Name_ > Groups**. Keep in mind that custom limits that are set on an individual basis will override the group limit.
+
+The Company Cards page will act as a hub to view all employees who have been issued a card and where you can view and edit the individual card limits. You’ll also be able to see anyone who has requested a card but doesn’t have one yet.
+
+# FAQ
+
+## Are there foreign transaction fees?
+
+There are no foreign transaction fees when using your Expensify Card for international purchases.
+
+## How does the Expensify Card affect my or my company's credit score?
+
+Applying for or using the Expensify Card will never have any positive or negative effect on your personal credit score or your business's credit score. We do not consider your or your business' credit score when determining approval and your card limit.
+
+## How much does the Expensify Card cost?
+
+The Expensify Card is a free corporate card, and no fees are associated with it. In addition, if you use the Expensify Card, you can save money on your Expensify subscription.
+
+## If I have staff outside the US, can they use the Expensify Card?
+
+As long as the verified bank account used to apply for the Expensify Card is a US bank account, your cardholders can be anywhere in the world.
+
+Otherwise, the Expensify Card is not available for customers using non-US banks. With that said, launching international support is a top priority for us. Let us know if you’re interested in contacting support, and we’ll reach out as soon as the Expensify Card is available outside the United States.
diff --git a/docs/articles/expensify-classic/expensify-card/Statements.md b/docs/articles/expensify-classic/expensify-card/Statements.md
index b48d303a1a9b..5b583370b810 100644
--- a/docs/articles/expensify-classic/expensify-card/Statements.md
+++ b/docs/articles/expensify-classic/expensify-card/Statements.md
@@ -1,5 +1,73 @@
---
-title: Statements
-description: Statements
+title: — Expensify Card Statements and Settlements
+description: Learn how the Expensify Card statement and settlements work!
---
-## Resource Coming Soon!
+
+# Overview
+Expensify offers several settlement types and a statement that provides a detailed view of transactions and settlements. We discuss specifics on both below.
+
+# How to use Expensify Card Statement and Settlements
+## Using the statement
+If your domain uses the Expensify Card and you have a validated Business Bank Account, access the Expensify Card statement at Settings > Domains > Company Cards > Reconciliation Tab > Settlements.
+
+The Expensify Card statement displays individual transactions (debits) and their corresponding settlements (credits). Each Expensify Cardholder has a Digital Card and a Physical Card, which are treated the same in settlement, reconciliation, and exporting to your accounting system.
+
+Here's a breakdown of crucial information in the statement:
+- **Date:** For card payments, it shows the debit date; for card transactions, it displays the purchase date.
+- **Entry ID:** This unique ID groups card payments and transactions together.
+- **Withdrawn Amount:** This applies to card payments, matching the debited amount from the Business Bank Account.
+- **Transaction Amount:** This applies to card transactions, matching the expense purchase amount.
+- **User email:** Applies to card transactions, indicating the cardholder's Expensify email address.
+- **Transaction ID:** A unique ID for locating transactions and assisting Expensify Support in case of issues. Transaction IDs are handy for reconciling pre-authorizations. To find the original purchase, locate the Transaction ID in the Settlements tab of the reconciliation dashboard, download the settlements as a CSV, and search for the Transaction ID within it.
+
+![Expanded card settlement that shows the various items that make up each card settlement.](https://help.expensify.com/assets/images/ExpensifyHelp_SettlementExpanded.png){:width="100%"}
+
+The Expensify Card statement only shows payments from existing Business Bank Accounts under Settings > Account > Payments > Business Accounts. If a Business Account is deleted, the statement won't contain data for payments from that account.
+
+## Exporting your statement
+When using the Expensify Card, you can export your statement to a CSV with these steps:
+
+ 1. Login to your account on the web app and click on Settings > Domains > Company Cards.
+ 2. Click the Reconciliation tab at the top right, then select Settlements.
+ 3. Enter your desired statement dates using the Start and End fields.
+ 4. Click Search to access the statement for that period.
+ 5. You can view the table or select Download to export it as a CSV.
+
+![Click the Download CSV button in the middle of the page to export your card settlements.](https://help.expensify.com/assets/images/ExpensifyHelp_SettlementExport.png){:width="100%"}
+
+## Expensify Card Settlement Frequency
+Paying your Expensify Card balance is simple with automatic settlement. There are two settlement frequency options:
+ - **Daily Settlement:** Your Expensify Card balance is paid in full every business day, meaning you’ll see an itemized debit each business day.
+ - **Monthly Settlement:** Expensify Cards are settled monthly, with your settlement date determined during the card activation process. With monthly, you’ll see only one itemized debit per month. (Available for Plaid-connected bank accounts with no recent negative balance.)
+
+## How settlement works
+Each business day (Monday through Friday, excluding US bank holidays) or on your monthly settlement date, we calculate the total of posted Expensify Card transactions since the last settlement. The settlement amount represents what you must pay to bring your Expensify Card balance back to $0.
+
+We'll automatically withdraw this settlement amount from the Verified Business Bank Account linked to the primary domain admin. You can set up this bank account in the web app under Settings > Account > Payments > Bank Accounts.
+
+Once the payment is made, your Expensify Card balance will be $0, and the transactions are considered "settled."
+
+To change your settlement frequency or bank account, go to Settings > Domains > [Domain Name] > Company Cards. On the Company Cards page, click the Settings tab, choose a new settlement frequency or account from the dropdown menu, and click Save to confirm the change.
+
+![Change your card settlement account or settlement frequency via the dropdown menus in the middle of the screen.](https://help.expensify.com/assets/images/ExpensifyHelp_CardSettings.png){:width="100%"}
+
+# Expensify Card Statement and Settlements FAQs
+## Can you pay your balance early if you've reached your Domain Limit?
+If you've chosen Monthly Settlement, you can manually initiate settlement using the "Settle Now" button. We'll settle the outstanding balance and then perform settlement again on your selected predetermined monthly settlement date.
+
+If you opt for Daily Settlement, the Expensify Card statement will automatically settle daily through an automatic withdrawal from your business bank account. No additional action is needed on your part.
+
+## Will our domain limit change if our Verified Bank Account has a higher balance?
+Your domain limit may fluctuate based on your cash balance, spending patterns, and history with Expensify. Suppose you've recently transferred funds to the business bank account linked to Expensify card settlements. In that case, you should expect a change in your domain limit within 24 hours of the transfer (assuming your business bank account is connected through Plaid).
+
+## How is the “Amount Owed” figure on the card list calculated?
+The amount owed consists of all Expensify Card transactions, both pending and posted, since the last settlement date. The settlement amount withdrawn from your designated Verified Business Bank Account only includes posted transactions.
+
+Your amount owed decreases when the settlement clears. Any pending transactions that don't post timely will automatically expire, reducing your amount owed.
+
+## **How do I view all unsettled expenses?**
+To view unsettled expenses since the last settlement, use the Reconciliation Dashboard's Expenses tab. Follow these steps:
+ 1. Note the dates of expenses in your last settlement.
+ 2. Switch to the Expenses tab on the Reconciliation Dashboard.
+ 3. Set the start date just after the last settled expenses and the end date to today.
+ 4. The Imported Total will show the outstanding amount, and you can click through to view individual expenses.
diff --git a/docs/articles/new-expensify/exports/Coming-Soon.md b/docs/articles/expensify-classic/expensify-partner-program/Coming-Soon.md
similarity index 100%
rename from docs/articles/new-expensify/exports/Coming-Soon.md
rename to docs/articles/expensify-classic/expensify-partner-program/Coming-Soon.md
diff --git a/docs/articles/expensify-classic/getting-started/Tips-And-Tricks.md b/docs/articles/expensify-classic/getting-started/Tips-And-Tricks.md
new file mode 100644
index 000000000000..b692bf466413
--- /dev/null
+++ b/docs/articles/expensify-classic/getting-started/Tips-And-Tricks.md
@@ -0,0 +1,72 @@
+---
+title: Tips and Tricks
+description: How to get started with setup tips for your Expensify account
+---
+
+# Overview
+In this article, we'll outline helpful tips for using Expensify, such as keyboard shortcuts and text formatting.
+
+# How to Format Text in Expensify
+You can use a basic markdown in report comments to emphasize or clarify your sentiments. This includes italicizing, bolding, and strikethrough for text, as well as adding basic hyperlinks.
+Formatting is consistent across both web and mobile applications, with three markdown options available for your report comments:
+- **Bold:** Place an asterisk on either side (*bold*)
+- **Italicize:** Place an underscore on either side (_italic_)
+- **Strikethrough:** Place a tilde on either side (~strikethrough~)
+
+# How to Use Keyboard Shortcuts
+Keyboard shortcuts can speed things up and simplify tasks. Expensify offers several shortcuts for your convenience. Let's explore them!
+- **Shift + ?** - Opens the keyboard shortcuts dialog
+- **Shift + G** - Prompts you for a reportID to open the report page for a specific report
+- **ESC** - Closes any shortcut dialog window
+- **Ctrl+Enter** - Submit a comment on a report from the comment field in the Report History & Comments section.
+- **Shift + P** - Takes you to the report’s policy when you’re on a report
+- **Shift + →** - Go to the next report
+- **Shift + ←** - Go to the previous report
+- **Shift + R** - Reloads the current page
+
+# How to Create a Copy of a Report
+If you have identical monthly expenses and want to copy them easily, visit your Reports page, check the box next to the report you would like to duplicate, and click "Copy" to duplicate all expenses (excluding receipt images).
+If you prefer, you can create a standard template for certain expenses:
+1. Go to the Reports page.
+2. Click "New Report."
+3. Assign an easily searchable name to the report.
+4. Click the green '+' button to add an expense.
+5. Choose "New Expense."
+6. Select the type of expense (e.g., regular expense, distance, time, etc.).
+7. Enter the expense details, code, and any relevant description.
+8. Click "Save."
+**Pro Tip:** If you use Scheduled Submit, place the template report under your individual workspace to avoid accidental submission. When you're ready to use it, check the report box, copy it, and make necessary changes to the name and workspace.
+
+# How to Enable Location Access on Web
+If you’d like to use features that rely on your current location, you will need to enable location permissions for Expensify. You can find instructions for enabling location settings on the three most common web browsers below. If your browser is not on the list, then please do a web search for your browser and “enable location settings”.
+
+## Chrome
+1. Open Chrome
+2. At the top right, click the three-dot Menu > Settings
+3. Click “Privacy and Security” and then “Site Settings”
+4. Click "Location"
+5. Check the “Not allowed to see your location” list to ensure that expensify.com and new.expensify.com are not listed. If they are, click the delete icon next to them to allow location access
+
+## Firefox
+1. Open Firefox
+2. In the URL bar enter “about:preferences”
+3. On the left-hand side select “Privacy & Security”
+4. Scroll down to Permissions
+5. Click on Settings next to Location
+6. If location access is blocked for expensify.com or new.expensify.com, you can update it here to allow access
+
+## Safari
+1. In the top menu bar, click Safari
+2. Then select Settings > Websites
+3. Click Location on the left-hand side
+4. If expensify.com or new.expensify.com have “Deny” set as their access, update it to “Ask” or “Allow”
+
+# Which browser works best with Expensify?
+We recommend using Google Chrome, but you can use Expensify on most major browsers, such as:
+- [Google Chrome](https://google.com/chrome/)
+- [Mozilla Firefox](https://mozilla.com/firefox)
+- [Microsoft Edge](https://microsoft.com/edge)
+- [Microsoft Internet Explorer](https://microsoft.com/ie). Please note: Microsoft has discontinued support and security updates for all versions below Version 11. This means those older versions may not work well. Due to the lack of security updates for the older versions, parts of our site may not be accessible. Please update your IE version or choose a different browser.
+- [Apple Safari (Apple devices only)](https://apple.com/safari)
+- [Opera](https://opera.com)
+It's always best practice to ensure you have the most recent updates for your browser and keep your operating system up to date.
diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
index a7553e6ae179..d933e66cc2d1 100644
--- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
+++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md
@@ -3,18 +3,18 @@ title: Expensify Playbook for Small to Medium-Sized Businesses
description: Best practices for how to deploy Expensify for your business
redirect_from: articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses/
---
-## Overview
+# Overview
This guide provides practical tips and recommendations for small businesses with 100 to 250 employees to effectively use Expensify to improve spend visibility, facilitate employee reimbursements, and reduce the risk of fraudulent expenses.
- See our [US-based VC-Backed Startups](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups) if you are more concerned with top-line revenue growth
-## Who you are
+# Who you are
As a small to medium-sized business owner, your main aim is to achieve success and grow your business. To achieve your goals, it is crucial that you make worthwhile investments in both your workforce and your business processes. This means providing your employees with the resources they need to generate revenue effectively, while also adopting measures to guarantee that expenses are compliant.
-## Step-by-step instructions for setting up Expensify
+# Step-by-step instructions for setting up Expensify
This playbook is built on best practices we’ve developed after processing expenses for tens of thousands of companies around the world. As such, use this playbook as your starting point, knowing that you can customize Expensify to suit your business needs. Every company is different, and your dedicated Setup Specialist is always one chat away with any questions you may have.
-### Step 1: Create your Expensify account
+## Step 1: Create your Expensify account
If you don't already have one, go to *[new.expensify.com](https://new.expensify.com)* and sign up for an account with your work email address. The account is free so don’t worry about the cost at this stage.
> _Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical_
@@ -22,7 +22,7 @@ If you don't already have one, go to *[new.expensify.com](https://new.expensify.
> **Robyn Gresham**
> Senior Accounting Systems Manager at SunCommon
-### Step 2: Create a Control Policy
+## Step 2: Create a Control Policy
There are three policy types, but for your small business needs we recommend the *Control Plan* for the following reasons:
- *The Control Plan* is designed for organizations with a high volume of employee expense submissions, who also rely on compliance controls
@@ -40,7 +40,7 @@ To create your Control Policy:
The Control Plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's *#admins* room in *[new.expensify.com](https://new.expensify.com)*, and in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider.
-### Step 3: Connect your accounting system
+## Step 3: Connect your accounting system
As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as:
- Every purchase is categorized into the correct account in your chart of accounts
@@ -65,7 +65,7 @@ Check out the links below for more information on how to connect to your account
*“Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical.”*
- Robyn Gresham, Senior Accounting Systems Manager at SunCommon
-### Step 4: Set category-specific compliance controls
+## Step 4: Set category-specific compliance controls
Head over to the *Categories* tab to set compliance controls on your newly imported list of categories. More specifically, we recommend the following:
1. First, enable *People Must Categorize Expenses*. Employees must select a category for each expense, otherwise, in most cases, it’s more work on you and our accounting connections will simply reject any attempt to export.
@@ -78,7 +78,7 @@ Head over to the *Categories* tab to set compliance controls on your newly impor
3. Disable any irrelevant expense categories that aren’t associated with employee spend
4. Configure *auto-categorization*, located just below your category list in the same tab. The section is titled *Default Categories*. Just find the right category, and match it with the presented category groups to allow for MCC (merchant category code) automated category selection with every imported connected card transaction.
-### Step 5: Make sure tags are required, or defaults are set
+## Step 5: Make sure tags are required, or defaults are set
Tags in Expensify often relate to departments, projects/customers, classes, and so on. And in some cases they are *required* to be selected on every transactions. And in others, something like *departments* is a static field, meaning we could set it as an employee default and not enforce the tag selection with each expense.
*Make Tags Required*
@@ -89,7 +89,7 @@ In the tags tab in your policy settings, you’ll notice the option to enable th
*Set Tags as an Employee Default*
Separately, if your policy is connected to NetSuite or Sage Intacct, you can set departments, for example, as an employee default. All that means is we’ll apply the department (for example) that’s assigned to the employee record in your accounting package and apply that to every exported transaction, eliminating the need for the employee to have to manually select a department for each expense.
-### Step 6: Set rules for all expenses regardless of categorization
+## Step 6: Set rules for all expenses regardless of categorization
In the Expenses tab in your group Control policy, you’ll notice a *Violations* section designed to enforce top-level compliance controls that apply to every expense, for every employee in your policy. We recommend the following confiuration:
*Max Expense Age: 90 days (or leave it blank)*
@@ -105,7 +105,7 @@ Receipts are important, and in most cases you prefer an itemized receipt. Howeve
At this point, you’ve set enough compliance controls around categorical spend and general expenses for all employees, such that you can put trust in our solution to audit all expenses up front so you don’t have to. Next, let’s dive into how we can comfortably take on more automation, while relying on compliance controls to capture bad behavior (or better yet, instill best practices in our employees).
-### Step 7: Set up scheduled submit
+## Step 7: Set up scheduled submit
For an efficient company, we recommend setting up [Scheduled Submit](https://community.expensify.com/discussion/4476/how-to-enable-scheduled-submit-for-a-group-policy) on a *Daily* frequency:
- Click *Settings > Policies*
@@ -125,7 +125,7 @@ Expenses with violations will stay behind for the employee to fix, while expense
> Kevin Valuska
> AP/AR at Road Trippers
-### Step 8: Connect your business bank account (US only)
+## Step 8: Connect your business bank account (US only)
If you’re located in the US, you can utilize Expensify’s payment processing and reimbursement features.
*Note:* Before you begin, you’ll need the following to validate your business bank account:
@@ -145,7 +145,7 @@ Let’s walk through the process of linking your business bank account:
You only need to do this once: you are fully set up for not only reimbursing expense reports, but issuing Expensify Cards, collecting customer invoice payments online (if applicable), as well as paying supplier bills online.
-### Step 9: Invite employees and set an approval workflow
+## Step 9: Invite employees and set an approval workflow
*Select an Approval Mode*
We recommend you select *Advanced Approval* as your Approval Mode to set up a middle-management layer of a approval. If you have a single layer of approval, we recommend selecting [Submit & Approve](https://community.expensify.com/discussion/5643/deep-dive-submit-and-approve). But if *Advanced Approval* if your jam, keep reading!
@@ -159,13 +159,13 @@ In most cases, at this stage, approvers prefer to review all expenses for a few
In this case we recommend setting *Manually approve all expenses over: $0*
-### Step 10: Configure Auto-Approval
+## Step 10: Configure Auto-Approval
Knowing you have all the control you need to review reports, we recommend configuring auto-approval for *all reports*. Why? Because you’ve already put reports through an entire approval workflow, and manually triggering reimbursement is an unnecessary action at this stage.
1. Navigate to *Settings > Policies > Group > [Policy Name] > Reimbursement*
2. Set your *Manual Reimbursement threshold to $20,0000*
-### Step 11: Enable Domains and set up your corporate card feed for employees
+## Step 11: Enable Domains and set up your corporate card feed for employees
Expensify is optimized to work with corporate cards from all banks – or even better, use our own perfectly integrated *[Expensify Card](https://use.expensify.com/company-credit-card)*. The first step for connecting to any bank you use for corporate cards, and the Expensify Card is to validate your company’s domain in Domain settings.
To do this:
@@ -173,7 +173,7 @@ To do this:
- Click *Settings*
- Then select *Domains*
-#### If you have an existing corporate card
+### If you have an existing corporate card
Expensify supports direct card feeds from most financial institutions. Setting up a corporate card feed will pull in the transactions from the connected cards on a daily basis. To set this up, do the following:
1. Go to *Company Cards >* Select your bank
@@ -187,7 +187,7 @@ Expensify supports direct card feeds from most financial institutions. Setting u
As mentioned above, we’ll be able to pull in transactions as they post (daily) and handle receipt matching for you and your employees. One benefit of the Expensify Card for your company is being able to see transactions at the point of purchase which provides you with real-time compliance. We even send users push notifications to SmartScan their receipt when it’s required and generate IRS-compliant e-receipts as a backup wherever applicable.
-#### If you don't have a corporate card, use the Expensify Card (US only)
+### If you don't have a corporate card, use the Expensify Card (US only)
Expensify provides a corporate card with the following features:
- Up to 2% cash back (up to 4% in your first 3 months!)
@@ -214,7 +214,7 @@ Once the Expensify Cards have been assigned, each employee will be prompted to e
If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period.
-### Step 12: Set up Bill Pay and Invoicing
+## Step 12: Set up Bill Pay and Invoicing
As a small business, managing bills and invoices can be a complex and time-consuming task. Whether you receive bills from vendors or need to invoice clients, it's important to have a solution that makes the process simple, efficient, and cost-effective.
Here are some of the key benefits of using Expensify for bill payments and invoicing:
@@ -246,7 +246,7 @@ Reports, invoices, and bills are largely the same, in theory, just with differen
You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your policy settings under the Invoices tab. Your customers can pay their invoice in Expensify via ACH, or Check, or Credit Card.
-### Step 13: Run monthly, quarterly and annual reporting
+## Step 13: Run monthly, quarterly and annual reporting
At this stage, reporting is important and given that Expensify is the primary point of entry for all employee spend, we make reporting visually appealing and wildly customizable.
1. Head to the *Expenses* tab on the far left of your left-hand navigation
@@ -261,7 +261,7 @@ We recommend reporting:
![Expenses!](https://help.expensify.com/assets/images/playbook-expenses.png){:width="100%"}
-### Step 14: Set your Subscription Size and Add a Payment card
+## Step 14: Set your Subscription Size and Add a Payment card
Our pricing model is unique in the sense that you are in full control of your billing. Meaning, you have the ability to set a minimum number of employees you know will be active each month and you can choose which level of commitment fits best. We recommend setting your subscription to *Annual* to get an additional 50% off on your monthly Expensify bill. In the end, you've spent enough time getting your company fully set up with Expensify, and you've seen how well it supports you and your employees. Committing annually just makes sense.
To set your subscription, head to:
@@ -280,5 +280,5 @@ Now that we’ve gone through all of the steps for setting up your account, let
3. Enter your name, card number, postal code, expiration and CVV
4. Click *Accept Terms*
-## You’re all set!
+# You’re all set!
Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://new.expensify.com)*. Don’t have one yet? Create a Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you.
diff --git a/docs/articles/expensify-classic/exports/Custom-Templates.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md
similarity index 100%
rename from docs/articles/expensify-classic/exports/Custom-Templates.md
rename to docs/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates.md
diff --git a/docs/articles/expensify-classic/exports/Default-Export-Templates.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Default-Export-Templates.md
similarity index 100%
rename from docs/articles/expensify-classic/exports/Default-Export-Templates.md
rename to docs/articles/expensify-classic/insights-and-custom-reporting/Default-Export-Templates.md
diff --git a/docs/articles/expensify-classic/exports/Insights.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Insights.md
similarity index 100%
rename from docs/articles/expensify-classic/exports/Insights.md
rename to docs/articles/expensify-classic/insights-and-custom-reporting/Insights.md
diff --git a/docs/articles/expensify-classic/exports/Other-Export-Options.md b/docs/articles/expensify-classic/insights-and-custom-reporting/Other-Export-Options.md
similarity index 100%
rename from docs/articles/expensify-classic/exports/Other-Export-Options.md
rename to docs/articles/expensify-classic/insights-and-custom-reporting/Other-Export-Options.md
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md
index 3ee1c8656b4b..5bbd2c4b583c 100644
--- a/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md
+++ b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md
@@ -1,5 +1,41 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Expensify and TSheets/QuickBooks Time Integration Guide
+description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify policy
---
-## Resource Coming Soon!
+# Overview
+
+Connecting Expensify with TSheets/QuickBooks Time can streamline your expense tracking and time management processes. This integration allows you to automatically sync time entries from TSheets/QuickBooks Time with expenses in Expensify, ensuring accurate and efficient expense reporting.
+
+# How to set up the Expensify and TSheets/QuickBooks Time integration
+
+Before you begin, make sure you have the following:
+
+- **Expensify account:** You must have an active Expensify account.
+- **TSheets account:** You must have a TSheets account and admin privileges to set up the integration.
+
+Now, follow these steps to set up the integration:
+
+1. Log into your Expensify account on your web browser
+
+2. Go to **Settings > Workspaces > Group > Workspace Name > Connections > TSheets**
+
+3. Click **Connect to TSheets**
+
+4. Follow the on-screen instructions to sign in to your TSheets account and grant Expensify access.
+
+5. Once the integration is authorized, you may need to configure some preferences.
+- Specify how you want TSheets time entries to be imported into Expensify. You can typically customize settings like the date range, project/task mapping, and expense categories.
+
+6. Now, we’d recommend testing the integration.
+- Create a sample time entry in TSheets and check if it’s automatically reflected in your Expensify account.
+
+7. If the test is successful, save your integration settings.
+
+8. You may also want to schedule regular syncs or specify how often Expensify should pull data from TSheets.
+
+With the integration set up, your TSheets time entries will now appear in Expensify as expenses. You can review, categorize, and submit these expenses as needed.
+
+Congratulations! You've successfully integrated Expensify with TSheets, simplifying your expense tracking and reporting process.
+
+For questions, don't hesitate to reach out to concierge@expensify.com or chat directly with your account manager
+
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md
index 3ee1c8656b4b..ac0a90ba6d37 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Sage-Intacct.md
@@ -1,5 +1,568 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Sage Intacct
+description: Connect your Expensify workspace with Sage Intacct
---
-## Resource Coming Soon!
+# Overview
+Expensify’s seamless integration with Sage Intacct allows you to connect using either Role-based permissions or User-based permissions.
+
+Once connected to Intacct you’re able to automate report exports, customize your coding preferences, and utilize Sage Intacct’s advanced features. When you’ve configured these settings in Expensify correctly, you can use the integration's settings to automate many tasks, streamlining your workflow for increased efficiency.
+
+# How to connect to Sage Intacct
+We support setting up Sage Intacct with both User-based permissions and Role-based permissions for Expense Reports and Vendor Bills.
+- User-based Permissions - Expense Reports
+- User-based Permissions - Vendor Bills
+- Role-based Permissions - Expense Reports
+- Role-based Permissions - Vendor Bills
+
+
+## User-based Permissions - Expense Reports
+
+Please follow these steps if exporting as Expense Reports with **user-based permissions**.
+
+
+### Checklist of items to complete:
+1. Create a web services user and set up permissions.
+2. Enable the Time & Expenses module **(Required if exporting as Expense Reports)**.
+3. Set up Employees in Sage Intacct **(Required if exporting as Expense Reports)**.
+4. Set up Expense Types in Sage Intacct **(Required if exporting as Expense Reports)**.
+5. Enable Customization Services (only applicable if you don't already use Platform Services).
+6. Create a test workspace and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage).
+7. Upload the Package in Sage Intacct.
+8. Add web services authorization.
+9. Enter credentials and connect Expensify and Sage Intacct.
+10. Configure integration sync options.
+11. Export a test report.
+12. Connect Sage Intacct to the production workspace.
+
+
+### Step 1: Create a web services user with user-based permissions
+
+_Note: If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that's the case, see the steps below on creating a web services user for role-based permissions._
+To connect to Sage Intacct, you'll need to create a special web services user. This user is essential for tracking actions in Sage Intacct, such as exporting expense reports and credit card charges from Expensify. It also helps ensure smooth operations when new members join or leave your accounting team. The good news is that setting up this web services user won't cost you anything. Just follow these steps:
+Go to **Company > Web Services Users > New**
+Setup the user using these configurations:
+ - **User ID:** "xmlgateway_expensify"
+ - **Last Name and First Name:** "Expensify"
+ - **Email Address:** Your shared accounting team email
+ - **User Type:** "Business"
+ - **Admin Privileges:** "Full"
+ - **Status:** "Active"
+Once you've created the user, you'll need to set the correct permissions. To set those, go to the **subscription** link for this user in the user list, **click on the checkbox** next to the Application/Module and then click on the **Permissions** link to modify those.
+
+These are the permissions required for a user to export reimbursable expenses as Expense Reports:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Time & Expense (All)**
+- **Projects (Read-only)** (only needed if using Projects and Customers)
+- **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills)
+
+**Note:** you can set permissions for each Application/Module by selecting the radio button next to the desired Permission and clicking **Save**.
+
+
+### Step 2: Enable the Time & Expenses Module (Only required if exporting reimbursable expenses as Expense Reports)
+The Time & Expenses (T&E) module is often included in your Sage Intacct instance, but if it wasn't part of your initial Sage Intacct setup, you may need to enable it. **Enabling the T&E module is a paid subscription through Sage Intacct. For information on the costs of enabling this module, please contact your Sage Intacct account manager**. It's necessary for our integration and only takes a few minutes to configure.
+1. In Sage Intacct, go to the **Company menu > Subscriptions > Time & Expenses** and toggle the switch to subscribe.
+2. After enabling T&E, configure it as follows:
+ - Ensure that **Expense types** is checked:
+ - Under **Auto-numbering sequences** set the following:
+ - **Expense Report:** EXP
+ - **Employee:** EMP
+ - **Duplicate Numbers:** Select “Do not allow creation”
+
+ - To create the EXP sequence, **click on the down arrow on the expense report line and select **Add**:
+ - **Sequence ID:** EXP
+ - **Print Title:** EXPENSE REPORT
+ - **Starting Number:** 1
+ - **Next Number:** 2
+3. Select **Advanced Settings** and configure the following:
+- **Fixed Number Length:** 4
+- **Fixed Prefix:** EXP
+4. Click **Save**
+5. Under Expense Report approval settings, ensure that **Enable expense report approval** is unchecked
+6. Click **Save** to confirm your configurations.
+
+
+### Step 3: Set up Employees in Sage Intacct (Only required if exporting reimbursable expenses as Expense Reports)
+To set up Employees in Sage Intacct, follow these steps:
+1. Navigate to **Time & Expenses** and click the plus button next to **Employees**.
+ - If you don't see the Time & Expense option in the top ribbon, you may need to adjust your settings. Go to **Company > Roles > Time & Expenses** and enable all permissions.
+2. To create an employee, you'll need to provide the following information:
+ - **Employee ID**
+ - **Primary contact name**
+ - **Email address**
+ - In the **Primary contact name** field, click the dropdown arrow.
+ - Select the employee if they've already been created.
+ - Otherwise, click **+ Add** to create a new employee.
+ - Fill in their **Primary Email Address** along with any other required information.
+
+
+### Step 4: Set up Expense Types in Sage Intacct (Only required if exporting reimbursable expenses as Expense Reports)
+
+Expense Types provide a user-friendly way to display the names of your expense accounts to your employees. They are essential for our integration. To set up Expense Types, follow these steps:
+1. **Setup Your Chart of Accounts:** Before configuring Expense Types, ensure your Chart of Accounts is set up. You can set up accounts in bulk by going to **Company > Open Setup > Company Setup Checklist > click Import**.
+2. **Set up Expense Types:**
+ - Go to **Time & Expense**.
+ - Open Setup and click the plus button next to **Expense Types**.
+3. For each Expense Type, provide the following information:
+ - **Expense Type**
+ - **Description**
+ - **Account Number** (from your General Ledger)
+This step is necessary if you are exporting reimbursable expenses as Expense Reports.
+
+
+### Step 5: Enable Customization Services
+To enable Customization Services go to **Company > Subscriptions > Customization Services**.
+ - If you already have Platform Services enabled, you can skip this step.
+
+
+### Step 6: Create a Test Workspace in Expensify and Download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+Creating a test workspace in Expensify allows you to have a sandbox environment for testing before implementing the integration live. If you are already using Expensify, creating a test workspace ensures that your existing group workspace rules and approval workflows remain intact. Here's how to set it up:
+1. Go to **expensify.com > Settings > Workspaces > New Workspace**.
+2. Name the workspace something like "Sage Intacct Test Workspace."
+3. Go to **Connections > Sage Intacct > Connect to Sage Intacct**.
+4. Select **Download Package** (You only need to download the file; we'll upload it from your Downloads folder later).
+
+
+### Step 7: Upload Package in Sage Intacct
+
+
+If you use **Customization Services**:
+1. Go to **Customization Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+
+If you use **Platform Services**:
+1. Go to **Platform Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+
+### Step 8: Add Web Services Authorization
+1. Go to **Company > Company Info > Security** in Intacct and click **Edit**.
+2. Scroll down to **Web Services Authorizations** and add "expensify" (all lower case) as a Sender ID.
+
+
+### Step 9: Enter Credentials and Connect Expensify and Sage Intacct
+
+
+1. Go back to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure**.
+2. Click **Connect to Sage Intacct** and enter the credentials you've set for your web services user.
+3. Click **Send** once you're done.
+
+Next, you’ll configure the Export, Coding, and Advanced tabs of the connection configuration in Expensify.
+
+
+## User-based Permissions - Vendor Bills
+In this setup guide, we'll take you through the steps to establish your connection for Vendor Bills with user-based permissions. Please follow this checklist of items to complete:
+1. Create a web services user and set up permissions.
+2. Enable Customization Services (only required if you don't already use Platform Services).
+3. Create a test workspace in Expensify and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+4. Upload the Package in Sage Intacct.
+5. Add web services authorization.
+6. Enter credentials and connect Expensify and Sage Intacct.
+7. Configure integration sync options.
+
+
+### Step 1: Create a web services user with user-based permissions
+**Note:** If the steps in this section look different in your Sage Intacct instance, you likely use role-based permissions. If that's the case, see the steps below on creating a web services user for role-based permissions.
+To connect to Sage Intacct, it's necessary to set up a web services user. This user simplifies tracking activity within Sage Intacct, such as exporting expense reports and credit card charges from Expensify. It also ensures a seamless transition when someone joins or leaves your accounting department. Setting up the web services user is free of charge. Please follow these steps:
+1. Go to **Company > Web Services Users > New**.
+2. Configure the user as shown in the screenshot below, making sure to follow these guidelines:
+ - **User ID:** "xmlgateway_expensify"
+ - **Last Name and First Name:** "Expensify"
+ - **Email Address:** Your shared accounting team email
+ - **User Type:** "Business"
+ - **Admin Privileges:** "Full"
+ - **Status:** "Active"
+
+
+Once you've created the user, you'll need to set the correct permissions. To do this, follow these steps:
+1. Go to the subscription link for this user in the user list.
+2. Click on the checkbox next to the Application/Module you want to modify permissions for.
+3. Click on the **Permissions** link to make modifications.
+
+These are the permissions the user needs to have if exporting reimbursable expenses as Vendor Bills:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Accounts Payable (All)**
+- **Projects (Read-only)** (required if you're going to be using Projects and Customers)
+
+**Note:** that selecting the radio button next to the Permission you want and clicking **Save** will set the permission for that particular Application/Module.
+
+
+### Step 2: Enable Customization Services (only applicable if you don't already use Platform Services)
+To enable Customization Services go to **Company > Subscriptions > Customization Services**.
+ - If you already have Platform Services enabled, you can skip this step.
+
+### Step 3: Create a Test Workspace in Expensify and Download [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+Creating a test workspace in Expensify allows you to establish a sandbox environment for testing before implementing the integration in a live environment. If you're already using Expensify, creating a test workspace ensures that your existing company workspace rules and approval workflows remain intact. Here's how to set it up:
+1. Go to **expensify.com > Settings > Workspaces > Groups > New Workspace**.
+2. Name the workspace something like "Sage Intacct Test Workspace."
+3. Go to **Connections > Sage Intacct > Connect to Sage Intacct**.
+4. Select "I've completed these" if you've downloaded the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage) and completed the previous steps in Sage Intacct.
+5. Select **Download Package** (You only need to download the file; we'll upload it from your Downloads folder later).
+
+### Step 4: Upload the Package in Sage Intacct
+If you use **Customization Services**:
+
+1. Go to **Customization Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+
+If you use **Platform Services**:
+
+1. Go to **Platform Services > Custom Packages > New Package**.
+2. Click on **Choose File** and select the Package file from your downloads folder.
+3. Click **Import**.
+
+### Step 5: Add Web Services Authorization
+1. Go to **Company > Company Info > Security** in Intacct and click **Edit**.
+2. Scroll down to **Web Services Authorizations** and add "expensify" (all lowercase) as a Sender ID.
+
+### Step 6: Enter Credentials and Connect Expensify with Sage Intacct
+1. Go back to **Settings > Workspaces > Groups > [Workspace Name] > Connections > Configure**.
+2. Click on **Connect to Sage Intacct**.
+3. Enter the credentials that you've previously set for your web services user.
+4. Click **Send** once you've finished entering the credentials.
+
+Next, you’ll configure the Export, Coding, and Advanced tabs of the connection configuration in Expensify.
+
+
+
+## Role-based Permissions - Expense Reports
+
+For this setup guide, we're going to walk you through how to get your connection up and running as Expense Reports with role-based permissions.
+
+### Checklist of items to complete:
+
+1. Create web services user and set up permissions
+2. Enable Time & Expenses module
+3. Set up Employees in Sage Intacct
+4. Set up Expense Types in Sage Intacct
+5. Enable Customization Services (only applicable if you don't already use Platform Services)
+6. Create a test workspace and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+7. Upload the Package in Sage Intacct
+8. Add web services authorization
+9. Enter credentials and connect Expensify and Sage Intacct
+10. Configure integration sync options
+
+
+### Step 1: Create a web services user with role-based permissions
+
+In Sage Intacct, click **Company**, then click on the **+** button next to **Roles**.
+
+Name the role, then click **Save**.
+
+Go to **Roles > Subscriptions** for the "Expensify" role you just created.
+
+Set the permissions for this role by clicking the checkbox and then clicking on the **Permissions** hyperlink.
+
+These are the permissions required for a user to export reimbursable expenses as Expense Reports:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Time & Expense (All)**
+- **Projects (Read-only)** (only needed if using Projects and Customers)
+- **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills)
+
+Now, you'll need to create a web services user and assign this role to that user.
+
+- **Company > Web Services Users > New**
+- Set up the user like the screenshot below, making sure to do the following:
+ - User ID: “xmlgateway_expensify"
+ - Last name and First name: "Expensify"
+ - Email address: your shared accounting team email
+ - User type: "Business"
+ - Admin privileges: "Full"
+ - Status: "Active"
+
+To assign the role, go to **Roles Information**:
+
+- Click the **+** button, then find the "Expensify" role and click **Save**.
+
+### Step 2: Enable the Time & Expenses module (Only required if exporting reimbursable expenses as Expense Reports)
+
+The T&E module often comes standard on your Sage Intacct instance, but you may need to enable it if it was not a part of your initial Sage Intacct implementation. Enabling the T&E module is a paid subscription through Sage Intacct. Please reach out to your Sage Intacct account manager with any questions on the costs of enabling this module. It's required for our integration and takes just a few minutes to configure.
+
+In Sage Intacct, click on the **Company** menu > **Subscriptions** > **Time & Expenses** and click the toggle to subscribe.
+
+Once you've enabled T&E, you'll need to configure it properly:
+- Ensure that **Expense types** is checked.
+- Under Auto-numbering sequences, please set the following:
+ - To create the EXP sequence, click on the down arrow on the expense report line > **Add**
+ - Sequence ID: EXP
+ - Print Title: EXPENSE REPORT
+ - Starting Number: 1
+ - Next Number: 2
+ - Once you've done this, select **Advanced Settings**
+ - Fixed Number Length: 4
+ - Fixed Prefix: EXP
+ - Once you've done this, hit **Save**
+- Under Expense Report approval settings, make sure the "Enable expense report approval" is unchecked.
+- Click **Save**!
+
+### Step 3: Set up Employees in Sage Intacct (Only required if exporting reimbursable expenses as Expense Reports)
+
+In order to set up Employees, go to **Time & Expenses** > click the plus button next to **Employees**. If you don't see Time & Expense in the top ribbon, you may need to adjust your settings by going to **Company > Roles > Time & Expenses > Enable all permissions**. To create an employee, you'll need to insert the following information:
+- Employee ID
+- Primary contact name
+- Email address (click the dropdown arrow in the Primary contact name field) > select the employee if they've already been created. Otherwise click **+ Add** > fill in their Primary Email Address along with any other information you require.
+
+### Step 4: Set up Expense Types in Sage Intacct (only required if exporting reimbursable expenses as Expense Reports)
+
+Expense Types are a user-friendly way of displaying the names of your expense accounts to your employees. They are required for our integration. In order to set up Expense Types, you'll first need to setup your Chart of Accounts (these can be set up in bulk by going to **Company > Open Setup > Company Setup Checklist > click Import**).
+
+Once you've setup your Chart of Accounts, to set Expense Types, go to **Time & Expense** > **Open Setup** > click the plus button next to **Expense Types**. For each Expense Type, you'll need to include the following information:
+- Expense Type
+- Description
+- Account Number (from your GL)
+
+### Step 5: Enable Customization Services
+
+To enable, go **Company > Subscriptions > Customization Services** (if you already have Platform Services enabled, you will skip this step).
+
+### Step 6: Create a test workspace in Expensify and download [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+
+The test workspace will be used as a sandbox environment where we can test before going live with the integration. If you're already using Expensify, creating a test workspace will ensure that your existing group workspace rules, approval workflow, etc remain intact. In order to set this up:
+
+- Go to **expensify.com > Settings > Workspaces > New Workspace**
+- Name the workspace something like "Sage Intacct Test Workspace"
+- Go to **Connections > Sage Intacct > Connect to Sage Intacct**
+- Select **Download Package** (All you need to do is download the file. We'll upload it from your Downloads folder later).
+
+### Step 7: Upload Package in Sage Intacct
+
+If you use Customization Services:
+
+- **Customization Services > Custom Packages > New Package > Choose File >** select the Package file from your downloads folder > Import
+
+If you use Platform Services:
+
+- **Platform Services > Custom Packages > New Package > Choose File >** select the Package file from your downloads folder > Import
+
+### Step 8: Add web services authorization
+
+- Go to **Company > Company Info > Security** in Intacct and click Edit. Next, scroll down to Web Services authorizations and add "expensify" (this must be all lower case) as a Sender ID.
+
+### Step 9: Enter credentials and connect Expensify and Sage Intacct
+
+- Now, go back to **Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Connect to Sage Intacct** and enter the credentials that you've set for your web services user. Click Send once you're done.
+
+Next, follow the links in the related articles section below to complete the configuration for the Export, Coding, and Advanced tabs of the connection settings.
+
+## Role-based Permissions - Vendor Bills
+
+Follow the steps below to set up Sage Intacct with role-based permissions and export Vendor Bills:
+
+### Checklist of items to complete:
+
+1. Create a web services user and configure permissions.
+2. Enable Customization Services (if not using Platform Services).
+3. Create a test workspace in Expensify and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage).
+4. Upload the Package in Sage Intacct.
+5. Add web services authorization.
+6. Enter credentials and connect Expensify and Sage Intacct.
+7. Configure integration sync options.
+
+
+### Step 1: Create a web services user with role-based permissions
+
+In Sage Intacct:
+- Navigate to "Company" and click the **+** button next to "Roles."
+- Name the role and click **Save**.
+- Go to "Roles" > "Subscriptions" for the "Expensify" role you created.
+- Set the permissions for this role by clicking the checkbox and then clicking on the Permissions hyperlink
+
+
+These are the permissions required for a user to export reimbursable expenses as Vendor Bills:
+- **Administration (All)**
+- **Company (Read-only)**
+- **Cash Management (All)**
+- **General Ledger (All)**
+- **Time & Expense (All)**
+- **Projects (Read-only)** (only needed if using Projects and Customers)
+- **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills)
+
+
+- Create a web services user:
+ - Go to **Company > Web Services Users > New**
+ - Configure the user as follows:
+ - User ID: "xmlgateway_expensify"
+ - Last Name and First Name: "Expensify"
+ - Email Address: Your shared accounting team email
+ - User Type: "Business"
+ - Admin Privileges: "Full"
+ - Status: "Active"
+ - To assign the role, go to "Roles Information", click the **+** button, find the "Expensify" role, and click **Save**
+
+### Step 2: Enable Customization Services
+
+Only required if you don't already use Platform Services:
+- To enable, go to **Company > Subscriptions > Customization Services**
+
+### Step 3: Create a test workspace in Expensify and download the [Expensify Package](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.expensify.com%2Ftools%2Fintegrations%2FdownloadPackage)
+
+Create a test workspace in Expensify:
+- Go to **Settings > Workspaces** and click **New Workspace** on the Expensify website.
+- Name the workspace something like "Sage Intacct Test Workspace."
+- Once created, navigate to **Settings > Workspaces > [Group Workspace Name] > Connections > Accounting Integrations > Connect to Sage Intacct**
+- Select **Create a new Sage Intacct connection/Connect to Sage Intacct**
+- Select **Download Package** (We'll upload it from your Downloads folder later.)
+
+### Step 4: Upload Package in Sage Intacct
+
+If you use **Customization Services**:
+- Go to **Customization Services > Custom Packages > New Package > Choose File > select the Package file from your downloads folder > Import**.
+
+If you use **Platform Services**:
+- Go to **Platform Services > Custom Packages > New Package > Choose File > select the Package file from your downloads folder > Import**.
+
+### Step 5: Add web services authorization
+
+- Go to **Company > Company Info > Security** in Intacct and click **Edit**
+- Scroll down to **Web Services Authorizations** and add **expensify** (all lowercase) as a Sender ID.
+
+### Step 6: Enter credentials and connect Expensify and Sage Intacct
+
+Now, go back to **Settings > Workspaces > [Group Workspace Name] > Connections > Accounting Integrations > Configure > Connect to Sage Intacct** and enter the credentials you set for your web services user. Click **Send** when finished.
+
+### Step 7: Configure your connection
+
+Once the initial sync completes, you may receive the error "No Expense Types Found" if you're not using the Time and Expenses module. Close the error dialogue, and your configuration options will appear. Switch the reimbursable export option to **Vendor Bills** and click **Save** before completing your configuration.
+
+Next, refer to the related articles section below to finish configuring the Export, Coding, and Advanced tabs of the connection configuration.
+
+# How to configure export settings
+
+When you connect Intacct with Expensify, you can configure how information appears once exported. To do this, Admins who are connected to Intacct can go to **Settings > Workspaces > Group > [Workspace Name] > Connections**, and then click on **Configure** under Intacct. This is where you can set things up the way you want.
+
+
+## Preferred Exporter
+
+Any workspace admin can export to Sage Intacct, but only the preferred exporter will see reports that are ready for export in their Inbox.
+
+
+
+## Date
+
+Choose which date you would like your Expense Reports or Vendor Bills to use when exported.
+
+- **Date of last expense:** Uses the date on the most recent expense added to the report.
+- **Exported date:** Is the date you export the report to Sage Intacct.
+- **Submitted date:** Is the date the report creator originally submitted the report.
+
+All export options except credit cards use the date in the drop-down. Credit card transactions use the transaction date.
+
+## Reimbursable Expenses
+
+Depending on your initial setup, your **reimbursable expenses** will be exported as either **Expense Reports** or **Vendor Bills** to Sage Intacct.
+
+## Non-Reimbursable Expenses
+
+**Non-reimbursable expenses** will export separately from reimbursable expenses, either as **Vendor Bills**, or as **credit card charges** to the account you select. It is not an option to export non-reimbursable expenses as **Journal** entries.
+
+
+If you are centrally managing your company cards through Domain Control, you can export expenses from each individual card to a specific account in Intacct.
+Please note, Credit Card Transactions cannot be exported to Sage Intacct at the top-level if you have **Multi-Currency** enabled, so you will need to select an entity in the configuration of your Expensify Workspace by going to **Settings > Workspaces > Groups > [Workspace Name] > Connections > Configure**.
+
+## Exporting Negative Expenses
+
+You can export negative expenses successfully to Intacct regardless of which Export Option you choose. The one thing to keep in mind is that if you have Expense Reports selected as your export option, the **total** of the report can not be negative.
+
+# How to configure coding settings
+
+The appearance of your expense data in Sage Intacct depends on how you've configured it in Expensify. It's important to understand each available option to achieve the desired results.
+
+## Expense Types
+
+Categories are always enabled and are the primary means of matching expenses to the correct accounts in Sage Intact. The Categories in Expensify depend on your **Reimbursable** export options:
+- If your Reimbursable export option is set to **Expense Reports** (the default), your Categories will be your **Expense Types**.
+- If your Reimbursable export option is set to **Vendor Bills**, your Categories will be your **Chart of Accounts** (also known as GL Codes or Account Codes).
+
+You can disable unnecessary categories from your **Settings > Workspaces > Group > [Workspace Name] > Categories** page if your list is too extensive. Note that every expense must be coded with a Category, or it will not export. Also, when you first set up the integration, your existing categories will be overwritten.
+
+## Billable Expenses
+
+Enabling Billable expenses allows you to map your expense types or accounts to items in Sage Intacct. To do this, you'll need to enable the correct permissions on your Sage Intacct user or role. This may vary based on the modules you use in Sage Intacct, so you should enable read-only permissions for relevant modules such as Projects, Purchasing, Inventory Control, and Order Entry.
+
+Once permissions are set, you can map your categories (expense types or accounts, depending on your export settings) to specific items, which will then export to Sage Intacct. When an expense is marked as Billable in Expensify, users must select the correct billable Category (Item), or there will be an error during export.
+
+## Dimensions - Departments, Classes, and Locations
+
+If you enable these dimensions, you can choose from three data options:
+- Not pulled into Expensify: Employee default (available when the reimbursable export option is set to Expense Reports)
+- Pulled into Expensify and selectable on reports/expenses: Tags (useful for cross-charging between Departments or Locations)
+- Report Fields (applies at the header level, useful when an employee's Location varies from one report to another)
+
+Please note that the term "tag" may appear instead of "Department" on your reports, so ensure that "Projects" is not disabled in your Tags configuration within your workspace settings. Make sure it's enabled within your coding settings of the Intacct configuration settings. When multiple options are available, the term will default to Tags.
+
+## Customers and Projects
+
+These settings are particularly relevant to billable expenses and can be configured as Tags or Report Fields.
+
+## Tax
+
+As of September 2023, our Sage Intacct integration supports native VAT and GST tax. To enable this feature, open the Sage Intacct configuration settings in your workspace, go to the Coding tab, and enable Tax. For existing Sage Intacct connectings, simply resync your workspace and the tax toggle will appear. For new Sage Intacct connections, the tax toggle will be available when you complete the integration steps.
+Having this option enabled will then import your native tax rates from Sage Intacct into Expensify. From there, you can select default rates for each category.
+
+## User-Defined Dimensions
+
+You can add User-Defined Dimensions (UDD) to your workspace by locating the "Integration Name" in Sage Intacct. Please note that you must be logged in as an administrator in Sage Intacct to find the required fields.
+
+To find the Integration Name in Sage Intacct:
+1. Go to **Platform Services > Objects > List**
+2. Set "filter by application" to "user-defined dimensions."
+
+Now, in Expensify, navigate to **Settings > Workspaces > Group > [Workspace Name] > Connections**, and click **Configure** under Sage Intacct. On the Coding tab, enable the toggle next to User Defined Dimensions. Enter the "Integration name" and choose whether to import it into Expensify as an expense-level Tag or as a Report Field, then click **Save**.
+
+You'll now see the values for your custom segment available under Tags settings or Report Fields settings in Expensify.
+
+
+
+# How to configure advanced settings
+In multi-entity environments, you'll find a dropdown at the top of the sync options menu, where you can choose to sync with the top-level or a specific entity in your Sage Intacct instance. If you sync at the top level, we pull in employees and dimensions shared at the top level and export transactions to the top level. Otherwise, we sync information with the selected entity.
+## Auto Sync
+When a non-reimbursable report is finally approved, it will be automatically exported to Sage Intacct. Typically, non-reimbursable expenses will sync to the next open period in Sage Intacct by default. If your company uses Expensify's ACH reimbursement, reimbursable expenses will be held back and exported to Sage when the report is reimbursed.
+## Inviting Employees
+Enabling **Invite Employees** allows the integration to automatically add your employees to your workspace and create an Expensify account for them if they don't have one.
+If you have your domain verified on your account, ensure that the Expensify account connected to Sage Intacct is an admin on your domain.
+When you toggle on "Invite Employees" on the Advanced tab, all employees in Sage Intacct who haven't been invited to the Expensify group workspace you're connecting will receive an email invitation to join the group workspace. Approval workflow will default to Manager Approval and can be further configured on the People settings page.
+## Import Sage Intacct Approvals
+When the "Import Sage Intacct Approvals" setting is enabled, Expensify will automatically set each user's manager listed in Sage Intacct as their first approver in Expensify. If no manager exists in Sage Intacct, the approver can be set in the Expensify People table. You can also add a second level of approval to your Sage Intacct integration by setting a final approver in Expensify.
+Please note that if you need to add or edit an optional final approver, you will need to select the **Manager Approval** option in the workflow. Here is how each option works:
+- **Basic Approval:** All users submit to one user.
+- **Manager Approval:** Each user submits to the manager (imported from Sage Intacct). Each manager forwards to one final approver (optional).
+- **Configure Manually:** Import employees only, configure workflow in Expensify.
+
+
+## Sync Reimbursed Reports
+When using Expensify ACH, reimbursable reports exported to Intacct are exported:
+- As Vendor Bills to the default Accounts Payable account set in your Intacct Accounts Payable module configuration, OR
+- As Expense Reports to the Employee Liabilities account in your Time & Expenses module configuration.
+When ACH reimbursement is enabled, the "Sync Reimbursed Reports" feature will additionally export a Bill Payment to the selected Cash and Cash Equivalents account listed. If **Auto Sync** is enabled, the payment will be created when the report is reimbursed; otherwise, it will be created the next time you manually sync the workspace.
+Intacct requires that the target account for the Bill Payment be a Cash and Cash Equivalents account type. If you aren't seeing the account you want in that list, please first confirm that the category on the account is Cash and Cash Equivalents.
+
+
+# FAQ
+## What if my report isn't automatically exported to Sage Intacct?
+There are a number of factors that can cause automatic export to fail. If this happens, the preferred exporter will receive an email and an Inbox task outlining the issue and any associated error messages.
+The same information will be populated in the comments section of the report.
+The fastest way to find a resolution for a specific error is to search the Community, and if you get stuck, give us a shout!
+Once you've resolved any errors, you can manually export the report to Sage Intacct.
+## How can I make sure that I final approve reports before they're exported to Sage Intacct?
+Make sure your approval workflow is configured correctly so that all reports are reviewed by the appropriate people within Expensify before exporting to Sage Intacct.
+Also, if you have verified your domain, consider strictly enforcing expense workspace workflows. You can set this up via Settings > Domains > [Domain Name] > Groups.
+
+
+## If I enable Auto Sync, what happens to existing approved and reimbursed reports?
+If your workspace has been connected to Intacct with Auto Sync disabled, you can safely turn on Auto Sync without affecting existing reports which have not been exported.
+If a report has been exported to Intacct and reimbursed via ACH in Expensify, we'll automatically mark it as paid in Intacct during the next sync.
+If a report has been exported to Intacct and marked as paid in Intacct, we'll automatically mark it as reimbursed in Expensify during the next sync.
+If a report has not been exported to Intacct, it will not be exported to Intacct automatically.
diff --git a/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md b/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md
index 3ee1c8656b4b..98cc6f2bfdf6 100644
--- a/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md
+++ b/docs/articles/expensify-classic/integrations/accounting-integrations/Xero.md
@@ -1,5 +1,260 @@
---
-title: Coming Soon
-description: Coming Soon
+title: The Xero Integration
+description: Everything you need to know about Expensify's direct integration with Xero
---
-## Resource Coming Soon!
+
+# About
+
+The integration enables seamless import of expense accounts into Expensify and sends expense reports back to Xero as purchasing bills awaiting payment or "spend money" bank transactions.
+
+# How-to Connect to Xero
+
+## Prerequisites
+
+You must be a Workspace Admin in Expensify using a Collect or Control Workspace to connect your Xero account to Expensify.
+
+## Connect Expensify and Xero
+
+1. Let's get started by heading over to your Settings. You can find it by following this path: *Settings > Workspaces > Groups > [Workspace Name] > Connections > Xero.*
+2. To connect Expensify to Xero, click on the "Connect to Xero” button, then choose "Create a new Xero connection."
+3. Next, enter your Xero login details. After that, you'll need to select the Xero organization you want to link with Expensify. Remember, you can connect one organization for each Workspace.
+
+One important note: Starting in September 2021, there's a chance for Cashbook and Ledger-type organizations in Xero. Apps like Expensify won't be able to create invoices and bills for these accounts using the Xero API. So, if you're using a Cashbook or Ledger Xero account, please be aware that this might affect your Expensify integration.
+
+# How to Configure Export Settings for Xero
+
+When you integrate Expensify with Xero you gain control over several settings that determine how your reports will be displayed in Xero. To manage these settings simply follow this path: *Settings > Workspaces > Group > [Workspace Name] > Connections > Accounting Integrations > Xero > Configure > Export*. This is where you can fine-tune how your reports appear on the Xero side, making your expense management a breeze!
+
+## Xero Organization
+
+When you have multiple organizations set up in Xero you can choose which one you'd like to connect. Here are some essential things to keep in mind:
+
+1. Organization Selection: You'll see this option only if you have multiple organizations configured in Xero.
+2. One Workspace, One Organization: Each Workspace can connect to just one organization at a time. It's a one-to-one connection.
+3. Adding New Organizations: If you create a new organization in Xero after your initial connection, you'll need to disconnect and then reconnect it to Xero. Don't forget to take a screenshot of your current settings by clicking on "Configure" and checking the Export, Coding, and Advanced tabs. This way, you can easily set everything up again.
+
+Now you can seamlessly manage your connections with Xero while staying in control of your configurations!
+
+## Preferred Exporter
+
+Any Workspace admin can export to Xero, but only the preferred exporter will see reports that are ready for export in their Home.
+
+## Reimbursable Expenses
+
+Export to Xero as bills awaiting payment with the following additional settings:
+
+- Bill date — the bill is posted on the last day of the month in which expenses were incurred.
+
+To view the bills in Xero, navigate to *Business > Purchase Overview > Awaiting Payments*. Bills will be payable to the individual who created and reported the expense.
+
+## Non-reimbursable Expenses
+
+When you export non-reimbursable expenses, like company card transactions, to Xero they'll show up as bank transactions. Each expense is neatly listed as a separate line item in the bank account of your choice. Plus the transaction date matches the date on your bank statement for seamless tracking.
+
+To check out these expenses in Xero please follow these steps:
+
+1. Head over to your Dashboard.
+2. Select your company card.
+3. Locate the specific expense you're interested in.
+
+If you're managing company cards centrally, you can export expenses from each card to a designated account in Xero using Domains. This way, you have complete control and clarity over your company's finances!
+
+# How to Configure Coding for Xero
+
+The Coding tab in Expensify is where you configure Xero information to ensure accurate expense coding by your employees. Here's how you can access these settings:
+
+1. Navigate to Settings.
+2. Go to Workspace within your specified group (Workspace Name).
+3. Click on Connections, and then hit the Configure button.
+4. Now, select the Coding tab.
+
+## Categories
+
+Xero expense accounts and those marked "Show In Expense Claims" will be automatically imported into Expensify as Categories.
+
+To manage these categories, follow these steps:
+
+1. After connecting, go to *Settings > Workspaces > Groups > [Workspace Name] > Categories*.
+2. You can enable/disable categories using the checkbox.
+3. For specific category rules (like default tax rate, maximum amount, receipts required, comments, and comment hints), click the settings cog.
+4. Note that each expense must have a category selected for it to export to Xero, and these categories need to be imported from Xero; manual creation isn't an option within Workspace settings.
+
+## Tracking Categories
+
+1. If you use Tracking categories in Xero, you can import them into Expensify as Tags, Report Fields, or the Xero contact default.
+- Tags apply a tracking category per expense.
+- Report Field applies a tracking category to the entire report.
+- Xero contact default applies the default tracking category set for the submitter in Xero.
+
+## Tax
+
+Looking to track tax in Expensify? Make sure that you have tax rates enabled in Xero and we will automatically grab those rates from Xero to allow your employees to categorize expenses with the appropriate tax rate. As an admin, you have the ability to set a default rate and also hide rates that are not applicable to the Workspace members.
+
+Tax tracking allows you to apply a tax rate and tax amount to each expense.
+1. To set this up, enable Tax tracking in your Xero configuration.
+2. After connecting, go to *Settings > Workspaces > Groups > [Workspace Name] > Tax to manage imported taxes from Xero.*
+3. You can enable/disable taxes and set default tax rates for both Workspace currency expenses and foreign currency expenses.
+
+## Billable Expenses
+
+If you bill expenses to your customers, you can track and invoice them using Expensify and Xero.
+
+1. When enabled, Xero customer contacts are imported into Expensify as Tags for expense tracking.
+- Note: In Xero, a Contact isn't a 'Customer' until they've had a bill raised against them. If you don't see your Customer imported as a tag, try raising a dummy invoice in Xero and then deleting/voiding it.
+2. After exporting to Xero, tagged billable expenses can be included on a sales invoice to your customer.
+
+Please ensure that you meet the following requirements for expenses to be placed on a sales invoice:
+1. Billable Expenses must be enabled in the Xero configuration settings.
+2. The expense must be marked as billable.
+3. The expense must be tagged with a customer.
+
+These steps should help you seamlessly manage your Xero integration within Expensify.
+
+# How to Configure Xero’s Advanced Settings
+
+If you've already set up your integration, but want to make adjustments, simply follow these steps:
+
+1. Go to Settings.
+2. Then, navigate to Workspaces within your designated group [Workspace Name].
+3. Click on Connections, and next, hit the Configure button.
+
+From there, you can dive into the "Advanced" tab to make any additional tweaks.
+
+## Auto Sync
+
+For non-reimbursable reports: Once a report has completed the approval workflow in Expensify, we'll automatically queue it for export to Xero.
+
+But, if you've added a business bank account for ACH reimbursement, any reimbursable expenses will be sent to Xero automatically when the report is marked as reimbursed or enabled for reimbursement.
+
+### Controlling Newly Imported Categories:
+
+You can decide how newly imported categories behave in Expensify:
+
+1. Enabling or disabling this control determines the status of new categories imported from Xero to Expensify. Enabled categories are visible for employees when they categorize expenses, while disabled categories remain hidden.
+
+These settings give you the flexibility to manage your expenses and Workspace in the way that best suits your needs!
+
+## Sync Reimbursed Reports
+
+This nifty setting lets you synchronize the status of your reports between Expensify and Xero. Utilizing this setting will make sure that there is no confusion or possibility that a reimbursable report is paid out twice by mistake or that a non-reimbursable report is double entered throwing off month-end reconciliation. Here's how it works:
+
+1. When you reimburse a report via ACH direct deposit within Expensify, the purchase bill will automatically be marked as paid in Xero, and Expensify will note it as reimbursed.
+2. Don't forget to pick the Xero account where the corresponding bill payment should be recorded.
+3. It's a simple way to keep everything in sync, especially when you're awaiting payment.
+
+# Deep Dive
+
+## An Automatic Export Fails
+
+Sometimes, reports may encounter issues during automatic export to Xero. Not to worry, though! Here's what happens:
+
+1. The Technical Contact, your go-to person for technical matters, will receive an email explaining the problem.
+2. You'll also find specific error messages at the bottom of the report.
+3. To get things back on track, the report will be placed in the preferred exporter’s Home. They can review it and resolve any issues.
+
+## Consider Enforcing Expense Workspace Workflows:
+
+For added control, you can adjust your Workspace settings to strictly enforce expense Workspace. This way, you guarantee that your Workspace’s workflow is always followed. By default this flow is in place, but employees can modify the person they submit their reports to if it's not strictly enforced.
+
+## Customize Purchase Bill Status (Optional):
+
+You have the flexibility to set the status of your purchase bills just the way you want. Choose from the following options:
+
+1. Draft: Keep bills in a draft state until you're ready to finalize them.
+2. Awaiting Approval: If you need approval before processing bills, this option is here for you.
+
+## Multi-Currency
+
+### Handling Multi-Currency in Xero
+
+When dealing with multi-currency transactions in Xero and exporting reimbursable expenses from Expensify here's what you need to know:
+
+1. The bill created in Xero will adopt the output currency set in your Expensify Workspace, provided that it's enabled in Xero.
+2. Your general ledger reports will automatically convert to your home currency in Xero, leveraging the currency exchange rates defined in your Xero settings. It ensures everything aligns seamlessly.
+
+Now, for non-reimbursable expenses, things work slightly differently:
+
+1. Bank transactions will use the currency specified in your bank account in Xero, regardless of the currency used in Expensify.
+2. If these currencies don't match, no worries! We apply a 1:1 exchange rate to make things smooth. To ensure a hassle-free experience, just ensure that the output currency in Expensify matches the currency specified in your Xero bank account.
+
+## Tax
+
+### Enabling Tax Tracking for Seamless Integration:
+
+To simplify tax tracking, enable it in your Xero configuration. This action will automatically bring all your Xero tax settings into Expensify, turning them into usable Taxes.
+
+### After connecting your Xero account with Expensify:
+
+1. Head to Settings.
+2. Navigate to Workspaces within your specific group [Workspace Name].
+3. Click on Tax to view the taxes that have been imported from Xero.
+
+Now, here's where you can take control:
+
+1. Use the enable/disable button to choose which taxes your employees can apply to their expenses. Customize it to fit your needs.
+2. You can set a default tax rate for expenses in your Workspace currency. Additionally, if you deal with foreign currency expenses, you have the option to set another default tax (including exempt) that will automatically apply to all new expenses in foreign currencies.
+
+This setup streamlines your tax management, making it effortless for your team to handle taxes on their expenses.
+
+## Export Invoices to Xero
+
+You can effortlessly export your invoices from Expensify to Xero and even attribute them to the right Customer. Plus, when you mark an invoice as paid in Expensify, the same status will smoothly transfer to Xero and vice versa, keeping your invoice tracking hassle-free. Let's dive in:
+
+### Setting up Invoice Export to Xero:
+
+1. Navigate to Settings.
+2. Go to Workspaces within your designated group [Workspace Name].
+3. Click on Connections, then select Configuration.
+4. Now, click on the Advanced tab.
+
+### Selecting Your Xero Invoice Collection Account:
+
+1. Scroll down until you find "Xero invoice collection account." You'll see a dropdown list of your available Accounts Receivable accounts imported from Xero.
+2. Simply choose the account where you'd like your invoices to be exported.
+
+Pro Tip: If you don't see any accounts in the dropdown, try syncing your Xero connection. To do this, go back to the Connections page and hit "Sync Now."
+
+### Exporting an Invoice to Xero:
+
+Invoices will automatically make their way to Xero when they're in the Processing or Paid state. This ensures consistent tracking of unpaid and paid invoices. However, if you have Auto Sync disabled, you'll need to manually export your invoices along with your expense reports. Here's how:
+
+1. Head to your Reports page.
+2. Use the filters to locate the invoices you want to export.
+3. Select the invoices you wish to export.
+4. Click Export to > Xero on the top right-hand side.
+
+### Matching Customers and Emails:
+
+When exporting to Xero, we match the recipient's email address with a customer record in Xero. So, make sure each customer in Xero has their email listed in their profile.
+If we can't find a match, we'll create a new customer record in Xero.
+
+### Updating Invoice Status:
+
+1. When you mark an invoice as Paid in Expensify, this status will automatically reflect in Xero.
+2. Similarly, if you mark an invoice as Paid in Xero, it will update automatically in Expensify.
+3. The payment will be recorded in the Collection account you've chosen in your Advanced Settings Configuration.
+
+And that's it! You've successfully set up and managed your invoice exports to Xero, making your tracking smooth and efficient.
+
+# FAQ
+
+## Will receipt images be exported to Xero?
+
+Yes! The receipt images will be exported to Xero. To see them in Xero click the 'paper' icon in the upper right corner of the expense details and view a PDF of the Expensify report including the receipt image.
+
+## How does Auto Sync work if your workspace was initially connected to Xero with Auto Sync disabled?
+
+You can safely switch it on without affecting existing reports that haven't been exported.
+
+## How does Auto Sync work if a report has already been exported to Xero and reimbursed through ACH or marked as reimbursed in Expensify?
+
+It will be automatically marked as paid in Xero during the next sync. You may either manually update by clicking Sync Now in the Connections tab or Expensify does this on your behalf overnight every day!
+
+## How does Auto Sync work if a report has been exported to Xero and marked as paid in Xero?
+
+It will be automatically marked as reimbursed in Expensify during the next sync. If you need it updated immediately please go to the Connections tab and click Sync Now or if you can wait just let Expensify do it for you overnight.
+
+## How does Auto Sync work if a report has been exported to Xero and marked as paid in Xero?
+
+Reports that haven't been exported to Xero won't be sent automatically.
+
diff --git a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md
index 9fd745838caf..a034d13dd143 100644
--- a/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md
+++ b/docs/articles/expensify-classic/integrations/other-integrations/Google-Apps-SSO.md
@@ -22,6 +22,7 @@ To enable Expensify for your Google Apps domain and add an “Expenses” link t
6. Click **Finish**. You can configure access for specific Organizational Units later if needed.
7. All account holders on your domain can now access Expensify from the Google Apps directory by clicking **More** and choosing **Expensify**.
8. Now, follow the steps below to sync your users with Expensify automatically.
+
# How to Sync Users from Google Apps to Expensify
To sync your Google Apps users to your Expensify Workspace, follow these steps:
1. Follow the above steps to install Expensify in your Google Apps directory.
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md b/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/integrations/travel-integrations/Grab.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md b/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md
deleted file mode 100644
index 3ee1c8656b4b..000000000000
--- a/docs/articles/expensify-classic/integrations/travel-integrations/TrainLine.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Coming Soon
-description: Coming Soon
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md b/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md
index 3ee1c8656b4b..51bf658db248 100644
--- a/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md
+++ b/docs/articles/expensify-classic/integrations/travel-integrations/TravelPerk.md
@@ -1,5 +1,71 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Connecting TravelPerk to your Expensify Account
+description: Help article that describes how to connect TravelPerk to your Expensify Account
---
-## Resource Coming Soon!
+# Connecting TravelPerk to your Expensify Account
+
+## Overview
+Expensify and TravelPerk are two powerful tools that can streamline your expense management and travel booking processes. By integrating these two platforms, you can make tracking travel expenses even more efficient. This article will walk you through the steps to integrate Expensify with Travel Perk seamlessly.
+
+## How to Connect TravelPerk to your Expensify Account
+**Prerequisites:**
+Before you begin, ensure that you have the following:
+- An active Expensify account.
+- An active TravelPerk account.
+- Administrative access to both Expensify and TravelPerk accounts.
+
+1. **Log in to your Expensify account (web)**
+ - Open your web browser and navigate to the Expensify login page.
+ - Enter your Expensify username and password.
+ - Click "Sign In" to access your Expensify account.
+
+2. **Access Your Expensify Account Settings**
+ - Once logged in, click on your profile icon or username in the upper-right corner.
+ - From the dropdown menu, select "Settings."
+
+3. **Navigate to Integrations**
+ - In the Settings menu, find and click on the "Integrations" option.
+
+4. **Search for TravelPerk Integration**
+ - In the Integrations section, locate the search bar.
+ - Type "TravelPerk" into the search bar and hit "Enter."
+
+5. **Connect TravelPerk to Expensify**
+ - Click on the Travel Perk integration option.
+ - You'll be prompted to log in to your Travel Perk account. Enter your TravelPerk credentials and log in.
+
+6. **Authorize the Integration**
+ - After logging in to TravelPerk, you'll be asked to authorize the integration. Review the permissions requested and click "Authorize" or "Allow."
+
+7. **Configure Integration Settings**
+ - Once the integration is authorized, you may have the option to configure settings such as expense categories and tags.
+ - Follow the on-screen prompts to customize the integration settings according to your preferences.
+
+8. **Save Integration Settings**
+ - After configuring the integration settings, click the "Save" or "Finish" button to confirm your choices.
+
+9. **Test the Integration**
+ - To ensure that the integration is working correctly, consider creating a test expense in TravelPerk.
+ - Wait for a few minutes and check your Expensify account to confirm that the expense has been automatically imported.
+
+10. **Regularly Review and Approve Expenses**
+ - With the integration in place, expenses from TravelPerk will be automatically synced to your Expensify account.
+ - Regularly review and approve these expenses in Expensify to keep your financial records up to date.
+
+## How to Book Travel
+- From the Trips dashboard in TravelPerk, click Create Trip.
+- Give your trip a unique name, then book your flights and hotels.
+- Review your itinerary and click Confirm Payment, and your TravelPerk invoice and itinerary will automatically populate in Expensify!
+
+## Deep Dive on the TravelPerk Integration
+
+The integration between Expensify and TravelPerk enables a seamless flow of data between the two platforms. When employees book travel through TravelPerk, their travel expenses are automatically transferred to Expensify.
+
+## Key Benefits
+- **Efficiency and accuracy:** The TravelPerk integration provides real-time data synchronization. Travel expenses are automatically input into Expensify, allowing for timely reporting and reimbursement.
+- **Expense policy compliance:** TravelPerk helps enforce corporate travel policies by offering pre-approved travel options. Expenses generated from these bookings automatically adhere to company policies.
+- **Visibility and control:** Finance teams gain greater visibility into travel expenses. They can track expenses in real-time, monitor spending trends, and enforce budget controls more effectively.
+- **Streamlined approval workflows:** Expense approval workflows can be set up in Expensify. Managers can review and approve expenses with ease, ensuring adherence to company policies.
+
+Integrating Expensify with TravelPerk can significantly simplify your expense management process. By following these steps, you can ensure that your travel expenses are automatically imported into Expensify, making it easier to track and report expenses accurately. If you encounter any issues or have questions, don’t hesitate to reach out to your Account Manager or concierge@expensify.com with any questions.
+
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md
index 3ee1c8656b4b..1f69c1eee8f4 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approval-Workflows.md
@@ -1,5 +1,109 @@
---
-title: Coming Soon
-description: Coming Soon
+title: Managing employees and reports > Approval workflows
+description: Set up the workflow that your employees reports should flow through.
---
-## Resource Coming Soon!
+
+
+# About
+## Overview
+
+
+This document explains how to manage employee expense reports and approval workflows in Expensify.
+
+
+### Approval workflow modes
+
+
+#### Submit and close
+- This is a workflow where no approval occurs in Expensify.
+- *What happens after submission?* The report state becomes Closed and is available to view by the member set in Submit reports to and any Workspace Admins.
+- *Who should use this workflow?* This mode should be used where you don't require approvals in Expensify.
+
+
+#### Submit and approve
+- *Submit and approve* is a workflow where all reports are submitted to a single member for approval. New policies have Submit and Approve enabled by default.
+- *What happens after submission?* The report state becomes Processing and it will be sent to the member indicated in Submit reports to for approval. When the member approves the report, the state will become Approved.
+- *Who should use this workflow?* This mode should be used where the same person is responsible for approving all reports for your organization. If submitters have different approvers or multiple levels of approval are required, then you will need to use Advance Approval.
+
+
+#### Advanced Approval
+- This approval mode is used to handle more complex workflows, including:
+ - *Multiple levels of approval.* This is for companies that require more than one person to approve a report before it can be reimbursed. The most common scenario is when an employee needs to submit to their manager, and their manager needs to approve and forward that report to their finance department for final approval.
+ - *Varying approval workflows.* For example, if a company has Team A submitting reports to Manager A, and Team B to Manager B, use Advanced Approval. Group Workspace Admins can also set amount thresholds in the case that a report needs to go to a different approver based on the amount.
+- *What happens after submission?* After the report is submitted, it will follow the set approval chain. The report state will be Processing until it is Final Approved. We have provided examples of how to set this up below.
+- *Who should use this workflow?* Organizations with complex workflows or 2+ levels of approval. This could be based on manager approvals or where reports over a certain size require additional approvals.
+- *For further automation:* use Concierge auto-approval for reports. You can set specific rules and guidelines in your Group Workspace for your team's expenses; if all expenses are below the Manual Approval Threshold and adhere to all the rules, then we will automatically approve these reports on behalf of the approver right after they are submitted.
+
+
+### How to set an approval workflow
+
+- Step-by-step instructions on how to set this up at the Workspace level [here](link-to-instructions).
+
+# Deep Dive
+
+### Setting multiple levels of approval
+- 'Submits to' is different than 'Approves to'.
+ - *Submits to* - is the person you are sending your reports to for 1st level approval
+ - *Approves to* - is the person you are sending the reports you've approved for higher-level approval
+- In the example below, a report needs to be approved by multiple managers: *Submitter > Manager > Director > Finance/Accountant*
+ - *Submitter (aka. Employee):* This is the person listed under the member column of the People page.
+ - *First Approver (Manager):* This is the person listed under the Submits to column of the People Page.
+ - *Second Approver (Director):* This is the person listed as 'Approves to' in the Settings of the First Approver.
+ - *Final Approver (Finance/Accountant):* This is the person listed as the 'Approves to' in the Settings of the Second Approver.
+- This is what this setup looks like in the Workspace Members table.
+ - Bryan submits his reports to Jim for 1st level approval.
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+
+ - All of the reports Jim approves are submitted to Kevin. Kevin is the 'approves to' in Jim's Settings.
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+
+ - All of the reports Kevin approves are submitted to Lucy. Lucy is the 'approves to' in Kevin's Settings.
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+
+
+ - Lucy is the final approver, so she doesn't submit her reports to anyone for review.
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+
+
+- The final outcome: The member in the Submits To line is different than the person noted as the Approves To.
+### Adding additional approver levels
+- You can also set a specific approver for Reports Totals in Settings.
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+
+- An example: The submitter's manager can approve any report up to a certain limit, let's say $500, and forward it to accounting. However, if a report is over that $500 limit, it has to be also approved by the department head before being forwarded to accounting.
+- To configure, click on Edit Settings next to the approving manager's email address and set the "If Report Total is Over" and "Then Approves to" fields.
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+![Insert alt text for accessibility here](https://help.expensify.com/assets/images/image-name.png){:width="100%"}
+
+
+### Setting category approvals
+- If your expense reports should be reviewed by an additional approver based on specific categories or tags selected on the expenses within the report, set up category approvers and tag approvers.
+- Category approvers can be set in the Category settings for each Workspace
+- Tag approvers can be set in the Tag settings for each Workspace
+
+
+#### Category approver
+- A category approver is a member who is added to the approval workflow for any reports in your Expensify Workspace that contain expenses with a specific category.
+- For example: Your HR director Jim may need to approve any relocation expenses submitted by employees. Set Jim up as the category approver for your Relocation category, then any reports containing Relocation expenses will first be routed to Jim before continuing through the approval workflow.
+- Adding category approvers
+ - To add a category approver in your Workspace:
+ - Navigate to *Settings > Policies > Group > [Workspace Name] > Categories*
+ - Click *"Edit Settings"* next to the category that requires the additional approver
+ - Select an approver and click *“Save”*
+
+
+#### Tag approver
+- A tag approver is a member who is added to the approval workflow for any reports in your Expensify Workspace that contain expenses with a specific tag.
+- For example: If employees must tag project-based expenses with the corresponding project tag. Pam, the project manager is set as the project approver for that project, then any reports containing expenses with that project tag will first be routed to Pam for approval before continuing through the approval workflow.
+- Please note: Tag approvers are only supported for a single level of tags, not for multi-level tags. The order in which the report is sent to tag approvers relies on the date of the expense.
+- Adding tag approvers
+ - To add a tag approver in your Workspace:
+ - Navigate to *Settings > Policies > Group > [Workspace Name] > Tags*
+ - Click in the "Approver" column next to the tag that requires an additional approver
+
+
+Category and Tag approvers are inserted at the beginning of the approval workflow already set on the People page. This means the workflow will look something like: * *Submitter > Category Approver(s) > Tag Approver(s) > Submits To > Previous approver's Approves To.*
+
+
+### Workflow enforcement
+- If you want to ensure your employees cannot override the workflow you set - enable workflow enforcement by following the steps below. As a Workspace Admin, you can choose to enforce your approval workflow by going.
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
index 14ade143a35b..1a567dbe6fa3 100644
--- a/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
+++ b/docs/articles/expensify-classic/send-payments/Third-Party-Payments.md
@@ -6,7 +6,7 @@ description: A help article that covers Third Party Payment options including Pa
Expensify offers convenient third party payment options that allow you to streamline the process of reimbursing expenses and managing your finances. With these options, you can pay your expenses and get reimbursed faster and more efficiently. In this guide, we'll walk you through the steps to set up and use Expensify's third party payment options.
-## Overview
+# Overview
Expensify offers integration with various third party payment providers, making it easy to reimburse employees and manage your expenses seamlessly. Some of the key benefits of using third-party payment options in Expensify include:
@@ -14,7 +14,7 @@ Expensify offers integration with various third party payment providers, making
- Secure Transactions: Benefit from the security features and protocols provided by trusted payment providers.
- Centralized Expense Management: Consolidate all your expenses and payments within Expensify for a more efficient financial workflow.
-## Setting Up Third Party Payments
+# Setting Up Third Party Payments
To get started with third party payments in Expensify, follow these steps:
@@ -30,7 +30,7 @@ To get started with third party payments in Expensify, follow these steps:
6. **Verify Your Account**: Confirm your linked account to ensure it's correctly integrated with Expensify.
-## Using Third Party Payments
+# Using Third Party Payments
Once you've set up your third party payment option, you can start using it to reimburse expenses and manage payments:
@@ -42,22 +42,18 @@ Once you've set up your third party payment option, you can start using it to re
4. **Track Payment Status**: You can track the status of payments and view transaction details within your Expensify account.
-## FAQ’s
+# FAQ’s
-### Q: Are there any fees associated with using third party payment options in Expensify?
+## Q: Are there any fees associated with using third party payment options in Expensify?
A: The fees associated with third party payments may vary depending on the payment provider you choose. Be sure to review the terms and conditions of your chosen provider for details on any applicable fees.
-### Q: Can I use multiple third party payment providers with Expensify?
+## Q: Can I use multiple third party payment providers with Expensify?
A: Expensify allows you to link multiple payment providers if needed. You can select the most suitable payment method for each expense report.
-### Q: Is there a limit on the amount I can reimburse using third party payments?
+## Q: Is there a limit on the amount I can reimburse using third party payments?
A: The reimbursement limit may depend on the policies and settings configured within your Expensify account and the limits imposed by your chosen payment provider.
With Expensify's third party payment options, you can simplify your expense management and reimbursement processes. By following the steps outlined in this guide, you can set up and use third party payments efficiently.
-
-
-
-
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Categories.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Categories.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Categories.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Categories.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domains-Overview.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Domains-Overview.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Domains-Overview.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Domains-Overview.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Expenses.md
similarity index 96%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Expenses.md
index 424338120010..388bb5d5cbc9 100644
--- a/docs/articles/expensify-classic/policy-and-domain-settings/Expenses.md
+++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Expenses.md
@@ -4,7 +4,7 @@ description: Expense Settings
---
# Overview
-Expensify offers multiple ways to customize how expenses are created in your workspace. In this doc, you’ll learn how to set up and expense basics, distance expenses, and time expenses.
+Expensify offers multiple ways to customize how expenses are created in your workspace. In this doc, you’ll learn how to set up expense basics, distance expenses, and time expenses.
Whether you’re flying solo with your Individual workspace or submitting with a team on your Group workspace, we have settings to support how you use Expensify.
@@ -69,7 +69,7 @@ Preliminary setup steps include:
3. Click **Add A Mileage Rate** to add as many rates as you need,
4. Set the reimbursable amount per mile or kilometer.
-Note: _If a rate is toggled off it is immediately disabled. This means that users are no longer able to select it when creating a new distance expense. If only one rate is available then this rate will be toggled on by default._
+Note: _If a rate is toggled off it is immediately disabled. This means that users are no longer able to select it when creating a new distance expense. If only one rate is available then that rate will be toggled on by default._
## Set an hourly rate
@@ -96,7 +96,7 @@ Note: _If a report has audit alerts on it, you'll need to Review the report and
## Tracking tax on mileage expenses
-If you’re tracking tax in Expensify you can also track tax on distance expenses. The first step is to enable tax the workspace. You can do this by going to **Settings** > **Workspaces** > **Individual** or **Group** > [_Workspace Name_] > **Tax**.
+If you’re tracking tax in Expensify you can also track tax on distance expenses. The first step is to enable tax in the workspace. You can do this by going to **Settings** > **Workspaces** > **Individual** or **Group** > [_Workspace Name_] > **Tax**.
Once tax is enabled on a workspace level you will see a toggle to _Track Tax_ in the Distance section of the workspace settings. If tax is disabled on the workspace the Track Tax toggle will not display.
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Invoicing.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Invoicing.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Invoicing.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Per-Diem.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Per-Diem.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Per-Diem.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Reimbursement.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Reimbursement.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Reimbursement.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/SAML-SSO.md b/docs/articles/expensify-classic/workspace-and-domain-settings/SAML-SSO.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/SAML-SSO.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/SAML-SSO.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Tags.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Tags.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/Tags.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/Tags.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md b/docs/articles/expensify-classic/workspace-and-domain-settings/reports/Currency.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/reports/Currency.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/workspace-and-domain-settings/reports/Report-Fields-And-Titles.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/reports/Report-Fields-And-Titles.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/workspace-and-domain-settings/reports/Scheduled-Submit.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/reports/Scheduled-Submit.md
diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/tax-tracking.md b/docs/articles/expensify-classic/workspace-and-domain-settings/tax-tracking.md
similarity index 100%
rename from docs/articles/expensify-classic/policy-and-domain-settings/tax-tracking.md
rename to docs/articles/expensify-classic/workspace-and-domain-settings/tax-tracking.md
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md b/docs/articles/new-expensify/expensify-partner-program/Coming-Soon.md
similarity index 67%
rename from docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md
rename to docs/articles/new-expensify/expensify-partner-program/Coming-Soon.md
index 3ee1c8656b4b..6b85bb0364b5 100644
--- a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md
+++ b/docs/articles/new-expensify/expensify-partner-program/Coming-Soon.md
@@ -2,4 +2,3 @@
title: Coming Soon
description: Coming Soon
---
-## Resource Coming Soon!
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
index 996d7896502f..17c7a60b8e5a 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md
@@ -4,16 +4,16 @@ description: Best Practices for Admins settings up Expensify Chat
redirect_from: articles/other/Expensify-Chat-For-Admins/
---
-## Overview
+# Overview
Expensify Chat is an incredible way to build a community and foster long-term relationships between event producers and attendees, or attendees with each other. Admins are a huge factor in the success of the connections built in Expensify Chat during the events, as they are generally the drivers of the conference schedule, and help ensure safety and respect is upheld by all attendees both on and offline.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Admin Best Practices
+# Admin Best Practices
In order to get the most out of Expensify Chat, we created a list of best practices for admins to review in order to use the tool to its fullest capabilities.
**During the conference:**
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
index 20e15aaa6c72..30eeb4158902 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md
@@ -4,19 +4,19 @@ description: Best Practices for Conference Attendees
redirect_from: articles/other/Expensify-Chat-For-Conference-Attendees/
---
-## Overview
+# Overview
Expensify Chat is the best way to meet and network with other event attendees. No more hunting down your contacts by walking the floor or trying to find someone in crowds at a party. Instead, you can use Expensify Chat to network and collaborate with others throughout the conference.
To help get you set up for a great event, we’ve created a guide to help you get the most out of using Expensify Chat at the event you’re attending.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your fellow attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Chat Best Practices
+# Chat Best Practices
To get the most out of your experience at your conference and engage people in a meaningful conversation that will fulfill your goals instead of turning people off, here are some tips on what to do and not to do as an event attendee using Expensify Chat:
**Do:**
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
index 3e19cf6fe26a..652fc2ee4d2b 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md
@@ -4,17 +4,17 @@ description: Best Practices for Conference Speakers
redirect_from: articles/other/Expensify-Chat-For-Conference-Speakers/
---
-## Overview
+# Overview
Are you a speaker at an event? Great! We're delighted to provide you with an extraordinary opportunity to connect with your session attendees using Expensify Chat — before, during, and after the event. Expensify Chat offers a powerful platform for introducing yourself and your topic, fostering engaging discussions about your presentation, and maintaining the conversation with attendees even after your session is over.
-## Getting Started
+# Getting Started
We’ve rounded up some resources to get you set up on Expensify Chat and ready to start connecting with your session attendees:
- [How to get set up and start using Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-use-chat-in-expensify)
- [How to format text in Expensify Chat](https://help.expensify.com/articles/other/Everything-About-Chat#how-to-format-text)
- [How to flag content and/or users for moderation](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive)
-## Setting Up a Chatroom for Your Session: Checklist
+# Setting Up a Chatroom for Your Session: Checklist
To make the most of Expensify Chat for your session, here's a handy checklist:
- Confirm that your session has an Expensify Chat room, and have the URL link ready to share with attendees in advance.
- You can find the link by clicking on the avatar for your chatroom > “Share Code” > “Copy URL to dashboard”
@@ -22,7 +22,7 @@ To make the most of Expensify Chat for your session, here's a handy checklist:
- Consider having a session moderator with you on the day to assist with questions and discussions while you're presenting.
- Include the QR code for your session's chat room in your presentation slides. Displaying it prominently on every slide ensures that attendees can easily join the chat throughout your presentation.
-## Tips to Enhance Engagement Around Your Session
+# Tips to Enhance Engagement Around Your Session
By following these steps and utilizing Expensify Chat, you can elevate your session to promote valuable interactions with your audience, and leave a lasting impact beyond the conference. We can't wait to see your sessions thrive with the power of Expensify Chat!
**Before the event:**
diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
index a81aef2044a2..caeccd1920b1 100644
--- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
+++ b/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md
@@ -3,10 +3,10 @@ title: Expensify Chat Playbook for Conferences
description: Best practices for how to deploy Expensify Chat for your conference
redirect_from: articles/playbooks/Expensify-Chat-Playbook-for-Conferences/
---
-## Overview
+# Overview
To help make setting up Expensify Chat for your event and your attendees super simple, we’ve created a guide for all of the technical setup details.
-## Who you are
+# Who you are
As a conference organizer, you’re expected to amaze and inspire attendees. You want attendees to get to the right place on time, engage with the speakers, and create relationships with each other that last long after the conference is done. Enter Expensify Chat, a free feature that allows attendees to interact with organizers and other attendees in realtime. With Expensify Chat, you can:
- Communicate logistics and key information
@@ -21,20 +21,20 @@ Sounds good? Great! In order to ensure your team, your speakers, and your attend
*Let’s get started!*
-## Support
+# Support
Connect with your dedicated account manager in any new.expensify.com #admins room. Your account manager is excited to brainstorm the best ways to make the most out of your event and work through any questions you have about the setup steps below.
We also have a number of [moderation tools](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) available to admins to help make sure your event is seamless, safe, and fun!
-## Step by step instructions for setting up your conference on Expensify Chat
+# Step by step instructions for setting up your conference on Expensify Chat
Based on our experience running conferences atop Expensify Chat, we recommend the following simple steps:
-### Step 1: Create your event workspace in Expensify
+## Step 1: Create your event workspace in Expensify
To create your event workspace in Expensify:
1. In [new.expensify.com](https://new.expensify.com): “+” > “New workspace”
1. Name the workspace (e.g. “ExpensiCon”)
-### Step 2: Set up all the Expensify Chat rooms you want to feature at your event
+## Step 2: Set up all the Expensify Chat rooms you want to feature at your event
**Protip**: Your Expensify account manager can complete this step with you. Chat them in #admins on new.expensify.com to coordinate!
To create a new chat room:
@@ -54,7 +54,7 @@ For an easy-to-follow event, we recommend creating these chat rooms:
**Protip** Check out our [moderation tools](https://help.expensify.com/articles/other/Everything-About-Chat#flagging-content-as-offensive) to help flag comments deemed to be spam, inconsiderate, intimidating, bullying, harassment, assault. On any comment just click the flag icon to moderate conversation.
-### Step 3: Add chat room QR codes to the applicable session slide deck
+## Step 3: Add chat room QR codes to the applicable session slide deck
Gather QR codes:
1. Go to [new.expensify.com](https://new.expensify.com)
1. Click into a room and click the room name or avatar in the top header
@@ -63,7 +63,7 @@ Gather QR codes:
Add the QR code to every slide so that if folks forget to scan the QR code at the beginning of the presentation, they can still join the discussion.
-### Step 4: Plan out your messaging and cadence before the event begins
+## Step 4: Plan out your messaging and cadence before the event begins
Expensify Chat is a great place to provide updates leading up to your event -- share news, get folks excited about speakers, and let attendees know of crucial event information like recommended attire, travel info, and more. For example, you might consider:
**Prep your announcements:**
@@ -80,15 +80,15 @@ Expensify Chat is a great place to provide updates leading up to your event -- s
**Protip**: Your account manager can help you create this document, and would be happy to send each message at the appointed time for you.
-### Step 5: Share Expensify Chat How-To Resources with Speakers, Attendees, Admins
+## Step 5: Share Expensify Chat How-To Resources with Speakers, Attendees, Admins
We’ve created a few helpful best practice docs for your speakers, admins, and attendees to help navigate using Expensify Chat at your event. Feel free to share the links below with them!
- [Expensify Chat for Conference Attendees](https://help.expensify.com/articles/other/Expensify-Chat-For-Conference-Attendees)
- [Expensify Chat for Conference Speakers](https://help.expensify.com/articles/other/Expensify-Chat-For-Conference-Speakers)
- [Expensify Chat for Admins](https://help.expensify.com/articles/other/Expensify-Chat-For-Admins)
-### Step 6: Follow up with attendees after the event
+## Step 6: Follow up with attendees after the event
Continue the connections by using Expensify Chat to keep your conference community connected. Encourage attendees to share photos, their favorite memories, funny stories, and more.
-## Conclusion
+# Conclusion
Once you have completed the above steps you are ready to host your conference on Expensify Chat! Let your account manager know any questions you have over in your [new.expensify.com](https://new.expensify.com) #admins room and start driving activity in your Expensify Chat rooms. Once you’ve reviewed this doc you should have the foundations in place, so a great next step is to start training your speakers on how to use Expensify Chat for their sessions. Coordinate with your account manager to make sure everything goes smoothly!
diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md b/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md
similarity index 67%
rename from docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md
rename to docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md
index 3ee1c8656b4b..6b85bb0364b5 100644
--- a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md
+++ b/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md
@@ -2,4 +2,3 @@
title: Coming Soon
description: Coming Soon
---
-## Resource Coming Soon!
diff --git a/docs/articles/new-expensify/integrations/accounting-integrations/Xero b/docs/articles/new-expensify/integrations/accounting-integrations/Xero
deleted file mode 100644
index 45aec32fb708..000000000000
--- a/docs/articles/new-expensify/integrations/accounting-integrations/Xero
+++ /dev/null
@@ -1,261 +0,0 @@
----
-title: The Xero Integration
-description: Everything you need to know about Expensify's direct integration with Xero
----
-
-
-# About
-
-The integration enables seamless import of expense accounts into Expensify and sends expense reports back to Xero as purchasing bills awaiting payment or "spend money" bank transactions.
-
-# How-to Connect to Xero
-
-## Prerequisites
-
-You must be a Workspace Admin in Expensify using a Collect or Control Workspace to connect your Xero account to Expensify.
-
-## Connect Expensify and Xero
-
-1. Let's get started by heading over to your Settings. You can find it by following this path: *Settings > Workspaces > Groups > [Workspace Name] > Connections > Xero.*
-2. To connect Expensify to Xero, click on the "Connect to Xero” button, then choose "Create a new Xero connection."
-3. Next, enter your Xero login details. After that, you'll need to select the Xero organization you want to link with Expensify. Remember, you can connect one organization for each Workspace.
-
-One important note: Starting in September 2021, there's a chance for Cashbook and Ledger-type organizations in Xero. Apps like Expensify won't be able to create invoices and bills for these accounts using the Xero API. So, if you're using a Cashbook or Ledger Xero account, please be aware that this might affect your Expensify integration.
-
-# How to Configure Export Settings for Xero
-
-When you integrate Expensify with Xero you gain control over several settings that determine how your reports will be displayed in Xero. To manage these settings simply follow this path: *Settings > Workspaces > Group > [Workspace Name] > Connections > Accounting Integrations > Xero > Configure > Export*. This is where you can fine-tune how your reports appear on the Xero side, making your expense management a breeze!
-
-## Xero Organization
-
-When you have multiple organizations set up in Xero you can choose which one you'd like to connect. Here are some essential things to keep in mind:
-
-1. Organization Selection: You'll see this option only if you have multiple organizations configured in Xero.
-2. One Workspace, One Organization: Each Workspace can connect to just one organization at a time. It's a one-to-one connection.
-3. Adding New Organizations: If you create a new organization in Xero after your initial connection, you'll need to disconnect and then reconnect it to Xero. Don't forget to take a screenshot of your current settings by clicking on "Configure" and checking the Export, Coding, and Advanced tabs. This way, you can easily set everything up again.
-
-Now you can seamlessly manage your connections with Xero while staying in control of your configurations!
-
-## Preferred Exporter
-
-Any Workspace admin can export to Xero, but only the preferred exporter will see reports that are ready for export in their Home.
-
-## Reimbursable Expenses
-
-Export to Xero as bills awaiting payment with the following additional settings:
-
-- Bill date — the bill is posted on the last day of the month in which expenses were incurred.
-
-To view the bills in Xero, navigate to *Business > Purchase Overview > Awaiting Payments*. Bills will be payable to the individual who created and reported the expense.
-
-## Non-reimbursable Expenses
-
-When you export non-reimbursable expenses, like company card transactions, to Xero they'll show up as bank transactions. Each expense is neatly listed as a separate line item in the bank account of your choice. Plus the transaction date matches the date on your bank statement for seamless tracking.
-
-To check out these expenses in Xero please follow these steps:
-
-1. Head over to your Dashboard.
-2. Select your company card.
-3. Locate the specific expense you're interested in.
-
-If you're managing company cards centrally, you can export expenses from each card to a designated account in Xero using Domains. This way, you have complete control and clarity over your company's finances!
-
-# How to Configure Coding for Xero
-
-The Coding tab in Expensify is where you configure Xero information to ensure accurate expense coding by your employees. Here's how you can access these settings:
-
-1. Navigate to Settings.
-2. Go to Workspace within your specified group (Workspace Name).
-3. Click on Connections, and then hit the Configure button.
-4. Now, select the Coding tab.
-
-## Categories
-
-Xero expense accounts and those marked "Show In Expense Claims" will be automatically imported into Expensify as Categories.
-
-To manage these categories, follow these steps:
-
-1. After connecting, go to *Settings > Workspaces > Groups > [Workspace Name] > Categories*.
-2. You can enable/disable categories using the checkbox.
-3. For specific category rules (like default tax rate, maximum amount, receipts required, comments, and comment hints), click the settings cog.
-4. Note that each expense must have a category selected for it to export to Xero, and these categories need to be imported from Xero; manual creation isn't an option within Workspace settings.
-
-## Tracking Categories
-
-1. If you use Tracking categories in Xero, you can import them into Expensify as Tags, Report Fields, or the Xero contact default.
-- Tags apply a tracking category per expense.
-- Report Field applies a tracking category to the entire report.
-- Xero contact default applies the default tracking category set for the submitter in Xero.
-
-## Tax
-
-Looking to track tax in Expensify? Make sure that you have tax rates enabled in Xero and we will automatically grab those rates from Xero to allow your employees to categorize expenses with the appropriate tax rate. As an admin, you have the ability to set a default rate and also hide rates that are not applicable to the Workspace members.
-
-Tax tracking allows you to apply a tax rate and tax amount to each expense.
-1. To set this up, enable Tax tracking in your Xero configuration.
-2. After connecting, go to *Settings > Workspaces > Groups > [Workspace Name] > Tax to manage imported taxes from Xero.*
-3. You can enable/disable taxes and set default tax rates for both Workspace currency expenses and foreign currency expenses.
-
-## Billable Expenses
-
-If you bill expenses to your customers, you can track and invoice them using Expensify and Xero.
-
-1. When enabled, Xero customer contacts are imported into Expensify as Tags for expense tracking.
-- Note: In Xero, a Contact isn't a 'Customer' until they've had a bill raised against them. If you don't see your Customer imported as a tag, try raising a dummy invoice in Xero and then deleting/voiding it.
-2. After exporting to Xero, tagged billable expenses can be included on a sales invoice to your customer.
-
-Please ensure that you meet the following requirements for expenses to be placed on a sales invoice:
-1. Billable Expenses must be enabled in the Xero configuration settings.
-2. The expense must be marked as billable.
-3. The expense must be tagged with a customer.
-
-These steps should help you seamlessly manage your Xero integration within Expensify.
-
-# How to Configure Xero’s Advanced Settings
-
-If you've already set up your integration, but want to make adjustments, simply follow these steps:
-
-1. Go to Settings.
-2. Then, navigate to Workspaces within your designated group [Workspace Name].
-3. Click on Connections, and next, hit the Configure button.
-
-From there, you can dive into the "Advanced" tab to make any additional tweaks.
-
-## Auto Sync
-
-For non-reimbursable reports: Once a report has completed the approval workflow in Expensify, we'll automatically queue it for export to Xero.
-
-But, if you've added a business bank account for ACH reimbursement, any reimbursable expenses will be sent to Xero automatically when the report is marked as reimbursed or enabled for reimbursement.
-
-### Controlling Newly Imported Categories:
-
-You can decide how newly imported categories behave in Expensify:
-
-1. Enabling or disabling this control determines the status of new categories imported from Xero to Expensify. Enabled categories are visible for employees when they categorize expenses, while disabled categories remain hidden.
-
-These settings give you the flexibility to manage your expenses and Workspace in the way that best suits your needs!
-
-## Sync Reimbursed Reports
-
-This nifty setting lets you synchronize the status of your reports between Expensify and Xero. Utilizing this setting will make sure that there is no confusion or possibility that a reimbursable report is paid out twice by mistake or that a non-reimbursable report is double entered throwing off month-end reconciliation. Here's how it works:
-
-1. When you reimburse a report via ACH direct deposit within Expensify, the purchase bill will automatically be marked as paid in Xero, and Expensify will note it as reimbursed.
-2. Don't forget to pick the Xero account where the corresponding bill payment should be recorded.
-3. It's a simple way to keep everything in sync, especially when you're awaiting payment.
-
-# Deep Dive
-
-## An Automatic Export Fails
-
-Sometimes, reports may encounter issues during automatic export to Xero. Not to worry, though! Here's what happens:
-
-1. The Technical Contact, your go-to person for technical matters, will receive an email explaining the problem.
-2. You'll also find specific error messages at the bottom of the report.
-3. To get things back on track, the report will be placed in the preferred exporter’s Home. They can review it and resolve any issues.
-
-## Consider Enforcing Expense Workspace Workflows:
-
-For added control, you can adjust your Workspace settings to strictly enforce expense Workspace. This way, you guarantee that your Workspace’s workflow is always followed. By default this flow is in place, but employees can modify the person they submit their reports to if it's not strictly enforced.
-
-## Customize Purchase Bill Status (Optional):
-
-You have the flexibility to set the status of your purchase bills just the way you want. Choose from the following options:
-
-1. Draft: Keep bills in a draft state until you're ready to finalize them.
-2. Awaiting Approval: If you need approval before processing bills, this option is here for you.
-
-## Multi-Currency
-
-### Handling Multi-Currency in Xero
-
-When dealing with multi-currency transactions in Xero and exporting reimbursable expenses from Expensify here's what you need to know:
-
-1. The bill created in Xero will adopt the output currency set in your Expensify Workspace, provided that it's enabled in Xero.
-2. Your general ledger reports will automatically convert to your home currency in Xero, leveraging the currency exchange rates defined in your Xero settings. It ensures everything aligns seamlessly.
-
-Now, for non-reimbursable expenses, things work slightly differently:
-
-1. Bank transactions will use the currency specified in your bank account in Xero, regardless of the currency used in Expensify.
-2. If these currencies don't match, no worries! We apply a 1:1 exchange rate to make things smooth. To ensure a hassle-free experience, just ensure that the output currency in Expensify matches the currency specified in your Xero bank account.
-
-## Tax
-
-### Enabling Tax Tracking for Seamless Integration:
-
-To simplify tax tracking, enable it in your Xero configuration. This action will automatically bring all your Xero tax settings into Expensify, turning them into usable Taxes.
-
-### After connecting your Xero account with Expensify:
-
-1. Head to Settings.
-2. Navigate to Workspaces within your specific group [Workspace Name].
-3. Click on Tax to view the taxes that have been imported from Xero.
-
-Now, here's where you can take control:
-
-1. Use the enable/disable button to choose which taxes your employees can apply to their expenses. Customize it to fit your needs.
-2. You can set a default tax rate for expenses in your Workspace currency. Additionally, if you deal with foreign currency expenses, you have the option to set another default tax (including exempt) that will automatically apply to all new expenses in foreign currencies.
-
-This setup streamlines your tax management, making it effortless for your team to handle taxes on their expenses.
-
-## Export Invoices to Xero
-
-You can effortlessly export your invoices from Expensify to Xero and even attribute them to the right Customer. Plus, when you mark an invoice as paid in Expensify, the same status will smoothly transfer to Xero and vice versa, keeping your invoice tracking hassle-free. Let's dive in:
-
-### Setting up Invoice Export to Xero:
-
-1. Navigate to Settings.
-2. Go to Workspaces within your designated group [Workspace Name].
-3. Click on Connections, then select Configuration.
-4. Now, click on the Advanced tab.
-
-### Selecting Your Xero Invoice Collection Account:
-
-1. Scroll down until you find "Xero invoice collection account." You'll see a dropdown list of your available Accounts Receivable accounts imported from Xero.
-2. Simply choose the account where you'd like your invoices to be exported.
-
-Pro Tip: If you don't see any accounts in the dropdown, try syncing your Xero connection. To do this, go back to the Connections page and hit "Sync Now."
-
-### Exporting an Invoice to Xero:
-
-Invoices will automatically make their way to Xero when they're in the Processing or Paid state. This ensures consistent tracking of unpaid and paid invoices. However, if you have Auto Sync disabled, you'll need to manually export your invoices along with your expense reports. Here's how:
-
-1. Head to your Reports page.
-2. Use the filters to locate the invoices you want to export.
-3. Select the invoices you wish to export.
-4. Click Export to > Xero on the top right-hand side.
-
-### Matching Customers and Emails:
-
-When exporting to Xero, we match the recipient's email address with a customer record in Xero. So, make sure each customer in Xero has their email listed in their profile.
-If we can't find a match, we'll create a new customer record in Xero.
-
-### Updating Invoice Status:
-
-1. When you mark an invoice as Paid in Expensify, this status will automatically reflect in Xero.
-2. Similarly, if you mark an invoice as Paid in Xero, it will update automatically in Expensify.
-3. The payment will be recorded in the Collection account you've chosen in your Advanced Settings Configuration.
-
-And that's it! You've successfully set up and managed your invoice exports to Xero, making your tracking smooth and efficient.
-
-# FAQ
-
-## Will receipt images be exported to Xero?
-
-Yes! The receipt images will be exported to Xero. To see them in Xero click the 'paper' icon in the upper right corner of the expense details and view a PDF of the Expensify report including the receipt image.
-
-## How does Auto Sync work if your workspace was initially connected to Xero with Auto Sync disabled?
-
-You can safely switch it on without affecting existing reports that haven't been exported.
-
-## How does Auto Sync work if a report has already been exported to Xero and reimbursed through ACH or marked as reimbursed in Expensify?
-
-It will be automatically marked as paid in Xero during the next sync. You may either manually update by clicking Sync Now in the Connections tab or Expensify does this on your behalf overnight every day!
-
-## How does Auto Sync work if a report has been exported to Xero and marked as paid in Xero?
-
-It will be automatically marked as reimbursed in Expensify during the next sync. If you need it updated immediately please go to the Connections tab and click Sync Now or if you can wait just let Expensify do it for you overnight.
-
-## How does Auto Sync work if a report has been exported to Xero and marked as paid in Xero?
-
-Reports that haven't been exported to Xero won't be sent automatically.
--->
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_01.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_01.png
new file mode 100644
index 000000000000..a9bc57525a1a
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_01.png differ
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_02.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_02.png
new file mode 100644
index 000000000000..4bd2c5af455b
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_02.png differ
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_03.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_03.png
new file mode 100644
index 000000000000..f5318cd5272a
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_03.png differ
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_04.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_04.png
new file mode 100644
index 000000000000..8913771747aa
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_04.png differ
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_05.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_05.png
new file mode 100644
index 000000000000..f1f43ae16f03
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_05.png differ
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_06.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_06.png
new file mode 100644
index 000000000000..51854b6e2690
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_06.png differ
diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_07.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_07.png
new file mode 100644
index 000000000000..b750ffdc486f
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_07.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png
new file mode 100644
index 000000000000..d4e73beb16b3
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_1.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png
new file mode 100644
index 000000000000..45956a586d98
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_2.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png
new file mode 100644
index 000000000000..32aae12d3687
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_3.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png
new file mode 100644
index 000000000000..ccd9335025bf
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_4.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png
new file mode 100644
index 000000000000..5363935f0ab5
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_5.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png
new file mode 100644
index 000000000000..739446de8383
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_6.png differ
diff --git a/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png
new file mode 100644
index 000000000000..21a1d3416858
Binary files /dev/null and b/docs/assets/images/ManagingEmployeesAndReports_ApprovalWorkflows_7.png differ
diff --git a/docs/assets/images/handshake.svg b/docs/assets/images/handshake.svg
new file mode 100644
index 000000000000..04872bd3a88b
--- /dev/null
+++ b/docs/assets/images/handshake.svg
@@ -0,0 +1,36 @@
+
diff --git a/docs/expensify-classic/hubs/expensify-partner-program/index.html b/docs/expensify-classic/hubs/expensify-partner-program/index.html
new file mode 100644
index 000000000000..c0a192c6e916
--- /dev/null
+++ b/docs/expensify-classic/hubs/expensify-partner-program/index.html
@@ -0,0 +1,6 @@
+---
+layout: default
+title: Expensify Partner Program
+---
+
+{% include hub.html %}
diff --git a/docs/expensify-classic/hubs/exports/index.html b/docs/expensify-classic/hubs/insights-and-custom-reporting/index.html
similarity index 100%
rename from docs/expensify-classic/hubs/exports/index.html
rename to docs/expensify-classic/hubs/insights-and-custom-reporting/index.html
diff --git a/docs/expensify-classic/hubs/policy-and-domain-settings/index.html b/docs/expensify-classic/hubs/workspace-and-domain-settings/index.html
similarity index 100%
rename from docs/expensify-classic/hubs/policy-and-domain-settings/index.html
rename to docs/expensify-classic/hubs/workspace-and-domain-settings/index.html
diff --git a/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html b/docs/expensify-classic/hubs/workspace-and-domain-settings/reports.html
similarity index 100%
rename from docs/expensify-classic/hubs/policy-and-domain-settings/reports.html
rename to docs/expensify-classic/hubs/workspace-and-domain-settings/reports.html
diff --git a/docs/new-expensify/hubs/expensify-partner-program/index.html b/docs/new-expensify/hubs/expensify-partner-program/index.html
new file mode 100644
index 000000000000..c0a192c6e916
--- /dev/null
+++ b/docs/new-expensify/hubs/expensify-partner-program/index.html
@@ -0,0 +1,6 @@
+---
+layout: default
+title: Expensify Partner Program
+---
+
+{% include hub.html %}
diff --git a/docs/new-expensify/hubs/exports/index.html b/docs/new-expensify/hubs/insights-and-custom-reporting/index.html
similarity index 100%
rename from docs/new-expensify/hubs/exports/index.html
rename to docs/new-expensify/hubs/insights-and-custom-reporting/index.html
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index dac53193fdc6..78abf8074155 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -305,7 +305,10 @@ platform :ios do
export_compliance_contains_proprietary_cryptography: false,
# We do not show any third party content
- content_rights_contains_third_party_content: false
+ content_rights_contains_third_party_content: false,
+
+ # Indicate that our key has admin permissions
+ content_rights_has_rights: true
},
release_notes: {
'en-US' => "Improvements and bug fixes"
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index eb83991d4c4e..e2667ec43358 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.3.84
+ 1.3.90CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.84.3
+ 1.3.90.2ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 600415bda113..1d2dca3bbddf 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.3.84
+ 1.3.90CFBundleSignature????CFBundleVersion
- 1.3.84.3
+ 1.3.90.2
diff --git a/ios/expensify_chat_adhoc.mobileprovision.gpg b/ios/expensify_chat_adhoc.mobileprovision.gpg
index 994136a07b6c..1dae451f168c 100644
Binary files a/ios/expensify_chat_adhoc.mobileprovision.gpg and b/ios/expensify_chat_adhoc.mobileprovision.gpg differ
diff --git a/metro.config.js b/metro.config.js
index bf2ff904df70..62ca2a25c6b2 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -7,9 +7,10 @@ require('dotenv').config();
const defaultConfig = getDefaultConfig(__dirname);
const isUsingMockAPI = process.env.E2E_TESTING === 'true';
+
if (isUsingMockAPI) {
// eslint-disable-next-line no-console
- console.warn('⚠️ Using mock API');
+ console.log('⚠️⚠️⚠️⚠️ Using mock API ⚠️⚠️⚠️⚠️');
}
/**
@@ -25,10 +26,14 @@ const config = {
resolveRequest: (context, moduleName, platform) => {
const resolution = context.resolveRequest(context, moduleName, platform);
if (isUsingMockAPI && moduleName.includes('/API')) {
+ const originalPath = resolution.filePath;
+ const mockPath = originalPath.replace('src/libs/API.ts', 'src/libs/E2E/API.mock.js').replace('/src/libs/API.js/', 'src/libs/E2E/API.mock.js');
+ // eslint-disable-next-line no-console
+ console.log('⚠️⚠️⚠️⚠️ Replacing resolution path', originalPath, ' => ', mockPath);
+
return {
...resolution,
- // TODO: Change API.mock.js extension once it is migrated to TypeScript
- filePath: resolution.filePath.replace(/src\/libs\/API.js/, 'src/libs/E2E/API.mock.js'),
+ filePath: mockPath,
};
}
return resolution;
diff --git a/package-lock.json b/package-lock.json
index 202d53fae123..f7ce98fdf111 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.84-3",
+ "version": "1.3.90-2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.84-3",
+ "version": "1.3.90-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -43,7 +43,6 @@
"@types/node": "^18.14.0",
"@ua/react-native-airship": "^15.2.6",
"awesome-phonenumber": "^5.4.0",
- "babel-plugin-transform-remove-console": "^6.9.4",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
"core-js": "^3.32.0",
@@ -51,7 +50,7 @@
"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#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -138,8 +137,6 @@
"@babel/runtime": "^7.20.0",
"@electron/notarize": "^1.2.3",
"@jest/globals": "^29.5.0",
- "@kie/act-js": "^2.0.1",
- "@kie/mock-github": "^1.0.0",
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
"@octokit/plugin-throttling": "4.1.0",
@@ -171,7 +168,7 @@
"@types/react-dom": "^18.2.4",
"@types/react-pdf": "^5.7.2",
"@types/react-test-renderer": "^18.0.0",
- "@types/semver": "^7.5.0",
+ "@types/semver": "^7.5.4",
"@types/setimmediate": "^1.0.2",
"@types/underscore": "^1.11.5",
"@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -196,7 +193,7 @@
"electron-builder": "24.6.4",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
- "eslint-config-expensify": "^2.0.39",
+ "eslint-config-expensify": "^2.0.42",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-jsdoc": "^46.2.6",
@@ -222,7 +219,7 @@
"react-native-performance-flipper-reporter": "^2.0.0",
"react-native-svg-transformer": "^1.0.0",
"react-test-renderer": "18.2.0",
- "reassure": "^0.9.0",
+ "reassure": "^0.10.1",
"setimmediate": "^1.0.5",
"shellcheck": "^1.1.0",
"style-loader": "^2.0.0",
@@ -2539,16 +2536,21 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
},
"node_modules/@babel/runtime": {
- "version": "7.22.3",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz",
- "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==",
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
+ "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"dependencies": {
- "regenerator-runtime": "^0.13.11"
+ "regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/runtime/node_modules/regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ },
"node_modules/@babel/template": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz",
@@ -2623,13 +2625,13 @@
"license": "Apache-2.0"
},
"node_modules/@callstack/reassure-cli": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.9.0.tgz",
- "integrity": "sha512-auoxqyilxkT5mDdEPJqRRY+ZGlrihJjFQpopcFd/15ng76OPVka3L48RMEY2wXkFXLaOOs6enNGb596jYPuEtQ==",
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.10.0.tgz",
+ "integrity": "sha512-CYgOGOAWcFgA2GrJw6RJAvImCpHCpPbtPoYMDol7esjlRCX2QwIKG7/9byq47hML57w94fhFAa76KD84YlgMBg==",
"dev": true,
"dependencies": {
- "@callstack/reassure-compare": "0.5.0",
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-compare": "0.6.0",
+ "@callstack/reassure-logger": "0.3.1",
"chalk": "4.1.2",
"simple-git": "^3.16.0",
"yargs": "^17.6.2"
@@ -2759,12 +2761,12 @@
}
},
"node_modules/@callstack/reassure-compare": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.5.0.tgz",
- "integrity": "sha512-3sBeJ/+Hxjdb01KVb8LszO1kcJ8TXcrVnerUj+LYn2dkBOohAMqGYaOvCeoWsVEHJ+MIOzmvAGBJQRu69RoJdQ==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.6.0.tgz",
+ "integrity": "sha512-P3nmv36CJrQSXg0+z6EuEV/0xAbvxWbAZ7diQHnkbvqk2z8PKRXpkcthrRUpe02wLewa0MLxBUJwLenFnhxIRg==",
"dev": true,
"dependencies": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"markdown-builder": "^0.9.0",
"markdown-table": "^2.0.0",
"zod": "^3.20.2"
@@ -2777,9 +2779,9 @@
"dev": true
},
"node_modules/@callstack/reassure-logger": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.0.tgz",
- "integrity": "sha512-JX5o+8qkIbIRL+cQn9XlQYdv9p/3L6J70zZX6NYi9j0VrSS9PZIRfo8ujMdLSqUNV6HZN1ay59RzuncLjVu0aQ==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.1.tgz",
+ "integrity": "sha512-IUsNrxVMdt0zgD2IN2snGVGUG8Yc6F3SWaPF8RXUn8qi7XZuYC6WevEL+mIKmlbTTa7qlX9brkn0pJoXAjfcPQ==",
"dev": true,
"dependencies": {
"chalk": "4.1.2"
@@ -2856,12 +2858,12 @@
}
},
"node_modules/@callstack/reassure-measure": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.5.0.tgz",
- "integrity": "sha512-KwlmNYcspBOp7FIw6XOz5O9mnKB4cWCCyM6vG4nFUPHSWQ6yVdRkawVvoPIV5qJ2hw7zCzdtqRrLWQSTF4eKlg==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.6.0.tgz",
+ "integrity": "sha512-phXY5DAtKhnu8dA2pmnl+pqFOfrVEFFDJOi4AnObwIcpDSn3IUXgNSe7rSi+JP/mXNWMLoUS8rnH4iIFDyf7qQ==",
"dev": true,
"dependencies": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"mathjs": "^11.5.0"
},
"peerDependencies": {
@@ -5460,7 +5462,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@kie/act-js/-/act-js-2.3.0.tgz",
"integrity": "sha512-Q9k0b05uA46jXKWmVfoGDW+0xsCcE7QPiHi8IH7h41P36DujHKBj4k28RCeIEx3IwUCxYHKwubN8DH4Vzc9XcA==",
- "dev": true,
"hasInstallScript": true,
"dependencies": {
"@kie/mock-github": "^2.0.0",
@@ -5480,7 +5481,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.0.tgz",
"integrity": "sha512-od6UyICJYKMnz9HgEWCQAFT/JsCpKkLp+JQH8fV23tf+ZmmQI1dK3C20k6aO5uJhAHA0yOcFtbKFVF4+8i3DTg==",
- "dev": true,
"dependencies": {
"@octokit/openapi-types-ghec": "^18.0.0",
"ajv": "^8.11.0",
@@ -5495,14 +5495,12 @@
"node_modules/@kie/act-js/node_modules/@octokit/openapi-types-ghec": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz",
- "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==",
- "dev": true
+ "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw=="
},
"node_modules/@kie/act-js/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5516,7 +5514,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -5525,7 +5522,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-1.1.0.tgz",
"integrity": "sha512-fD+utlOiyZSOutOcXL0G9jfjbtvOO44PLUyTfgfkrm1+575R/dbvU6AcJfjc1DtHeRv7FC7f4ebyU+a1wgL6CA==",
- "dev": true,
"dependencies": {
"@octokit/openapi-types-ghec": "^14.0.0",
"ajv": "^8.11.0",
@@ -5541,7 +5537,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -5555,7 +5550,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -5564,7 +5558,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
- "dev": true,
"dependencies": {
"debug": "^4.1.1"
}
@@ -5572,8 +5565,7 @@
"node_modules/@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
- "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
- "dev": true
+ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.4",
@@ -5952,7 +5944,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -5966,7 +5957,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -5976,7 +5966,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -6107,8 +6096,7 @@
"node_modules/@octokit/openapi-types-ghec": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-14.0.0.tgz",
- "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA==",
- "dev": true
+ "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA=="
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "3.1.0",
@@ -19888,9 +19876,9 @@
"license": "MIT"
},
"node_modules/@types/semver": {
- "version": "7.5.0",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
- "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==",
"dev": true
},
"node_modules/@types/serve-index": {
@@ -21213,7 +21201,6 @@
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz",
"integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==",
- "dev": true,
"engines": {
"node": ">=6.0"
}
@@ -21844,7 +21831,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "dev": true,
"license": "MIT"
},
"node_modules/array-includes": {
@@ -23287,7 +23273,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.2.tgz",
"integrity": "sha512-jxJ0PbXR8eQyPlExCvCs3JFnikvs1Yp4gUJt6nmgathdOwvur+q22KWC3h20gvWl4T/14DXKj2IlkJwwZkZPOw==",
- "dev": true,
"dependencies": {
"cmd-shim": "^6.0.0",
"npm-normalize-package-bin": "^3.0.0",
@@ -23302,7 +23287,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
"engines": {
"node": ">=14"
},
@@ -23314,7 +23298,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
- "dev": true,
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -23397,7 +23380,6 @@
},
"node_modules/body-parser": {
"version": "1.20.0",
- "dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -23422,7 +23404,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -23432,7 +23413,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -23442,7 +23422,6 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
@@ -23455,7 +23434,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
"license": "MIT"
},
"node_modules/bonjour-service": {
@@ -24531,7 +24509,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -24960,7 +24937,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.1.tgz",
"integrity": "sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q==",
- "dev": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -25403,7 +25379,6 @@
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -25416,7 +25391,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -25435,7 +25409,6 @@
},
"node_modules/content-type": {
"version": "1.0.4",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -25450,7 +25423,6 @@
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -25460,7 +25432,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/copy-concurrently": {
@@ -28404,9 +28375,9 @@
}
},
"node_modules/eslint-config-expensify": {
- "version": "2.0.39",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.39.tgz",
- "integrity": "sha512-DIxR3k99ZIDPE2NK+WLLRWpoDq06gTXdY8XZg9Etd1UqZ7fXm/Yz3/QkTxu7CH7UaXbCH3P4PTo023ULQGKOSw==",
+ "version": "2.0.42",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.42.tgz",
+ "integrity": "sha512-TNwbfIGjOp4EjT6HKEpp10mr6dkBNCNMTeMmpuQyS0Nqv1tRGJltoU3GFmUHJywrLkEmu21iC0NNMmoJ1XzmLg==",
"dev": true,
"dependencies": {
"@lwc/eslint-plugin-lwc": "^0.11.0",
@@ -30218,8 +30189,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
- "integrity": "sha512-mD9p6Qj8FfvLdb6JLXvF0UNqLN6ymssUU67Fm37zmK18hd1Abw+vR/pQkNpHR2iv+KRs8HdcyuZ0vaOec4VrFQ==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
+ "integrity": "sha512-/kXD/18YQJY/iWB5MOSN0ixB1mpxUA+NXEYgKjac1tJd+DoY3K6+bDeu++Qfqg26LCNfvjTkjkDGZVdGsJQ7Hw==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -30315,7 +30286,6 @@
},
"node_modules/express": {
"version": "4.18.1",
- "dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@@ -30358,7 +30328,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -30368,14 +30337,12 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
"license": "MIT"
},
"node_modules/express/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -30577,7 +30544,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
- "dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -30662,7 +30628,6 @@
},
"node_modules/fastq": {
"version": "1.13.0",
- "dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -30896,7 +30861,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
"integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
@@ -30915,7 +30879,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
@@ -30925,7 +30888,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true,
"license": "MIT"
},
"node_modules/find-babel-config": {
@@ -31112,7 +31074,6 @@
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
"integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
- "dev": true,
"funding": [
{
"type": "individual",
@@ -31373,23 +31334,22 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fraction.js": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
- "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.4.tgz",
+ "integrity": "sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==",
"dev": true,
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
- "url": "https://www.patreon.com/infusion"
+ "url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fragment-cache": {
@@ -31450,7 +31410,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
@@ -31715,7 +31674,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "devOptional": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -33535,7 +33493,6 @@
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -33860,7 +33817,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -33936,7 +33892,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -37363,7 +37318,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -38283,20 +38237,20 @@
}
},
"node_modules/mathjs": {
- "version": "11.8.0",
- "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.8.0.tgz",
- "integrity": "sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==",
+ "version": "11.11.2",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.11.2.tgz",
+ "integrity": "sha512-SL4/0Fxm9X4sgovUpJTeyVeZ2Ifnk4tzLPTYWDyR3AIx9SabnXYqtCkyJtmoF3vZrDPKGkLvrhbIL4YN2YbXLQ==",
"dev": true,
"dependencies": {
- "@babel/runtime": "^7.21.0",
+ "@babel/runtime": "^7.23.1",
"complex.js": "^2.1.1",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
- "fraction.js": "^4.2.0",
+ "fraction.js": "4.3.4",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
- "typed-function": "^4.1.0"
+ "typed-function": "^4.1.1"
},
"bin": {
"mathjs": "bin/cli.js"
@@ -38879,7 +38833,6 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -39119,7 +39072,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
"integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
- "dev": true,
"license": "MIT"
},
"node_modules/merge-options": {
@@ -39155,7 +39107,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -39165,7 +39116,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -41062,7 +41012,6 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -41113,7 +41062,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
@@ -41210,7 +41158,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true,
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
@@ -41446,7 +41393,6 @@
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.3.tgz",
"integrity": "sha512-z+KUlILy9SK/RjpeXDiDUEAq4T94ADPHE3qaRkf66mpEhzc/ytOMm3Bwdrbq6k1tMWkbdujiKim3G2tfQARuJw==",
- "dev": true,
"dependencies": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
@@ -41676,7 +41622,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
"integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
- "dev": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -42888,7 +42833,6 @@
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/path-type": {
@@ -43605,7 +43549,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
- "dev": true,
"engines": {
"node": ">= 8"
}
@@ -43633,7 +43576,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -43921,7 +43863,6 @@
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
"integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
- "dev": true,
"dependencies": {
"side-channel": "^1.0.4"
},
@@ -43982,7 +43923,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -44064,7 +44004,6 @@
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
- "dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
@@ -44080,7 +44019,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -44090,7 +44028,6 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
@@ -45942,7 +45879,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz",
"integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==",
- "dev": true,
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
@@ -46189,14 +46125,14 @@
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="
},
"node_modules/reassure": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.9.0.tgz",
- "integrity": "sha512-FIf0GPchyPGItsrW5Wwff/NWVrfOcCUuJJSs4Nur6iRdQt8yvmCpcba4UyemdZ1KaFTIW1gKbAV3u2tuA7zmtQ==",
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.10.1.tgz",
+ "integrity": "sha512-+GANr5ojh32NZu1YGfa6W8vIJm3iOIZJUvXT5Gc9fQyre7okYsCzyBq9WsHbnAQDjNq1g9SsM/4bwcVET9OIqA==",
"dev": true,
"dependencies": {
- "@callstack/reassure-cli": "0.9.0",
+ "@callstack/reassure-cli": "0.10.0",
"@callstack/reassure-danger": "0.1.1",
- "@callstack/reassure-measure": "0.5.0"
+ "@callstack/reassure-measure": "0.6.0"
}
},
"node_modules/recast": {
@@ -47097,7 +47033,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -47192,7 +47127,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -47927,7 +47861,6 @@
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.0.tgz",
"integrity": "sha512-hyH2p9Ptxjf/xPuL7HfXbpYt9gKhC1yWDh3KYIAYJJePAKV7AEjLN4xhp7lozOdNiaJ9jlVvAbBymVlcS2jRiA==",
- "dev": true,
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
@@ -49131,7 +49064,6 @@
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
"integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=0.10.0"
}
@@ -49470,7 +49402,6 @@
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
"integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
- "dev": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -49487,7 +49418,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -50319,7 +50249,6 @@
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
@@ -50391,9 +50320,9 @@
}
},
"node_modules/typed-function": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz",
- "integrity": "sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.1.tgz",
+ "integrity": "sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==",
"dev": true,
"engines": {
"node": ">= 14"
@@ -53266,9 +53195,9 @@
}
},
"node_modules/zod": {
- "version": "3.21.4",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
- "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+ "version": "3.22.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
+ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -54803,11 +54732,18 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
},
"@babel/runtime": {
- "version": "7.22.3",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz",
- "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==",
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
+ "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"requires": {
- "regenerator-runtime": "^0.13.11"
+ "regenerator-runtime": "^0.14.0"
+ },
+ "dependencies": {
+ "regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ }
}
},
"@babel/template": {
@@ -54871,13 +54807,13 @@
"dev": true
},
"@callstack/reassure-cli": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.9.0.tgz",
- "integrity": "sha512-auoxqyilxkT5mDdEPJqRRY+ZGlrihJjFQpopcFd/15ng76OPVka3L48RMEY2wXkFXLaOOs6enNGb596jYPuEtQ==",
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.10.0.tgz",
+ "integrity": "sha512-CYgOGOAWcFgA2GrJw6RJAvImCpHCpPbtPoYMDol7esjlRCX2QwIKG7/9byq47hML57w94fhFAa76KD84YlgMBg==",
"dev": true,
"requires": {
- "@callstack/reassure-compare": "0.5.0",
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-compare": "0.6.0",
+ "@callstack/reassure-logger": "0.3.1",
"chalk": "4.1.2",
"simple-git": "^3.16.0",
"yargs": "^17.6.2"
@@ -54973,12 +54909,12 @@
}
},
"@callstack/reassure-compare": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.5.0.tgz",
- "integrity": "sha512-3sBeJ/+Hxjdb01KVb8LszO1kcJ8TXcrVnerUj+LYn2dkBOohAMqGYaOvCeoWsVEHJ+MIOzmvAGBJQRu69RoJdQ==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.6.0.tgz",
+ "integrity": "sha512-P3nmv36CJrQSXg0+z6EuEV/0xAbvxWbAZ7diQHnkbvqk2z8PKRXpkcthrRUpe02wLewa0MLxBUJwLenFnhxIRg==",
"dev": true,
"requires": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"markdown-builder": "^0.9.0",
"markdown-table": "^2.0.0",
"zod": "^3.20.2"
@@ -54991,9 +54927,9 @@
"dev": true
},
"@callstack/reassure-logger": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.0.tgz",
- "integrity": "sha512-JX5o+8qkIbIRL+cQn9XlQYdv9p/3L6J70zZX6NYi9j0VrSS9PZIRfo8ujMdLSqUNV6HZN1ay59RzuncLjVu0aQ==",
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.1.tgz",
+ "integrity": "sha512-IUsNrxVMdt0zgD2IN2snGVGUG8Yc6F3SWaPF8RXUn8qi7XZuYC6WevEL+mIKmlbTTa7qlX9brkn0pJoXAjfcPQ==",
"dev": true,
"requires": {
"chalk": "4.1.2"
@@ -55051,12 +54987,12 @@
}
},
"@callstack/reassure-measure": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.5.0.tgz",
- "integrity": "sha512-KwlmNYcspBOp7FIw6XOz5O9mnKB4cWCCyM6vG4nFUPHSWQ6yVdRkawVvoPIV5qJ2hw7zCzdtqRrLWQSTF4eKlg==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.6.0.tgz",
+ "integrity": "sha512-phXY5DAtKhnu8dA2pmnl+pqFOfrVEFFDJOi4AnObwIcpDSn3IUXgNSe7rSi+JP/mXNWMLoUS8rnH4iIFDyf7qQ==",
"dev": true,
"requires": {
- "@callstack/reassure-logger": "0.3.0",
+ "@callstack/reassure-logger": "0.3.1",
"mathjs": "^11.5.0"
}
},
@@ -56877,7 +56813,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@kie/act-js/-/act-js-2.3.0.tgz",
"integrity": "sha512-Q9k0b05uA46jXKWmVfoGDW+0xsCcE7QPiHi8IH7h41P36DujHKBj4k28RCeIEx3IwUCxYHKwubN8DH4Vzc9XcA==",
- "dev": true,
"requires": {
"@kie/mock-github": "^2.0.0",
"adm-zip": "^0.5.10",
@@ -56893,7 +56828,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.0.tgz",
"integrity": "sha512-od6UyICJYKMnz9HgEWCQAFT/JsCpKkLp+JQH8fV23tf+ZmmQI1dK3C20k6aO5uJhAHA0yOcFtbKFVF4+8i3DTg==",
- "dev": true,
"requires": {
"@octokit/openapi-types-ghec": "^18.0.0",
"ajv": "^8.11.0",
@@ -56908,14 +56842,12 @@
"@octokit/openapi-types-ghec": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz",
- "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==",
- "dev": true
+ "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw=="
},
"fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -56925,8 +56857,7 @@
"totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
- "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
}
}
},
@@ -56934,7 +56865,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-1.1.0.tgz",
"integrity": "sha512-fD+utlOiyZSOutOcXL0G9jfjbtvOO44PLUyTfgfkrm1+575R/dbvU6AcJfjc1DtHeRv7FC7f4ebyU+a1wgL6CA==",
- "dev": true,
"requires": {
"@octokit/openapi-types-ghec": "^14.0.0",
"ajv": "^8.11.0",
@@ -56950,7 +56880,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
- "dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -56960,8 +56889,7 @@
"totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
- "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
- "dev": true
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
}
}
},
@@ -56969,7 +56897,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
- "dev": true,
"requires": {
"debug": "^4.1.1"
}
@@ -56977,8 +56904,7 @@
"@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
- "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
- "dev": true
+ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="
},
"@leichtgewicht/ip-codec": {
"version": "2.0.4",
@@ -57260,7 +57186,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
"requires": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -57269,14 +57194,12 @@
"@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
},
"@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
"requires": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -57388,8 +57311,7 @@
"@octokit/openapi-types-ghec": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-14.0.0.tgz",
- "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA==",
- "dev": true
+ "integrity": "sha512-xhd9oEvn2aroGn+sk09Ptx/76Y7aKU0EIgHukHPCU1+rGJreO36baEEk6k8ZPblieHNM39FcykJQmtDrETm0KA=="
},
"@octokit/plugin-paginate-rest": {
"version": "3.1.0",
@@ -67333,9 +67255,9 @@
"integrity": "sha512-AnxLHewubLVzoF/A4qdxBGHCKifw8cY32iro3DQX9TPcetE95zBeVt3jnsvtvAUf1vwzMfwzp4t/L2yqPlnjkQ=="
},
"@types/semver": {
- "version": "7.5.0",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz",
- "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==",
"dev": true
},
"@types/serve-index": {
@@ -68302,8 +68224,7 @@
"adm-zip": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz",
- "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==",
- "dev": true
+ "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="
},
"agent-base": {
"version": "6.0.2",
@@ -68781,8 +68702,7 @@
"array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
- "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "dev": true
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"array-includes": {
"version": "3.1.6",
@@ -69838,7 +69758,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.2.tgz",
"integrity": "sha512-jxJ0PbXR8eQyPlExCvCs3JFnikvs1Yp4gUJt6nmgathdOwvur+q22KWC3h20gvWl4T/14DXKj2IlkJwwZkZPOw==",
- "dev": true,
"requires": {
"cmd-shim": "^6.0.0",
"npm-normalize-package-bin": "^3.0.0",
@@ -69849,14 +69768,12 @@
"signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="
},
"write-file-atomic": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
- "dev": true,
"requires": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -69929,7 +69846,6 @@
},
"body-parser": {
"version": "1.20.0",
- "dev": true,
"requires": {
"bytes": "3.1.2",
"content-type": "~1.0.4",
@@ -69948,14 +69864,12 @@
"bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -69964,7 +69878,6 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
@@ -69972,8 +69885,7 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}
}
},
@@ -70740,8 +70652,7 @@
"chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
- "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
- "dev": true
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
},
"chrome-trace-event": {
"version": "1.0.3",
@@ -71042,8 +70953,7 @@
"cmd-shim": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.1.tgz",
- "integrity": "sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q==",
- "dev": true
+ "integrity": "sha512-S9iI9y0nKR4hwEQsVWpyxld/6kRfGepGfzff83FcaiEBpmvlbA2nnGe7Cylgrx2f/p1P5S5wpRm9oL8z1PbS3Q=="
},
"co": {
"version": "4.6.0",
@@ -71377,7 +71287,6 @@
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
- "dev": true,
"requires": {
"safe-buffer": "5.2.1"
},
@@ -71385,14 +71294,12 @@
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
"content-type": {
- "version": "1.0.4",
- "dev": true
+ "version": "1.0.4"
},
"convert-source-map": {
"version": "1.9.0",
@@ -71402,14 +71309,12 @@
"cookie": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
- "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==",
- "dev": true
+ "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw=="
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
- "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
- "dev": true
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"copy-concurrently": {
"version": "1.0.5",
@@ -73704,9 +73609,9 @@
}
},
"eslint-config-expensify": {
- "version": "2.0.39",
- "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.39.tgz",
- "integrity": "sha512-DIxR3k99ZIDPE2NK+WLLRWpoDq06gTXdY8XZg9Etd1UqZ7fXm/Yz3/QkTxu7CH7UaXbCH3P4PTo023ULQGKOSw==",
+ "version": "2.0.42",
+ "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.42.tgz",
+ "integrity": "sha512-TNwbfIGjOp4EjT6HKEpp10mr6dkBNCNMTeMmpuQyS0Nqv1tRGJltoU3GFmUHJywrLkEmu21iC0NNMmoJ1XzmLg==",
"dev": true,
"requires": {
"@lwc/eslint-plugin-lwc": "^0.11.0",
@@ -74869,9 +74774,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
- "integrity": "sha512-mD9p6Qj8FfvLdb6JLXvF0UNqLN6ymssUU67Fm37zmK18hd1Abw+vR/pQkNpHR2iv+KRs8HdcyuZ0vaOec4VrFQ==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
+ "integrity": "sha512-/kXD/18YQJY/iWB5MOSN0ixB1mpxUA+NXEYgKjac1tJd+DoY3K6+bDeu++Qfqg26LCNfvjTkjkDGZVdGsJQ7Hw==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -74943,7 +74848,6 @@
},
"express": {
"version": "4.18.1",
- "dev": true,
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -74982,7 +74886,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -74990,14 +74893,12 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "dev": true
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
@@ -75137,7 +75038,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
- "dev": true,
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -75196,7 +75096,6 @@
},
"fastq": {
"version": "1.13.0",
- "dev": true,
"requires": {
"reusify": "^1.0.4"
}
@@ -75375,7 +75274,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
"integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
- "dev": true,
"requires": {
"debug": "2.6.9",
"encodeurl": "~1.0.2",
@@ -75390,7 +75288,6 @@
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -75398,8 +75295,7 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "dev": true
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}
}
},
@@ -75537,8 +75433,7 @@
"follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
- "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
- "dev": true
+ "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q=="
},
"for-each": {
"version": "0.3.3",
@@ -75704,13 +75599,12 @@
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
- "dev": true
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
},
"fraction.js": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
- "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.4.tgz",
+ "integrity": "sha512-pwiTgt0Q7t+GHZA4yaLjObx4vXmmdcS0iSJ19o8d/goUGgItX9UZWKWNnLHehxviD8wU2IWRsnR8cD5+yOJP2Q==",
"dev": true
},
"fragment-cache": {
@@ -75757,7 +75651,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
- "dev": true,
"requires": {
"minipass": "^3.0.0"
}
@@ -75940,7 +75833,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "devOptional": true,
"requires": {
"is-glob": "^4.0.1"
}
@@ -77230,8 +77122,7 @@
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
- "dev": true
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
},
"is-absolute-url": {
"version": "3.0.3",
@@ -77426,8 +77317,7 @@
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "devOptional": true
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-finalizationregistry": {
"version": "1.0.2",
@@ -77474,7 +77364,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "devOptional": true,
"requires": {
"is-extglob": "^2.1.1"
}
@@ -79885,8 +79774,7 @@
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
- "dev": true
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"json5": {
"version": "2.2.3",
@@ -80554,20 +80442,20 @@
}
},
"mathjs": {
- "version": "11.8.0",
- "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.8.0.tgz",
- "integrity": "sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==",
+ "version": "11.11.2",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.11.2.tgz",
+ "integrity": "sha512-SL4/0Fxm9X4sgovUpJTeyVeZ2Ifnk4tzLPTYWDyR3AIx9SabnXYqtCkyJtmoF3vZrDPKGkLvrhbIL4YN2YbXLQ==",
"dev": true,
"requires": {
- "@babel/runtime": "^7.21.0",
+ "@babel/runtime": "^7.23.1",
"complex.js": "^2.1.1",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
- "fraction.js": "^4.2.0",
+ "fraction.js": "4.3.4",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
- "typed-function": "^4.1.0"
+ "typed-function": "^4.1.1"
}
},
"md5.js": {
@@ -81009,8 +80897,7 @@
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
- "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
- "dev": true
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
},
"mem": {
"version": "8.1.1",
@@ -81185,8 +81072,7 @@
"merge-descriptors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
- "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
- "dev": true
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
},
"merge-options": {
"version": "3.0.4",
@@ -81212,14 +81098,12 @@
"merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
- "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
- "dev": true
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
"methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
- "dev": true
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
},
"metro": {
"version": "0.76.8",
@@ -82582,7 +82466,6 @@
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
- "dev": true,
"requires": {
"yallist": "^4.0.0"
}
@@ -82618,7 +82501,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
- "dev": true,
"requires": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
@@ -82692,8 +82574,7 @@
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
},
"mock-fs": {
"version": "4.14.0",
@@ -82865,7 +82746,6 @@
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.3.tgz",
"integrity": "sha512-z+KUlILy9SK/RjpeXDiDUEAq4T94ADPHE3qaRkf66mpEhzc/ytOMm3Bwdrbq6k1tMWkbdujiKim3G2tfQARuJw==",
- "dev": true,
"requires": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
@@ -83041,8 +82921,7 @@
"npm-normalize-package-bin": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
- "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
- "dev": true
+ "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ=="
},
"npm-run-path": {
"version": "4.0.1",
@@ -83888,8 +83767,7 @@
"path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
- "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
- "dev": true
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
},
"path-type": {
"version": "4.0.0",
@@ -84394,8 +84272,7 @@
"propagate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
- "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
- "dev": true
+ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag=="
},
"property-information": {
"version": "5.6.0",
@@ -84415,7 +84292,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
- "dev": true,
"requires": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -84632,7 +84508,6 @@
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz",
"integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==",
- "dev": true,
"requires": {
"side-channel": "^1.0.4"
}
@@ -84673,8 +84548,7 @@
"queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
- "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
- "dev": true
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
"quick-lru": {
"version": "5.1.1",
@@ -84724,7 +84598,6 @@
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
"integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
- "dev": true,
"requires": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
@@ -84735,14 +84608,12 @@
"bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
- "dev": true
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
- "dev": true,
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
@@ -85993,8 +85864,7 @@
"read-cmd-shim": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz",
- "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==",
- "dev": true
+ "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q=="
},
"read-config-file": {
"version": "6.3.2",
@@ -86180,14 +86050,14 @@
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="
},
"reassure": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.9.0.tgz",
- "integrity": "sha512-FIf0GPchyPGItsrW5Wwff/NWVrfOcCUuJJSs4Nur6iRdQt8yvmCpcba4UyemdZ1KaFTIW1gKbAV3u2tuA7zmtQ==",
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.10.1.tgz",
+ "integrity": "sha512-+GANr5ojh32NZu1YGfa6W8vIJm3iOIZJUvXT5Gc9fQyre7okYsCzyBq9WsHbnAQDjNq1g9SsM/4bwcVET9OIqA==",
"dev": true,
"requires": {
- "@callstack/reassure-cli": "0.9.0",
+ "@callstack/reassure-cli": "0.10.0",
"@callstack/reassure-danger": "0.1.1",
- "@callstack/reassure-measure": "0.5.0"
+ "@callstack/reassure-measure": "0.6.0"
}
},
"recast": {
@@ -86849,8 +86719,7 @@
"reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
- "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
- "dev": true
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
},
"right-align": {
"version": "0.1.3",
@@ -86915,7 +86784,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
- "dev": true,
"requires": {
"queue-microtask": "^1.2.2"
}
@@ -87461,7 +87329,6 @@
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.0.tgz",
"integrity": "sha512-hyH2p9Ptxjf/xPuL7HfXbpYt9gKhC1yWDh3KYIAYJJePAKV7AEjLN4xhp7lozOdNiaJ9jlVvAbBymVlcS2jRiA==",
- "dev": true,
"requires": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
@@ -88612,7 +88479,6 @@
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
"integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
- "dev": true,
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -88625,8 +88491,7 @@
"minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
- "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
- "dev": true
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="
}
}
},
@@ -89223,7 +89088,6 @@
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
- "dev": true,
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
@@ -89273,9 +89137,9 @@
}
},
"typed-function": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz",
- "integrity": "sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.1.tgz",
+ "integrity": "sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==",
"dev": true
},
"typedarray": {
@@ -91301,9 +91165,9 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
"zod": {
- "version": "3.21.4",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
- "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
+ "version": "3.22.4",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
+ "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"dev": true
},
"zwitch": {
diff --git a/package.json b/package.json
index c152190fd47a..18886571cefc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.84-3",
+ "version": "1.3.90-2",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -49,7 +49,9 @@
"analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production",
"symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map",
"symbolicate:ios": "npx metro-symbolicate main.jsbundle.map",
- "test:e2e": "node tests/e2e/testRunner.js --development",
+ "test:e2e:main": "node tests/e2e/testRunner.js --development --branch main --skipCheckout",
+ "test:e2e:delta": "node tests/e2e/testRunner.js --development --branch main --label delta --skipCheckout",
+ "test:e2e:compare": "node tests/e2e/merge.js",
"gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh",
"workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh",
"workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js"
@@ -88,7 +90,6 @@
"@types/node": "^18.14.0",
"@ua/react-native-airship": "^15.2.6",
"awesome-phonenumber": "^5.4.0",
- "babel-plugin-transform-remove-console": "^6.9.4",
"babel-polyfill": "^6.26.0",
"canvas-size": "^1.2.6",
"core-js": "^3.32.0",
@@ -96,7 +97,7 @@
"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#009c2ab79bf7ddeab0eea7a3a4c0d9cc4277c34b",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#bdbdf44825658500ba581d3e86237d7b8996cc2e",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
@@ -183,8 +184,6 @@
"@babel/runtime": "^7.20.0",
"@electron/notarize": "^1.2.3",
"@jest/globals": "^29.5.0",
- "@kie/act-js": "^2.0.1",
- "@kie/mock-github": "^1.0.0",
"@octokit/core": "4.0.4",
"@octokit/plugin-paginate-rest": "3.1.0",
"@octokit/plugin-throttling": "4.1.0",
@@ -216,7 +215,7 @@
"@types/react-dom": "^18.2.4",
"@types/react-pdf": "^5.7.2",
"@types/react-test-renderer": "^18.0.0",
- "@types/semver": "^7.5.0",
+ "@types/semver": "^7.5.4",
"@types/setimmediate": "^1.0.2",
"@types/underscore": "^1.11.5",
"@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -241,7 +240,7 @@
"electron-builder": "24.6.4",
"eslint": "^7.6.0",
"eslint-config-airbnb-typescript": "^17.1.0",
- "eslint-config-expensify": "^2.0.39",
+ "eslint-config-expensify": "^2.0.42",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^24.1.0",
"eslint-plugin-jsdoc": "^46.2.6",
@@ -267,7 +266,7 @@
"react-native-performance-flipper-reporter": "^2.0.0",
"react-native-svg-transformer": "^1.0.0",
"react-test-renderer": "18.2.0",
- "reassure": "^0.9.0",
+ "reassure": "^0.10.1",
"setimmediate": "^1.0.5",
"shellcheck": "^1.1.0",
"style-loader": "^2.0.0",
diff --git a/src/CONFIG.ts b/src/CONFIG.ts
index c02ed8065836..8b1dab5b3d71 100644
--- a/src/CONFIG.ts
+++ b/src/CONFIG.ts
@@ -64,6 +64,7 @@ export default {
CONCIERGE_URL_PATHNAME: 'concierge/',
DEVPORTAL_URL_PATHNAME: '_devportal/',
CONCIERGE_URL: `${expensifyURL}concierge/`,
+ SAML_URL: `${expensifyURL}authentication/saml/login`,
},
IS_IN_PRODUCTION: Platform.OS === 'web' ? process.env.NODE_ENV === 'production' : !__DEV__,
IS_IN_STAGING: ENVIRONMENT === CONST.ENVIRONMENT.STAGING,
diff --git a/src/CONST.ts b/src/CONST.ts
index 3760c93ee7e2..9d912a4df20e 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -79,6 +79,10 @@ const CONST = {
RESERVED_FIRST_NAMES: ['Expensify', 'Concierge'],
},
+ LEGAL_NAME: {
+ MAX_LENGTH: 40,
+ },
+
PULL_REQUEST_NUMBER,
MERCHANT_NAME_MAX_LENGTH: 255,
@@ -120,7 +124,16 @@ const CONST = {
VIEW_HEIGHT: 275,
},
MONEY_REPORT: {
- MIN_HEIGHT: 280,
+ SMALL_SCREEN: {
+ IMAGE_HEIGHT: 300,
+ CONTAINER_MINHEIGHT: 280,
+ VIEW_HEIGHT: 220,
+ },
+ WIDE_SCREEN: {
+ IMAGE_HEIGHT: 450,
+ CONTAINER_MINHEIGHT: 280,
+ VIEW_HEIGHT: 275,
+ },
},
},
@@ -243,6 +256,7 @@ const CONST = {
CUSTOM_STATUS: 'customStatus',
NEW_DOT_CATEGORIES: 'newDotCategories',
NEW_DOT_TAGS: 'newDotTags',
+ NEW_DOT_SAML: 'newDotSAML',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -516,6 +530,8 @@ const CONST = {
DELETE_TAG: 'POLICYCHANGELOG_DELETE_TAG',
IMPORT_CUSTOM_UNIT_RATES: 'POLICYCHANGELOG_IMPORT_CUSTOM_UNIT_RATES',
IMPORT_TAGS: 'POLICYCHANGELOG_IMPORT_TAGS',
+ INVITE_TO_ROOM: 'POLICYCHANGELOG_INVITETOROOM',
+ REMOVE_FROM_ROOM: 'POLICYCHANGELOG_REMOVEFROMROOM',
SET_AUTOREIMBURSEMENT: 'POLICYCHANGELOG_SET_AUTOREIMBURSEMENT',
SET_AUTO_JOIN: 'POLICYCHANGELOG_SET_AUTO_JOIN',
SET_CATEGORY_NAME: 'POLICYCHANGELOG_SET_CATEGORY_NAME',
@@ -550,6 +566,11 @@ const CONST = {
UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED',
UPDATE_TIME_RATE: 'POLICYCHANGELOG_UPDATE_TIME_RATE',
},
+ ROOMCHANGELOG: {
+ INVITE_TO_ROOM: 'INVITETOROOM',
+ REMOVE_FROM_ROOM: 'REMOVEFROMROOM',
+ JOIN_ROOM: 'JOINROOM',
+ },
},
},
ARCHIVE_REASON: {
@@ -1016,8 +1037,10 @@ const CONST = {
ACTIVATE: 'ActivateStep',
},
TIER_NAME: {
+ PLATINUM: 'PLATINUM',
GOLD: 'GOLD',
SILVER: 'SILVER',
+ BRONZE: 'BRONZE',
},
WEB_MESSAGE_TYPE: {
STATEMENT: 'STATEMENT_NAVIGATE',
@@ -1059,6 +1082,12 @@ const CONST = {
},
},
+ KYC_WALL_SOURCE: {
+ REPORT: 'REPORT', // The user attempted to pay a money request
+ ENABLE_WALLET: 'ENABLE_WALLET', // The user clicked on the `Enable wallet` button on the Wallet page
+ TRANSFER_BALANCE: 'TRANSFER_BALANCE', // The user attempted to transfer their wallet balance to their bank account or debit card
+ },
+
OS: {
WINDOWS: 'Windows',
MAC_OS: PLATFORM_OS_MACOS,
@@ -1096,7 +1125,7 @@ const CONST = {
EXPENSIFY: 'Expensify',
VBBA: 'ACH',
},
- MONEY_REQUEST_TYPE: {
+ TYPE: {
SEND: 'send',
SPLIT: 'split',
REQUEST: 'request',
@@ -1234,10 +1263,11 @@ const CONST = {
BANK: 'Expensify Card',
FRAUD_TYPES: {
DOMAIN: 'domain',
- INDIVIDUAL: 'individal',
+ INDIVIDUAL: 'individual',
NONE: 'none',
},
STATE: {
+ STATE_NOT_ISSUED: 2,
OPEN: 3,
NOT_ACTIVATED: 4,
STATE_DEACTIVATED: 5,
@@ -1272,6 +1302,8 @@ const CONST = {
CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/,
ROOM_NAME: /^#[\p{Ll}0-9-]{1,80}$/u,
+ // eslint-disable-next-line max-len, no-misleading-character-class
+ EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
// eslint-disable-next-line max-len, no-misleading-character-class
EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu,
@@ -1290,18 +1322,26 @@ const CONST = {
HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/,
HAS_AT_MOST_TWO_AT_SIGNS: /^@[^@]*@?[^@]*$/,
- SPECIAL_CHAR_OR_EMOJI:
- // eslint-disable-next-line no-misleading-character-class
- /[\n\s,/?"{}[\]()&_~^%\\;`$=#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
+ SPECIAL_CHAR: /[,/?"{}[\]()&^%;`$=#<>!*]/g,
- SPACE_OR_EMOJI:
- // eslint-disable-next-line no-misleading-character-class
- /(\s+|(?:[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3)+)/gu,
+ get SPECIAL_CHAR_OR_EMOJI() {
+ return new RegExp(`[~\\n\\s]|(_\\b(?!$))|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
+ },
+
+ get SPACE_OR_EMOJI() {
+ return new RegExp(`(\\s+|(?:${this.EMOJI.source})+)`, 'gu');
+ },
+
+ // Define the regular expression pattern to find a potential end of a mention suggestion:
+ // It might be a space, a newline character, an emoji, or a special character (excluding underscores & tildes, which might be used in usernames)
+ get MENTION_BREAKER() {
+ return new RegExp(`[\\n\\s]|${this.SPECIAL_CHAR.source}|${this.EMOJI.source}`, 'gu');
+ },
// Define the regular expression pattern to match a string starting with an at sign and ending with a space or newline character
- MENTION_REPLACER:
- // eslint-disable-next-line no-misleading-character-class
- /^@[^\n\r]*?(?=$|[\s,/?"{}[\]()&^%\\;`$=#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3)/u,
+ get MENTION_REPLACER() {
+ return new RegExp(`^@[^\\n\\r]*?(?=$|\\s|${this.SPECIAL_CHAR.source}|${this.EMOJI.source})`, 'u');
+ },
MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/,
@@ -1411,6 +1451,7 @@ const CONST = {
REPORT_DETAILS_MENU_ITEM: {
SHARE_CODE: 'shareCode',
MEMBERS: 'member',
+ INVITE: 'invite',
SETTINGS: 'settings',
LEAVE_ROOM: 'leaveRoom',
WELCOME_MESSAGE: 'welcomeMessage',
@@ -2738,6 +2779,8 @@ const CONST = {
SCROLLING: 'scrolling',
},
+ CHAT_HEADER_LOADER_HEIGHT: 36,
+
HORIZONTAL_SPACER: {
DEFAULT_BORDER_BOTTOM_WIDTH: 1,
DEFAULT_MARGIN_VERTICAL: 8,
@@ -2745,6 +2788,11 @@ const CONST = {
HIDDEN_BORDER_BOTTOM_WIDTH: 0,
},
+ LIST_COMPONENTS: {
+ HEADER: 'header',
+ FOOTER: 'footer',
+ },
+
GLOBAL_NAVIGATION_OPTION: {
HOME: 'home',
CHATS: 'chats',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index d9ea3488f85f..8a3df3153326 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -1,4 +1,5 @@
import {ValueOf} from 'type-fest';
+import {OnyxEntry} from 'react-native-onyx/lib/types';
import DeepValueOf from './types/utils/DeepValueOf';
import * as OnyxTypes from './types/onyx';
import CONST from './CONST';
@@ -235,13 +236,15 @@ const ONYXKEYS = {
DOWNLOAD: 'download_',
POLICY: 'policy_',
POLICY_MEMBERS: 'policyMembers_',
+ POLICY_DRAFTS: 'policyDrafts_',
+ POLICY_MEMBERS_DRAFTS: 'policyMembersDrafts_',
POLICY_CATEGORIES: 'policyCategories_',
POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_',
POLICY_TAGS: 'policyTags_',
POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
REPORT: 'report_',
- // REPORT_METADATA is a perf optimization used to hold loading states (isLoadingReportActions, isLoadingMoreReportActions).
+ // 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
// directly on the report caused a lot of unnecessary re-renders
REPORT_METADATA: 'reportMetadata_',
@@ -257,6 +260,7 @@ const ONYXKEYS = {
TRANSACTION: 'transactions_',
SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_',
PRIVATE_NOTES_DRAFT: 'privateNotesDraft_',
+ NEXT_STEP: 'reportNextStep_',
// Manual request tab selector
SELECTED_TAB: 'selectedTab_',
@@ -296,6 +300,7 @@ const ONYXKEYS = {
PRIVATE_NOTES_FORM: 'privateNotesForm',
I_KNOW_A_TEACHER_FORM: 'iKnowTeacherForm',
INTRO_SCHOOL_PRINCIPAL_FORM: 'introSchoolPrincipalForm',
+ REPORT_PHYSICAL_CARD_FORM: 'requestPhysicalCardForm',
REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm',
},
} as const;
@@ -386,7 +391,7 @@ type OnyxValues = {
[ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record;
[ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report;
[ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata;
- [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportAction;
+ [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions;
[ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: string;
[ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions;
[ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string;
@@ -427,7 +432,10 @@ type OnyxValues = {
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
};
+type OnyxKeyValue = OnyxEntry;
+
export default ONYXKEYS;
-export type {OnyxKey, OnyxCollectionKey, OnyxValues};
+export type {OnyxKey, OnyxCollectionKey, OnyxValues, OnyxKeyValue};
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 398b9ac6ba4f..bcc4685368cb 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -5,6 +5,17 @@ import CONST from './CONST';
* This is a file containing constants for all of the routes we want to be able to go to
*/
+/**
+ * This is a file containing constants for all of the routes we want to be able to go to
+ * Returns the URL with an encoded URI component for the backTo param which can be added to the end of URLs
+ * @param backTo
+ * @returns
+ */
+function getUrlWithBackToParam(url: string, backTo?: string): string {
+ const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : '';
+ return url + backToParam;
+}
+
export default {
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 */
@@ -20,10 +31,7 @@ export default {
},
PROFILE: {
route: 'a/:accountID',
- getRoute: (accountID: string | number, backTo = '') => {
- const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : '';
- return `a/${accountID}${backToParam}`;
- },
+ getRoute: (accountID: string | number, backTo?: string) => getUrlWithBackToParam(`a/${accountID}`, backTo),
},
TRANSITION_BETWEEN_APPS: 'transition',
@@ -36,6 +44,8 @@ export default {
APPLE_SIGN_IN: 'sign-in-with-apple',
GOOGLE_SIGN_IN: 'sign-in-with-google',
DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect',
+ SAML_SIGN_IN: 'sign-in-with-saml',
+
// This is a special validation URL that will take the user to /workspace/new after validation. This is used
// when linking users from e.com in order to share a session in this app.
ENABLE_PAYMENTS: 'enable-payments',
@@ -47,10 +57,7 @@ export default {
BANK_ACCOUNT_PERSONAL: 'bank-account/personal',
BANK_ACCOUNT_WITH_STEP_TO_OPEN: {
route: 'bank-account/:stepToOpen?',
- getRoute: (stepToOpen = '', policyID = '', backTo = ''): string => {
- const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : '';
- return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`;
- },
+ getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo),
},
SETTINGS: 'settings',
@@ -71,22 +78,30 @@ export default {
SETTINGS_ABOUT: 'settings/about',
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
SETTINGS_WALLET: 'settings/wallet',
- SETTINGS_WALLET_DOMAINCARDS: {
+ SETTINGS_WALLET_DOMAINCARD: {
route: '/settings/wallet/card/:domain',
getRoute: (domain: string) => `/settings/wallet/card/${domain}`,
},
SETTINGS_REPORT_FRAUD: {
- route: '/settings/wallet/cards/:domain/report-virtual-fraud',
- getRoute: (domain: string) => `/settings/wallet/cards/${domain}/report-virtual-fraud`,
+ route: '/settings/wallet/card/:domain/report-virtual-fraud',
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`,
},
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`,
+ },
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`,
+ },
SETTINGS_WALLET_CARD_ACTIVATE: {
- route: 'settings/wallet/cards/:domain/activate',
- getRoute: (domain: string) => `settings/wallet/cards/${domain}/activate`,
+ route: 'settings/wallet/card/:domain/activate',
+ getRoute: (domain: string) => `settings/wallet/card/${domain}/activate`,
},
SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details',
SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name',
@@ -94,13 +109,7 @@ export default {
SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address',
SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: {
route: 'settings/profile/personal-details/address/country',
- getRoute: (country: string, backTo?: string) => {
- let route = `settings/profile/personal-details/address/country?country=${country}`;
- if (backTo) {
- route += `&backTo=${encodeURIComponent(backTo)}`;
- }
- return route;
- },
+ getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo),
},
SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods',
SETTINGS_CONTACT_METHOD_DETAILS: {
@@ -203,8 +212,16 @@ export default {
route: 'r/:reportID/notes/:accountID/edit',
getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`,
},
+ ROOM_MEMBERS: {
+ route: 'r/:reportID/members',
+ getRoute: (reportID: string) => `r/${reportID}/members`,
+ },
+ ROOM_INVITE: {
+ route: 'r/:reportID/invite',
+ getRoute: (reportID: string) => `r/${reportID}/invite`,
+ },
- // To see the available iouType, please refer to CONST.IOU.MONEY_REQUEST_TYPE
+ // To see the available iouType, please refer to CONST.IOU.TYPE
MONEY_REQUEST: {
route: ':iouType/new/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`,
@@ -286,6 +303,11 @@ export default {
I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher',
INTRO_SCHOOL_PRINCIPAL: 'teachersunite/intro-school-principal',
+ ERECEIPT: {
+ route: 'eReceipt/:transactionID',
+ getRoute: (transactionID: string) => `eReceipt/${transactionID}`,
+ },
+
WORKSPACE_NEW: 'workspace/new',
WORKSPACE_NEW_ROOM: 'workspace/new-room',
WORKSPACE_INITIAL: {
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 69f905e4a7a3..8ef787edec2e 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -2,17 +2,15 @@
* This is a file containing constants for all of the screen names. In most cases, we should use the routes for
* navigation. But there are situations where we may need to access screen names directly.
*/
-const PROTECTED_SCREENS = {
- HOME: 'Home',
- CONCIERGE: 'Concierge',
- REPORT_ATTACHMENTS: 'ReportAttachments',
-} as const;
-
export default {
- ...PROTECTED_SCREENS,
+ HOME: 'Home',
LOADING: 'Loading',
REPORT: 'Report',
+ REPORT_ATTACHMENTS: 'ReportAttachments',
NOT_FOUND: 'not-found',
+ TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps',
+ VALIDATE_LOGIN: 'ValidateLogin',
+ CONCIERGE: 'Concierge',
SETTINGS: {
ROOT: 'Settings_Root',
PREFERENCES: 'Settings_Preferences',
@@ -25,11 +23,10 @@ export default {
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
},
- TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps',
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
DESKTOP_SIGN_IN_REDIRECT: 'DesktopSignInRedirect',
- VALIDATE_LOGIN: 'ValidateLogin',
+ SAML_SIGN_IN: 'SAMLSignIn',
// Iframe screens from olddot
HOME_OLDDOT: 'Home_OLDDOT',
@@ -44,5 +41,3 @@ export default {
GROUPS_WORKSPACES_OLDDOT: 'GroupWorkspaces_OLDDOT',
CARDS_AND_DOMAINS_OLDDOT: 'CardsAndDomains_OLDDOT',
} as const;
-
-export {PROTECTED_SCREENS};
diff --git a/src/components/AddressSearch/CurrentLocationButton.js b/src/components/AddressSearch/CurrentLocationButton.js
new file mode 100644
index 000000000000..893ec031ab7f
--- /dev/null
+++ b/src/components/AddressSearch/CurrentLocationButton.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {Text} from 'react-native';
+import colors from '../../styles/colors';
+import styles from '../../styles/styles';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import getButtonState from '../../libs/getButtonState';
+import * as StyleUtils from '../../styles/StyleUtils';
+import useLocalize from '../../hooks/useLocalize';
+
+const propTypes = {
+ /** Callback that runs when location button is clicked */
+ onPress: PropTypes.func,
+
+ /** Boolean to indicate if the button is clickable */
+ isDisabled: PropTypes.bool,
+};
+
+const defaultProps = {
+ isDisabled: false,
+ onPress: () => {},
+};
+
+function CurrentLocationButton({onPress, isDisabled}) {
+ const {translate} = useLocalize();
+
+ return (
+ e.preventDefault()}
+ onTouchStart={(e) => e.preventDefault()}
+ >
+
+ {translate('location.useCurrent')}
+
+ );
+}
+
+CurrentLocationButton.displayName = 'CurrentLocationButton';
+CurrentLocationButton.propTypes = propTypes;
+CurrentLocationButton.defaultProps = defaultProps;
+
+export default CurrentLocationButton;
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index fe220d442674..b84e67df634f 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -1,7 +1,7 @@
import _ from 'underscore';
-import React, {useMemo, useRef, useState} from 'react';
+import React, {useEffect, useMemo, useRef, useState} from 'react';
import PropTypes from 'prop-types';
-import {LogBox, ScrollView, View, Text, ActivityIndicator} from 'react-native';
+import {Keyboard, LogBox, ScrollView, View, Text, ActivityIndicator} from 'react-native';
import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete';
import lodashGet from 'lodash/get';
import compose from '../../libs/compose';
@@ -11,12 +11,16 @@ import themeColors from '../../styles/themes/default';
import TextInput from '../TextInput';
import * as ApiUtils from '../../libs/ApiUtils';
import * as GooglePlacesUtils from '../../libs/GooglePlacesUtils';
+import getCurrentPosition from '../../libs/getCurrentPosition';
import CONST from '../../CONST';
import * as StyleUtils from '../../styles/StyleUtils';
-import resetDisplayListViewBorderOnBlur from './resetDisplayListViewBorderOnBlur';
+import isCurrentTargetInsideContainer from './isCurrentTargetInsideContainer';
import variables from '../../styles/variables';
+import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator';
+import LocationErrorMessage from '../LocationErrorMessage';
import {withNetwork} from '../OnyxProvider';
import networkPropTypes from '../networkPropTypes';
+import CurrentLocationButton from './CurrentLocationButton';
// The error that's being thrown below will be ignored until we fork the
// react-native-google-places-autocomplete repo and replace the
@@ -61,6 +65,9 @@ const propTypes = {
/** Should address search be limited to results in the USA */
isLimitedToUSA: PropTypes.bool,
+ /** Shows a current location button in suggestion list */
+ canUseCurrentLocation: PropTypes.bool,
+
/** A list of predefined places that can be shown when the user isn't searching for something */
predefinedPlaces: PropTypes.arrayOf(
PropTypes.shape({
@@ -115,6 +122,7 @@ const defaultProps = {
defaultValue: undefined,
containerStyles: [],
isLimitedToUSA: false,
+ canUseCurrentLocation: false,
renamedInputKeys: {
street: 'addressStreet',
street2: 'addressStreet2',
@@ -135,6 +143,11 @@ const defaultProps = {
function AddressSearch(props) {
const [displayListViewBorder, setDisplayListViewBorder] = useState(false);
const [isTyping, setIsTyping] = useState(false);
+ const [isFocused, setIsFocused] = useState(false);
+ const [searchValue, setSearchValue] = useState(props.value || props.defaultValue || '');
+ const [locationErrorCode, setLocationErrorCode] = useState(null);
+ const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false);
+ const shouldTriggerGeolocationCallbacks = useRef(true);
const containerRef = useRef();
const query = useMemo(
() => ({
@@ -144,6 +157,7 @@ function AddressSearch(props) {
}),
[props.preferredLocale, props.resultTypes, props.isLimitedToUSA],
);
+ const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused;
const saveLocationDetails = (autocompleteData, details) => {
const addressComponents = details.address_components;
@@ -262,6 +276,72 @@ function AddressSearch(props) {
props.onPress(values);
};
+ /** Gets the user's current location and registers success/error callbacks */
+ const getCurrentLocation = () => {
+ if (isFetchingCurrentLocation) {
+ return;
+ }
+
+ setIsTyping(false);
+ setIsFocused(false);
+ setDisplayListViewBorder(false);
+ setIsFetchingCurrentLocation(true);
+
+ Keyboard.dismiss();
+
+ getCurrentPosition(
+ (successData) => {
+ if (!shouldTriggerGeolocationCallbacks.current) {
+ return;
+ }
+
+ setIsFetchingCurrentLocation(false);
+ setLocationErrorCode(null);
+
+ const location = {
+ lat: successData.coords.latitude,
+ lng: successData.coords.longitude,
+ address: CONST.YOUR_LOCATION_TEXT,
+ };
+ props.onPress(location);
+ },
+ (errorData) => {
+ if (!shouldTriggerGeolocationCallbacks.current) {
+ return;
+ }
+
+ setIsFetchingCurrentLocation(false);
+ setLocationErrorCode(errorData.code);
+ },
+ {
+ maximumAge: 0, // No cache, always get fresh location info
+ timeout: 5000,
+ },
+ );
+ };
+
+ const renderHeaderComponent = () =>
+ props.predefinedPlaces.length > 0 && (
+ <>
+ {/* This will show current location button in list if there are some recent destinations */}
+ {shouldShowCurrentLocationButton && (
+
+ )}
+ {!props.value && {props.translate('common.recentDestinations')}}
+ >
+ );
+
+ // eslint-disable-next-line arrow-body-style
+ useEffect(() => {
+ return () => {
+ // If the component unmounts we don't want any of the callback for geolocation to run.
+ shouldTriggerGeolocationCallbacks.current = false;
+ };
+ }, []);
+
return (
/*
* The GooglePlacesAutocomplete component uses a VirtualizedList internally,
@@ -269,119 +349,149 @@ function AddressSearch(props) {
* To work around this, we wrap the GooglePlacesAutocomplete component with a horizontal ScrollView
* that has scrolling disabled and would otherwise not be needed
*/
-
-
+
- {props.translate('common.noResultsFound')}
- )
- }
- listLoaderComponent={
-
-
-
- }
- renderHeaderComponent={() =>
- !props.value &&
- props.predefinedPlaces && (
- {props.translate('common.recentDestinations')}
- )
- }
- onPress={(data, details) => {
- saveLocationDetails(data, details);
- setIsTyping(false);
-
- // After we select an option, we set displayListViewBorder to false to prevent UI flickering
- setDisplayListViewBorder(false);
- }}
- query={query}
- requestUrl={{
- useOnPlatform: 'all',
- url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
- }}
- textInputProps={{
- InputComp: TextInput,
- ref: (node) => {
- if (!props.innerRef) {
- return;
- }
-
- if (_.isFunction(props.innerRef)) {
- props.innerRef(node);
- return;
- }
-
- // eslint-disable-next-line no-param-reassign
- props.innerRef.current = node;
- },
- label: props.label,
- containerStyles: props.containerStyles,
- errorText: props.errorText,
- hint: displayListViewBorder ? undefined : props.hint,
- value: props.value,
- defaultValue: props.defaultValue,
- inputID: props.inputID,
- shouldSaveDraft: props.shouldSaveDraft,
- onBlur: (event) => {
- resetDisplayListViewBorderOnBlur(setDisplayListViewBorder, event, containerRef);
- props.onBlur();
- },
- autoComplete: 'off',
- onInputChange: (text) => {
- setIsTyping(true);
- if (props.inputID) {
- props.onInputChange(text);
- } else {
- props.onInputChange({street: text});
- }
-
- // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
- if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) {
- setDisplayListViewBorder(false);
- }
- },
- maxLength: props.maxInputLength,
- spellCheck: false,
- }}
- styles={{
- textInputContainer: [styles.flexColumn],
- listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight],
- row: [styles.pv4, styles.ph3, styles.overflowAuto],
- description: [styles.googleSearchText],
- separator: [styles.googleSearchSeparator],
- }}
- numberOfLines={2}
- isRowScrollable={false}
- listHoverColor={themeColors.border}
- listUnderlayColor={themeColors.buttonPressedBG}
- onLayout={(event) => {
- // We use the height of the element to determine if we should hide the border of the listView dropdown
- // to prevent a lingering border when there are no address suggestions.
- setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight);
- }}
- />
-
-
+
+ {props.translate('common.noResultsFound')}
+ )
+ }
+ listLoaderComponent={
+
+
+
+ }
+ renderHeaderComponent={renderHeaderComponent}
+ onPress={(data, details) => {
+ saveLocationDetails(data, details);
+ setIsTyping(false);
+
+ // After we select an option, we set displayListViewBorder to false to prevent UI flickering
+ setDisplayListViewBorder(false);
+ setIsFocused(false);
+
+ // Clear location error code after address is selected
+ setLocationErrorCode(null);
+ }}
+ query={query}
+ requestUrl={{
+ useOnPlatform: 'all',
+ url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}),
+ }}
+ textInputProps={{
+ InputComp: TextInput,
+ ref: (node) => {
+ if (!props.innerRef) {
+ return;
+ }
+
+ if (_.isFunction(props.innerRef)) {
+ props.innerRef(node);
+ return;
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ props.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,
+ onFocus: () => {
+ setIsFocused(true);
+ },
+ onBlur: (event) => {
+ if (!isCurrentTargetInsideContainer(event, containerRef)) {
+ setDisplayListViewBorder(false);
+ setIsFocused(false);
+ setIsTyping(false);
+ }
+ props.onBlur();
+ },
+ autoComplete: 'off',
+ onInputChange: (text) => {
+ setSearchValue(text);
+ setIsTyping(true);
+ if (props.inputID) {
+ props.onInputChange(text);
+ } else {
+ props.onInputChange({street: text});
+ }
+
+ // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering
+ if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) {
+ setDisplayListViewBorder(false);
+ }
+ },
+ maxLength: props.maxInputLength,
+ spellCheck: false,
+ }}
+ styles={{
+ textInputContainer: [styles.flexColumn],
+ listView: [StyleUtils.getGoogleListViewStyle(displayListViewBorder), styles.overflowAuto, styles.borderLeft, styles.borderRight, !isFocused && {height: 0}],
+ row: [styles.pv4, styles.ph3, styles.overflowAuto],
+ description: [styles.googleSearchText],
+ separator: [styles.googleSearchSeparator],
+ }}
+ numberOfLines={2}
+ isRowScrollable={false}
+ listHoverColor={themeColors.border}
+ listUnderlayColor={themeColors.buttonPressedBG}
+ onLayout={(event) => {
+ // We use the height of the element to determine if we should hide the border of the listView dropdown
+ // to prevent a lingering border when there are no address suggestions.
+ setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight);
+ }}
+ inbetweenCompo={
+ // We want to show the current location button even if there are no recent destinations
+ props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? (
+
+
+
+ ) : (
+ <>>
+ )
+ }
+ />
+ setLocationErrorCode(null)}
+ locationErrorCode={locationErrorCode}
+ />
+
+
+ {isFetchingCurrentLocation && }
+ >
);
}
@@ -389,15 +499,14 @@ AddressSearch.propTypes = propTypes;
AddressSearch.defaultProps = defaultProps;
AddressSearch.displayName = 'AddressSearch';
-export default compose(
- withNetwork(),
- withLocalize,
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+const AddressSearchWithRef = React.forwardRef((props, ref) => (
+
+));
+
+AddressSearchWithRef.displayName = 'AddressSearchWithRef';
+
+export default compose(withNetwork(), withLocalize)(AddressSearchWithRef);
diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.js
new file mode 100644
index 000000000000..18bfc10a8dcb
--- /dev/null
+++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.js
@@ -0,0 +1,8 @@
+function isCurrentTargetInsideContainer(event, containerRef) {
+ // The related target check is required here
+ // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false
+ // it will make the auto complete component re-render before onPress is called making selecting an option not working.
+ return containerRef.current && event.target && containerRef.current.contains(event.relatedTarget);
+}
+
+export default isCurrentTargetInsideContainer;
diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js
new file mode 100644
index 000000000000..dbf0004b08d9
--- /dev/null
+++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js
@@ -0,0 +1,6 @@
+function isCurrentTargetInsideContainer() {
+ // The related target check is not required here because in native there is no race condition rendering like on the web
+ return false;
+}
+
+export default isCurrentTargetInsideContainer;
diff --git a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js b/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js
deleted file mode 100644
index def4da13a9a2..000000000000
--- a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.js
+++ /dev/null
@@ -1,11 +0,0 @@
-function resetDisplayListViewBorderOnBlur(setDisplayListViewBorder, event, containerRef) {
- // The related target check is required here
- // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false
- // it will make the auto complete component re-render before onPress is called making selecting an option not working.
- if (containerRef.current && event.target && containerRef.current.contains(event.relatedTarget)) {
- return;
- }
- setDisplayListViewBorder(false);
-}
-
-export default resetDisplayListViewBorderOnBlur;
diff --git a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js b/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js
deleted file mode 100644
index 7ae5a44cae71..000000000000
--- a/src/components/AddressSearch/resetDisplayListViewBorderOnBlur.native.js
+++ /dev/null
@@ -1,7 +0,0 @@
-function resetDisplayListViewBorderOnBlur(setDisplayListViewBorder) {
- // The related target check is not required here because in native there is no race condition rendering like on the web
- // onPress still called when cliking the option
- setDisplayListViewBorder(false);
-}
-
-export default resetDisplayListViewBorderOnBlur;
diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js
index cc2e1e1e872b..d110fe39cd6c 100644
--- a/src/components/AmountTextInput.js
+++ b/src/components/AmountTextInput.js
@@ -64,10 +64,14 @@ AmountTextInput.propTypes = propTypes;
AmountTextInput.defaultProps = defaultProps;
AmountTextInput.displayName = 'AmountTextInput';
-export default React.forwardRef((props, ref) => (
+const AmountTextInputWithRef = React.forwardRef((props, ref) => (
));
+
+AmountTextInputWithRef.displayName = 'AmountTextInputWithRef';
+
+export default AmountTextInputWithRef;
diff --git a/src/components/AnimatedStep/AnimatedStepProvider.js b/src/components/AnimatedStep/AnimatedStepProvider.js
index 280fbd1a2776..86d40b5bddeb 100644
--- a/src/components/AnimatedStep/AnimatedStepProvider.js
+++ b/src/components/AnimatedStep/AnimatedStepProvider.js
@@ -1,4 +1,4 @@
-import React, {useState} from 'react';
+import React, {useMemo, useState} from 'react';
import PropTypes from 'prop-types';
import AnimatedStepContext from './AnimatedStepContext';
import CONST from '../../CONST';
@@ -9,8 +9,9 @@ const propTypes = {
function AnimatedStepProvider({children}) {
const [animationDirection, setAnimationDirection] = useState(CONST.ANIMATION_DIRECTION.IN);
+ const contextValue = useMemo(() => ({animationDirection, setAnimationDirection}), [animationDirection, setAnimationDirection]);
- return {children};
+ return {children};
}
AnimatedStepProvider.propTypes = propTypes;
diff --git a/src/components/AnonymousReportFooter.js b/src/components/AnonymousReportFooter.js
index dd1a0864b0cf..43933210dc0b 100644
--- a/src/components/AnonymousReportFooter.js
+++ b/src/components/AnonymousReportFooter.js
@@ -36,6 +36,7 @@ function AnonymousReportFooter(props) {
report={props.report}
personalDetails={props.personalDetails}
isAnonymous
+ shouldEnableDetailPageNavigation
/>
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 61b138747950..1984ee77207a 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -123,7 +123,7 @@ function AttachmentModal(props) {
const [source, setSource] = useState(props.source);
const [modalType, setModalType] = useState(CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE);
const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false);
- const [confirmButtonFadeAnimation] = useState(new Animated.Value(1));
+ const [confirmButtonFadeAnimation] = useState(() => new Animated.Value(1));
const [shouldShowDownloadButton, setShouldShowDownloadButton] = React.useState(true);
const {windowWidth} = useWindowDimensions();
@@ -376,7 +376,7 @@ function AttachmentModal(props) {
text: props.translate('common.download'),
onSelected: () => downloadAttachment(source),
});
- if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && !isSettled) {
+ if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && canEdit) {
menuItems.push({
icon: Expensicons.Trashcan,
text: props.translate('receipt.deleteReceipt'),
@@ -447,6 +447,7 @@ function AttachmentModal(props) {
onToggleKeyboard={updateConfirmButtonVisibility}
isWorkspaceAvatar={props.isWorkspaceAvatar}
fallbackSource={props.fallbackSource}
+ isUsedInAttachmentModal
/>
)
)}
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
index 096b6d60d428..2fe62a00b90f 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
@@ -52,7 +52,7 @@ function CarouselItem({item, isFocused, onPress}) {
const {translate} = useLocalize();
const {isAttachmentHidden} = useContext(ReportAttachmentsContext);
// eslint-disable-next-line es/no-nullish-coalescing-operators
- const [isHidden, setIsHidden] = useState(isAttachmentHidden(item.reportActionID) ?? item.hasBeenFlagged);
+ const [isHidden, setIsHidden] = useState(() => isAttachmentHidden(item.reportActionID) ?? item.hasBeenFlagged);
const renderButton = (style) => (
+
+
+ >
+ );
}
-DatePicker.propTypes = datepickerPropTypes;
+DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
+DatePicker.displayName = 'DatePicker';
/**
* We're applying localization here because we present a modal (with buttons) ourselves
@@ -149,15 +138,14 @@ DatePicker.defaultProps = defaultProps;
* locale. Otherwise the spinner would be present in the system locale and it would be weird if it happens
* that the modal buttons are in one locale (app) while the (spinner) month names are another (system)
*/
-export default compose(
- withLocalize,
- withKeyboardState,
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+const DatePickerWithRef = React.forwardRef((props, ref) => (
+
+));
+
+DatePickerWithRef.displayName = 'DatePickerWithRef';
+
+export default DatePickerWithRef;
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index d14886fd1c59..163f120b8b98 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -77,10 +77,14 @@ DatePicker.displayName = 'DatePicker';
DatePicker.propTypes = propTypes;
DatePicker.defaultProps = defaultProps;
-export default React.forwardRef((props, ref) => (
+const DatePickerWithRef = React.forwardRef((props, ref) => (
));
+
+DatePickerWithRef.displayName = 'DatePickerWithRef';
+
+export default DatePickerWithRef;
diff --git a/src/components/DisplayNames/displayNamesPropTypes.js b/src/components/DisplayNames/displayNamesPropTypes.js
index 5ad332f7a117..0f5a2a304b29 100644
--- a/src/components/DisplayNames/displayNamesPropTypes.js
+++ b/src/components/DisplayNames/displayNamesPropTypes.js
@@ -34,7 +34,7 @@ const propTypes = {
const defaultProps = {
numberOfLines: 1,
tooltipEnabled: false,
- titleStyles: [],
+ textStyles: [],
};
export {propTypes, defaultProps};
diff --git a/src/components/DistanceEReceipt.js b/src/components/DistanceEReceipt.js
index 7c7837b8413d..f866de0b885e 100644
--- a/src/components/DistanceEReceipt.js
+++ b/src/components/DistanceEReceipt.js
@@ -31,7 +31,7 @@ const defaultProps = {
function DistanceEReceipt({transaction}) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
- const {thumbnail} = TransactionUtils.hasReceipt(transaction) ? ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename) : {};
+ const {thumbnail} = TransactionUtils.hasReceipt(transaction) ? ReceiptUtils.getThumbnailAndImageURIs(transaction) : {};
const {amount: transactionAmount, currency: transactionCurrency, merchant: transactionMerchant, created: transactionDate} = ReportUtils.getTransactionDetails(transaction);
const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : translate('common.tbd');
const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail || '');
diff --git a/src/components/DistanceRequest/DistanceRequestFooter.js b/src/components/DistanceRequest/DistanceRequestFooter.js
index c96adfee9ba0..d8214774d2c1 100644
--- a/src/components/DistanceRequest/DistanceRequestFooter.js
+++ b/src/components/DistanceRequest/DistanceRequestFooter.js
@@ -115,18 +115,19 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig
pitchEnabled={false}
initialState={{
zoom: CONST.MAPBOX.DEFAULT_ZOOM,
- location: CONST.MAPBOX.DEFAULT_COORDINATE,
+ location: lodashGet(waypointMarkers, [0, 'coordinate'], CONST.MAPBOX.DEFAULT_COORDINATE),
}}
directionCoordinates={lodashGet(transaction, 'routes.route0.geometry.coordinates', [])}
- style={styles.mapView}
+ style={[styles.mapView, styles.mapEditView]}
waypoints={waypointMarkers}
styleURL={CONST.MAPBOX.STYLE_URL}
- overlayStyle={styles.m4}
+ overlayStyle={styles.mapEditView}
/>
) : (
)}
diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js
index 416fefc5af89..bd35678273ec 100644
--- a/src/components/DistanceRequest/index.js
+++ b/src/components/DistanceRequest/index.js
@@ -2,7 +2,6 @@ import React, {useCallback, useEffect, useMemo, useState, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import lodashIsEmpty from 'lodash/isEmpty';
import PropTypes from 'prop-types';
import _ from 'underscore';
import ROUTES from '../../ROUTES';
@@ -169,8 +168,7 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe
const newWaypoints = {};
_.each(data, (waypoint, index) => {
- const newWaypoint = lodashGet(waypoints, waypoint, {});
- newWaypoints[`waypoint${index}`] = lodashIsEmpty(newWaypoint) ? null : newWaypoint;
+ newWaypoints[`waypoint${index}`] = lodashGet(waypoints, waypoint, {});
});
setOptimisticWaypoints(newWaypoints);
@@ -276,7 +274,4 @@ export default withOnyx({
transaction: {
key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID || 0}`,
},
- mapboxAccessToken: {
- key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
- },
})(DistanceRequest);
diff --git a/src/components/DotIndicatorMessage.js b/src/components/DotIndicatorMessage.js
index b3528b43dc75..fc4d74339d6e 100644
--- a/src/components/DotIndicatorMessage.js
+++ b/src/components/DotIndicatorMessage.js
@@ -3,6 +3,7 @@ import _ from 'underscore';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import styles from '../styles/styles';
+import stylePropTypes from '../styles/stylePropTypes';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import themeColors from '../styles/themes/default';
@@ -25,11 +26,15 @@ const propTypes = {
// 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) {
@@ -64,7 +69,7 @@ function DotIndicatorMessage(props) {
{_.map(sortedMessages, (message, i) => (
{message}
diff --git a/src/components/DragAndDrop/Provider/index.js b/src/components/DragAndDrop/Provider/index.js
index 6408f6dbfbfa..f76bf13c99fd 100644
--- a/src/components/DragAndDrop/Provider/index.js
+++ b/src/components/DragAndDrop/Provider/index.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import React, {useRef, useCallback, useEffect} from 'react';
+import React, {useRef, useCallback, useEffect, useMemo} from 'react';
import {View} from 'react-native';
import {PortalHost} from '@gorhom/portal';
import Str from 'expensify-common/lib/str';
@@ -37,8 +37,9 @@ function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver =
setIsDraggingOver(isDraggingOver);
}, [isDraggingOver, setIsDraggingOver]);
+ const contextValue = useMemo(() => ({isDraggingOver, setOnDropHandler, dropZoneID: dropZoneID.current}), [isDraggingOver, setOnDropHandler]);
return (
-
+ (dropZone.current = e)}
style={[styles.flex1, styles.w100, styles.h100]}
diff --git a/src/components/DraggableList/index.native.tsx b/src/components/DraggableList/index.native.tsx
index 9f180ba35b2e..e3b7558c1e21 100644
--- a/src/components/DraggableList/index.native.tsx
+++ b/src/components/DraggableList/index.native.tsx
@@ -2,11 +2,15 @@ import React from 'react';
import DraggableFlatList from 'react-native-draggable-flatlist';
import {FlatList} from 'react-native-gesture-handler';
import type {DraggableListProps} from './types';
+import styles from '../../styles/styles';
function DraggableList({renderClone, shouldUsePortal, ...viewProps}: DraggableListProps, ref: React.ForwardedRef>) {
return (
diff --git a/src/components/DraggableList/index.tsx b/src/components/DraggableList/index.tsx
index 674a95179e5d..ea9ac548e850 100644
--- a/src/components/DraggableList/index.tsx
+++ b/src/components/DraggableList/index.tsx
@@ -73,6 +73,7 @@ function DraggableList(
-
+ {currency}
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index a12b089ddf97..5c2f65e24b01 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -1,15 +1,15 @@
import React, {useState, useEffect, useRef, forwardRef, useImperativeHandle} from 'react';
import {Dimensions} from 'react-native';
import _ from 'underscore';
+import PropTypes from 'prop-types';
import EmojiPickerMenu from './EmojiPickerMenu';
import CONST from '../../CONST';
import styles from '../../styles/styles';
import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
-import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../withViewportOffsetTop';
-import compose from '../../libs/compose';
+import withViewportOffsetTop from '../withViewportOffsetTop';
import * as StyleUtils from '../../styles/StyleUtils';
import calculateAnchorPosition from '../../libs/calculateAnchorPosition';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
const DEFAULT_ANCHOR_ORIGIN = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
@@ -17,8 +17,7 @@ const DEFAULT_ANCHOR_ORIGIN = {
};
const propTypes = {
- ...windowDimensionsPropTypes,
- ...viewportOffsetTopPropTypes,
+ viewportOffsetTop: PropTypes.number.isRequired,
};
const EmojiPicker = forwardRef((props, ref) => {
@@ -33,6 +32,7 @@ const EmojiPicker = forwardRef((props, ref) => {
const onModalHide = useRef(() => {});
const onEmojiSelected = useRef(() => {});
const emojiSearchInput = useRef();
+ const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
/**
* Show the emoji picker menu.
@@ -124,7 +124,7 @@ const EmojiPicker = forwardRef((props, ref) => {
const emojiPopoverDimensionListener = Dimensions.addEventListener('change', () => {
if (!emojiPopoverAnchor.current) {
// In small screen width, the window size change might be due to keyboard open/hide, we should avoid hide EmojiPicker in those cases
- if (isEmojiPickerVisible && !props.isSmallScreenWidth) {
+ if (isEmojiPickerVisible && !isSmallScreenWidth) {
hideEmojiPicker();
}
return;
@@ -136,7 +136,7 @@ const EmojiPicker = forwardRef((props, ref) => {
return () => {
emojiPopoverDimensionListener.remove();
};
- }, [isEmojiPickerVisible, props.isSmallScreenWidth, emojiPopoverAnchorOrigin]);
+ }, [isEmojiPickerVisible, isSmallScreenWidth, emojiPopoverAnchorOrigin]);
// There is no way to disable animations, and they are really laggy, because there are so many
// emojis. The best alternative is to set it to 1ms so it just "pops" in and out
@@ -161,7 +161,7 @@ const EmojiPicker = forwardRef((props, ref) => {
height: CONST.EMOJI_PICKER_SIZE.HEIGHT,
}}
anchorAlignment={emojiPopoverAnchorOrigin}
- outerStyle={StyleUtils.getOuterModalStyle(props.windowHeight, props.viewportOffsetTop)}
+ outerStyle={StyleUtils.getOuterModalStyle(windowHeight, props.viewportOffsetTop)}
innerContainerStyle={styles.popoverInnerContainer}
avoidKeyboard
>
@@ -175,4 +175,4 @@ const EmojiPicker = forwardRef((props, ref) => {
EmojiPicker.propTypes = propTypes;
EmojiPicker.displayName = 'EmojiPicker';
-export default compose(withViewportOffsetTop, withWindowDimensions)(EmojiPicker);
+export default withViewportOffsetTop(EmojiPicker);
diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js
index cbfc3517117c..0d1426cbf987 100644
--- a/src/components/EmojiPicker/EmojiPickerButton.js
+++ b/src/components/EmojiPicker/EmojiPickerButton.js
@@ -4,7 +4,7 @@ import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import getButtonState from '../../libs/getButtonState';
import * as Expensicons from '../Icon/Expensicons';
-import Tooltip from '../Tooltip';
+import Tooltip from '../Tooltip/PopoverAnchorTooltip';
import Icon from '../Icon';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction';
diff --git a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
index 3023a9abf95c..a1a22d8c50dd 100644
--- a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
+++ b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
@@ -28,12 +28,18 @@ function EmojiPickerButtonDropdown(props) {
const emojiPopoverAnchor = useRef(null);
useEffect(() => EmojiPickerAction.resetEmojiPopoverAnchor, []);
- const onPress = () =>
+ const onPress = () => {
+ if (EmojiPickerAction.isEmojiPickerVisible()) {
+ EmojiPickerAction.hideEmojiPicker();
+ return;
+ }
+
EmojiPickerAction.showEmojiPicker(props.onModalHide, (emoji) => props.onInputChange(emoji), emojiPopoverAnchor.current, {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
shiftVertical: 4,
});
+ };
return (
@@ -44,7 +50,7 @@ function EmojiPickerButtonDropdown(props) {
onPress={onPress}
nativeID="emojiDropdownButton"
accessibilityLabel="statusEmoji"
- accessibilityRole="text"
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
>
{({hovered, pressed}) => (
@@ -70,12 +76,15 @@ function EmojiPickerButtonDropdown(props) {
EmojiPickerButtonDropdown.propTypes = propTypes;
EmojiPickerButtonDropdown.defaultProps = defaultProps;
EmojiPickerButtonDropdown.displayName = 'EmojiPickerButtonDropdown';
-export default withLocalize(
- React.forwardRef((props, ref) => (
-
- )),
-);
+
+const EmojiPickerButtonDropdownWithRef = React.forwardRef((props, ref) => (
+
+));
+
+EmojiPickerButtonDropdownWithRef.displayName = 'EmojiPickerButtonDropdownWithRef';
+
+export default withLocalize(EmojiPickerButtonDropdownWithRef);
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 3dfc5f59bb38..48daa983b5b0 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -1,4 +1,4 @@
-import React, {Component} from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View, FlatList} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
@@ -8,20 +8,21 @@ import CONST from '../../../CONST';
import ONYXKEYS from '../../../ONYXKEYS';
import styles from '../../../styles/styles';
import * as StyleUtils from '../../../styles/StyleUtils';
-import emojis from '../../../../assets/emojis';
+import emojiAssets from '../../../../assets/emojis';
import EmojiPickerMenuItem from '../EmojiPickerMenuItem';
import Text from '../../Text';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions';
import withLocalize, {withLocalizePropTypes} from '../../withLocalize';
import compose from '../../../libs/compose';
import getOperatingSystem from '../../../libs/getOperatingSystem';
import * as User from '../../../libs/actions/User';
import EmojiSkinToneList from '../EmojiSkinToneList';
import * as EmojiUtils from '../../../libs/EmojiUtils';
+import * as Browser from '../../../libs/Browser';
import CategoryShortcutBar from '../CategoryShortcutBar';
import TextInput from '../../TextInput';
import isEnterWhileComposition from '../../../libs/KeyboardShortcut/isEnterWhileComposition';
import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus';
+import useWindowDimensions from '../../../hooks/useWindowDimensions';
const propTypes = {
/** Function to add the selected emoji to the main compose text input */
@@ -32,14 +33,10 @@ const propTypes = {
/** Stores user's preferred skin tone */
preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-
/** Stores user's frequently used emojis */
// eslint-disable-next-line react/forbid-prop-types
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.object),
- /** Props related to the dimensions of the window */
- ...windowDimensionsPropTypes,
-
...withLocalizePropTypes,
};
@@ -49,105 +46,37 @@ const defaultProps = {
frequentlyUsedEmojis: [],
};
-class EmojiPickerMenu extends Component {
- constructor(props) {
- super(props);
-
- // Ref for the emoji search input
- this.searchInput = undefined;
-
- // Ref for emoji FlatList
- this.emojiList = undefined;
-
- // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
- // prevent auto focus when open picker for mobile device
- this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
-
- this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
- this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
- this.setupEventHandlers = this.setupEventHandlers.bind(this);
- this.cleanupEventHandlers = this.cleanupEventHandlers.bind(this);
- this.renderItem = this.renderItem.bind(this);
- this.isMobileLandscape = this.isMobileLandscape.bind(this);
- this.onSelectionChange = this.onSelectionChange.bind(this);
- this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this);
- this.setFirstNonHeaderIndex = this.setFirstNonHeaderIndex.bind(this);
- this.getItemLayout = this.getItemLayout.bind(this);
- this.scrollToHeader = this.scrollToHeader.bind(this);
-
- this.firstNonHeaderIndex = 0;
-
- const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices();
- this.emojis = filteredEmojis;
- this.headerEmojis = headerEmojis;
- this.headerRowIndices = headerRowIndices;
-
- this.state = {
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- highlightedIndex: -1,
- arePointerEventsDisabled: false,
- selection: {
- start: 0,
- end: 0,
- },
- isFocused: false,
- isUsingKeyboardMovement: false,
- };
- }
+const throttleTime = Browser.isMobile() ? 200 : 50;
- componentDidMount() {
- // This callback prop is used by the parent component using the constructor to
- // get a ref to the inner textInput element e.g. if we do
- // this.textInput = el} /> this will not
- // return a ref to the component, but rather the HTML element by default
- if (this.shouldFocusInputOnScreenFocus && this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
- this.props.forwardedRef(this.searchInput);
- }
- this.setupEventHandlers();
- this.setFirstNonHeaderIndex(this.emojis);
- }
+function EmojiPickerMenu(props) {
+ const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, translate} = props;
- componentDidUpdate(prevProps) {
- if (prevProps.frequentlyUsedEmojis === this.props.frequentlyUsedEmojis) {
- return;
- }
+ const {isSmallScreenWidth, windowHeight} = useWindowDimensions();
- const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices();
- this.emojis = filteredEmojis;
- this.headerEmojis = headerEmojis;
- this.headerRowIndices = headerRowIndices;
- this.setState({
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- });
- }
+ // Ref for the emoji search input
+ const searchInputRef = useRef(null);
- componentWillUnmount() {
- this.cleanupEventHandlers();
- }
+ // Ref for emoji FlatList
+ const emojiListRef = useRef(null);
- /**
- * On text input selection change
- *
- * @param {Event} event
- */
- onSelectionChange(event) {
- this.setState({selection: event.nativeEvent.selection});
- }
+ // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+ // prevent auto focus when open picker for mobile device
+ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
+
+ const firstNonHeaderIndex = useRef(0);
/**
* Calculate the filtered + header emojis and header row indices
* @returns {Object}
*/
- getEmojisAndHeaderRowIndices() {
+ function getEmojisAndHeaderRowIndices() {
// If we're on Windows, don't display the flag emojis (the last category),
// since Windows doesn't support them
- const flagHeaderIndex = _.findIndex(emojis, (emoji) => emoji.header && emoji.code === 'flags');
+ const flagHeaderIndex = _.findIndex(emojiAssets, (emoji) => emoji.header && emoji.code === 'flags');
const filteredEmojis =
getOperatingSystem() === CONST.OS.WINDOWS
- ? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis.slice(0, flagHeaderIndex))
- : EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis);
+ ? EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets.slice(0, flagHeaderIndex))
+ : EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets);
// Get the header emojis along with the code, index and icon.
// index is the actual header index starting at the first emoji and counting each one
@@ -161,76 +90,56 @@ class EmojiPickerMenu extends Component {
return {filteredEmojis, headerEmojis, headerRowIndices};
}
+ const emojis = useRef([]);
+ if (emojis.current.length === 0) {
+ emojis.current = getEmojisAndHeaderRowIndices().filteredEmojis;
+ }
+ const headerRowIndices = useRef([]);
+ if (headerRowIndices.current.length === 0) {
+ headerRowIndices.current = getEmojisAndHeaderRowIndices().headerRowIndices;
+ }
+ const [headerEmojis, setHeaderEmojis] = useState(() => getEmojisAndHeaderRowIndices().headerEmojis);
+
+ const [filteredEmojis, setFilteredEmojis] = useState(emojis.current);
+ const [headerIndices, setHeaderIndices] = useState(headerRowIndices.current);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false);
+ const [selection, setSelection] = useState({start: 0, end: 0});
+ const [isFocused, setIsFocused] = useState(false);
+ const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false);
+
+ useEffect(() => {
+ const emojisAndHeaderRowIndices = getEmojisAndHeaderRowIndices();
+ emojis.current = emojisAndHeaderRowIndices.filteredEmojis;
+ headerRowIndices.current = emojisAndHeaderRowIndices.headerRowIndices;
+ setHeaderEmojis(emojisAndHeaderRowIndices.headerEmojis);
+ setFilteredEmojis(emojis.current);
+ setHeaderIndices(headerRowIndices.current);
+ }, [frequentlyUsedEmojis]);
+
/**
- * Find and store index of the first emoji item
- * @param {Array} filteredEmojis
+ * On text input selection change
+ *
+ * @param {Event} event
*/
- setFirstNonHeaderIndex(filteredEmojis) {
- this.firstNonHeaderIndex = _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header);
- }
+ const onSelectionChange = useCallback((event) => {
+ setSelection(event.nativeEvent.selection);
+ }, []);
/**
- * Setup and attach keypress/mouse handlers for highlight navigation.
+ * Find and store index of the first emoji item
+ * @param {Array} filteredEmojisArr
*/
- setupEventHandlers() {
- if (!document) {
+ function updateFirstNonHeaderIndex(filteredEmojisArr) {
+ firstNonHeaderIndex.current = _.findIndex(filteredEmojisArr, (item) => !item.spacer && !item.header);
+ }
+
+ const mouseMoveHandler = useCallback(() => {
+ if (!arePointerEventsDisabled) {
return;
}
-
- this.keyDownHandler = (keyBoardEvent) => {
- if (keyBoardEvent.key.startsWith('Arrow')) {
- if (!this.state.isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
- keyBoardEvent.preventDefault();
- }
-
- // Move the highlight when arrow keys are pressed
- this.highlightAdjacentEmoji(keyBoardEvent.key);
- return;
- }
-
- // Select the currently highlighted emoji if enter is pressed
- if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && this.state.highlightedIndex !== -1) {
- const item = this.state.filteredEmojis[this.state.highlightedIndex];
- if (!item) {
- return;
- }
- const emoji = lodashGet(item, ['types', this.props.preferredSkinTone], item.code);
- this.props.onEmojiSelected(emoji, item);
- return;
- }
-
- // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input
- // is not focused, so that the navigation and tab cycling can be done using the keyboard without
- // interfering with the input behaviour.
- if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && this.searchInput && !this.searchInput.isFocused())) {
- this.setState({isUsingKeyboardMovement: true});
- return;
- }
-
- // We allow typing in the search box if any key is pressed apart from Arrow keys.
- if (this.searchInput && !this.searchInput.isFocused()) {
- this.setState({selectTextOnFocus: false});
- this.searchInput.focus();
-
- // Re-enable selection on the searchInput
- this.setState({selectTextOnFocus: true});
- }
- };
-
- // Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
- // event handler attached to document root. To fix this, trigger event handler in Capture phase.
- document.addEventListener('keydown', this.keyDownHandler, true);
-
- // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
- this.mouseMoveHandler = () => {
- if (!this.state.arePointerEventsDisabled) {
- return;
- }
-
- this.setState({arePointerEventsDisabled: false});
- };
- document.addEventListener('mousemove', this.mouseMoveHandler);
- }
+ setArePointerEventsDisabled(false);
+ }, [arePointerEventsDisabled]);
/**
* This function will be used with FlatList getItemLayout property for optimization purpose that allows skipping
@@ -242,179 +151,252 @@ class EmojiPickerMenu extends Component {
* @param {Number} index row index
* @returns {Object}
*/
- getItemLayout(data, index) {
- return {length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index};
- }
+ const getItemLayout = useCallback((data, index) => ({length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}), []);
/**
- * Cleanup all mouse/keydown event listeners that we've set up
+ * Focuses the search Input and has the text selected
*/
- cleanupEventHandlers() {
- if (!document) {
+ function focusInputWithTextSelect() {
+ if (!searchInputRef.current) {
return;
}
-
- document.removeEventListener('keydown', this.keyDownHandler, true);
- document.removeEventListener('mousemove', this.mouseMoveHandler);
+ searchInputRef.current.focus();
}
- /**
- * Focuses the search Input and has the text selected
- */
- focusInputWithTextSelect() {
- if (!this.searchInput) {
+ const filterEmojis = _.throttle((searchTerm) => {
+ const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
+ if (emojiListRef.current) {
+ emojiListRef.current.scrollToOffset({offset: 0, animated: false});
+ }
+ if (normalizedSearchTerm === '') {
+ // There are no headers when searching, so we need to re-make them sticky when there is no search term
+ setFilteredEmojis(emojis.current);
+ setHeaderIndices(headerRowIndices.current);
+ setHighlightedIndex(-1);
+ updateFirstNonHeaderIndex(emojis.current);
return;
}
+ const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, emojis.current.length);
- this.setState({selectTextOnFocus: true});
- this.searchInput.focus();
- }
+ // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
+ setFilteredEmojis(newFilteredEmojiList);
+ setHeaderIndices([]);
+ setHighlightedIndex(0);
+ updateFirstNonHeaderIndex(newFilteredEmojiList);
+ }, throttleTime);
/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
- highlightAdjacentEmoji(arrowKey) {
- if (this.state.filteredEmojis.length === 0) {
- return;
- }
-
- // Arrow Down and Arrow Right enable arrow navigation when search is focused
- if (this.searchInput && this.searchInput.isFocused()) {
- if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
+ const highlightAdjacentEmoji = useCallback(
+ (arrowKey) => {
+ if (filteredEmojis.length === 0) {
return;
}
- if (arrowKey === 'ArrowRight' && !(this.searchInput.value.length === this.state.selection.start && this.state.selection.start === this.state.selection.end)) {
+ // Arrow Down and Arrow Right enable arrow navigation when search is focused
+ if (searchInputRef.current && searchInputRef.current.isFocused()) {
+ if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
+ return;
+ }
+
+ if (arrowKey === 'ArrowRight' && !(searchInputRef.current.value.length === selection.start && selection.start === selection.end)) {
+ return;
+ }
+
+ // Blur the input, change the highlight type to keyboard, and disable pointer events
+ searchInputRef.current.blur();
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
+
+ // We only want to hightlight the Emoji if none was highlighted already
+ // If we already have a highlighted Emoji, lets just skip the first navigation
+ if (highlightedIndex !== -1) {
+ return;
+ }
+ }
+
+ // If nothing is highlighted and an arrow key is pressed
+ // select the first emoji, apply keyboard movement styles, and disable pointer events
+ if (highlightedIndex === -1) {
+ setHighlightedIndex(firstNonHeaderIndex.current);
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
return;
}
- // Blur the input, change the highlight type to keyboard, and disable pointer events
- this.searchInput.blur();
- this.setState({isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
+ let newIndex = highlightedIndex;
+ const move = (steps, boundsCheck, onBoundReached = () => {}) => {
+ if (boundsCheck()) {
+ onBoundReached();
+ return;
+ }
+
+ // Move in the prescribed direction until we reach an element that isn't a header
+ const isHeader = (e) => e.header || e.spacer;
+ do {
+ newIndex += steps;
+ if (newIndex < 0) {
+ break;
+ }
+ } while (isHeader(filteredEmojis[newIndex]));
+ };
- // We only want to hightlight the Emoji if none was highlighted already
- // If we already have a highlighted Emoji, lets just skip the first navigation
- if (this.state.highlightedIndex !== -1) {
- return;
+ switch (arrowKey) {
+ case 'ArrowDown':
+ move(CONST.EMOJI_NUM_PER_ROW, () => highlightedIndex + CONST.EMOJI_NUM_PER_ROW > filteredEmojis.length - 1);
+ break;
+ case 'ArrowLeft':
+ move(
+ -1,
+ () => highlightedIndex - 1 < firstNonHeaderIndex.current,
+ () => {
+ // Reaching start of the list, arrow left set the focus to searchInput.
+ focusInputWithTextSelect();
+ newIndex = -1;
+ },
+ );
+ break;
+ case 'ArrowRight':
+ move(1, () => highlightedIndex + 1 > filteredEmojis.length - 1);
+ break;
+ case 'ArrowUp':
+ move(
+ -CONST.EMOJI_NUM_PER_ROW,
+ () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex.current,
+ () => {
+ // Reaching start of the list, arrow up set the focus to searchInput.
+ focusInputWithTextSelect();
+ newIndex = -1;
+ },
+ );
+ break;
+ default:
+ break;
}
- }
- // If nothing is highlighted and an arrow key is pressed
- // select the first emoji, apply keyboard movement styles, and disable pointer events
- if (this.state.highlightedIndex === -1) {
- this.setState({highlightedIndex: this.firstNonHeaderIndex, isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
- return;
- }
+ // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
+ if (newIndex !== highlightedIndex) {
+ setHighlightedIndex(newIndex);
+ setArePointerEventsDisabled(true);
+ setIsUsingKeyboardMovement(true);
+ }
+ },
+ [filteredEmojis, highlightedIndex, selection.end, selection.start],
+ );
+
+ const keyDownHandler = useCallback(
+ (keyBoardEvent) => {
+ if (keyBoardEvent.key.startsWith('Arrow')) {
+ if (!isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
+ keyBoardEvent.preventDefault();
+ }
- let newIndex = this.state.highlightedIndex;
- const move = (steps, boundsCheck, onBoundReached = () => {}) => {
- if (boundsCheck()) {
- onBoundReached();
+ // Move the highlight when arrow keys are pressed
+ highlightAdjacentEmoji(keyBoardEvent.key);
return;
}
- // Move in the prescribed direction until we reach an element that isn't a header
- const isHeader = (e) => e.header || e.spacer;
- do {
- newIndex += steps;
- if (newIndex < 0) {
- break;
+ // Select the currently highlighted emoji if enter is pressed
+ if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && highlightedIndex !== -1) {
+ const item = filteredEmojis[highlightedIndex];
+ if (!item) {
+ return;
}
- } while (isHeader(this.state.filteredEmojis[newIndex]));
- };
-
- switch (arrowKey) {
- case 'ArrowDown':
- move(CONST.EMOJI_NUM_PER_ROW, () => this.state.highlightedIndex + CONST.EMOJI_NUM_PER_ROW > this.state.filteredEmojis.length - 1);
- break;
- case 'ArrowLeft':
- move(
- -1,
- () => this.state.highlightedIndex - 1 < this.firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow left set the focus to searchInput.
- this.focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- case 'ArrowRight':
- move(1, () => this.state.highlightedIndex + 1 > this.state.filteredEmojis.length - 1);
- break;
- case 'ArrowUp':
- move(
- -CONST.EMOJI_NUM_PER_ROW,
- () => this.state.highlightedIndex - CONST.EMOJI_NUM_PER_ROW < this.firstNonHeaderIndex,
- () => {
- // Reaching start of the list, arrow up set the focus to searchInput.
- this.focusInputWithTextSelect();
- newIndex = -1;
- },
- );
- break;
- default:
- break;
- }
+ const emoji = lodashGet(item, ['types', preferredSkinTone], item.code);
+ onEmojiSelected(emoji, item);
+ return;
+ }
- // Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
- if (newIndex !== this.state.highlightedIndex) {
- this.setState({highlightedIndex: newIndex, isUsingKeyboardMovement: true, arePointerEventsDisabled: true});
- }
- }
+ // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input
+ // is not focused, so that the navigation and tab cycling can be done using the keyboard without
+ // interfering with the input behaviour.
+ if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) {
+ setIsUsingKeyboardMovement(true);
+ return;
+ }
- scrollToHeader(headerIndex) {
- const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
- this.emojiList.flashScrollIndicators();
- this.emojiList.scrollToOffset({offset: calculatedOffset, animated: true});
- }
+ // We allow typing in the search box if any key is pressed apart from Arrow keys.
+ if (searchInputRef.current && !searchInputRef.current.isFocused()) {
+ searchInputRef.current.focus();
+ }
+ },
+ [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone],
+ );
/**
- * Filter the entire list of emojis to only emojis that have the search term in their keywords
- *
- * @param {String} searchTerm
+ * Setup and attach keypress/mouse handlers for highlight navigation.
*/
- filterEmojis(searchTerm) {
- const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
- if (this.emojiList) {
- this.emojiList.scrollToOffset({offset: 0, animated: false});
- }
- if (normalizedSearchTerm === '') {
- // There are no headers when searching, so we need to re-make them sticky when there is no search term
- this.setState({
- filteredEmojis: this.emojis,
- headerIndices: this.headerRowIndices,
- highlightedIndex: -1,
- });
- this.setFirstNonHeaderIndex(this.emojis);
+ const setupEventHandlers = useCallback(() => {
+ if (!document) {
return;
}
- const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, this.props.preferredLocale, this.emojis.length);
- // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
- this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: [], highlightedIndex: 0});
- this.setFirstNonHeaderIndex(newFilteredEmojiList);
- }
+ // Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
+ // event handler attached to document root. To fix this, trigger event handler in Capture phase.
+ document.addEventListener('keydown', keyDownHandler, true);
+
+ // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
+ document.addEventListener('mousemove', mouseMoveHandler);
+ }, [keyDownHandler, mouseMoveHandler]);
/**
- * Check if its a landscape mode of mobile device
- *
- * @returns {Boolean}
+ * Cleanup all mouse/keydown event listeners that we've set up
*/
- isMobileLandscape() {
- return this.props.isSmallScreenWidth && this.props.windowWidth >= this.props.windowHeight;
- }
+ const cleanupEventHandlers = useCallback(() => {
+ if (!document) {
+ return;
+ }
+
+ document.removeEventListener('keydown', keyDownHandler, true);
+ document.removeEventListener('mousemove', mouseMoveHandler);
+ }, [keyDownHandler, mouseMoveHandler]);
+
+ useEffect(() => {
+ // This callback prop is used by the parent component using the constructor to
+ // get a ref to the inner textInput element e.g. if we do
+ // this.textInput = el} /> this will not
+ // return a ref to the component, but rather the HTML element by default
+ if (shouldFocusInputOnScreenFocus && forwardedRef && _.isFunction(forwardedRef)) {
+ forwardedRef(searchInputRef.current);
+ }
+
+ setupEventHandlers();
+
+ return () => {
+ cleanupEventHandlers();
+ };
+ }, [forwardedRef, shouldFocusInputOnScreenFocus, cleanupEventHandlers, setupEventHandlers]);
+
+ useEffect(() => {
+ // Find and store index of the first emoji item on mount
+ updateFirstNonHeaderIndex(emojis.current);
+ }, []);
+
+ const scrollToHeader = useCallback((headerIndex) => {
+ if (!emojiListRef.current) {
+ return;
+ }
+
+ const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT;
+ emojiListRef.current.flashScrollIndicators();
+ emojiListRef.current.scrollToOffset({offset: calculatedOffset, animated: true});
+ }, []);
/**
* @param {Number} skinTone
*/
- updatePreferredSkinTone(skinTone) {
- if (this.props.preferredSkinTone === skinTone) {
- return;
- }
+ const updatePreferredSkinTone = useCallback(
+ (skinTone) => {
+ if (Number(preferredSkinTone) === Number(skinTone)) {
+ return;
+ }
- User.updatePreferredSkinTone(skinTone);
- }
+ User.updatePreferredSkinTone(skinTone);
+ },
+ [preferredSkinTone],
+ );
/**
* Return a unique key for each emoji item
@@ -423,9 +405,7 @@ class EmojiPickerMenu extends Component {
* @param {Number} index
* @returns {String}
*/
- keyExtractor(item, index) {
- return `emoji_picker_${item.code}_${index}`;
- }
+ const keyExtractor = useCallback((item, index) => `emoji_picker_${item.code}_${index}`, []);
/**
* Given an emoji item object, render a component based on its type.
@@ -436,119 +416,127 @@ class EmojiPickerMenu extends Component {
* @param {Number} index
* @returns {*}
*/
- renderItem({item, index}) {
- const {code, header, types} = item;
- if (item.spacer) {
- return null;
- }
+ const renderItem = useCallback(
+ ({item, index}) => {
+ const {code, header, types} = item;
+ if (item.spacer) {
+ return null;
+ }
- if (header) {
- return (
-
- {this.props.translate(`emojiPicker.headers.${code}`)}
-
- );
- }
+ if (header) {
+ return (
+
+ {translate(`emojiPicker.headers.${code}`)}
+
+ );
+ }
- const emojiCode = types && types[this.props.preferredSkinTone] ? types[this.props.preferredSkinTone] : code;
+ const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code;
- const isEmojiFocused = index === this.state.highlightedIndex && this.state.isUsingKeyboardMovement;
+ const isEmojiFocused = index === highlightedIndex && isUsingKeyboardMovement;
- return (
- this.props.onEmojiSelected(emoji, item)}
- onHoverIn={() => this.setState({highlightedIndex: index, isUsingKeyboardMovement: false})}
- onHoverOut={() => {
- if (this.state.arePointerEventsDisabled) {
- return;
- }
- this.setState({highlightedIndex: -1});
- }}
- emoji={emojiCode}
- onFocus={() => this.setState({highlightedIndex: index})}
- onBlur={() =>
- this.setState((prevState) => ({
+ return (
+ onEmojiSelected(emoji, item)}
+ onHoverIn={() => {
+ if (!isUsingKeyboardMovement) {
+ return;
+ }
+ setIsUsingKeyboardMovement(false);
+ }}
+ emoji={emojiCode}
+ onFocus={() => setHighlightedIndex(index)}
+ onBlur={() =>
// Only clear the highlighted index if the highlighted index is the same,
// meaning that the focus changed to an element that is not an emoji item.
- highlightedIndex: prevState.highlightedIndex === index ? -1 : prevState.highlightedIndex,
- }))
- }
- isFocused={isEmojiFocused}
- isHighlighted={index === this.state.highlightedIndex}
- isUsingKeyboardMovement={this.state.isUsingKeyboardMovement}
- />
- );
- }
-
- render() {
- const isFiltered = this.emojis.length !== this.state.filteredEmojis.length;
- const listStyle = StyleUtils.getEmojiPickerListHeight(isFiltered, this.props.windowHeight);
- const height = !listStyle.maxHeight || listStyle.height < listStyle.maxHeight ? listStyle.height : listStyle.maxHeight;
- const overflowLimit = Math.floor(height / CONST.EMOJI_PICKER_ITEM_HEIGHT) * 8;
- return (
-
-
- (this.searchInput = el)}
- autoFocus={this.shouldFocusInputOnScreenFocus}
- selectTextOnFocus={this.state.selectTextOnFocus}
- onSelectionChange={this.onSelectionChange}
- onFocus={() => this.setState({isFocused: true, highlightedIndex: -1, isUsingKeyboardMovement: false})}
- onBlur={() => this.setState({isFocused: false})}
- autoCorrect={false}
- blurOnSubmit={this.state.filteredEmojis.length > 0}
- />
-
- {!isFiltered && (
-
- )}
- (this.emojiList = el)}
- data={this.state.filteredEmojis}
- renderItem={this.renderItem}
- keyExtractor={this.keyExtractor}
- numColumns={CONST.EMOJI_NUM_PER_ROW}
- style={[
- listStyle,
- // This prevents elastic scrolling when scroll reaches the start or end
- {overscrollBehaviorY: 'contain'},
- // Set overflow to hidden to prevent elastic scrolling when there are not enough contents to scroll in FlatList
- {overflowY: this.state.filteredEmojis.length > overflowLimit ? 'auto' : 'hidden'},
- // Set scrollPaddingTop to consider sticky headers while scrolling
- {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT},
- ]}
- extraData={[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]}
- stickyHeaderIndices={this.state.headerIndices}
- getItemLayout={this.getItemLayout}
- contentContainerStyle={styles.flexGrow1}
- ListEmptyComponent={{this.props.translate('common.noResultsFound')}}
+ setHighlightedIndex((prevState) => (prevState === index ? -1 : prevState))
+ }
+ isFocused={isEmojiFocused}
/>
-
+
+ {
+ setHighlightedIndex(-1);
+ setIsFocused(true);
+ setIsUsingKeyboardMovement(false);
+ }}
+ onBlur={() => setIsFocused(false)}
+ autoCorrect={false}
+ blurOnSubmit={filteredEmojis.length > 0}
/>
- );
- }
+ {!isFiltered && (
+
+ )}
+ overflowLimit ? 'auto' : 'hidden'},
+ // Set scrollPaddingTop to consider sticky headers while scrolling
+ {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT},
+ ]}
+ extraData={[filteredEmojis, highlightedIndex, preferredSkinTone]}
+ stickyHeaderIndices={headerIndices}
+ getItemLayout={getItemLayout}
+ contentContainerStyle={styles.flexGrow1}
+ ListEmptyComponent={{translate('common.noResultsFound')}}
+ />
+
+
+ );
}
EmojiPickerMenu.propTypes = propTypes;
EmojiPickerMenu.defaultProps = defaultProps;
+const EmojiPickerMenuWithRef = React.forwardRef((props, ref) => (
+
+));
+
+EmojiPickerMenuWithRef.displayName = 'EmojiPickerMenuWithRef';
+
export default compose(
- withWindowDimensions,
withLocalize,
withOnyx({
preferredSkinTone: {
@@ -558,12 +546,4 @@ export default compose(
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
},
}),
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+)(EmojiPickerMenuWithRef);
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
index fe8c3e275ad2..b54b67294d40 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
@@ -201,6 +201,16 @@ EmojiPickerMenu.displayName = 'EmojiPickerMenu';
EmojiPickerMenu.propTypes = propTypes;
EmojiPickerMenu.defaultProps = defaultProps;
+const EmojiPickerMenuWithRef = React.forwardRef((props, ref) => (
+
+));
+
+EmojiPickerMenuWithRef.displayName = 'EmojiPickerMenuWithRef';
+
export default compose(
withLocalize,
withOnyx({
@@ -211,12 +221,4 @@ export default compose(
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
},
}),
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+)(EmojiPickerMenuWithRef);
diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
index b51a8b07537c..c5ca5463aec4 100644
--- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js
@@ -27,14 +27,8 @@ const propTypes = {
/** Handles what to do when the pressable is blurred */
onBlur: PropTypes.func,
- /** Whether this menu item is currently highlighted or not */
- isHighlighted: PropTypes.bool,
-
/** Whether this menu item is currently focused or not */
isFocused: PropTypes.bool,
-
- /** Whether the emoji is highlighted by the keyboard/mouse */
- isUsingKeyboardMovement: PropTypes.bool,
};
class EmojiPickerMenuItem extends PureComponent {
@@ -43,6 +37,9 @@ class EmojiPickerMenuItem extends PureComponent {
this.ref = null;
this.focusAndScroll = this.focusAndScroll.bind(this);
+ this.state = {
+ isHovered: false,
+ };
}
componentDidMount() {
@@ -72,15 +69,29 @@ class EmojiPickerMenuItem extends PureComponent {
this.props.onPress(this.props.emoji)}
+ // In order to prevent haptic feedback, pass empty callback as onLongPress props. Please refer https://github.com/necolas/react-native-web/issues/2349#issuecomment-1195564240
+ onLongPress={Browser.isMobileChrome() ? () => {} : undefined}
onPressOut={Browser.isMobile() ? this.props.onHoverOut : undefined}
- onHoverIn={this.props.onHoverIn}
- onHoverOut={this.props.onHoverOut}
+ onHoverIn={() => {
+ if (this.props.onHoverIn) {
+ this.props.onHoverIn();
+ }
+
+ this.setState({isHovered: true});
+ }}
+ onHoverOut={() => {
+ if (this.props.onHoverOut) {
+ this.props.onHoverOut();
+ }
+
+ this.setState({isHovered: false});
+ }}
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
ref={(ref) => (this.ref = ref)}
style={({pressed}) => [
- this.props.isHighlighted && this.props.isUsingKeyboardMovement ? styles.emojiItemKeyboardHighlighted : {},
- this.props.isHighlighted && !this.props.isUsingKeyboardMovement ? styles.emojiItemHighlighted : {},
+ this.props.isFocused ? styles.emojiItemKeyboardHighlighted : {},
+ this.state.isHovered ? styles.emojiItemHighlighted : {},
Browser.isMobile() && StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)),
styles.emojiItem,
]}
@@ -95,9 +106,7 @@ class EmojiPickerMenuItem extends PureComponent {
EmojiPickerMenuItem.propTypes = propTypes;
EmojiPickerMenuItem.defaultProps = {
- isHighlighted: false,
isFocused: false,
- isUsingKeyboardMovement: false,
onHoverIn: () => {},
onHoverOut: () => {},
onFocus: () => {},
@@ -106,8 +115,4 @@ EmojiPickerMenuItem.defaultProps = {
// Significantly speeds up re-renders of the EmojiPickerMenu's FlatList
// by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action.
-export default React.memo(
- EmojiPickerMenuItem,
- (prevProps, nextProps) =>
- prevProps.isHighlighted === nextProps.isHighlighted && prevProps.emoji === nextProps.emoji && prevProps.isUsingKeyboardMovement === nextProps.isUsingKeyboardMovement,
-);
+export default React.memo(EmojiPickerMenuItem, (prevProps, nextProps) => prevProps.isFocused === nextProps.isFocused && prevProps.emoji === nextProps.emoji);
diff --git a/src/components/FixedFooter.js b/src/components/FixedFooter.js
deleted file mode 100644
index bad2639ae7e8..000000000000
--- a/src/components/FixedFooter.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import styles from '../styles/styles';
-
-const propTypes = {
- /** Children to wrap in FixedFooter. */
- children: PropTypes.node.isRequired,
-
- /** Styles to be assigned to Container */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- style: [],
-};
-
-function FixedFooter(props) {
- return {props.children};
-}
-
-FixedFooter.propTypes = propTypes;
-FixedFooter.defaultProps = defaultProps;
-FixedFooter.displayName = 'FixedFooter';
-export default FixedFooter;
diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx
new file mode 100644
index 000000000000..c44b9bf3d0e0
--- /dev/null
+++ b/src/components/FixedFooter.tsx
@@ -0,0 +1,19 @@
+import React, {ReactNode} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import styles from '../styles/styles';
+
+type FixedFooterProps = {
+ /** Children to wrap in FixedFooter. */
+ children: ReactNode;
+
+ /** Styles to be assigned to Container */
+ style: Array>;
+};
+
+function FixedFooter({style = [], children}: FixedFooterProps) {
+ return {children};
+}
+
+FixedFooter.displayName = 'FixedFooter';
+
+export default FixedFooter;
diff --git a/src/components/FlatList/index.android.js b/src/components/FlatList/index.android.js
index 8763488cf180..d047124da914 100644
--- a/src/components/FlatList/index.android.js
+++ b/src/components/FlatList/index.android.js
@@ -65,10 +65,14 @@ function CustomFlatList(props) {
CustomFlatList.propTypes = propTypes;
CustomFlatList.defaultProps = defaultProps;
-export default forwardRef((props, ref) => (
+const CustomFlatListWithRef = forwardRef((props, ref) => (
));
+
+CustomFlatListWithRef.displayName = 'CustomFlatListWithRef';
+
+export default CustomFlatListWithRef;
diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js
index d6f5b907ace0..947acf801f19 100644
--- a/src/components/FloatingActionButton.js
+++ b/src/components/FloatingActionButton.js
@@ -6,7 +6,7 @@ import * as Expensicons from './Icon/Expensicons';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
import themeColors from '../styles/themes/default';
-import Tooltip from './Tooltip';
+import Tooltip from './Tooltip/PopoverAnchorTooltip';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import variables from '../styles/variables';
@@ -118,10 +118,14 @@ FloatingActionButton.defaultProps = defaultProps;
const FloatingActionButtonWithLocalize = withLocalize(FloatingActionButton);
-export default React.forwardRef((props, ref) => (
+const FloatingActionButtonWithLocalizeWithRef = React.forwardRef((props, ref) => (
));
+
+FloatingActionButtonWithLocalizeWithRef.displayName = 'FloatingActionButtonWithLocalizeWithRef';
+
+export default FloatingActionButtonWithLocalizeWithRef;
diff --git a/src/components/Form.js b/src/components/Form.js
index b4e639dcf964..436af78a2b2c 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -108,7 +108,7 @@ const defaultProps = {
function Form(props) {
const [errors, setErrors] = useState({});
- const [inputValues, setInputValues] = useState({...props.draftValues});
+ const [inputValues, setInputValues] = useState(() => ({...props.draftValues}));
const formRef = useRef(null);
const formContentRef = useRef(null);
const inputRefs = useRef({});
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index ada40c24ed89..add58dbef18c 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -100,7 +100,7 @@ function getInitialValueByType(valueType) {
}
function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) {
- const inputRefs = useRef(null);
+ const inputRefs = useRef({});
const touchedInputs = useRef({});
const [inputValues, setInputValues] = useState({});
const [errors, setErrors] = useState({});
@@ -204,8 +204,10 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
const registerInput = useCallback(
(inputID, propsToParse = {}) => {
- const newRef = propsToParse.ref || createRef();
- inputRefs[inputID] = newRef;
+ const newRef = inputRefs.current[inputID] || propsToParse.ref || createRef();
+ if (inputRefs.current[inputID] !== newRef) {
+ inputRefs.current[inputID] = newRef;
+ }
if (!_.isUndefined(propsToParse.value)) {
inputValues[inputID] = propsToParse.value;
diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js
index 3d9fd37d6f22..82e70b68b3f0 100644
--- a/src/components/Form/FormWrapper.js
+++ b/src/components/Form/FormWrapper.js
@@ -105,8 +105,8 @@ function FormWrapper(props) {
footerContent={footerContent}
onFixTheErrorsLinkPressed={() => {
const errorFields = !_.isEmpty(errors) ? errors : formState.errorFields;
- const focusKey = _.find(_.keys(inputRefs), (key) => _.keys(errorFields).includes(key));
- const focusInput = inputRefs[focusKey].current;
+ const focusKey = _.find(_.keys(inputRefs.current), (key) => _.keys(errorFields).includes(key));
+ const focusInput = inputRefs.current[focusKey].current;
// Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method.
if (typeof focusInput.isFocused !== 'function') {
diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js
index 43064b5a6690..8a87bc2f5a5a 100644
--- a/src/components/Form/InputWrapper.js
+++ b/src/components/Form/InputWrapper.js
@@ -25,10 +25,14 @@ InputWrapper.propTypes = propTypes;
InputWrapper.defaultProps = defaultProps;
InputWrapper.displayName = 'InputWrapper';
-export default forwardRef((props, ref) => (
+const InputWrapperWithRef = forwardRef((props, ref) => (
));
+
+InputWrapperWithRef.displayName = 'InputWrapperWithRef';
+
+export default InputWrapperWithRef;
diff --git a/src/components/FormAlertWrapper.js b/src/components/FormAlertWrapper.js
index 704d9b5a241c..67e031ce6ab6 100644
--- a/src/components/FormAlertWrapper.js
+++ b/src/components/FormAlertWrapper.js
@@ -66,7 +66,7 @@ function FormAlertWrapper(props) {
);
} else if (props.isMessageHtml) {
- children = ${props.message}`} />;
+ children = ${props.message}`} />;
}
return (
diff --git a/src/components/FormScrollView.js b/src/components/FormScrollView.js
index aa84bfefcc2f..b52c8d00c51a 100644
--- a/src/components/FormScrollView.js
+++ b/src/components/FormScrollView.js
@@ -8,7 +8,7 @@ const propTypes = {
children: PropTypes.node.isRequired,
};
-const FormScrollView = React.forwardRef((props, ref) => (
+const FormScrollViewWithRef = React.forwardRef((props, ref) => (
(
));
-FormScrollView.propTypes = propTypes;
-export default FormScrollView;
+FormScrollViewWithRef.displayName = 'FormScrollViewWithRef';
+FormScrollViewWithRef.propTypes = propTypes;
+export default FormScrollViewWithRef;
diff --git a/src/components/FormSubmit/index.js b/src/components/FormSubmit/index.js
index 7f76f77fe549..343f23bc1087 100644
--- a/src/components/FormSubmit/index.js
+++ b/src/components/FormSubmit/index.js
@@ -75,10 +75,14 @@ function FormSubmit({innerRef, children, onSubmit, style}) {
FormSubmit.propTypes = formSubmitPropTypes.propTypes;
FormSubmit.defaultProps = formSubmitPropTypes.defaultProps;
-export default React.forwardRef((props, ref) => (
+const FormSubmitWithRef = React.forwardRef((props, ref) => (
));
+
+FormSubmitWithRef.displayName = 'FormSubmitWithRef';
+
+export default FormSubmitWithRef;
diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
index c806bedc31c7..04759b89e5d0 100755
--- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
+++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js
@@ -29,9 +29,13 @@ 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.formError, ...styles.mb0},
+ mixedUAStyles: {...styles.colorMuted, ...styles.mb0},
}),
comment: defaultHTMLElementModels.div.extend({
tagName: 'comment',
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js
index 812f4e951f74..6c098e8bea05 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js
@@ -6,6 +6,9 @@ import lodashGet from 'lodash/get';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import Text from '../../Text';
+import styles from '../../../styles/styles';
+import * as ReportUtils from '../../../libs/ReportUtils';
+import {ShowContextMenuContext, showContextMenuForReport} from '../../ShowContextMenuContext';
import UserDetailsTooltip from '../../UserDetailsTooltip';
import htmlRendererPropTypes from './htmlRendererPropTypes';
import withCurrentUserPersonalDetails from '../../withCurrentUserPersonalDetails';
@@ -13,10 +16,10 @@ import personalDetailsPropType from '../../../pages/personalDetailsPropType';
import * as StyleUtils from '../../../styles/StyleUtils';
import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils';
import compose from '../../../libs/compose';
-import TextLink from '../../TextLink';
import ONYXKEYS from '../../../ONYXKEYS';
import useLocalize from '../../../hooks/useLocalize';
import CONST from '../../../CONST';
+import * as LocalePhoneNumber from '../../../libs/LocalePhoneNumber';
const propTypes = {
...htmlRendererPropTypes,
@@ -40,7 +43,7 @@ function MentionUserRenderer(props) {
if (!_.isEmpty(htmlAttribAccountID)) {
const user = lodashGet(props.personalDetails, htmlAttribAccountID);
accountID = parseInt(htmlAttribAccountID, 10);
- displayNameOrLogin = lodashGet(user, 'login', '') || lodashGet(user, 'displayName', '') || translate('common.hidden');
+ displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(lodashGet(user, 'login', '')) || lodashGet(user, 'displayName', '') || translate('common.hidden');
navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID);
} else if (!_.isEmpty(props.tnode.data)) {
// We need to remove the LTR unicode and leading @ from data as it is not part of the login
@@ -56,26 +59,38 @@ function MentionUserRenderer(props) {
const isOurMention = accountID === props.currentUserPersonalDetails.accountID;
return (
-
-
- Navigation.navigate(navigationRoute)}
- // Add testID so it is NOT selected as an anchor tag by SelectionScraper
- testID="span"
+
+ {({anchor, report, action, checkIfContextMenuActive}) => (
+ showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))}
+ onPress={(event) => {
+ event.preventDefault();
+ Navigation.navigate(navigationRoute);
+ }}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.LINK}
+ accessibilityLabel={`/${navigationRoute}`}
>
- {!_.isEmpty(htmlAttribAccountID) ? `@${displayNameOrLogin}` : }
-
-
-
+
+
+ {!_.isEmpty(htmlAttribAccountID) ? `@${displayNameOrLogin}` : }
+
+
+
+ )}
+
);
}
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js
index 26febfe5745d..6a02ce02237d 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.js
@@ -54,7 +54,12 @@ function HeaderWithBackButton({
const {translate} = useLocalize();
const {isKeyboardShown} = useKeyboardState();
return (
-
+
{shouldShowBackButton && (
diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js
index 5cba52db5a7b..2ded0e52e94d 100644
--- a/src/components/Hoverable/index.js
+++ b/src/components/Hoverable/index.js
@@ -1,197 +1,216 @@
import _ from 'underscore';
-import React, {Component} from 'react';
+import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {propTypes, defaultProps} from './hoverablePropTypes';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import CONST from '../../CONST';
+/**
+ * Maps the children of a Hoverable component to
+ * - a function that is called with the parameter
+ * - the child itself if it is the only child
+ * @param {Array|Function|ReactNode} children - The children to map.
+ * @param {Object} callbackParam - The parameter to pass to the children function.
+ * @returns {ReactNode} The mapped children.
+ */
+function mapChildren(children, callbackParam) {
+ if (_.isArray(children) && children.length === 1) {
+ return children[0];
+ }
+
+ if (_.isFunction(children)) {
+ return children(callbackParam);
+ }
+
+ return children;
+}
+
+/**
+ * Assigns a ref to an element, either by setting the current property of the ref object or by calling the ref function
+ * @param {Object|Function} ref - The ref object or function.
+ * @param {HTMLElement} el - The element to assign the ref to.
+ */
+function assignRef(ref, el) {
+ if (!ref) {
+ return;
+ }
+
+ if (_.has(ref, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ ref.current = el;
+ }
+
+ if (_.isFunction(ref)) {
+ ref(el);
+ }
+}
+
/**
* It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state,
* because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the
* parent. https://github.com/necolas/react-native-web/issues/1875
*/
-class Hoverable extends Component {
- constructor(props) {
- super(props);
- this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
- this.checkHover = this.checkHover.bind(this);
+const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnter, onMouseLeave, children, shouldHandleScroll}, outerRef) => {
+ const [isHovered, setIsHovered] = useState(false);
- this.state = {
- isHovered: false,
- };
+ const isScrolling = useRef(false);
+ const isHoveredRef = useRef(false);
+ const ref = useRef(null);
- this.isHoveredRef = false;
- this.isScrollingRef = false;
- this.wrapperView = null;
- }
+ const updateIsHoveredOnScrolling = useCallback(
+ (hovered) => {
+ if (disabled) {
+ return;
+ }
- componentDidMount() {
- document.addEventListener('visibilitychange', this.handleVisibilityChange);
- document.addEventListener('mouseover', this.checkHover);
+ isHoveredRef.current = hovered;
- /**
- * Only add the scrolling listener if the shouldHandleScroll prop is true
- * and the scrollingListener is not already set.
- */
- if (!this.scrollingListener && this.props.shouldHandleScroll) {
- this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
- /**
- * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state.
- */
- if (!scrolling && this.isHoveredRef) {
- this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn);
- } else if (scrolling && this.isHoveredRef) {
- /**
- * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false.
- * This is to hide the existing hover and reaction bar.
- */
- this.setState({isHovered: false}, this.props.onHoverOut);
- }
- this.isScrollingRef = scrolling;
- });
- }
- }
+ if (shouldHandleScroll && isScrolling.current) {
+ return;
+ }
+ setIsHovered(hovered);
+ },
+ [disabled, shouldHandleScroll],
+ );
+
+ useEffect(() => {
+ const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
+
+ document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
- componentDidUpdate(prevProps) {
- if (prevProps.disabled === this.props.disabled) {
+ return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
+ }, []);
+
+ useEffect(() => {
+ if (!shouldHandleScroll) {
return;
}
- if (this.props.disabled && this.state.isHovered) {
- this.setState({isHovered: false});
- }
- }
+ const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
+ isScrolling.current = scrolling;
+ if (!scrolling) {
+ setIsHovered(isHoveredRef.current);
+ }
+ });
- componentWillUnmount() {
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
- document.removeEventListener('mouseover', this.checkHover);
- if (this.scrollingListener) {
- this.scrollingListener.remove();
- }
- }
+ return () => scrollingListener.remove();
+ }, [shouldHandleScroll]);
- /**
- * Sets the hover state of this component to true and execute the onHoverIn callback.
- *
- * @param {Boolean} isHovered - Whether or not this component is hovered.
- */
- setIsHovered(isHovered) {
- if (this.props.disabled) {
+ useEffect(() => {
+ if (!DeviceCapabilities.hasHoverSupport()) {
return;
}
/**
- * Capture whther or not the user is hovering over the component.
- * We will use this to determine if we should update the hover state when the user has stopped scrolling.
+ * Checks the hover state of a component and updates it based on the event target.
+ * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
+ * such as when an element is removed before the mouseleave event is triggered.
+ * @param {Event} e - The hover event object.
*/
- this.isHoveredRef = isHovered;
+ const unsetHoveredIfOutside = (e) => {
+ if (!ref.current || !isHovered) {
+ return;
+ }
- /**
- * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state.
- */
- if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) {
- return;
- }
+ if (ref.current.contains(e.target)) {
+ return;
+ }
- if (isHovered !== this.state.isHovered) {
- this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut);
- }
- }
+ setIsHovered(false);
+ };
- /**
- * Checks the hover state of a component and updates it based on the event target.
- * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger,
- * such as when an element is removed before the mouseleave event is triggered.
- * @param {Event} e - The hover event object.
- */
- checkHover(e) {
- if (!this.wrapperView || !this.state.isHovered) {
- return;
- }
+ document.addEventListener('mouseover', unsetHoveredIfOutside);
- if (this.wrapperView.contains(e.target)) {
- return;
- }
-
- this.setIsHovered(false);
- }
+ return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
+ }, [isHovered]);
- handleVisibilityChange() {
- if (document.visibilityState !== 'hidden') {
+ useEffect(() => {
+ if (!disabled || !isHovered) {
return;
}
+ setIsHovered(false);
+ }, [disabled, isHovered]);
- this.setIsHovered(false);
- }
-
- render() {
- let child = this.props.children;
- if (_.isArray(this.props.children) && this.props.children.length === 1) {
- child = this.props.children[0];
+ useEffect(() => {
+ if (disabled) {
+ return;
}
-
- if (_.isFunction(child)) {
- child = child(this.state.isHovered);
+ if (onHoverIn && isHovered) {
+ return onHoverIn();
}
-
- if (!DeviceCapabilities.hasHoverSupport()) {
- return child;
+ if (onHoverOut && !isHovered) {
+ return onHoverOut();
}
-
- return React.cloneElement(React.Children.only(child), {
- ref: (el) => {
- this.wrapperView = el;
-
- // Call the original ref, if any
- const {ref} = child;
- if (_.isFunction(ref)) {
- ref(el);
- return;
- }
-
- if (_.isObject(ref)) {
- ref.current = el;
- }
- },
- onMouseEnter: (el) => {
- if (_.isFunction(this.props.onMouseEnter)) {
- this.props.onMouseEnter(el);
- }
-
- this.setIsHovered(true);
-
- if (_.isFunction(child.props.onMouseEnter)) {
- child.props.onMouseEnter(el);
- }
- },
- onMouseLeave: (el) => {
- if (_.isFunction(this.props.onMouseLeave)) {
- this.props.onMouseLeave(el);
- }
-
- this.setIsHovered(false);
-
- if (_.isFunction(child.props.onMouseLeave)) {
- child.props.onMouseLeave(el);
- }
- },
- onBlur: (el) => {
- // Check if the blur event occurred due to clicking outside the element
- // and the wrapperView contains the element that caused the blur and reset isHovered
- if (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) {
- this.setIsHovered(false);
- }
-
- if (_.isFunction(child.props.onBlur)) {
- child.props.onBlur(el);
- }
- },
- });
+ }, [disabled, isHovered, onHoverIn, onHoverOut]);
+
+ // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
+ useImperativeHandle(outerRef, () => ref.current, []);
+
+ const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]);
+
+ const enableHoveredOnMouseEnter = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(true);
+
+ if (_.isFunction(onMouseEnter)) {
+ onMouseEnter(el);
+ }
+
+ if (_.isFunction(child.props.onMouseEnter)) {
+ child.props.onMouseEnter(el);
+ }
+ },
+ [child.props, onMouseEnter, updateIsHoveredOnScrolling],
+ );
+
+ const disableHoveredOnMouseLeave = useCallback(
+ (el) => {
+ updateIsHoveredOnScrolling(false);
+
+ if (_.isFunction(onMouseLeave)) {
+ onMouseLeave(el);
+ }
+
+ if (_.isFunction(child.props.onMouseLeave)) {
+ child.props.onMouseLeave(el);
+ }
+ },
+ [child.props, onMouseLeave, updateIsHoveredOnScrolling],
+ );
+
+ const disableHoveredOnBlur = useCallback(
+ (el) => {
+ // Check if the blur event occurred due to clicking outside the element
+ // and the wrapperView contains the element that caused the blur and reset isHovered
+ if (!ref.current.contains(el.target) && !ref.current.contains(el.relatedTarget)) {
+ setIsHovered(false);
+ }
+
+ if (_.isFunction(child.props.onBlur)) {
+ child.props.onBlur(el);
+ }
+ },
+ [child.props],
+ );
+
+ if (!DeviceCapabilities.hasHoverSupport()) {
+ return child;
}
-}
+
+ return React.cloneElement(child, {
+ ref: (el) => {
+ ref.current = el;
+ assignRef(child.ref, el);
+ },
+ onMouseEnter: enableHoveredOnMouseEnter,
+ onMouseLeave: disableHoveredOnMouseLeave,
+ onBlur: disableHoveredOnBlur,
+ });
+});
Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
+Hoverable.displayName = 'Hoverable';
export default Hoverable;
diff --git a/src/components/Icon/BankIcons.ts b/src/components/Icon/BankIcons.ts
index 3118eec56a6d..a30594d1ab3f 100644
--- a/src/components/Icon/BankIcons.ts
+++ b/src/components/Icon/BankIcons.ts
@@ -1,5 +1,6 @@
import {SvgProps} from 'react-native-svg';
-import * as Expensicons from './Expensicons';
+import {CSSProperties} from 'react';
+import {ViewStyle} from 'react-native';
import AmericanExpress from '../../../assets/images/bankicons/american-express.svg';
import BankOfAmerica from '../../../assets/images/bankicons/bank-of-america.svg';
import BB_T from '../../../assets/images/bankicons/bb-t.svg';
@@ -19,11 +20,36 @@ import SunTrust from '../../../assets/images/bankicons/suntrust.svg';
import TdBank from '../../../assets/images/bankicons/td-bank.svg';
import USBank from '../../../assets/images/bankicons/us-bank.svg';
import USAA from '../../../assets/images/bankicons/usaa.svg';
+// Card Icons
+import AmericanExpressCard from '../../../assets/images/cardicons/american-express.svg';
+import BankOfAmericaCard from '../../../assets/images/cardicons/bank-of-america.svg';
+import BB_TCard from '../../../assets/images/cardicons/bb-t.svg';
+import CapitalOneCard from '../../../assets/images/cardicons/capital-one.svg';
+import CharlesSchwabCard from '../../../assets/images/cardicons/charles-schwab.svg';
+import ChaseCard from '../../../assets/images/cardicons/chase.svg';
+import CitiBankCard from '../../../assets/images/cardicons/citibank.svg';
+import CitizensBankCard from '../../../assets/images/cardicons/citizens.svg';
+import DiscoverCard from '../../../assets/images/cardicons/discover.svg';
+import FidelityCard from '../../../assets/images/cardicons/fidelity.svg';
+import HuntingtonBankCard from '../../../assets/images/cardicons/huntington-bank.svg';
+import GenericBankCard from '../../../assets/images/cardicons/generic-bank-card.svg';
+import NavyFederalCreditUnionCard from '../../../assets/images/cardicons/navy-federal-credit-union.svg';
+import PNCCard from '../../../assets/images/cardicons/pnc.svg';
+import RegionsBankCard from '../../../assets/images/cardicons/regions-bank.svg';
+import SunTrustCard from '../../../assets/images/cardicons/suntrust.svg';
+import TdBankCard from '../../../assets/images/cardicons/td-bank.svg';
+import USBankCard from '../../../assets/images/cardicons/us-bank.svg';
+import USAACard from '../../../assets/images/cardicons/usaa.svg';
+import ExpensifyCardImage from '../../../assets/images/cardicons/expensify-card-dark.svg';
+import styles from '../../styles/styles';
import variables from '../../styles/variables';
type BankIcon = {
icon: React.FC;
iconSize?: number;
+ iconHeight?: number;
+ iconWidth?: number;
+ iconStyles?: Array;
};
/**
@@ -31,79 +57,83 @@ type BankIcon = {
*/
function getAssetIcon(bankName: string, isCard: boolean): React.FC {
+ if (bankName.includes('expensify')) {
+ return ExpensifyCardImage;
+ }
+
if (bankName.includes('americanexpress')) {
- return AmericanExpress;
+ return isCard ? AmericanExpressCard : AmericanExpress;
}
if (bankName.includes('bank of america') || bankName.includes('bankofamerica')) {
- return BankOfAmerica;
+ return isCard ? BankOfAmericaCard : BankOfAmerica;
}
if (bankName.startsWith('bbt')) {
- return BB_T;
+ return isCard ? BB_TCard : BB_T;
}
if (bankName.startsWith('capital one') || bankName.includes('capitalone')) {
- return CapitalOne;
+ return isCard ? CapitalOneCard : CapitalOne;
}
if (bankName.startsWith('chase') || bankName.includes('chase')) {
- return Chase;
+ return isCard ? ChaseCard : Chase;
}
if (bankName.includes('charles schwab') || bankName.includes('charlesschwab')) {
- return CharlesSchwab;
+ return isCard ? CharlesSchwabCard : CharlesSchwab;
}
if (bankName.startsWith('citibank') || bankName.includes('citibank')) {
- return CitiBank;
+ return isCard ? CitiBankCard : CitiBank;
}
if (bankName.startsWith('citizens bank') || bankName.includes('citizensbank')) {
- return CitizensBank;
+ return isCard ? CitizensBankCard : CitizensBank;
}
if (bankName.startsWith('discover ') || bankName.includes('discover.') || bankName === 'discover') {
- return Discover;
+ return isCard ? DiscoverCard : Discover;
}
if (bankName.startsWith('fidelity')) {
- return Fidelity;
+ return isCard ? FidelityCard : Fidelity;
}
if (bankName.startsWith('huntington bank') || bankName.includes('huntingtonnational') || bankName.includes('huntington national')) {
- return HuntingtonBank;
+ return isCard ? HuntingtonBankCard : HuntingtonBank;
}
if (bankName.startsWith('navy federal credit union') || bankName.includes('navy federal credit union')) {
- return NavyFederalCreditUnion;
+ return isCard ? NavyFederalCreditUnionCard : NavyFederalCreditUnion;
}
if (bankName.startsWith('pnc') || bankName.includes('pnc')) {
- return PNC;
+ return isCard ? PNCCard : PNC;
}
if (bankName.startsWith('regions bank') || bankName.includes('regionsbank')) {
- return RegionsBank;
+ return isCard ? RegionsBankCard : RegionsBank;
}
if (bankName.startsWith('suntrust') || bankName.includes('suntrust')) {
- return SunTrust;
+ return isCard ? SunTrustCard : SunTrust;
}
if (bankName.startsWith('td bank') || bankName.startsWith('tdbank') || bankName.includes('tdbank')) {
- return TdBank;
+ return isCard ? TdBankCard : TdBank;
}
if (bankName.startsWith('us bank') || bankName.startsWith('usbank')) {
- return USBank;
+ return isCard ? USBankCard : USBank;
}
if (bankName.includes('usaa')) {
- return USAA;
+ return isCard ? USAACard : USAA;
}
- return isCard ? Expensicons.CreditCard : GenericBank;
+ return isCard ? GenericBankCard : GenericBank;
}
/**
@@ -112,7 +142,7 @@ function getAssetIcon(bankName: string, isCard: boolean): React.FC {
export default function getBankIcon(bankName: string, isCard = false): BankIcon {
const bankIcon: BankIcon = {
- icon: isCard ? Expensicons.CreditCard : GenericBank,
+ icon: isCard ? GenericBankCard : GenericBank,
};
if (bankName) {
@@ -120,8 +150,13 @@ export default function getBankIcon(bankName: string, isCard = false): BankIcon
}
// For default Credit Card icon the icon size should not be set.
- if (![Expensicons.CreditCard].includes(bankIcon.icon)) {
+ if (!isCard) {
bankIcon.iconSize = variables.iconSizeExtraLarge;
+ bankIcon.iconStyles = [styles.bankIconContainer];
+ } else {
+ bankIcon.iconHeight = variables.bankCardHeight;
+ bankIcon.iconWidth = variables.bankCardWidth;
+ bankIcon.iconStyles = [styles.assignedCardsIconContainer];
}
return bankIcon;
diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js
index 0e39872a3da6..c9a86cf8f10c 100644
--- a/src/components/Icon/Illustrations.js
+++ b/src/components/Icon/Illustrations.js
@@ -46,6 +46,7 @@ import TreasureChest from '../../../assets/images/simple-illustrations/simple-il
import ThumbsUpStars from '../../../assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg';
import Hands from '../../../assets/images/product-illustrations/home-illustration-hands.svg';
import HandEarth from '../../../assets/images/simple-illustrations/simple-illustration__handearth.svg';
+import SmartScan from '../../../assets/images/product-illustrations/simple-illustration__smartscan.svg';
export {
Abracadabra,
@@ -96,4 +97,5 @@ export {
ThumbsUpStars,
Hands,
HandEarth,
+ SmartScan,
};
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js
index f49214f5de70..baee08eae4cd 100644
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.js
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js
@@ -133,6 +133,9 @@ function BaseInvertedFlatList(props) {
// Web requires that items be measured or else crazy things happen when scrolling.
getItemLayout={shouldMeasureItems ? getItemLayout : undefined}
windowSize={15}
+ maintainVisibleContentPosition={{
+ minIndexForVisible: 0,
+ }}
inverted
/>
);
@@ -142,10 +145,14 @@ BaseInvertedFlatList.propTypes = propTypes;
BaseInvertedFlatList.defaultProps = defaultProps;
BaseInvertedFlatList.displayName = 'BaseInvertedFlatList';
-export default forwardRef((props, ref) => (
+const BaseInvertedFlatListWithRef = forwardRef((props, ref) => (
));
+
+BaseInvertedFlatListWithRef.displayName = 'BaseInvertedFlatListWithRef';
+
+export default BaseInvertedFlatListWithRef;
diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js
index 564db6296c9b..d328ca93575b 100644
--- a/src/components/InvertedFlatList/index.js
+++ b/src/components/InvertedFlatList/index.js
@@ -131,10 +131,14 @@ InvertedFlatList.defaultProps = {
onScroll: () => {},
};
-export default forwardRef((props, ref) => (
+const InvertedFlatListWithRef = forwardRef((props, ref) => (
));
+
+InvertedFlatListWithRef.displayName = 'InvertedFlatListWithRef';
+
+export default InvertedFlatListWithRef;
diff --git a/src/components/InvertedFlatList/index.native.js b/src/components/InvertedFlatList/index.native.js
index ece86032d80b..8473b602d45f 100644
--- a/src/components/InvertedFlatList/index.native.js
+++ b/src/components/InvertedFlatList/index.native.js
@@ -2,7 +2,7 @@ import React, {forwardRef} from 'react';
import BaseInvertedFlatList from './BaseInvertedFlatList';
import CellRendererComponent from './CellRendererComponent';
-export default forwardRef((props, ref) => (
+const BaseInvertedFlatListWithRef = forwardRef((props, ref) => (
(
removeClippedSubviews={false}
/>
));
+
+BaseInvertedFlatListWithRef.displayName = 'BaseInvertedFlatListWithRef';
+
+export default BaseInvertedFlatListWithRef;
diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js
index 1c1552d55844..ccee8bc4e6a0 100644
--- a/src/components/KYCWall/BaseKYCWall.js
+++ b/src/components/KYCWall/BaseKYCWall.js
@@ -1,3 +1,4 @@
+import _ from 'underscore';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import {Dimensions} from 'react-native';
@@ -23,6 +24,7 @@ class KYCWall extends React.Component {
this.continue = this.continue.bind(this);
this.setMenuPosition = this.setMenuPosition.bind(this);
+ this.selectPaymentMethod = this.selectPaymentMethod.bind(this);
this.anchorRef = React.createRef(null);
this.state = {
@@ -38,7 +40,6 @@ class KYCWall extends React.Component {
if (this.props.shouldListenForResize) {
this.dimensionsSubscription = Dimensions.addEventListener('change', this.setMenuPosition);
}
- Wallet.setKYCWallSourceChatReportID(this.props.chatReportID);
}
componentWillUnmount() {
@@ -87,6 +88,18 @@ class KYCWall extends React.Component {
});
}
+ /**
+ * @param {String} paymentMethod
+ */
+ selectPaymentMethod(paymentMethod) {
+ this.props.onSelectPaymentMethod(paymentMethod);
+ if (paymentMethod === CONST.PAYMENT_METHODS.BANK_ACCOUNT) {
+ Navigation.navigate(this.props.addBankAccountRoute);
+ } else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) {
+ Navigation.navigate(this.props.addDebitCardRoute);
+ }
+ }
+
/**
* Take the position of the button that calls this method and show the Add Payment method menu when the user has no valid payment method.
* If they do have a valid payment method they are navigated to the "enable payments" route to complete KYC checks.
@@ -96,6 +109,14 @@ class KYCWall extends React.Component {
* @param {String} iouPaymentType
*/
continue(event, iouPaymentType) {
+ const currentSource = lodashGet(this.props.walletTerms, 'source', this.props.source);
+
+ /**
+ * Set the source, so we can tailor the process according to how we got here.
+ * We do not want to set this on mount, as the source can change upon completing the flow, e.g. when upgrading the wallet to Gold.
+ */
+ Wallet.setKYCWallSource(this.props.source, this.props.chatReportID);
+
if (this.state.shouldShowAddPaymentMenu) {
this.setState({shouldShowAddPaymentMenu: false});
return;
@@ -110,9 +131,13 @@ class KYCWall extends React.Component {
// Check to see if user has a valid payment method on file and display the add payment popover if they don't
if (
(isExpenseReport && lodashGet(this.props.reimbursementAccount, 'achData.state', '') !== CONST.BANK_ACCOUNT.STATE.OPEN) ||
- (!isExpenseReport && !PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, this.props.bankAccountList))
+ (!isExpenseReport && !PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, this.props.bankAccountList, this.props.shouldIncludeDebitCard))
) {
Log.info('[KYC Wallet] User does not have valid payment method');
+ if (!this.props.shouldIncludeDebitCard) {
+ this.selectPaymentMethod(CONST.PAYMENT_METHODS.BANK_ACCOUNT);
+ return;
+ }
const clickedElementLocation = getClickedTargetLocation(targetElement);
const position = this.getAnchorPosition(clickedElementLocation);
this.setPositionAddPaymentMenu(position);
@@ -123,15 +148,15 @@ class KYCWall extends React.Component {
}
if (!isExpenseReport) {
// Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks
- const hasGoldWallet = this.props.userWallet.tierName && this.props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD;
- if (!hasGoldWallet) {
- Log.info('[KYC Wallet] User does not have gold wallet');
+ const hasActivatedWallet = this.props.userWallet.tierName && _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], this.props.userWallet.tierName);
+ if (!hasActivatedWallet) {
+ Log.info('[KYC Wallet] User does not have active wallet');
Navigation.navigate(this.props.enablePaymentsRoute);
return;
}
}
Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them');
- this.props.onSuccessfulKYC(iouPaymentType);
+ this.props.onSuccessfulKYC(iouPaymentType, currentSource);
}
render() {
@@ -148,11 +173,7 @@ class KYCWall extends React.Component {
anchorAlignment={this.props.anchorAlignment}
onItemSelected={(item) => {
this.setState({shouldShowAddPaymentMenu: false});
- if (item === CONST.PAYMENT_METHODS.BANK_ACCOUNT) {
- Navigation.navigate(this.props.addBankAccountRoute);
- } else if (item === CONST.PAYMENT_METHODS.DEBIT_CARD) {
- Navigation.navigate(this.props.addDebitCardRoute);
- }
+ this.selectPaymentMethod(item);
}}
/>
{this.props.children(this.continue, this.anchorRef)}
@@ -168,6 +189,9 @@ export default withOnyx({
userWallet: {
key: ONYXKEYS.USER_WALLET,
},
+ walletTerms: {
+ key: ONYXKEYS.WALLET_TERMS,
+ },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
diff --git a/src/components/KYCWall/kycWallPropTypes.js b/src/components/KYCWall/kycWallPropTypes.js
index 6c117eb67f5b..b585535784dc 100644
--- a/src/components/KYCWall/kycWallPropTypes.js
+++ b/src/components/KYCWall/kycWallPropTypes.js
@@ -5,6 +5,7 @@ import bankAccountPropTypes from '../bankAccountPropTypes';
import cardPropTypes from '../cardPropTypes';
import iouReportPropTypes from '../../pages/iouReportPropTypes';
import reimbursementAccountPropTypes from '../../pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes';
+import walletTermsPropTypes from '../../pages/EnablePayments/walletTermsPropTypes';
import CONST from '../../CONST';
const propTypes = {
@@ -26,6 +27,12 @@ const propTypes = {
/** The user's wallet */
userWallet: userWalletPropTypes,
+ /** Information related to the last step of the wallet activation flow */
+ walletTerms: walletTermsPropTypes,
+
+ /** The source that triggered the KYC wall */
+ source: PropTypes.oneOf(_.values(CONST.KYC_WALL_SOURCE)).isRequired,
+
/** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */
chatReportID: PropTypes.string,
@@ -49,10 +56,17 @@ const propTypes = {
horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
}),
+
+ /** Whether the option to add a debit card should be included */
+ shouldIncludeDebitCard: PropTypes.bool,
+
+ /** Callback for when a payment method has been selected */
+ onSelectPaymentMethod: PropTypes.func,
};
const defaultProps = {
userWallet: {},
+ walletTerms: {},
shouldListenForResize: false,
isDisabled: false,
chatReportID: '',
@@ -66,6 +80,8 @@ const defaultProps = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
},
+ shouldIncludeDebitCard: true,
+ onSelectPaymentMethod: () => {},
};
export {propTypes, defaultProps};
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index 17c2ef0c1998..2b992e462e34 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -154,6 +154,10 @@ function OptionRowLHN(props) {
const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText;
const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem);
+ const isGroupChat =
+ optionItem.type === CONST.REPORT.TYPE.CHAT && _.isEmpty(optionItem.chatType) && !optionItem.isThread && lodashGet(optionItem, 'displayNamesWithTooltips.length', 0) > 2;
+ const fullTitle = isGroupChat ? ReportUtils.getDisplayNamesStringFromTooltips(optionItem.displayNamesWithTooltips) : optionItem.text;
+
return (
{
showPopover(e);
// Ensure that we blur the composer when opening context menu, so that only one component is focused at a time
- DomUtils.getActiveElement().blur();
+ if (DomUtils.getActiveElement()) {
+ DomUtils.getActiveElement().blur();
+ }
}}
withoutFocusOnSecondaryInteraction
activeOpacity={0.8}
@@ -231,7 +237,7 @@ function OptionRowLHN(props) {
- {optionItem.isLastMessageDeletedParentAction ? translate('parentReportAction.deletedMessage') : optionItem.alternateText}
+ {optionItem.alternateText}
) : null}
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js
index 3386dbe8c8cd..e93e3690138e 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.js
+++ b/src/components/LHNOptionsList/OptionRowLHNData.js
@@ -4,7 +4,6 @@ import _ from 'underscore';
import PropTypes from 'prop-types';
import React, {useEffect, useRef, useMemo} from 'react';
import {deepEqual} from 'fast-equals';
-import {withReportCommentDrafts} from '../OnyxProvider';
import SidebarUtils from '../../libs/SidebarUtils';
import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
@@ -164,14 +163,10 @@ const personalDetailsSelector = (personalDetails) =>
*/
export default React.memo(
compose(
- withReportCommentDrafts({
- propName: 'comment',
- transformValue: (drafts, props) => {
- const draftKey = `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${props.reportID}`;
- return lodashGet(drafts, draftKey, '');
- },
- }),
withOnyx({
+ comment: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
+ },
fullReport: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
},
diff --git a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js
index bdcd6bed3638..3a638f3e999e 100644
--- a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js
+++ b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js
@@ -59,6 +59,7 @@ function BaseLocationErrorMessage({onClose, onAllowLocationLinkPress, locationEr
e.preventDefault()}
style={[styles.touchableButtonImage]}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={translate('common.close')}
diff --git a/src/components/LottieAnimations.js b/src/components/LottieAnimations.ts
similarity index 100%
rename from src/components/LottieAnimations.js
rename to src/components/LottieAnimations.ts
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index dcaa0273f96a..8b68d37d9c74 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -103,6 +103,7 @@ function MagicCodeInput(props) {
const [input, setInput] = useState('');
const [focusedIndex, setFocusedIndex] = useState(0);
const [editIndex, setEditIndex] = useState(0);
+ const [wasSubmitted, setWasSubmitted] = useState(false);
const blurMagicCodeInput = () => {
inputRefs.current[editIndex].blur();
@@ -124,9 +125,12 @@ function MagicCodeInput(props) {
const validateAndSubmit = () => {
const numbers = decomposeString(props.value, props.maxLength);
- if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
+ if (wasSubmitted || !props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) {
return;
}
+ if (!wasSubmitted) {
+ setWasSubmitted(true);
+ }
// Blurs the input and removes focus from the last input and, if it should submit
// on complete, it will call the onFulfill callback.
blurMagicCodeInput();
@@ -352,12 +356,14 @@ function MagicCodeInput(props) {
MagicCodeInput.propTypes = propTypes;
MagicCodeInput.defaultProps = defaultProps;
-export default withNetwork()(
- forwardRef((props, ref) => (
-
- )),
-);
+const MagicCodeInputWithRef = forwardRef((props, ref) => (
+
+));
+
+MagicCodeInputWithRef.displayName = 'MagicCodeInputWithRef';
+
+export default withNetwork()(MagicCodeInputWithRef);
diff --git a/src/components/MapView/MapViewTypes.ts b/src/components/MapView/MapViewTypes.ts
index dc56cb4642c4..6cc52ac91d18 100644
--- a/src/components/MapView/MapViewTypes.ts
+++ b/src/components/MapView/MapViewTypes.ts
@@ -33,6 +33,9 @@ type PendingMapViewProps = {
/** Subtitle message below the title */
subtitle?: string;
+
+ /** Style applied to PendingMapView */
+ style?: StyleProp;
};
// Initial state of the map
diff --git a/src/components/MapView/PendingMapView.tsx b/src/components/MapView/PendingMapView.tsx
index 6a35d2a9c369..d97d4aaee16f 100644
--- a/src/components/MapView/PendingMapView.tsx
+++ b/src/components/MapView/PendingMapView.tsx
@@ -8,11 +8,11 @@ import {PendingMapViewProps} from './MapViewTypes';
import BlockingView from '../BlockingViews/BlockingView';
import * as Expensicons from '../Icon/Expensicons';
-function PendingMapView({title = '', subtitle = ''}: PendingMapViewProps) {
+function PendingMapView({title = '', subtitle = '', style}: PendingMapViewProps) {
const hasTextContent = !_.isEmpty(title) || !_.isEmpty(subtitle);
return (
-
+
{hasTextContent ? (
{
@@ -117,39 +120,57 @@ const MenuItem = React.forwardRef((props, ref) => {
return;
}
const parser = new ExpensiMark();
- setHtml(parser.replace(convertToLTR(props.title)));
+ setHtml(parser.replace(props.title));
titleRef.current = props.title;
}, [props.title, props.shouldParseTitle]);
const getProcessedTitle = useMemo(() => {
+ let title = '';
if (props.shouldRenderAsHTML) {
- return convertToLTR(props.title);
+ title = convertToLTR(props.title);
}
if (props.shouldParseTitle) {
- return html;
+ title = html;
}
- return '';
+ return title ? `${title}` : '';
}, [props.title, props.shouldRenderAsHTML, props.shouldParseTitle, html]);
const hasPressableRightComponent = props.iconRight || (props.rightComponent && props.shouldShowRightComponent);
+ const renderTitleContent = () => {
+ if (props.titleWithTooltips && _.isArray(props.titleWithTooltips) && props.titleWithTooltips.length > 0) {
+ return (
+
+ );
+ }
+
+ return convertToLTR(props.title);
+ };
+
+ const onPressAction = (e) => {
+ if (props.disabled || !props.interactive) {
+ return;
+ }
+
+ if (e && e.type === 'click') {
+ e.currentTarget.blur();
+ }
+
+ props.onPress(e);
+ };
+
return (
{(isHovered) => (
{
- if (props.disabled || !props.interactive) {
- return;
- }
-
- if (e && e.type === 'click') {
- e.currentTarget.blur();
- }
-
- props.onPress(e);
- }, props.isAnonymousAction)}
+ onPress={props.shouldCheckActionAllowedOnPress ? Session.checkIfActionIsAllowed(onPressAction, props.isAnonymousAction) : onPressAction}
onPressIn={() => props.shouldBlockSelection && isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={ControlSelection.unblock}
onSecondaryInteraction={props.onSecondaryInteraction}
@@ -262,7 +283,7 @@ const MenuItem = React.forwardRef((props, ref) => {
numberOfLines={props.numberOfLinesTitle || undefined}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: props.interactive && props.disabled}}
>
- {convertToLTR(props.title)}
+ {renderTitleContent()}
)}
{Boolean(props.shouldShowTitleIcon) && (
diff --git a/src/components/MenuItemWithTopDescription.js b/src/components/MenuItemWithTopDescription.js
index ee51d2f41ccd..94f44a1869b6 100644
--- a/src/components/MenuItemWithTopDescription.js
+++ b/src/components/MenuItemWithTopDescription.js
@@ -19,10 +19,14 @@ function MenuItemWithTopDescription(props) {
MenuItemWithTopDescription.propTypes = propTypes;
MenuItemWithTopDescription.displayName = 'MenuItemWithTopDescription';
-export default React.forwardRef((props, ref) => (
+const MenuItemWithTopDescriptionWithRef = React.forwardRef((props, ref) => (
));
+
+MenuItemWithTopDescriptionWithRef.displayName = 'MenuItemWithTopDescriptionWithRef';
+
+export default MenuItemWithTopDescriptionWithRef;
diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js
index 051c4ba3f80a..d1a906efc951 100644
--- a/src/components/Modal/BaseModal.js
+++ b/src/components/Modal/BaseModal.js
@@ -219,10 +219,14 @@ BaseModal.propTypes = propTypes;
BaseModal.defaultProps = defaultProps;
BaseModal.displayName = 'BaseModal';
-export default forwardRef((props, ref) => (
+const BaseModalWithRef = forwardRef((props, ref) => (
));
+
+BaseModalWithRef.displayName = 'BaseModalWithRef';
+
+export default BaseModalWithRef;
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index 6b2b4e16db65..ab0b77c21653 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -1,4 +1,5 @@
import React, {useMemo} from 'react';
+import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
import PropTypes from 'prop-types';
@@ -15,11 +16,13 @@ import Navigation from '../libs/Navigation/Navigation';
import ROUTES from '../ROUTES';
import ONYXKEYS from '../ONYXKEYS';
import CONST from '../CONST';
+import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar';
import SettlementButton from './SettlementButton';
import Button from './Button';
import * as IOU from '../libs/actions/IOU';
import * as CurrencyUtils from '../libs/CurrencyUtils';
import reportPropTypes from '../pages/reportPropTypes';
+import nextStepPropTypes from '../pages/nextStepPropTypes';
const propTypes = {
/** The report currently being looked at */
@@ -40,6 +43,9 @@ const propTypes = {
/** The chat report this report is linked to */
chatReport: reportPropTypes,
+ /** The next step for the report */
+ nextStep: nextStepPropTypes,
+
/** Personal details so we can get the ones for the report participants */
personalDetails: PropTypes.objectOf(participantPropTypes).isRequired,
@@ -54,15 +60,16 @@ const propTypes = {
const defaultProps = {
chatReport: {},
+ nextStep: {},
session: {
email: null,
},
policy: {},
};
-function MoneyReportHeader({session, personalDetails, policy, chatReport, report: moneyRequestReport, isSmallScreenWidth}) {
+function MoneyReportHeader({session, personalDetails, policy, chatReport, nextStep, report: moneyRequestReport, isSmallScreenWidth}) {
const {translate} = useLocalize();
- const reportTotal = ReportUtils.getMoneyRequestTotal(moneyRequestReport);
+ const reimbursableTotal = ReportUtils.getMoneyRequestReimbursableTotal(moneyRequestReport);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const policyType = lodashGet(policy, 'type');
@@ -71,8 +78,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
const isPayer = policyType === CONST.POLICY.TYPE.CORPORATE ? isPolicyAdmin && isApproved : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager);
const isDraft = ReportUtils.isReportDraft(moneyRequestReport);
const shouldShowSettlementButton = useMemo(
- () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
- [isPayer, isDraft, isSettled, moneyRequestReport, reportTotal, chatReport],
+ () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableTotal !== 0 && !ReportUtils.isArchivedRoom(chatReport),
+ [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableTotal, chatReport],
);
const shouldShowApproveButton = useMemo(() => {
if (policyType !== CONST.POLICY.TYPE.CORPORATE) {
@@ -80,10 +87,12 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
}
return isManager && !isDraft && !isApproved && !isSettled;
}, [policyType, isManager, isDraft, isApproved, isSettled]);
- const shouldShowSubmitButton = isDraft && reportTotal !== 0;
- const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton;
+ const shouldShowSubmitButton = isDraft && reimbursableTotal !== 0;
+ const shouldShowNextSteps = isDraft && nextStep && (!_.isEmpty(nextStep.message) || !_.isEmpty(nextStep.expenseMessage));
+ const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextSteps;
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
- const formattedAmount = CurrencyUtils.convertToDisplayString(reportTotal, moneyRequestReport.currency);
+ const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableTotal, moneyRequestReport.currency);
+ const isMoreContentShown = shouldShowNextSteps || (shouldShowAnyButton && isSmallScreenWidth);
return (
@@ -96,7 +105,8 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
personalDetails={personalDetails}
shouldShowBackButton={isSmallScreenWidth}
onBackButtonPress={() => Navigation.goBack(ROUTES.HOME, false, true)}
- shouldShowBorderBottom={!shouldShowAnyButton || !isSmallScreenWidth}
+ // Shows border if no buttons or next steps are showing below the header
+ shouldShowBorderBottom={!(shouldShowAnyButton && isSmallScreenWidth) && !(shouldShowNextSteps && !isSmallScreenWidth)}
>
{shouldShowSettlementButton && !isSmallScreenWidth && (
@@ -111,10 +121,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
shouldShowPaymentOptions
style={[styles.pv2]}
formattedAmount={formattedAmount}
- anchorAlignment={{
- horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
- vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
- }}
/>
)}
@@ -141,43 +147,50 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, report
)}
- {shouldShowSettlementButton && isSmallScreenWidth && (
-
- IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
- enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
- addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
- formattedAmount={formattedAmount}
- />
-
- )}
- {shouldShowApproveButton && isSmallScreenWidth && (
-
- IOU.approveMoneyRequest(moneyRequestReport)}
- />
-
- )}
- {shouldShowSubmitButton && isSmallScreenWidth && (
-
- IOU.submitReport(moneyRequestReport)}
- />
-
- )}
+
+ {shouldShowNextSteps && (
+
+
+
+ )}
+ {shouldShowSettlementButton && isSmallScreenWidth && (
+
+ IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
+ enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
+ addBankAccountRoute={bankAccountRoute}
+ shouldShowPaymentOptions
+ formattedAmount={formattedAmount}
+ />
+
+ )}
+ {shouldShowApproveButton && isSmallScreenWidth && (
+
+ IOU.approveMoneyRequest(moneyRequestReport)}
+ />
+
+ )}
+ {shouldShowSubmitButton && isSmallScreenWidth && (
+
+ IOU.submitReport(moneyRequestReport)}
+ />
+
+ )}
+
);
}
@@ -192,6 +205,9 @@ export default compose(
chatReport: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`,
},
+ nextStep: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`,
+ },
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/components/MoneyReportHeaderStatusBar.js b/src/components/MoneyReportHeaderStatusBar.js
new file mode 100644
index 000000000000..9c4362b620d1
--- /dev/null
+++ b/src/components/MoneyReportHeaderStatusBar.js
@@ -0,0 +1,43 @@
+import React, {useMemo} from 'react';
+import {Text, View} from 'react-native';
+import _ from 'underscore';
+import styles from '../styles/styles';
+import * as NextStepUtils from '../libs/NextStepUtils';
+import useLocalize from '../hooks/useLocalize';
+import nextStepPropTypes from '../pages/nextStepPropTypes';
+import RenderHTML from './RenderHTML';
+
+const propTypes = {
+ /** The next step for the report */
+ nextStep: nextStepPropTypes,
+};
+
+const defaultProps = {
+ nextStep: {},
+};
+
+function MoneyReportHeaderStatusBar({nextStep}) {
+ const {translate} = useLocalize();
+
+ const messageContent = useMemo(() => {
+ const messageArray = _.isEmpty(nextStep.expenseMessage) ? nextStep.message : nextStep.expenseMessage;
+ return NextStepUtils.parseMessage(messageArray);
+ }, [nextStep.expenseMessage, nextStep.message]);
+
+ return (
+
+
+ {translate('iou.nextSteps')}
+
+
+
+
+
+ );
+}
+
+MoneyReportHeaderStatusBar.displayName = 'MoneyReportHeaderStatusBar';
+MoneyReportHeaderStatusBar.propTypes = propTypes;
+MoneyReportHeaderStatusBar.defaultProps = defaultProps;
+
+export default MoneyReportHeaderStatusBar;
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 42fa1db48220..0b266351a60c 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -6,6 +6,7 @@ import _ from 'underscore';
import {View} from 'react-native';
import lodashGet from 'lodash/get';
import {useIsFocused} from '@react-navigation/native';
+import {isEmpty} from 'lodash';
import Text from './Text';
import styles from '../styles/styles';
import * as ReportUtils from '../libs/ReportUtils';
@@ -42,6 +43,7 @@ import * as IOU from '../libs/actions/IOU';
import * as TransactionUtils from '../libs/TransactionUtils';
import * as PolicyUtils from '../libs/PolicyUtils';
import * as MoneyRequestUtils from '../libs/MoneyRequestUtils';
+import {iouDefaultProps, iouPropTypes} from '../pages/iou/propTypes';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -165,13 +167,16 @@ const propTypes = {
/** Collection of tags attached to a policy */
policyTags: tagPropTypes,
+
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ iou: iouPropTypes,
};
const defaultProps = {
onConfirm: () => {},
onSendMoney: () => {},
onSelectParticipant: () => {},
- iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST,
+ iouType: CONST.IOU.TYPE.REQUEST,
iouCategory: '',
iouTag: '',
iouIsBillable: false,
@@ -199,6 +204,7 @@ const defaultProps = {
isScanRequest: false,
shouldShowSmartScanFields: true,
isPolicyExpenseChat: false,
+ iou: iouDefaultProps,
};
function MoneyRequestConfirmationList(props) {
@@ -208,9 +214,9 @@ function MoneyRequestConfirmationList(props) {
const {translate, toLocaleDigit} = useLocalize();
const transaction = props.isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction;
- const isTypeRequest = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST;
- const isSplitBill = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT;
- const isTypeSend = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND;
+ const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST;
+ const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT;
+ const isTypeSend = props.iouType === CONST.IOU.TYPE.SEND;
const isSplitWithScan = isSplitBill && props.isScanRequest;
@@ -445,7 +451,7 @@ function MoneyRequestConfirmationList(props) {
return;
}
- if (props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND) {
+ if (props.iouType === CONST.IOU.TYPE.SEND) {
if (!paymentMethod) {
return;
}
@@ -491,7 +497,7 @@ function MoneyRequestConfirmationList(props) {
return;
}
- const shouldShowSettlementButton = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND;
+ const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND;
const shouldDisableButton = selectedParticipants.length === 0;
const button = shouldShowSettlementButton ? (
@@ -506,7 +512,11 @@ function MoneyRequestConfirmationList(props) {
policyID={props.policyID}
shouldShowPaymentOptions
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
- anchorAlignment={{
+ kycWallAnchorAlignment={{
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ }}
+ paymentMethodDropdownAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
@@ -537,8 +547,7 @@ function MoneyRequestConfirmationList(props) {
}, [confirm, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions, translate, formError]);
const {image: receiptImage, thumbnail: receiptThumbnail} =
- props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(props.receiptPath, props.receiptFilename) : {};
-
+ props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {};
return (
@@ -753,5 +762,8 @@ export default compose(
policy: {
key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
},
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
}),
)(MoneyRequestConfirmationList);
diff --git a/src/components/OfflineWithFeedback.js b/src/components/OfflineWithFeedback.js
index dae170dd1d5c..643e7b2f4a2f 100644
--- a/src/components/OfflineWithFeedback.js
+++ b/src/components/OfflineWithFeedback.js
@@ -58,6 +58,9 @@ const propTypes = {
/** Whether to apply needsOffscreenAlphaCompositing prop to the children */
needsOffscreenAlphaCompositing: PropTypes.bool,
+
+ /** Whether we can dismiss the error message */
+ canDismissError: PropTypes.bool,
};
const defaultProps = {
@@ -72,6 +75,7 @@ const defaultProps = {
errorRowStyles: [],
shouldDisableStrikeThrough: false,
needsOffscreenAlphaCompositing: false,
+ canDismissError: true,
};
/**
@@ -130,16 +134,18 @@ function OfflineWithFeedback(props) {
messages={errorMessages}
type="error"
/>
-
-
-
-
-
+ {props.canDismissError && (
+
+
+
+
+
+ )}
)}
diff --git a/src/components/Onfido/index.css b/src/components/Onfido/index.css
index 5c76f42037a5..53f7888fc385 100644
--- a/src/components/Onfido/index.css
+++ b/src/components/Onfido/index.css
@@ -39,6 +39,15 @@
background-image: var(--back-icon-svg) !important;
}
+.onfido-sdk-ui-Theme-root .ods-button.-action--primary:disabled {
+ opacity: 0.5 !important;
+ background-color: var(--osdk-color-background-button-primary) !important;
+}
+
+.onfido-sdk-ui-crossDevice-CrossDeviceLink-sending::before {
+ margin-left: 0 !important;
+}
+
@media only screen and (max-width: 600px) {
.onfido-sdk-ui-Modal-inner {
/* This keeps the bottom of the Onfido window from being cut off on mobile web because the height was being
diff --git a/src/components/OnyxProvider.js b/src/components/OnyxProvider.tsx
similarity index 86%
rename from src/components/OnyxProvider.js
rename to src/components/OnyxProvider.tsx
index 380328cf8137..8682e832debc 100644
--- a/src/components/OnyxProvider.js
+++ b/src/components/OnyxProvider.tsx
@@ -1,13 +1,12 @@
import React from 'react';
-import PropTypes from 'prop-types';
import ONYXKEYS from '../ONYXKEYS';
import createOnyxContext from './createOnyxContext';
import ComposeProviders from './ComposeProviders';
// Set up any providers for individual keys. This should only be used in cases where many components will subscribe to
// the same key (e.g. FlatList renderItem components)
-const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK, {});
-const [withPersonalDetails, PersonalDetailsProvider] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST);
+const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK);
+const [withPersonalDetails, PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE);
const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS);
const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE);
@@ -15,12 +14,12 @@ const [withBetas, BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETA
const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT);
const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME);
-const propTypes = {
+type OnyxProviderProps = {
/** Rendered child component */
- children: PropTypes.node.isRequired,
+ children: React.ReactNode;
};
-function OnyxProvider(props) {
+function OnyxProvider(props: OnyxProviderProps) {
return (
(
-
- )),
-);
+const OptionsListWithRef = forwardRef((props, ref) => (
+
+));
+
+OptionsListWithRef.displayName = 'OptionsListWithRef';
+
+export default withWindowDimensions(OptionsListWithRef);
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 4ffddd700359..0125fc8e178e 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -9,7 +9,7 @@ import OptionsList from '../OptionsList';
import CONST from '../../CONST';
import styles from '../../styles/styles';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
-import withNavigationFocus, {withNavigationFocusPropTypes} from '../withNavigationFocus';
+import withNavigationFocus from '../withNavigationFocus';
import TextInput from '../TextInput';
import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
import KeyboardShortcut from '../../libs/KeyboardShortcut';
@@ -32,9 +32,11 @@ const propTypes = {
/** List styles for OptionsList */
listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ /** Whether navigation is focused */
+ isFocused: PropTypes.bool.isRequired,
+
...optionsSelectorPropTypes,
...withLocalizePropTypes,
- ...withNavigationFocusPropTypes,
};
const defaultProps = {
diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js
index 58a4e64a28a5..6b6163992589 100644
--- a/src/components/PDFView/PDFPasswordForm.js
+++ b/src/components/PDFView/PDFPasswordForm.js
@@ -131,7 +131,7 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat
autoCorrect={false}
textContentType="password"
onChangeText={updatePassword}
- returnKeyType="done"
+ returnKeyType="go"
onSubmitEditing={submitPassword}
errorText={errorText}
onFocus={() => onPasswordFieldFocused(true)}
diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js
index bd5fe8162d2e..66e9df30b5c3 100644
--- a/src/components/PDFView/index.js
+++ b/src/components/PDFView/index.js
@@ -15,11 +15,11 @@ import PDFPasswordForm from './PDFPasswordForm';
import * as pdfViewPropTypes from './pdfViewPropTypes';
import withWindowDimensions from '../withWindowDimensions';
import withLocalize from '../withLocalize';
-import Text from '../Text';
import compose from '../../libs/compose';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
import Log from '../../libs/Log';
import ONYXKEYS from '../../ONYXKEYS';
+import Text from '../Text';
/**
* Each page has a default border. The app should take this size into account
@@ -283,7 +283,7 @@ class PDFView extends Component {
}) => this.setState({containerWidth: width, containerHeight: height})}
>
{this.props.translate('attachmentView.failedToLoadPDF')}}
+ error={{this.props.translate('attachmentView.failedToLoadPDF')}}
loading={}
file={this.props.sourceURL}
options={{
diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js
index 0bd9936c628b..fc1a204b3324 100644
--- a/src/components/PDFView/index.native.js
+++ b/src/components/PDFView/index.native.js
@@ -143,7 +143,7 @@ class PDFView extends Component {
{this.state.failedToLoadPDF && (
- {this.props.translate('attachmentView.failedToLoadPDF')}
+ {this.props.translate('attachmentView.failedToLoadPDF')}
)}
{this.state.shouldAttemptPDFLoad && (
diff --git a/src/components/PDFView/pdfViewPropTypes.js b/src/components/PDFView/pdfViewPropTypes.js
index 21ebc880301e..4568ed527983 100644
--- a/src/components/PDFView/pdfViewPropTypes.js
+++ b/src/components/PDFView/pdfViewPropTypes.js
@@ -27,6 +27,9 @@ const propTypes = {
/** Should focus to the password input */
isFocused: PropTypes.bool,
+ /** Styles for the error label */
+ errorLabelStyles: stylePropTypes,
+
...windowDimensionsPropTypes,
};
@@ -39,6 +42,7 @@ const defaultProps = {
onScaleChanged: () => {},
onLoadComplete: () => {},
isFocused: false,
+ errorLabelStyles: [],
};
export {propTypes, defaultProps};
diff --git a/src/components/Picker/BasePicker.js b/src/components/Picker/BasePicker.js
index 697dfc509d22..c69adec097cf 100644
--- a/src/components/Picker/BasePicker.js
+++ b/src/components/Picker/BasePicker.js
@@ -283,7 +283,7 @@ BasePicker.propTypes = propTypes;
BasePicker.defaultProps = defaultProps;
BasePicker.displayName = 'BasePicker';
-export default React.forwardRef((props, ref) => (
+const BasePickerWithRef = React.forwardRef((props, ref) => (
(
key={props.inputID}
/>
));
+
+BasePickerWithRef.displayName = 'BasePickerWithRef';
+
+export default BasePickerWithRef;
diff --git a/src/components/Picker/index.js b/src/components/Picker/index.js
index d0a6a4911880..8e49a42e8932 100644
--- a/src/components/Picker/index.js
+++ b/src/components/Picker/index.js
@@ -13,7 +13,7 @@ const additionalPickerEvents = (onMouseDown, onChange) => ({
},
});
-export default forwardRef((props, ref) => (
+const BasePickerWithRef = forwardRef((props, ref) => (
(
additionalPickerEvents={additionalPickerEvents}
/>
));
+
+BasePickerWithRef.displayName = 'BasePickerWithRef';
+
+export default BasePickerWithRef;
diff --git a/src/components/Picker/index.native.js b/src/components/Picker/index.native.js
index 4032e79b6d17..f441609fd4d0 100644
--- a/src/components/Picker/index.native.js
+++ b/src/components/Picker/index.native.js
@@ -1,10 +1,14 @@
import React, {forwardRef} from 'react';
import BasePicker from './BasePicker';
-export default forwardRef((props, ref) => (
+const BasePickerWithRef = forwardRef((props, ref) => (
));
+
+BasePickerWithRef.displayName = 'BasePickerWithRef';
+
+export default BasePickerWithRef;
diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js
index 4cdc7a5a4f47..c4e9587bb667 100644
--- a/src/components/PopoverMenu/index.js
+++ b/src/components/PopoverMenu/index.js
@@ -34,6 +34,9 @@ const propTypes = {
}),
withoutOverlay: PropTypes.bool,
+
+ /** Should we announce the Modal visibility changes? */
+ shouldSetModalVisibility: PropTypes.bool,
};
const defaultProps = {
@@ -44,6 +47,7 @@ const defaultProps = {
},
anchorRef: () => {},
withoutOverlay: false,
+ shouldSetModalVisibility: true,
};
function PopoverMenu(props) {
@@ -89,6 +93,7 @@ function PopoverMenu(props) {
disableAnimation={props.disableAnimation}
fromSidebarMediumScreen={props.fromSidebarMediumScreen}
withoutOverlay={props.withoutOverlay}
+ shouldSetModalVisibility={props.shouldSetModalVisibility}
>
{!_.isEmpty(props.headerText) && {props.headerText}}
@@ -100,6 +105,7 @@ function PopoverMenu(props) {
iconHeight={item.iconHeight}
iconFill={item.iconFill}
title={item.text}
+ shouldCheckActionAllowedOnPress={false}
description={item.description}
onPress={() => selectItem(menuIndex)}
focused={focusedIndex === menuIndex}
diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.js
index efa230d920d5..86f09579a758 100644
--- a/src/components/PopoverProvider/index.js
+++ b/src/components/PopoverProvider/index.js
@@ -22,7 +22,11 @@ function PopoverContextProvider(props) {
if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) {
return;
}
+
activePopoverRef.current.close();
+ if (activePopoverRef.current.onCloseCallback) {
+ activePopoverRef.current.onCloseCallback();
+ }
activePopoverRef.current = null;
setIsOpen(false);
}, []);
@@ -106,23 +110,25 @@ function PopoverContextProvider(props) {
closePopover(activePopoverRef.current.anchorRef);
}
activePopoverRef.current = popoverParams;
+ if (popoverParams && popoverParams.onOpenCallback) {
+ popoverParams.onOpenCallback();
+ }
setIsOpen(true);
},
[closePopover],
);
- return (
-
- {props.children}
-
+ const contextValue = React.useMemo(
+ () => ({
+ onOpen,
+ close: closePopover,
+ popover: activePopoverRef.current,
+ isOpen,
+ }),
+ [onOpen, closePopover, isOpen],
);
+
+ return {props.children};
}
PopoverContextProvider.defaultProps = defaultProps;
diff --git a/src/components/PopoverProvider/index.native.js b/src/components/PopoverProvider/index.native.js
index f34abcb1fa62..e4da13752b6d 100644
--- a/src/components/PopoverProvider/index.native.js
+++ b/src/components/PopoverProvider/index.native.js
@@ -15,18 +15,17 @@ const PopoverContext = React.createContext({
});
function PopoverContextProvider(props) {
- return (
- {},
- close: () => {},
- popover: {},
- isOpen: false,
- }}
- >
- {props.children}
-
+ const contextValue = React.useMemo(
+ () => ({
+ onOpen: () => {},
+ close: () => {},
+ popover: {},
+ isOpen: false,
+ }),
+ [],
);
+
+ return {props.children};
}
PopoverContextProvider.defaultProps = defaultProps;
diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js
index 3b194ad4b9cf..2036807e0df0 100644
--- a/src/components/PopoverWithoutOverlay/index.js
+++ b/src/components/PopoverWithoutOverlay/index.js
@@ -1,4 +1,4 @@
-import React, {useRef} from 'react';
+import React from 'react';
import {View} from 'react-native';
import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
import {PopoverContext} from '../PopoverProvider';
@@ -11,7 +11,6 @@ import withWindowDimensions from '../withWindowDimensions';
function Popover(props) {
const {onOpen, close} = React.useContext(PopoverContext);
- const firstRenderRef = useRef(true);
const {modalStyle, modalContainerStyle, shouldAddTopSafeAreaMargin, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaPadding, shouldAddBottomSafeAreaPadding} = getModalStyles(
'popover',
{
@@ -31,6 +30,8 @@ function Popover(props) {
ref: props.withoutOverlayRef,
close: props.onClose,
anchorRef: props.anchorRef,
+ onCloseCallback: () => Modal.setCloseModal(null),
+ onOpenCallback: () => Modal.setCloseModal(() => props.onClose(props.anchorRef)),
});
} else {
props.onModalHide();
@@ -39,14 +40,6 @@ function Popover(props) {
}
Modal.willAlertModalBecomeVisible(props.isVisible);
- // We prevent setting closeModal function to null when the component is invisible the first time it is rendered
- if (!firstRenderRef.current || !props.isVisible) {
- firstRenderRef.current = false;
- return;
- }
- firstRenderRef.current = false;
- Modal.setCloseModal(props.isVisible ? () => props.onClose(props.anchorRef) : null);
-
// We want this effect to run strictly ONLY when isVisible prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isVisible]);
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
index 79ce5629c9e9..24d81f59f4f8 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js
@@ -5,7 +5,6 @@ import _ from 'underscore';
import Accessibility from '../../../libs/Accessibility';
import HapticFeedback from '../../../libs/HapticFeedback';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
-import * as Browser from '../../../libs/Browser';
import styles from '../../../styles/styles';
import genericPressablePropTypes from './PropTypes';
import CONST from '../../../CONST';
@@ -129,15 +128,13 @@ const GenericPressable = forwardRef((props, ref) => {
return KeyboardShortcut.subscribe(shortcutKey, onPressHandler, descriptionKey, modifiers, true, false, 0, false);
}, [keyboardShortcut, onPressHandler]);
- const defaultLongPressHandler = Browser.isMobileChrome() ? () => {} : undefined;
return (
(
WebGenericPressable.propTypes = GenericPressablePropTypes.pressablePropTypes;
WebGenericPressable.defaultProps = GenericPressablePropTypes.defaultProps;
+WebGenericPressable.displayName = 'WebGenericPressable';
export default WebGenericPressable;
diff --git a/src/components/Pressable/GenericPressable/index.native.js b/src/components/Pressable/GenericPressable/index.native.js
index 3de74eda35de..14a2c2bcbf82 100644
--- a/src/components/Pressable/GenericPressable/index.native.js
+++ b/src/components/Pressable/GenericPressable/index.native.js
@@ -15,5 +15,6 @@ const NativeGenericPressable = forwardRef((props, ref) => (
NativeGenericPressable.propTypes = GenericPressablePropTypes.pressablePropTypes;
NativeGenericPressable.defaultProps = GenericPressablePropTypes.defaultProps;
+NativeGenericPressable.displayName = 'WebGenericPressable';
export default NativeGenericPressable;
diff --git a/src/components/Pressable/PressableWithDelayToggle.js b/src/components/Pressable/PressableWithDelayToggle.js
index b55770a63196..c56ab49382b4 100644
--- a/src/components/Pressable/PressableWithDelayToggle.js
+++ b/src/components/Pressable/PressableWithDelayToggle.js
@@ -141,10 +141,14 @@ function PressableWithDelayToggle(props) {
PressableWithDelayToggle.propTypes = propTypes;
PressableWithDelayToggle.defaultProps = defaultProps;
-export default React.forwardRef((props, ref) => (
+const PressableWithDelayToggleWithRef = React.forwardRef((props, ref) => (
));
+
+PressableWithDelayToggleWithRef.displayName = 'PressableWithDelayToggleWithRef';
+
+export default PressableWithDelayToggleWithRef;
diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js
index d84a3f282e97..7eb6f7ca376b 100644
--- a/src/components/PressableWithSecondaryInteraction/index.js
+++ b/src/components/PressableWithSecondaryInteraction/index.js
@@ -117,10 +117,14 @@ PressableWithSecondaryInteraction.propTypes = pressableWithSecondaryInteractionP
PressableWithSecondaryInteraction.defaultProps = pressableWithSecondaryInteractionPropTypes.defaultProps;
PressableWithSecondaryInteraction.displayName = 'PressableWithSecondaryInteraction';
-export default forwardRef((props, ref) => (
+const PressableWithSecondaryInteractionWithRef = forwardRef((props, ref) => (
));
+
+PressableWithSecondaryInteractionWithRef.displayName = 'PressableWithSecondaryInteractionWithRef';
+
+export default PressableWithSecondaryInteractionWithRef;
diff --git a/src/components/PressableWithSecondaryInteraction/index.native.js b/src/components/PressableWithSecondaryInteraction/index.native.js
index 0ca668bb4234..1b6690ad2f33 100644
--- a/src/components/PressableWithSecondaryInteraction/index.native.js
+++ b/src/components/PressableWithSecondaryInteraction/index.native.js
@@ -38,10 +38,14 @@ PressableWithSecondaryInteraction.propTypes = pressableWithSecondaryInteractionP
PressableWithSecondaryInteraction.defaultProps = pressableWithSecondaryInteractionPropTypes.defaultProps;
PressableWithSecondaryInteraction.displayName = 'PressableWithSecondaryInteraction';
-export default forwardRef((props, ref) => (
+const PressableWithSecondaryInteractionWithRef = forwardRef((props, ref) => (
));
+
+PressableWithSecondaryInteractionWithRef.displayName = 'PressableWithSecondaryInteractionWithRef';
+
+export default PressableWithSecondaryInteractionWithRef;
diff --git a/src/components/QRCode/index.js b/src/components/QRCode/index.js
deleted file mode 100644
index f27cf28066ef..000000000000
--- a/src/components/QRCode/index.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react';
-import QRCodeLibrary from 'react-native-qrcode-svg';
-import PropTypes from 'prop-types';
-import defaultTheme from '../../styles/themes/default';
-import CONST from '../../CONST';
-
-const propTypes = {
- /**
- * The QR code URL
- */
- url: PropTypes.string.isRequired,
- /**
- * The logo which will be displayed in the middle of the QR code.
- * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg.
- */
- logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number, PropTypes.string]),
- /**
- * The size ratio of logo to QR code
- */
- logoRatio: PropTypes.number,
- /**
- * The size ratio of margin around logo to QR code
- */
- logoMarginRatio: PropTypes.number,
- /**
- * The QRCode size
- */
- size: PropTypes.number,
- /**
- * The QRCode color
- */
- color: PropTypes.string,
- /**
- * The QRCode background color
- */
- backgroundColor: PropTypes.string,
- /**
- * Function to retrieve the internal component ref and be able to call it's
- * methods
- */
- getRef: PropTypes.func,
-};
-
-const defaultProps = {
- logo: undefined,
- size: 120,
- color: defaultTheme.text,
- backgroundColor: defaultTheme.highlightBG,
- getRef: undefined,
- logoRatio: CONST.QR.DEFAULT_LOGO_SIZE_RATIO,
- logoMarginRatio: CONST.QR.DEFAULT_LOGO_MARGIN_RATIO,
-};
-
-function QRCode(props) {
- return (
-
- );
-}
-
-QRCode.displayName = 'QRCode';
-QRCode.propTypes = propTypes;
-QRCode.defaultProps = defaultProps;
-
-export default QRCode;
diff --git a/src/components/QRCode/index.tsx b/src/components/QRCode/index.tsx
new file mode 100644
index 000000000000..bca45c02fffa
--- /dev/null
+++ b/src/components/QRCode/index.tsx
@@ -0,0 +1,71 @@
+import React, {Ref} from 'react';
+import QRCodeLibrary from 'react-native-qrcode-svg';
+import {ImageSourcePropType} from 'react-native';
+import defaultTheme from '../../styles/themes/default';
+import CONST from '../../CONST';
+
+type LogoRatio = typeof CONST.QR.DEFAULT_LOGO_SIZE_RATIO | typeof CONST.QR.EXPENSIFY_LOGO_SIZE_RATIO;
+
+type LogoMarginRatio = typeof CONST.QR.DEFAULT_LOGO_MARGIN_RATIO | typeof CONST.QR.EXPENSIFY_LOGO_MARGIN_RATIO;
+
+type QRCodeProps = {
+ /** The QR code URL */
+ url: string;
+
+ /**
+ * The logo which will be displayed in the middle of the QR code.
+ * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg.
+ */
+ logo?: ImageSourcePropType;
+
+ /** The size ratio of logo to QR code */
+ logoRatio?: LogoRatio;
+
+ /** The size ratio of margin around logo to QR code */
+ logoMarginRatio?: LogoMarginRatio;
+
+ /** The QRCode size */
+ size?: number;
+
+ /** The QRCode color */
+ color?: string;
+
+ /** The QRCode background color */
+ backgroundColor?: string;
+
+ /**
+ * Function to retrieve the internal component ref and be able to call it's
+ * methods
+ */
+ getRef?: (ref: Ref) => Ref;
+};
+
+function QRCode({
+ url,
+ logo,
+ getRef,
+ size = 120,
+ color = defaultTheme.text,
+ backgroundColor = defaultTheme.highlightBG,
+ logoRatio = CONST.QR.DEFAULT_LOGO_SIZE_RATIO,
+ logoMarginRatio = CONST.QR.DEFAULT_LOGO_MARGIN_RATIO,
+}: QRCodeProps) {
+ return (
+
+ );
+}
+
+QRCode.displayName = 'QRCode';
+
+export default QRCode;
diff --git a/src/components/QRShare/getQrCodeDownloadFileName.js b/src/components/QRShare/getQrCodeDownloadFileName.js
index cc3b38d42348..c1e73a1794fb 100644
--- a/src/components/QRShare/getQrCodeDownloadFileName.js
+++ b/src/components/QRShare/getQrCodeDownloadFileName.js
@@ -1,4 +1,3 @@
-// eslint-disable-next-line rulesdir/display-name-property
const getQrCodeDownloadFileName = (title) => `${title}-ShareCode.png`;
export default getQrCodeDownloadFileName;
diff --git a/src/components/RNTextInput.js b/src/components/RNTextInput.js
index 5a790cde91d7..30815376ca8c 100644
--- a/src/components/RNTextInput.js
+++ b/src/components/RNTextInput.js
@@ -38,10 +38,14 @@ RNTextInput.propTypes = propTypes;
RNTextInput.defaultProps = defaultProps;
RNTextInput.displayName = 'RNTextInput';
-export default React.forwardRef((props, ref) => (
+const RNTextInputWithRef = React.forwardRef((props, ref) => (
));
+
+RNTextInputWithRef.displayName = 'RNTextInputWithRef';
+
+export default RNTextInputWithRef;
diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js
index 656188559334..8a862c7e1b96 100644
--- a/src/components/Reactions/AddReactionBubble.js
+++ b/src/components/Reactions/AddReactionBubble.js
@@ -1,7 +1,7 @@
import React, {useRef, useEffect} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
-import Tooltip from '../Tooltip';
+import Tooltip from '../Tooltip/PopoverAnchorTooltip';
import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import Icon from '../Icon';
diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js
index 818bc8f33309..3e40216bd870 100644
--- a/src/components/Reactions/EmojiReactionBubble.js
+++ b/src/components/Reactions/EmojiReactionBubble.js
@@ -96,12 +96,14 @@ EmojiReactionBubble.propTypes = propTypes;
EmojiReactionBubble.defaultProps = defaultProps;
EmojiReactionBubble.displayName = 'EmojiReactionBubble';
-export default withWindowDimensions(
- React.forwardRef((props, ref) => (
-
- )),
-);
+const EmojiReactionBubbleWithRef = React.forwardRef((props, ref) => (
+
+));
+
+EmojiReactionBubbleWithRef.displayName = 'EmojiReactionBubbleWithRef';
+
+export default withWindowDimensions(EmojiReactionBubbleWithRef);
diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.js
index 7ead2ab67ae7..5fdf74f877dd 100644
--- a/src/components/Reactions/ReportActionItemEmojiReactions.js
+++ b/src/components/Reactions/ReportActionItemEmojiReactions.js
@@ -9,7 +9,6 @@ import AddReactionBubble from './AddReactionBubble';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails';
import withLocalize from '../withLocalize';
import compose from '../../libs/compose';
-import * as Report from '../../libs/actions/Report';
import EmojiReactionsPropTypes from './EmojiReactionsPropTypes';
import Tooltip from '../Tooltip';
import ReactionTooltipContent from './ReactionTooltipContent';
@@ -52,71 +51,42 @@ function ReportActionItemEmojiReactions(props) {
const reportAction = props.reportAction;
const reportActionID = reportAction.reportActionID;
- // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone
- const sortedReactions = _.sortBy(props.emojiReactions, (emojiReaction, emojiName) => {
- // Since the emojiName is only stored as the object key, when _.sortBy() runs, the object is converted to an array and the
- // keys are lost. To keep from losing the emojiName, it's copied to the emojiReaction object.
- // eslint-disable-next-line no-param-reassign
- emojiReaction.emojiName = emojiName;
- const oldestUserReactionTimestamp = _.chain(emojiReaction.users)
- .reduce((allTimestampsArray, userData) => {
- if (!userData) {
- return allTimestampsArray;
- }
- _.each(userData.skinTones, (createdAt) => {
- allTimestampsArray.push(createdAt);
- });
- return allTimestampsArray;
- }, [])
- .sort()
- .first()
- .value();
-
- // Just in case two emojis have the same timestamp, also combine the timestamp with the
- // emojiName so that the order will always be the same. Without this, the order can be pretty random
- // and shift around a little bit.
- return (oldestUserReactionTimestamp || emojiReaction.createdAt) + emojiName;
- });
-
- const formattedReactions = _.map(sortedReactions, (reaction) => {
- const reactionEmojiName = reaction.emojiName;
- const usersWithReactions = _.pick(reaction.users, _.identity);
- let reactionCount = 0;
-
- // Loop through the users who have reacted and see how many skintones they reacted with so that we get the total count
- _.forEach(usersWithReactions, (user) => {
- reactionCount += _.size(user.skinTones);
- });
- if (!reactionCount) {
- return null;
- }
- totalReactionCount += reactionCount;
- const emojiAsset = EmojiUtils.findEmojiByName(reactionEmojiName);
- const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emojiAsset, reaction.users);
- const hasUserReacted = Report.hasAccountIDEmojiReacted(props.currentUserPersonalDetails.accountID, reaction.users);
- const reactionUsers = _.keys(usersWithReactions);
- const reactionUserAccountIDs = _.map(reactionUsers, Number);
-
- const onPress = () => {
- props.toggleReaction(emojiAsset);
- };
-
- const onReactionListOpen = (event) => {
- reactionListRef.current.showReactionList(event, popoverReactionListAnchors.current[reactionEmojiName], reactionEmojiName, reportActionID);
- };
-
- return {
- reactionEmojiName,
- emojiCodes,
- reactionUserAccountIDs,
- onPress,
- reactionUsers,
- reactionCount,
- hasUserReacted,
- onReactionListOpen,
- pendingAction: reaction.pendingAction,
- };
- });
+ const formattedReactions = _.chain(props.emojiReactions)
+ .map((emojiReaction, emojiName) => {
+ const {emoji, emojiCodes, reactionCount, hasUserReacted, userAccountIDs, oldestTimestamp} = EmojiUtils.getEmojiReactionDetails(
+ emojiName,
+ emojiReaction,
+ props.currentUserPersonalDetails.accountID,
+ );
+
+ if (reactionCount === 0) {
+ return null;
+ }
+ totalReactionCount += reactionCount;
+
+ const onPress = () => {
+ props.toggleReaction(emoji);
+ };
+
+ const onReactionListOpen = (event) => {
+ reactionListRef.current.showReactionList(event, popoverReactionListAnchors.current[emojiName], emojiName, reportActionID);
+ };
+
+ return {
+ emojiCodes,
+ userAccountIDs,
+ reactionCount,
+ hasUserReacted,
+ oldestTimestamp,
+ onPress,
+ onReactionListOpen,
+ reactionEmojiName: emojiName,
+ pendingAction: emojiReaction.pendingAction,
+ };
+ })
+ // Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone
+ .sortBy('oldestTimestamp')
+ .value();
return (
totalReactionCount > 0 && (
@@ -131,11 +101,11 @@ function ReportActionItemEmojiReactions(props) {
)}
- renderTooltipContentKey={[..._.map(reaction.reactionUsers, (user) => user.toString()), ...reaction.emojiCodes]}
+ renderTooltipContentKey={[..._.map(reaction.userAccountIDs, String), ...reaction.emojiCodes]}
key={reaction.reactionEmojiName}
>
@@ -148,7 +118,6 @@ function ReportActionItemEmojiReactions(props) {
count={reaction.reactionCount}
emojiCodes={reaction.emojiCodes}
onPress={reaction.onPress}
- reactionUsers={reaction.reactionUsers}
hasUserReacted={reaction.hasUserReacted}
onReactionListOpen={reaction.onReactionListOpen}
shouldBlockReactions={props.shouldBlockReactions}
diff --git a/src/components/ReportActionItem/MoneyReportView.js b/src/components/ReportActionItem/MoneyReportView.js
index bfdcc59bf89f..3f9b8bf53837 100644
--- a/src/components/ReportActionItem/MoneyReportView.js
+++ b/src/components/ReportActionItem/MoneyReportView.js
@@ -7,7 +7,6 @@ import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
import * as ReportUtils from '../../libs/ReportUtils';
import * as StyleUtils from '../../styles/StyleUtils';
-import CONST from '../../CONST';
import Text from '../Text';
import Icon from '../Icon';
import * as Expensicons from '../Icon/Expensicons';
@@ -28,45 +27,93 @@ const propTypes = {
};
function MoneyReportView(props) {
- const formattedAmount = CurrencyUtils.convertToDisplayString(ReportUtils.getMoneyRequestTotal(props.report), props.report.currency);
- const isSettled = ReportUtils.isSettled(props.report.reportID);
const {translate} = useLocalize();
+ const isSettled = ReportUtils.isSettled(props.report.reportID);
+
+ const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(props.report);
+
+ const shouldShowBreakdown = nonReimbursableSpend && reimbursableSpend;
+ const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.report.currency, ReportUtils.hasOnlyDistanceRequestTransactions(props.report.reportID));
+ const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, props.report.currency);
+ const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, props.report.currency);
+
+ const subAmountTextStyles = [styles.taskTitleMenuItem, styles.alignSelfCenter, StyleUtils.getFontSizeStyle(variables.fontSizeh1), StyleUtils.getColorStyle(themeColors.textSupporting)];
return (
-
-
-
-
-
-
-
- {translate('common.total')}
-
+
+
+
+
+
+
+ {translate('common.total')}
+
+
+
+ {isSettled && (
+
+
+
+ )}
+
+ {formattedTotalAmount}
+
+
-
- {isSettled && (
-
-
+ {shouldShowBreakdown ? (
+ <>
+
+
+
+ {translate('cardTransactions.outOfPocket')}
+
+
+
+
+ {formattedOutOfPocketAmount}
+
+
- )}
-
- {formattedAmount}
-
-
+
+
+
+ {translate('cardTransactions.companySpend')}
+
+
+
+
+ {formattedCompanySpendAmount}
+
+
+
+ >
+ ) : undefined}
+
-
);
}
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index 720fa292de53..43500c731728 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -172,7 +172,7 @@ function MoneyRequestPreview(props) {
!_.isEmpty(requestMerchant) && !props.isBillSplit && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT;
const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant;
- const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction.receipt.source, props.transaction.filename || '')] : [];
+ const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : [];
const getSettledMessage = () => {
if (isExpensifyCardTransaction) {
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 289cd70c3332..19f4a5b8e103 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -106,6 +106,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
const isExpensifyCardTransaction = TransactionUtils.isExpensifyCardTransaction(transaction);
const cardProgramName = isExpensifyCardTransaction ? CardUtils.getCardDescription(transactionCardID) : '';
+ // Flags for allowing or disallowing editing a money request
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction) && !isExpensifyCardTransaction;
@@ -151,7 +152,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
let receiptURIs;
let hasErrors = false;
if (hasReceipt) {
- receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction);
hasErrors = canEdit && TransactionUtils.hasMissingSmartscanFields(transaction);
}
@@ -159,130 +160,130 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
const getPendingFieldAction = (fieldPath) => lodashGet(transaction, fieldPath) || pendingAction;
return (
-
-
-
-
-
- {hasReceipt && (
-
-
-
-
-
- )}
-
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
- brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''}
- />
-
-
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))}
- wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
- numberOfLinesTitle={0}
- />
-
- {isDistanceRequest ? (
-
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))}
- />
-
- ) : (
-
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
- brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''}
- />
-
- )}
-
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
- brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''}
- />
-
- {shouldShowCategory && (
-
+
+
+
+ {hasReceipt && (
+
+
+
+
+
+ )}
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))}
+ title={formattedTransactionAmount ? formattedTransactionAmount.toString() : ''}
+ shouldShowTitleIcon={isSettled}
+ titleIcon={Expensicons.Checkmark}
+ description={amountDescription}
+ titleStyle={styles.newKansasLarge}
+ interactive={canEdit && !isSettled}
+ shouldShowRightIcon={canEdit && !isSettled}
+ onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
+ brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''}
/>
- )}
- {shouldShowTag && (
-
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))}
+ onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))}
+ wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
+ numberOfLinesTitle={0}
/>
- )}
- {isExpensifyCardTransaction ? (
-
+ {isDistanceRequest ? (
+
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))}
+ />
+
+ ) : (
+
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
+ brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''}
+ />
+
+ )}
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
+ brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''}
/>
- ) : null}
- {shouldShowBillable && (
-
- {translate('common.billable')}
- IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})}
- />
-
- )}
+ {shouldShowCategory && (
+
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))}
+ />
+
+ )}
+ {shouldShowTag && (
+
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.TAG))}
+ />
+
+ )}
+ {isExpensifyCardTransaction && (
+
+
+
+ )}
+ {shouldShowBillable && (
+
+ {translate('common.billable')}
+ IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})}
+ />
+
+ )}
+
- ) : (
-
- );
+ if (isEReceipt) {
+ receiptImageComponent = (
+
+
+
+ );
+ } else if (thumbnail) {
+ receiptImageComponent = (
+
+ );
+ } else {
+ receiptImageComponent = (
+
+ );
+ }
if (enablePreviewModal) {
return (
diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js
index 773c66d6e7b6..bd1ee6d45a07 100644
--- a/src/components/ReportActionItem/ReportActionItemImages.js
+++ b/src/components/ReportActionItem/ReportActionItemImages.js
@@ -7,6 +7,7 @@ import Text from '../Text';
import ReportActionItemImage from './ReportActionItemImage';
import * as StyleUtils from '../../styles/StyleUtils';
import variables from '../../styles/variables';
+import transactionPropTypes from '../transactionPropTypes';
const propTypes = {
/** array of image and thumbnail URIs */
@@ -14,6 +15,7 @@ const propTypes = {
PropTypes.shape({
thumbnail: PropTypes.string,
image: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ transaction: transactionPropTypes,
}),
).isRequired,
@@ -68,7 +70,7 @@ function ReportActionItemImages({images, size, total, isHovered}) {
return (
- {_.map(shownImages, ({thumbnail, image}, index) => {
+ {_.map(shownImages, ({thumbnail, image, transaction}, index) => {
const isLastImage = index === numberOfShownImages - 1;
// Show a border to separate multiple images. Shown to the right for each except the last.
@@ -82,6 +84,7 @@ function ReportActionItemImages({images, size, total, isHovered}) {
{isLastImage && remaining > 0 && (
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 088fba955c92..bdeec2640cdc 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -111,7 +111,7 @@ function ReportPreview(props) {
const managerID = props.iouReport.managerID || 0;
const isCurrentUserManager = managerID === lodashGet(props.session, 'accountID');
- const reportTotal = ReportUtils.getMoneyRequestTotal(props.iouReport);
+ const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(props.iouReport);
const iouSettled = ReportUtils.isSettled(props.iouReportID);
const iouCanceled = ReportUtils.isArchivedRoom(props.chatReport);
@@ -123,10 +123,11 @@ function ReportPreview(props) {
const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(props.iouReportID);
const numberOfScanningReceipts = _.filter(transactionsWithReceipts, (transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length;
const hasReceipts = transactionsWithReceipts.length > 0;
+ const hasOnlyDistanceRequests = ReportUtils.hasOnlyDistanceRequestTransactions(props.iouReportID);
const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action);
const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID);
- const lastThreeTransactionsWithReceipts = ReportUtils.getReportPreviewDisplayTransactions(props.action);
- const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename}) => ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || ''));
+ const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3);
+ const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, (transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction));
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(props.iouReportID);
const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts;
const previewSubtitle = hasOnlyOneReceiptRequest
@@ -136,15 +137,18 @@ function ReportPreview(props) {
scanningReceipts: numberOfScanningReceipts,
});
- const shouldShowSubmitButton = isReportDraft && reportTotal !== 0;
+ const shouldShowSubmitButton = isReportDraft && reimbursableSpend !== 0;
const getDisplayAmount = () => {
- if (reportTotal) {
- return CurrencyUtils.convertToDisplayString(reportTotal, props.iouReport.currency);
+ if (totalDisplaySpend) {
+ return CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.iouReport.currency);
}
if (isScanning) {
return props.translate('iou.receiptScanning');
}
+ if (hasOnlyDistanceRequests) {
+ return props.translate('common.tbd');
+ }
// If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere")
let displayAmount = '';
@@ -176,7 +180,7 @@ function ReportPreview(props) {
const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
const shouldShowSettlementButton = ReportUtils.isControlPolicyExpenseChat(props.chatReport)
? props.policy.role === CONST.POLICY.ROLE.ADMIN && ReportUtils.isReportApproved(props.iouReport) && !iouSettled && !iouCanceled
- : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
+ : !_.isEmpty(props.iouReport) && isCurrentUserManager && !isReportDraft && !iouSettled && !iouCanceled && !props.iouReport.isWaitingOnBankAccount && reimbursableSpend !== 0;
return (
@@ -241,11 +245,16 @@ function ReportPreview(props) {
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- style={[styles.requestPreviewBox]}
- anchorAlignment={{
+ shouldShowPaymentOptions
+ style={[styles.mt3]}
+ kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
+ paymentMethodDropdownAnchorAlignment={{
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
+ }}
/>
)}
{shouldShowSubmitButton && (
@@ -253,7 +262,7 @@ function ReportPreview(props) {
medium
success={props.chatReport.isOwnPolicyExpenseChat}
text={translate('common.submit')}
- style={styles.requestPreviewBox}
+ style={styles.mt3}
onPress={() => IOU.submitReport(props.iouReport)}
/>
)}
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index 7cddc7a969dc..61e7f9ea6ece 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -47,11 +47,13 @@ function TaskView(props) {
}, [props.report]);
const taskTitle = convertToLTR(props.report.reportName || '');
+ const assigneeTooltipDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([props.report.managerID], props.personalDetails), false);
const isCompleted = ReportUtils.isCompletedTaskReport(props.report);
const isOpen = ReportUtils.isOpenTaskReport(props.report);
const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID);
const disableState = !canModifyTask;
const isDisableInteractive = !canModifyTask || !isOpen;
+
return (
) : (
diff --git a/src/components/ReportActionsSkeletonView/index.js b/src/components/ReportActionsSkeletonView/index.js
index 6bdc993c2055..6efda2682ecd 100644
--- a/src/components/ReportActionsSkeletonView/index.js
+++ b/src/components/ReportActionsSkeletonView/index.js
@@ -7,23 +7,26 @@ import CONST from '../../CONST';
const propTypes = {
/** Whether to animate the skeleton view */
shouldAnimate: PropTypes.bool,
+
+ /** Number of possible visible content items */
+ possibleVisibleContentItems: PropTypes.number,
};
const defaultProps = {
shouldAnimate: true,
+ possibleVisibleContentItems: 0,
};
-function ReportActionsSkeletonView(props) {
- // Determines the number of content items based on container height
- const possibleVisibleContentItems = Math.ceil(Dimensions.get('window').height / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT);
+function ReportActionsSkeletonView({shouldAnimate, possibleVisibleContentItems}) {
+ const contentItems = possibleVisibleContentItems || Math.ceil(Dimensions.get('window').height / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT);
const skeletonViewLines = [];
- for (let index = 0; index < possibleVisibleContentItems; index++) {
+ for (let index = 0; index < contentItems; index++) {
const iconIndex = (index + 1) % 4;
switch (iconIndex) {
case 2:
skeletonViewLines.push(
,
@@ -32,7 +35,7 @@ function ReportActionsSkeletonView(props) {
case 0:
skeletonViewLines.push(
,
@@ -41,7 +44,7 @@ function ReportActionsSkeletonView(props) {
default:
skeletonViewLines.push(
,
diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js
index 7c8444a5d5b9..23a27682a7d4 100644
--- a/src/components/ReportWelcomeText.js
+++ b/src/components/ReportWelcomeText.js
@@ -133,7 +133,7 @@ function ReportWelcomeText(props) {
))}
)}
- {(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)) && (
+ {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)) && (
{props.translate('reportActionsView.usePlusButton')}
)}
diff --git a/src/components/RoomNameInput/index.js b/src/components/RoomNameInput/index.js
index 37d131108e9e..ec9bf7a090ab 100644
--- a/src/components/RoomNameInput/index.js
+++ b/src/components/RoomNameInput/index.js
@@ -71,10 +71,14 @@ RoomNameInput.propTypes = roomNameInputPropTypes.propTypes;
RoomNameInput.defaultProps = roomNameInputPropTypes.defaultProps;
RoomNameInput.displayName = 'RoomNameInput';
-export default React.forwardRef((props, ref) => (
+const RoomNameInputWithRef = React.forwardRef((props, ref) => (
));
+
+RoomNameInputWithRef.displayName = 'RoomNameInputWithRef';
+
+export default RoomNameInputWithRef;
diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.js
index 78500a8f0be2..9e83a673982c 100644
--- a/src/components/RoomNameInput/index.native.js
+++ b/src/components/RoomNameInput/index.native.js
@@ -53,10 +53,14 @@ RoomNameInput.propTypes = roomNameInputPropTypes.propTypes;
RoomNameInput.defaultProps = roomNameInputPropTypes.defaultProps;
RoomNameInput.displayName = 'RoomNameInput';
-export default React.forwardRef((props, ref) => (
+const RoomNameInputWithRef = React.forwardRef((props, ref) => (
));
+
+RoomNameInputWithRef.displayName = 'RoomNameInputWithRef';
+
+export default RoomNameInputWithRef;
diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js
index ab1ac37d32c8..7f8292f0123e 100644
--- a/src/components/RoomNameInput/roomNameInputPropTypes.js
+++ b/src/components/RoomNameInput/roomNameInputPropTypes.js
@@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
-import {withNavigationFocusPropTypes} from '../withNavigationFocus';
const propTypes = {
/** Callback to execute when the text input is modified correctly */
@@ -29,7 +28,8 @@ const propTypes = {
/** Whether we should wait before focusing the TextInput, useful when using transitions on Android */
shouldDelayFocus: PropTypes.bool,
- ...withNavigationFocusPropTypes,
+ /** Whether navigation is focused */
+ isFocused: PropTypes.bool.isRequired,
};
const defaultProps = {
diff --git a/src/components/ScrollViewWithContext.js b/src/components/ScrollViewWithContext.js
index bf0e7c6d62e8..01018601a781 100644
--- a/src/components/ScrollViewWithContext.js
+++ b/src/components/ScrollViewWithContext.js
@@ -1,4 +1,4 @@
-import React, {useState, useRef} from 'react';
+import React, {useState, useRef, useMemo} from 'react';
import {ScrollView} from 'react-native';
const MIN_SMOOTH_SCROLL_EVENT_THROTTLE = 16;
@@ -27,6 +27,14 @@ function ScrollViewWithContext({onScroll, scrollEventThrottle, children, innerRe
setContentOffsetY(event.nativeEvent.contentOffset.y);
};
+ const contextValue = useMemo(
+ () => ({
+ scrollViewRef,
+ contentOffsetY,
+ }),
+ [scrollViewRef, contentOffsetY],
+ );
+
return (
-
- {children}
-
+ {children}
);
}
@@ -50,11 +51,15 @@ function ScrollViewWithContext({onScroll, scrollEventThrottle, children, innerRe
ScrollViewWithContext.propTypes = propTypes;
ScrollViewWithContext.displayName = 'ScrollViewWithContext';
-export default React.forwardRef((props, ref) => (
+const ScrollViewWithContextWithRef = React.forwardRef((props, ref) => (
));
+
+ScrollViewWithContextWithRef.displayName = 'ScrollViewWithContextWithRef';
+
+export default ScrollViewWithContextWithRef;
export {ScrollContext};
diff --git a/src/components/SectionList/index.android.tsx b/src/components/SectionList/index.android.tsx
index a50e9102e473..82477fffc3ee 100644
--- a/src/components/SectionList/index.android.tsx
+++ b/src/components/SectionList/index.android.tsx
@@ -3,7 +3,7 @@ import {SectionList as RNSectionList} from 'react-native';
import ForwardedSectionList from './types';
// eslint-disable-next-line react/function-component-definition
-const SectionList: ForwardedSectionList = (props, ref) => (
+const SectionListWithRef: ForwardedSectionList = (props, ref) => (
(
/>
);
-SectionList.displayName = 'SectionList';
+SectionListWithRef.displayName = 'SectionListWithRef';
-export default forwardRef(SectionList);
+export default forwardRef(SectionListWithRef);
diff --git a/src/components/SelectCircle.js b/src/components/SelectCircle.js
index 93cf285eab59..55e410f8baa1 100644
--- a/src/components/SelectCircle.js
+++ b/src/components/SelectCircle.js
@@ -9,15 +9,20 @@ import themeColors from '../styles/themes/default';
const propTypes = {
/** Should we show the checkmark inside the circle */
isChecked: PropTypes.bool,
+
+ /** Additional styles to pass to SelectCircle */
+ // eslint-disable-next-line react/forbid-prop-types
+ styles: PropTypes.arrayOf(PropTypes.object),
};
const defaultProps = {
isChecked: false,
+ styles: [],
};
function SelectCircle(props) {
return (
-
+
{props.isChecked && (
{
if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) {
triggerKYCFlow(event, iouPaymentType);
+ BankAccounts.setPersonalBankAccountContinueKYCOnSuccess(ROUTES.ENABLE_PAYMENTS);
return;
}
@@ -204,9 +218,10 @@ function SettlementButton({
addBankAccountRoute={addBankAccountRoute}
addDebitCardRoute={addDebitCardRoute}
isDisabled={isOffline}
+ source={CONST.KYC_WALL_SOURCE.REPORT}
chatReportID={chatReportID}
iouReport={iouReport}
- anchorAlignment={anchorAlignment}
+ anchorAlignment={kycWallAnchorAlignment}
>
{(triggerKYCFlow, buttonRef) => (
)}
diff --git a/src/components/SingleOptionSelector.js b/src/components/SingleOptionSelector.js
new file mode 100644
index 000000000000..889b6a7d1f96
--- /dev/null
+++ b/src/components/SingleOptionSelector.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import {View} from 'react-native';
+import SelectCircle from './SelectCircle';
+import styles from '../styles/styles';
+import CONST from '../CONST';
+import Text from './Text';
+import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
+import withLocalize, {withLocalizePropTypes} from './withLocalize';
+
+const propTypes = {
+ /** Array of options for the selector, key is a unique identifier, label is a localize key that will be translated and displayed */
+ options: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.string,
+ label: PropTypes.string,
+ }),
+ ),
+
+ /** Key of the option that is currently selected */
+ selectedOptionKey: PropTypes.string,
+
+ /** Function to be called when an option is selected */
+ onSelectOption: PropTypes.func,
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ options: [],
+ selectedOptionKey: undefined,
+ onSelectOption: () => {},
+};
+
+function SingleOptionSelector({options, selectedOptionKey, onSelectOption, translate}) {
+ return (
+
+ {_.map(options, (option) => (
+
+ onSelectOption(option)}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityState={{checked: selectedOptionKey === option.key}}
+ aria-checked={selectedOptionKey === option.key}
+ accessibilityLabel={option.label}
+ >
+
+ {translate(option.label)}
+
+
+ ))}
+
+ );
+}
+
+SingleOptionSelector.propTypes = propTypes;
+SingleOptionSelector.defaultProps = defaultProps;
+SingleOptionSelector.displayName = 'SingleOptionSelector';
+
+export default withLocalize(SingleOptionSelector);
diff --git a/src/components/StatePicker/index.js b/src/components/StatePicker/index.js
index f7f894af2a07..586abc26f702 100644
--- a/src/components/StatePicker/index.js
+++ b/src/components/StatePicker/index.js
@@ -88,10 +88,14 @@ StatePicker.propTypes = propTypes;
StatePicker.defaultProps = defaultProps;
StatePicker.displayName = 'StatePicker';
-export default React.forwardRef((props, ref) => (
+const StatePickerWithRef = React.forwardRef((props, ref) => (
));
+
+StatePickerWithRef.displayName = 'StatePickerWithRef';
+
+export default StatePickerWithRef;
diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js
deleted file mode 100644
index 96640b107608..000000000000
--- a/src/components/SwipeableView/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default ({children}) => children;
-
-// Swipeable View is available just on Android/iOS for now.
diff --git a/src/components/SwipeableView/index.native.js b/src/components/SwipeableView/index.native.tsx
similarity index 65%
rename from src/components/SwipeableView/index.native.js
rename to src/components/SwipeableView/index.native.tsx
index 2f1148721af1..ac500f025016 100644
--- a/src/components/SwipeableView/index.native.js
+++ b/src/components/SwipeableView/index.native.tsx
@@ -1,41 +1,34 @@
import React, {useRef} from 'react';
import {PanResponder, View} from 'react-native';
-import PropTypes from 'prop-types';
import CONST from '../../CONST';
+import SwipeableViewProps from './types';
-const propTypes = {
- children: PropTypes.element.isRequired,
-
- /** Callback to fire when the user swipes down on the child content */
- onSwipeDown: PropTypes.func.isRequired,
-};
-
-function SwipeableView(props) {
+function SwipeableView({children, onSwipeDown}: SwipeableViewProps) {
const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT;
const oldYRef = useRef(0);
const panResponder = useRef(
PanResponder.create({
- // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance
- // & swipe direction is downwards
+ // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards
+ // eslint-disable-next-line @typescript-eslint/naming-convention
onMoveShouldSetPanResponderCapture: (_event, gestureState) => {
if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) {
return true;
}
oldYRef.current = gestureState.dy;
+ return false;
},
// Calls the callback when the swipe down is released; after the completion of the gesture
- onPanResponderRelease: props.onSwipeDown,
+ onPanResponderRelease: onSwipeDown,
}),
).current;
return (
// eslint-disable-next-line react/jsx-props-no-spreading
- {props.children}
+ {children}
);
}
-SwipeableView.propTypes = propTypes;
SwipeableView.displayName = 'SwipeableView';
export default SwipeableView;
diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx
new file mode 100644
index 000000000000..335c3e7dcf03
--- /dev/null
+++ b/src/components/SwipeableView/index.tsx
@@ -0,0 +1,4 @@
+import SwipeableViewProps from './types';
+
+// Swipeable View is available just on Android/iOS for now.
+export default ({children}: SwipeableViewProps) => children;
diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts
new file mode 100644
index 000000000000..560df7ef5a45
--- /dev/null
+++ b/src/components/SwipeableView/types.ts
@@ -0,0 +1,11 @@
+import {ReactNode} from 'react';
+
+type SwipeableViewProps = {
+ /** The content to be rendered within the SwipeableView */
+ children: ReactNode;
+
+ /** Callback to fire when the user swipes down on the child content */
+ onSwipeDown: () => void;
+};
+
+export default SwipeableViewProps;
diff --git a/src/components/TabSelector/TabSelector.js b/src/components/TabSelector/TabSelector.js
index 4efa033c60d0..3483ec10f804 100644
--- a/src/components/TabSelector/TabSelector.js
+++ b/src/components/TabSelector/TabSelector.js
@@ -1,5 +1,5 @@
import {View} from 'react-native';
-import React from 'react';
+import React, {useMemo, useState} from 'react';
import PropTypes from 'prop-types';
import _ from 'underscore';
import * as Expensicons from '../Icon/Expensicons';
@@ -53,7 +53,7 @@ const getIconAndTitle = (route, translate) => {
}
};
-const getOpacity = (position, routesLength, tabIndex, active) => {
+const getOpacity = (position, routesLength, tabIndex, active, affectedTabs) => {
const activeValue = active ? 1 : 0;
const inactiveValue = active ? 0 : 1;
@@ -62,19 +62,19 @@ const getOpacity = (position, routesLength, tabIndex, active) => {
return position.interpolate({
inputRange,
- outputRange: _.map(inputRange, (i) => (i === tabIndex ? activeValue : inactiveValue)),
+ outputRange: _.map(inputRange, (i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? activeValue : inactiveValue)),
});
}
return activeValue;
};
-const getBackgroundColor = (position, routesLength, tabIndex) => {
+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) => (i === tabIndex ? themeColors.border : themeColors.appBG)),
+ outputRange: _.map(inputRange, (i) => (affectedTabs.includes(tabIndex) && i === tabIndex ? themeColors.border : themeColors.appBG)),
});
}
return themeColors.border;
@@ -82,12 +82,23 @@ const getBackgroundColor = (position, routesLength, tabIndex) => {
function TabSelector({state, navigation, onTabPress, position}) {
const {translate} = useLocalize();
+
+ const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: state.routes.length}, (v, i) => i), [state.routes.length]);
+ const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs);
+
+ React.useEffect(() => {
+ // It is required to wait transition end to reset affectedAnimatedTabs because tabs style is still animating during transition.
+ setTimeout(() => {
+ setAffectedAnimatedTabs(defaultAffectedAnimatedTabs);
+ }, CONST.ANIMATED_TRANSITION);
+ }, [defaultAffectedAnimatedTabs, state.index]);
+
return (
{_.map(state.routes, (route, index) => {
- const activeOpacity = getOpacity(position, state.routes.length, index, true);
- const inactiveOpacity = getOpacity(position, state.routes.length, index, false);
- const backgroundColor = getBackgroundColor(position, state.routes.length, index);
+ const activeOpacity = getOpacity(position, state.routes.length, index, true, affectedAnimatedTabs);
+ const inactiveOpacity = getOpacity(position, state.routes.length, index, false, affectedAnimatedTabs);
+ const backgroundColor = getBackgroundColor(position, state.routes.length, index, affectedAnimatedTabs);
const isFocused = index === state.index;
const {icon, title} = getIconAndTitle(route.name, translate);
@@ -96,6 +107,8 @@ function TabSelector({state, navigation, onTabPress, position}) {
return;
}
+ setAffectedAnimatedTabs([state.index, index]);
+
const event = navigation.emit({
type: 'tabPress',
target: route.key,
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index 8e7cf11f7e5a..05eca664bd0f 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -53,7 +53,7 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
[searchValue, selectedOptions, policyTagList, policyRecentlyUsedTagsList],
);
- const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, '');
+ const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, '');
return (
- {props.title}
+ {title}
- {props.children}
+ {children}
);
}
-TestToolRow.propTypes = propTypes;
TestToolRow.displayName = 'TestToolRow';
export default TestToolRow;
diff --git a/src/components/Text.js b/src/components/Text.tsx
similarity index 61%
rename from src/components/Text.js
rename to src/components/Text.tsx
index 83b6be8fffb0..60a59aae1520 100644
--- a/src/components/Text.js
+++ b/src/components/Text.tsx
@@ -1,54 +1,46 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import _ from 'underscore';
+import React, {ForwardedRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import {Text as RNText} from 'react-native';
+import type {TextStyle} from 'react-native';
import fontFamily from '../styles/fontFamily';
import themeColors from '../styles/themes/default';
import variables from '../styles/variables';
-const propTypes = {
+type TextProps = {
/** The color of the text */
- color: PropTypes.string,
+ color?: string;
/** The size of the text */
- fontSize: PropTypes.number,
+ fontSize?: number;
/** The alignment of the text */
- textAlign: PropTypes.string,
+ textAlign?: 'left' | 'right' | 'auto' | 'center' | 'justify';
/** Any children to display */
- children: PropTypes.node,
+ children: React.ReactNode;
/** The family of the font to use */
- family: PropTypes.string,
+ family?: keyof typeof fontFamily;
/** Any additional styles to apply */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.any,
-};
-const defaultProps = {
- color: themeColors.text,
- fontSize: variables.fontSizeNormal,
- family: 'EXP_NEUE',
- textAlign: 'left',
- children: null,
- style: {},
+ style?: TextStyle | TextStyle[];
};
-const Text = React.forwardRef(({color, fontSize, textAlign, children, family, style, ...props}, ref) => {
+function Text(
+ {color = themeColors.text, fontSize = variables.fontSizeNormal, textAlign = 'left', children = null, family = 'EXP_NEUE', style = {}, ...props}: TextProps,
+ ref: ForwardedRef,
+) {
// If the style prop is an array of styles, we need to mix them all together
- const mergedStyles = !_.isArray(style)
+ const mergedStyles = !Array.isArray(style)
? style
- : _.reduce(
- style,
+ : style.reduce(
(finalStyles, s) => ({
...finalStyles,
...s,
}),
{},
);
- const componentStyle = {
+ const componentStyle: TextStyle = {
color,
fontSize,
textAlign,
@@ -71,10 +63,8 @@ const Text = React.forwardRef(({color, fontSize, textAlign, children, family, st
{children}
);
-});
+}
-Text.propTypes = propTypes;
-Text.defaultProps = defaultProps;
Text.displayName = 'Text';
-export default Text;
+export default React.forwardRef(Text);
diff --git a/src/components/TextInput/index.js b/src/components/TextInput/index.js
index 6cefe04e71a1..010121282a45 100644
--- a/src/components/TextInput/index.js
+++ b/src/components/TextInput/index.js
@@ -73,10 +73,14 @@ TextInput.displayName = 'TextInput';
TextInput.propTypes = baseTextInputPropTypes.propTypes;
TextInput.defaultProps = baseTextInputPropTypes.defaultProps;
-export default React.forwardRef((props, ref) => (
+const TextInputWithRef = React.forwardRef((props, ref) => (
));
+
+TextInputWithRef.displayName = 'TextInputWithRef';
+
+export default TextInputWithRef;
diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js
index 6dd1aacb0b09..ac0f4ccbe143 100644
--- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js
+++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js
@@ -63,10 +63,14 @@ BaseTextInputWithCurrencySymbol.propTypes = textInputWithCurrencySymbolPropTypes
BaseTextInputWithCurrencySymbol.defaultProps = textInputWithCurrencySymbolPropTypes.defaultProps;
BaseTextInputWithCurrencySymbol.displayName = 'BaseTextInputWithCurrencySymbol';
-export default React.forwardRef((props, ref) => (
+const BaseTextInputWithCurrencySymbolWithRef = React.forwardRef((props, ref) => (
));
+
+BaseTextInputWithCurrencySymbolWithRef.displayName = 'BaseTextInputWithCurrencySymbolWithRef';
+
+export default BaseTextInputWithCurrencySymbolWithRef;
diff --git a/src/components/TextInputWithCurrencySymbol/index.android.js b/src/components/TextInputWithCurrencySymbol/index.android.js
index e597566d6ffd..57b3c96136e8 100644
--- a/src/components/TextInputWithCurrencySymbol/index.android.js
+++ b/src/components/TextInputWithCurrencySymbol/index.android.js
@@ -30,10 +30,14 @@ TextInputWithCurrencySymbol.propTypes = textInputWithCurrencySymbolPropTypes.pro
TextInputWithCurrencySymbol.defaultProps = textInputWithCurrencySymbolPropTypes.defaultProps;
TextInputWithCurrencySymbol.displayName = 'TextInputWithCurrencySymbol';
-export default React.forwardRef((props, ref) => (
+const TextInputWithCurrencySymbolWithRef = React.forwardRef((props, ref) => (
));
+
+TextInputWithCurrencySymbolWithRef.displayName = 'TextInputWithCurrencySymbolWithRef';
+
+export default TextInputWithCurrencySymbolWithRef;
diff --git a/src/components/TextInputWithCurrencySymbol/index.js b/src/components/TextInputWithCurrencySymbol/index.js
index f70134a2e0eb..2102882a74a3 100644
--- a/src/components/TextInputWithCurrencySymbol/index.js
+++ b/src/components/TextInputWithCurrencySymbol/index.js
@@ -17,10 +17,14 @@ TextInputWithCurrencySymbol.propTypes = textInputWithCurrencySymbolPropTypes.pro
TextInputWithCurrencySymbol.defaultProps = textInputWithCurrencySymbolPropTypes.defaultProps;
TextInputWithCurrencySymbol.displayName = 'TextInputWithCurrencySymbol';
-export default React.forwardRef((props, ref) => (
+const TextInputWithCurrencySymbolWithRef = React.forwardRef((props, ref) => (
));
+
+TextInputWithCurrencySymbolWithRef.displayName = 'TextInputWithCurrencySymbolWithRef';
+
+export default TextInputWithCurrencySymbolWithRef;
diff --git a/src/components/TextLink.js b/src/components/TextLink.js
index 233aaf50644e..3f7b7ff729c3 100644
--- a/src/components/TextLink.js
+++ b/src/components/TextLink.js
@@ -84,10 +84,15 @@ function TextLink(props) {
TextLink.defaultProps = defaultProps;
TextLink.propTypes = propTypes;
TextLink.displayName = 'TextLink';
-export default React.forwardRef((props, ref) => (
+
+const TextLinkWithRef = React.forwardRef((props, ref) => (
));
+
+TextLinkWithRef.displayName = 'TextLinkWithRef';
+
+export default TextLinkWithRef;
diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js
index f0cee6fdea2f..3aac98fa1275 100644
--- a/src/components/ThreeDotsMenu/index.js
+++ b/src/components/ThreeDotsMenu/index.js
@@ -6,11 +6,12 @@ import Icon from '../Icon';
import PopoverMenu from '../PopoverMenu';
import styles from '../../styles/styles';
import useLocalize from '../../hooks/useLocalize';
-import Tooltip from '../Tooltip';
+import Tooltip from '../Tooltip/PopoverAnchorTooltip';
import * as Expensicons from '../Icon/Expensicons';
import ThreeDotsMenuItemPropTypes from './ThreeDotsMenuItemPropTypes';
import CONST from '../../CONST';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
+import * as Browser from '../../libs/Browser';
const propTypes = {
/** Tooltip for the popup icon */
@@ -48,6 +49,9 @@ const propTypes = {
/** Whether the popover menu should overlay the current view */
shouldOverlay: PropTypes.bool,
+
+ /** Should we announce the Modal visibility changes? */
+ shouldSetModalVisibility: PropTypes.bool,
};
const defaultProps = {
@@ -61,9 +65,10 @@ const defaultProps = {
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
},
shouldOverlay: false,
+ shouldSetModalVisibility: true,
};
-function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay}) {
+function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay, shouldSetModalVisibility}) {
const [isPopupMenuVisible, setPopupMenuVisible] = useState(false);
const buttonRef = useRef(null);
const {translate} = useLocalize();
@@ -91,6 +96,13 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me
onIconPress();
}
}}
+ onMouseDown={(e) => {
+ /* Keep the focus state on mWeb like we did on the native apps. */
+ if (!Browser.isMobile()) {
+ return;
+ }
+ e.preventDefault();
+ }}
ref={buttonRef}
style={[styles.touchableButtonImage, ...iconStyles]}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
@@ -111,6 +123,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me
onItemSelected={hidePopoverMenu}
menuItems={menuItems}
withoutOverlay={!shouldOverlay}
+ shouldSetModalVisibility={shouldSetModalVisibility}
anchorRef={buttonRef}
/>
>
diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js
index 50ade2026bae..7ef80c552980 100644
--- a/src/components/Tooltip/BaseTooltip.js
+++ b/src/components/Tooltip/BaseTooltip.js
@@ -52,7 +52,7 @@ function chooseBoundingBox(target, clientX, clientY) {
return target.getBoundingClientRect();
}
-function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey, shouldHandleScroll, shiftHorizontal, shiftVertical}) {
+function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey, shouldHandleScroll, shiftHorizontal, shiftVertical, tooltipRef}) {
const {preferredLocale} = useLocalize();
const {windowWidth} = useWindowDimensions();
@@ -78,7 +78,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
const initialMousePosition = useRef({x: 0, y: 0});
const updateTargetAndMousePosition = useCallback((e) => {
- target.current = e.target;
+ target.current = e.currentTarget;
initialMousePosition.current = {x: e.clientX, y: e.clientY};
}, []);
@@ -86,10 +86,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
* Display the tooltip in an animation.
*/
const showTooltip = useCallback(() => {
- if (!isRendered) {
- setIsRendered(true);
- }
-
+ setIsRendered(true);
setIsVisible(true);
animation.current.stopAnimation();
@@ -109,7 +106,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
});
}
TooltipSense.activate();
- }, [isRendered]);
+ }, []);
// eslint-disable-next-line rulesdir/prefer-early-return
useEffect(() => {
@@ -130,11 +127,17 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
if (bounds.width === 0) {
setIsRendered(false);
}
+ if (!target.current) {
+ return;
+ }
// Choose a bounding box for the tooltip to target.
// In the case when the target is a link that has wrapped onto
// multiple lines, we want to show the tooltip over the part
// of the link that the user is hovering over.
const betterBounds = chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y);
+ if (!betterBounds) {
+ return;
+ }
setWrapperWidth(betterBounds.width);
setWrapperHeight(betterBounds.height);
setXOffset(betterBounds.x);
@@ -144,7 +147,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
/**
* Hide the tooltip in an animation.
*/
- const hideTooltip = () => {
+ const hideTooltip = useCallback(() => {
animation.current.stopAnimation();
if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) {
@@ -162,7 +165,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
TooltipSense.deactivate();
setIsVisible(false);
- };
+ }, []);
// Skip the tooltip and return the children if the text is empty,
// we don't have a render function or the device does not support hovering
@@ -194,6 +197,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent,
{
+ // eslint-disable-next-line
+ const tooltipNode = tooltipRef.current ? tooltipRef.current._childNode : null;
+ if (
+ isOpen &&
+ popover &&
+ popover.anchorRef &&
+ popover.anchorRef.current &&
+ tooltipNode &&
+ (tooltipNode.contains(popover.anchorRef.current) || tooltipNode === popover.anchorRef.current)
+ ) {
+ return true;
+ }
+
+ return false;
+ }, [isOpen, popover]);
+
+ if (!shouldRender || isPopoverRelatedToTooltipOpen) {
+ return children;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+PopoverAnchorTooltip.displayName = 'PopoverAnchorTooltip';
+PopoverAnchorTooltip.propTypes = propTypes;
+PopoverAnchorTooltip.defaultProps = defaultProps;
+
+export default PopoverAnchorTooltip;
diff --git a/src/components/Tooltip/tooltipPropTypes.js b/src/components/Tooltip/tooltipPropTypes.js
index 2ddf8120d58c..684a102e0339 100644
--- a/src/components/Tooltip/tooltipPropTypes.js
+++ b/src/components/Tooltip/tooltipPropTypes.js
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import refPropTypes from '../refPropTypes';
import variables from '../../styles/variables';
import CONST from '../../CONST';
@@ -31,6 +32,9 @@ const propTypes = {
/** passes this down to Hoverable component to decide whether to handle the scroll behaviour to show hover once the scroll ends */
shouldHandleScroll: PropTypes.bool,
+
+ /** Reference to the tooltip container */
+ tooltipRef: refPropTypes,
};
const defaultProps = {
@@ -42,6 +46,7 @@ const defaultProps = {
renderTooltipContent: undefined,
renderTooltipContentKey: [],
shouldHandleScroll: false,
+ tooltipRef: () => {},
};
export {propTypes, defaultProps};
diff --git a/src/components/UserCurrentLocationButton.js b/src/components/UserCurrentLocationButton.js
deleted file mode 100644
index fa22eb602886..000000000000
--- a/src/components/UserCurrentLocationButton.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useEffect, useRef, useState} from 'react';
-import {Text} from 'react-native';
-import getCurrentPosition from '../libs/getCurrentPosition';
-import styles from '../styles/styles';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import LocationErrorMessage from './LocationErrorMessage';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import colors from '../styles/colors';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
-
-const propTypes = {
- /** Callback that runs when location data is fetched */
- onLocationFetched: PropTypes.func.isRequired,
-
- /** Callback that runs when fetching location has errors */
- onLocationError: PropTypes.func,
-
- /** Callback that runs when location button is clicked */
- onClick: PropTypes.func,
-
- /** Boolean to indicate if the button is clickable */
- isDisabled: PropTypes.bool,
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- isDisabled: false,
- onLocationError: () => {},
- onClick: () => {},
-};
-
-function UserCurrentLocationButton({onLocationFetched, onLocationError, onClick, isDisabled, translate}) {
- const isFetchingLocation = useRef(false);
- const shouldTriggerCallbacks = useRef(true);
- const [locationErrorCode, setLocationErrorCode] = useState(null);
-
- /** Gets the user's current location and registers success/error callbacks */
- const getUserLocation = () => {
- if (isFetchingLocation.current) {
- return;
- }
-
- isFetchingLocation.current = true;
-
- onClick();
-
- getCurrentPosition(
- (successData) => {
- isFetchingLocation.current = false;
- if (!shouldTriggerCallbacks.current) {
- return;
- }
-
- setLocationErrorCode(null);
- onLocationFetched(successData);
- },
- (errorData) => {
- isFetchingLocation.current = false;
- if (!shouldTriggerCallbacks.current) {
- return;
- }
-
- setLocationErrorCode(errorData.code);
- onLocationError(errorData);
- },
- {
- maximumAge: 0, // No cache, always get fresh location info
- timeout: 5000,
- },
- );
- };
-
- // eslint-disable-next-line arrow-body-style
- useEffect(() => {
- return () => {
- // If the component unmounts we don't want any of the callback for geolocation to run.
- shouldTriggerCallbacks.current = false;
- };
- }, []);
-
- return (
- <>
- e.preventDefault()}
- onTouchStart={(e) => e.preventDefault()}
- >
-
- {translate('location.useCurrent')}
-
- setLocationErrorCode(null)}
- locationErrorCode={locationErrorCode}
- />
- >
- );
-}
-
-UserCurrentLocationButton.displayName = 'UserCurrentLocationButton';
-UserCurrentLocationButton.propTypes = propTypes;
-UserCurrentLocationButton.defaultProps = defaultProps;
-
-// This components gets used inside , we are using an HOC (withLocalize) as function components with
-// hooks give hook errors when nested inside .
-export default withLocalize(UserCurrentLocationButton);
diff --git a/src/components/ValuePicker/index.js b/src/components/ValuePicker/index.js
index 161fbbfadb8b..ce248baf707e 100644
--- a/src/components/ValuePicker/index.js
+++ b/src/components/ValuePicker/index.js
@@ -93,10 +93,14 @@ ValuePicker.propTypes = propTypes;
ValuePicker.defaultProps = defaultProps;
ValuePicker.displayName = 'ValuePicker';
-export default React.forwardRef((props, ref) => (
+const ValuePickerWithRef = React.forwardRef((props, ref) => (
));
+
+ValuePickerWithRef.displayName = 'ValuePickerWithRef';
+
+export default ValuePickerWithRef;
diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
index f052116697b3..ed1b71c8fb0f 100755
--- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
+++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js
@@ -14,7 +14,7 @@ import themeColors from '../../styles/themes/default';
import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import compose from '../../libs/compose';
-import Tooltip from '../Tooltip';
+import Tooltip from '../Tooltip/PopoverAnchorTooltip';
import {propTypes as videoChatButtonAndMenuPropTypes, defaultProps} from './videoChatButtonAndMenuPropTypes';
import * as Session from '../../libs/actions/Session';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
@@ -118,7 +118,6 @@ function BaseVideoChatButtonAndMenu(props) {
left: videoChatIconPosition.x - 150,
top: videoChatIconPosition.y + 40,
}}
- shouldSetModalVisibility={false}
withoutOverlay
anchorRef={videoChatButtonRef}
>
diff --git a/src/components/ZeroWidthView/index.js b/src/components/ZeroWidthView/index.js
new file mode 100644
index 000000000000..6c3809a40a04
--- /dev/null
+++ b/src/components/ZeroWidthView/index.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import * as EmojiUtils from '../../libs/EmojiUtils';
+import * as Browser from '../../libs/Browser';
+import Text from '../Text';
+
+const propTypes = {
+ /** If this is the Concierge chat, we'll open the modal for requesting a setup call instead of showing popover menu */
+ text: PropTypes.string,
+
+ /** URL to the assigned guide's appointment booking calendar */
+ displayAsGroup: PropTypes.bool,
+};
+
+const defaultProps = {
+ text: '',
+ displayAsGroup: false,
+};
+
+function ZeroWidthView({text, displayAsGroup}) {
+ const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text);
+ if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) {
+ return ;
+ }
+ return null;
+}
+
+ZeroWidthView.propTypes = propTypes;
+ZeroWidthView.defaultProps = defaultProps;
+ZeroWidthView.displayName = 'ZeroWidthView';
+
+export default ZeroWidthView;
diff --git a/src/components/ZeroWidthView/index.native.js b/src/components/ZeroWidthView/index.native.js
new file mode 100644
index 000000000000..59c3cc74ab72
--- /dev/null
+++ b/src/components/ZeroWidthView/index.native.js
@@ -0,0 +1,5 @@
+function ZeroWidthView() {
+ return null;
+}
+
+export default ZeroWidthView;
diff --git a/src/components/createOnyxContext.js b/src/components/createOnyxContext.js
deleted file mode 100644
index 3dbc07a7032e..000000000000
--- a/src/components/createOnyxContext.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, {createContext, forwardRef} from 'react';
-import PropTypes from 'prop-types';
-import {withOnyx} from 'react-native-onyx';
-import Str from 'expensify-common/lib/str';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-
-const propTypes = {
- /** Rendered child component */
- children: PropTypes.node.isRequired,
-};
-
-export default (onyxKeyName, defaultValue) => {
- const Context = createContext();
- function Provider(props) {
- return {props.children};
- }
-
- Provider.propTypes = propTypes;
- Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`;
-
- // eslint-disable-next-line rulesdir/onyx-props-must-have-default
- const ProviderWithOnyx = withOnyx({
- [onyxKeyName]: {
- key: onyxKeyName,
- },
- })(Provider);
-
- const withOnyxKey =
- ({propName = onyxKeyName, transformValue} = {}) =>
- (WrappedComponent) => {
- const Consumer = forwardRef((props, ref) => (
-
- {(value) => {
- const propsToPass = {
- ...props,
- [propName]: transformValue ? transformValue(value, props) : value,
- };
-
- if (propsToPass[propName] === undefined && defaultValue) {
- propsToPass[propName] = defaultValue;
- }
- return (
-
- );
- }}
-
- ));
-
- Consumer.displayName = `with${Str.UCFirst(onyxKeyName)}(${getComponentDisplayName(WrappedComponent)})`;
- return Consumer;
- };
-
- return [withOnyxKey, ProviderWithOnyx, Context];
-};
diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx
new file mode 100644
index 000000000000..a0ac5942b098
--- /dev/null
+++ b/src/components/createOnyxContext.tsx
@@ -0,0 +1,94 @@
+import React, {ComponentType, ForwardRefExoticComponent, ForwardedRef, PropsWithoutRef, ReactNode, RefAttributes, createContext, forwardRef, useContext} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import Str from 'expensify-common/lib/str';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+import {OnyxCollectionKey, OnyxKey, OnyxKeyValue, OnyxValues} from '../ONYXKEYS';
+import ChildrenProps from '../types/utils/ChildrenProps';
+
+type OnyxKeys = (OnyxKey | OnyxCollectionKey) & keyof OnyxValues;
+
+// Provider types
+type ProviderOnyxProps = Record>;
+
+type ProviderPropsWithOnyx = ChildrenProps & ProviderOnyxProps;
+
+// withOnyxKey types
+type WithOnyxKeyProps = {
+ propName?: TOnyxKey | TNewOnyxKey;
+ // It's not possible to infer the type of props of the wrapped component, so we have to use `any` here
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ transformValue?: (value: OnyxKeyValue, props: any) => TTransformedValue;
+};
+
+type WrapComponentWithConsumer = , TRef>(
+ WrappedComponent: ComponentType>,
+) => ForwardRefExoticComponent> & RefAttributes>;
+
+type WithOnyxKey = >(
+ props?: WithOnyxKeyProps,
+) => WrapComponentWithConsumer;
+
+// createOnyxContext return type
+type CreateOnyxContext = [
+ WithOnyxKey,
+ ComponentType, TOnyxKey>>,
+ React.Context>,
+ () => OnyxValues[TOnyxKey],
+];
+
+export default (onyxKeyName: TOnyxKey): CreateOnyxContext => {
+ const Context = createContext>(null);
+ function Provider(props: ProviderPropsWithOnyx): ReactNode {
+ return {props.children};
+ }
+
+ Provider.displayName = `${Str.UCFirst(onyxKeyName)}Provider`;
+
+ const ProviderWithOnyx = withOnyx, ProviderOnyxProps>({
+ [onyxKeyName]: {
+ key: onyxKeyName,
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as Record)(Provider);
+
+ function withOnyxKey>({
+ propName,
+ transformValue,
+ }: WithOnyxKeyProps = {}) {
+ return , TRef>(WrappedComponent: ComponentType>) => {
+ function Consumer(props: Omit, ref: ForwardedRef): ReactNode {
+ return (
+
+ {(value) => {
+ const propsToPass = {
+ ...props,
+ [propName ?? onyxKeyName]: transformValue ? transformValue(value, props) : value,
+ } as TProps;
+
+ return (
+
+ );
+ }}
+
+ );
+ }
+
+ Consumer.displayName = `with${Str.UCFirst(onyxKeyName)}(${getComponentDisplayName(WrappedComponent)})`;
+ return forwardRef(Consumer);
+ };
+ }
+
+ const useOnyxContext = () => {
+ const value = useContext(Context);
+ if (value === null) {
+ throw new Error(`useOnyxContext must be used within a OnyxProvider [key: ${onyxKeyName}]`);
+ }
+ return value;
+ };
+
+ return [withOnyxKey, ProviderWithOnyx, Context, useOnyxContext];
+};
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index e33170ac67f4..4b37e8040d45 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -153,6 +153,12 @@ const propTypes = {
/** Should render component on the right */
shouldShowRightComponent: PropTypes.bool,
+
+ /** Array of objects that map display names to their corresponding tooltip */
+ titleWithTooltips: PropTypes.arrayOf(PropTypes.object),
+
+ /** Should check anonymous user in onPress function */
+ shouldCheckActionAllowedOnPress: PropTypes.bool,
};
export default propTypes;
diff --git a/src/components/withAnimatedRef.js b/src/components/withAnimatedRef.js
index 71ef130b9ce7..bb04c6a599d7 100644
--- a/src/components/withAnimatedRef.js
+++ b/src/components/withAnimatedRef.js
@@ -23,11 +23,15 @@ export default function withAnimatedRef(WrappedComponent) {
forwardedRef: undefined,
};
- return React.forwardRef((props, ref) => (
+ const WithAnimatedRefWithRef = React.forwardRef((props, ref) => (
));
+
+ WithAnimatedRefWithRef.displayName = 'WithAnimatedRefWithRef';
+
+ return WithAnimatedRefWithRef;
}
diff --git a/src/components/withCurrentUserPersonalDetails.js b/src/components/withCurrentUserPersonalDetails.js
deleted file mode 100644
index 7a47ea7cc712..000000000000
--- a/src/components/withCurrentUserPersonalDetails.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import React, {useMemo} from 'react';
-import PropTypes from 'prop-types';
-import {withOnyx} from 'react-native-onyx';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-import ONYXKEYS from '../ONYXKEYS';
-import personalDetailsPropType from '../pages/personalDetailsPropType';
-import refPropTypes from './refPropTypes';
-
-const withCurrentUserPersonalDetailsPropTypes = {
- currentUserPersonalDetails: personalDetailsPropType,
-};
-
-const withCurrentUserPersonalDetailsDefaultProps = {
- currentUserPersonalDetails: {},
-};
-
-export default function (WrappedComponent) {
- const propTypes = {
- forwardedRef: refPropTypes,
-
- /** Personal details of all the users, including current user */
- personalDetails: PropTypes.objectOf(personalDetailsPropType),
-
- /** Session of the current user */
- session: PropTypes.shape({
- accountID: PropTypes.number,
- }),
- };
- const defaultProps = {
- forwardedRef: undefined,
- personalDetails: {},
- session: {
- accountID: 0,
- },
- };
-
- function WithCurrentUserPersonalDetails(props) {
- const accountID = props.session.accountID;
- const accountPersonalDetails = props.personalDetails[accountID];
- const currentUserPersonalDetails = useMemo(() => ({...accountPersonalDetails, accountID}), [accountPersonalDetails, accountID]);
- return (
-
- );
- }
-
- WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`;
- WithCurrentUserPersonalDetails.propTypes = propTypes;
-
- WithCurrentUserPersonalDetails.defaultProps = defaultProps;
-
- const withCurrentUserPersonalDetails = React.forwardRef((props, ref) => (
-
- ));
-
- return withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- })(withCurrentUserPersonalDetails);
-}
-
-export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps};
diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx
new file mode 100644
index 000000000000..e1472f280f17
--- /dev/null
+++ b/src/components/withCurrentUserPersonalDetails.tsx
@@ -0,0 +1,67 @@
+import React, {ComponentType, RefAttributes, ForwardedRef, useMemo} from 'react';
+import {OnyxEntry, withOnyx} from 'react-native-onyx';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+import ONYXKEYS from '../ONYXKEYS';
+import personalDetailsPropType from '../pages/personalDetailsPropType';
+import type {PersonalDetails, Session} from '../types/onyx';
+
+type CurrentUserPersonalDetails = PersonalDetails | Record;
+
+type OnyxProps = {
+ /** Personal details of all the users, including current user */
+ personalDetails: OnyxEntry>;
+
+ /** Session of the current user */
+ session: OnyxEntry;
+};
+
+type HOCProps = {
+ currentUserPersonalDetails: CurrentUserPersonalDetails;
+};
+
+type ComponentProps = OnyxProps & HOCProps;
+
+// TODO: remove when all components that use it will be migrated to TS
+const withCurrentUserPersonalDetailsPropTypes = {
+ currentUserPersonalDetails: personalDetailsPropType,
+};
+
+const withCurrentUserPersonalDetailsDefaultProps: HOCProps = {
+ currentUserPersonalDetails: {},
+};
+
+export default function (
+ WrappedComponent: ComponentType>,
+): ComponentType & RefAttributes, keyof OnyxProps>> {
+ function WithCurrentUserPersonalDetails(props: Omit, ref: ForwardedRef) {
+ const accountID = props.session?.accountID ?? 0;
+ const accountPersonalDetails = props.personalDetails?.[accountID];
+ const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo(
+ () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}),
+ [accountPersonalDetails, accountID],
+ );
+ return (
+
+ );
+ }
+
+ WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`;
+
+ const withCurrentUserPersonalDetails = React.forwardRef(WithCurrentUserPersonalDetails);
+
+ return withOnyx & RefAttributes, OnyxProps>({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(withCurrentUserPersonalDetails);
+}
+
+export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps};
diff --git a/src/components/withKeyboardState.js b/src/components/withKeyboardState.js
index 8ddf667b4e30..3154f7e98d67 100755
--- a/src/components/withKeyboardState.js
+++ b/src/components/withKeyboardState.js
@@ -1,4 +1,4 @@
-import React, {forwardRef, createContext, useEffect, useState} from 'react';
+import React, {forwardRef, createContext, useEffect, useState, useMemo} from 'react';
import {Keyboard} from 'react-native';
import PropTypes from 'prop-types';
import getComponentDisplayName from '../libs/getComponentDisplayName';
@@ -31,7 +31,13 @@ function KeyboardStateProvider(props) {
};
}, []);
- return {children};
+ const contextValue = useMemo(
+ () => ({
+ isKeyboardShown,
+ }),
+ [isKeyboardShown],
+ );
+ return {children};
}
KeyboardStateProvider.propTypes = keyboardStateProviderPropTypes;
diff --git a/src/components/withNavigation.js b/src/components/withNavigation.js
deleted file mode 100644
index ef0f599dc982..000000000000
--- a/src/components/withNavigation.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {useNavigation} from '@react-navigation/native';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-import refPropTypes from './refPropTypes';
-
-const withNavigationPropTypes = {
- navigation: PropTypes.object.isRequired,
-};
-
-export default function withNavigation(WrappedComponent) {
- function WithNavigation(props) {
- const navigation = useNavigation();
- return (
-
- );
- }
-
- WithNavigation.displayName = `withNavigation(${getComponentDisplayName(WrappedComponent)})`;
- WithNavigation.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithNavigation.defaultProps = {
- forwardedRef: () => {},
- };
- return React.forwardRef((props, ref) => (
-
- ));
-}
-
-export {withNavigationPropTypes};
diff --git a/src/components/withNavigation.tsx b/src/components/withNavigation.tsx
new file mode 100644
index 000000000000..c5842fdacc44
--- /dev/null
+++ b/src/components/withNavigation.tsx
@@ -0,0 +1,26 @@
+import React, {ComponentType, ForwardedRef, RefAttributes} from 'react';
+import {NavigationProp, useNavigation} from '@react-navigation/native';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+
+type WithNavigationProps = {
+ navigation: NavigationProp;
+};
+
+export default function withNavigation(
+ WrappedComponent: ComponentType>,
+): (props: Omit, ref: ForwardedRef) => React.JSX.Element | null {
+ function WithNavigation(props: Omit, ref: ForwardedRef) {
+ const navigation = useNavigation();
+ return (
+
+ );
+ }
+
+ WithNavigation.displayName = `withNavigation(${getComponentDisplayName(WrappedComponent)})`;
+ return React.forwardRef(WithNavigation);
+}
diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js
index e82946c9e049..413d0734507a 100644
--- a/src/components/withNavigationFallback.js
+++ b/src/components/withNavigationFallback.js
@@ -33,11 +33,15 @@ export default function (WrappedComponent) {
forwardedRef: undefined,
};
- return forwardRef((props, ref) => (
+ const WithNavigationFallbackWithRef = forwardRef((props, ref) => (
));
+
+ WithNavigationFallbackWithRef.displayName = `WithNavigationFallbackWithRef`;
+
+ return WithNavigationFallbackWithRef;
}
diff --git a/src/components/withNavigationFocus.js b/src/components/withNavigationFocus.js
deleted file mode 100644
index f934f038e311..000000000000
--- a/src/components/withNavigationFocus.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import {useIsFocused} from '@react-navigation/native';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-import refPropTypes from './refPropTypes';
-
-const withNavigationFocusPropTypes = {
- isFocused: PropTypes.bool.isRequired,
-};
-
-export default function withNavigationFocus(WrappedComponent) {
- function WithNavigationFocus(props) {
- const isFocused = useIsFocused();
- return (
-
- );
- }
-
- WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`;
- WithNavigationFocus.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithNavigationFocus.defaultProps = {
- forwardedRef: undefined,
- };
- return React.forwardRef((props, ref) => (
-
- ));
-}
-
-export {withNavigationFocusPropTypes};
diff --git a/src/components/withNavigationFocus.tsx b/src/components/withNavigationFocus.tsx
new file mode 100644
index 000000000000..f3f1d3561d9c
--- /dev/null
+++ b/src/components/withNavigationFocus.tsx
@@ -0,0 +1,26 @@
+import React, {ComponentType, ForwardedRef, RefAttributes} from 'react';
+import {useIsFocused} from '@react-navigation/native';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+
+type WithNavigationFocusProps = {
+ isFocused: boolean;
+};
+
+export default function withNavigationFocus(
+ WrappedComponent: ComponentType>,
+): (props: Omit & React.RefAttributes) => React.ReactElement | null {
+ function WithNavigationFocus(props: Omit, ref: ForwardedRef) {
+ const isFocused = useIsFocused();
+ return (
+
+ );
+ }
+
+ WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`;
+ return React.forwardRef(WithNavigationFocus);
+}
diff --git a/src/components/withTheme.js b/src/components/withTheme.js
index 99de2a2c7fc7..753a75d2c354 100644
--- a/src/components/withTheme.js
+++ b/src/components/withTheme.js
@@ -28,13 +28,18 @@ export default function withTheme(WrappedComponent) {
WithTheme.defaultProps = {
forwardedRef: () => {},
};
- return React.forwardRef((props, ref) => (
+
+ const WithThemeWithRef = React.forwardRef((props, ref) => (
));
+
+ WithThemeWithRef.displayName = `WithThemeWithRef`;
+
+ return WithThemeWithRef;
}
export {withThemePropTypes};
diff --git a/src/components/withThemeStyles.js b/src/components/withThemeStyles.js
index 0320fcb71808..63356e20d990 100644
--- a/src/components/withThemeStyles.js
+++ b/src/components/withThemeStyles.js
@@ -28,13 +28,18 @@ export default function withThemeStyles(WrappedComponent) {
WithThemeStyles.defaultProps = {
forwardedRef: () => {},
};
- return React.forwardRef((props, ref) => (
+
+ const WithThemeStylesWithRef = React.forwardRef((props, ref) => (
));
+
+ WithThemeStylesWithRef.displayName = `WithThemeStylesWithRef`;
+
+ return WithThemeStylesWithRef;
}
export {withThemeStylesPropTypes};
diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js
index eef5135d02b6..c168b49ced20 100644
--- a/src/components/withToggleVisibilityView.js
+++ b/src/components/withToggleVisibilityView.js
@@ -35,13 +35,18 @@ export default function (WrappedComponent) {
forwardedRef: undefined,
isVisible: false,
};
- return React.forwardRef((props, ref) => (
+
+ const WithToggleVisibilityViewWithRef = React.forwardRef((props, ref) => (
));
+
+ WithToggleVisibilityViewWithRef.displayName = `WithToggleVisibilityViewWithRef`;
+
+ return WithToggleVisibilityViewWithRef;
}
export {toggleVisibilityViewPropTypes};
diff --git a/src/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js
deleted file mode 100644
index ccf928b3bd13..000000000000
--- a/src/components/withViewportOffsetTop.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import React, {useEffect, forwardRef, useState} from 'react';
-import PropTypes from 'prop-types';
-import lodashGet from 'lodash/get';
-import getComponentDisplayName from '../libs/getComponentDisplayName';
-import addViewportResizeListener from '../libs/VisualViewport';
-import refPropTypes from './refPropTypes';
-
-const viewportOffsetTopPropTypes = {
- // viewportOffsetTop returns the offset of the top edge of the visual viewport from the
- // top edge of the layout viewport in CSS pixels, when the visual viewport is resized.
-
- viewportOffsetTop: PropTypes.number.isRequired,
-};
-
-export default function (WrappedComponent) {
- function WithViewportOffsetTop(props) {
- const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
-
- useEffect(() => {
- /**
- * @param {SyntheticEvent} e
- */
- const updateDimensions = (e) => {
- const targetOffsetTop = lodashGet(e, 'target.offsetTop', 0);
- setViewportOffsetTop(targetOffsetTop);
- };
-
- const removeViewportResizeListener = addViewportResizeListener(updateDimensions);
-
- return () => {
- removeViewportResizeListener();
- };
- }, []);
-
- return (
-
- );
- }
-
- WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`;
- WithViewportOffsetTop.propTypes = {
- forwardedRef: refPropTypes,
- };
- WithViewportOffsetTop.defaultProps = {
- forwardedRef: undefined,
- };
- return forwardRef((props, ref) => (
-
- ));
-}
-
-export {viewportOffsetTopPropTypes};
diff --git a/src/components/withViewportOffsetTop.tsx b/src/components/withViewportOffsetTop.tsx
new file mode 100644
index 000000000000..e2e1dc2d3484
--- /dev/null
+++ b/src/components/withViewportOffsetTop.tsx
@@ -0,0 +1,41 @@
+import React, {useEffect, forwardRef, useState, ComponentType, RefAttributes, ForwardedRef} from 'react';
+import getComponentDisplayName from '../libs/getComponentDisplayName';
+import addViewportResizeListener from '../libs/VisualViewport';
+
+type ViewportOffsetTopProps = {
+ // viewportOffsetTop returns the offset of the top edge of the visual viewport from the
+ // top edge of the layout viewport in CSS pixels, when the visual viewport is resized.
+ viewportOffsetTop: number;
+};
+
+export default function withViewportOffsetTop(WrappedComponent: ComponentType>) {
+ function WithViewportOffsetTop(props: Omit, ref: ForwardedRef) {
+ const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
+
+ useEffect(() => {
+ const updateDimensions = (event: Event) => {
+ const targetOffsetTop = (event.target instanceof VisualViewport && event.target.offsetTop) || 0;
+ setViewportOffsetTop(targetOffsetTop);
+ };
+
+ const removeViewportResizeListener = addViewportResizeListener(updateDimensions);
+
+ return () => {
+ removeViewportResizeListener();
+ };
+ }, []);
+
+ return (
+
+ );
+ }
+
+ WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`;
+
+ return forwardRef(WithViewportOffsetTop);
+}
diff --git a/src/components/withWindowDimensions/index.js b/src/components/withWindowDimensions/index.js
index 37d5c94688a2..16e5985e0985 100644
--- a/src/components/withWindowDimensions/index.js
+++ b/src/components/withWindowDimensions/index.js
@@ -1,8 +1,8 @@
-import React, {forwardRef, createContext, useState, useEffect} from 'react';
+import React, {forwardRef, createContext, useState, useEffect, useMemo} from 'react';
import PropTypes from 'prop-types';
import lodashDebounce from 'lodash/debounce';
import {Dimensions} from 'react-native';
-import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
import getComponentDisplayName from '../../libs/getComponentDisplayName';
import variables from '../../styles/variables';
import getWindowHeightAdjustment from '../../libs/getWindowHeightAdjustment';
@@ -62,31 +62,23 @@ function WindowDimensionsProvider(props) {
dimensionsEventListener.remove();
};
}, []);
-
- return (
-
- {(insets) => {
- const isExtraSmallScreenWidth = windowDimension.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint;
- const isSmallScreenWidth = windowDimension.windowWidth <= variables.mobileResponsiveWidthBreakpoint;
- const isMediumScreenWidth = !isSmallScreenWidth && windowDimension.windowWidth <= variables.tabletResponsiveWidthBreakpoint;
- const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth;
- return (
-
- {props.children}
-
- );
- }}
-
- );
+ const insets = useSafeAreaInsets();
+ const adjustment = getWindowHeightAdjustment(insets);
+ const contextValue = useMemo(() => {
+ const isExtraSmallScreenWidth = windowDimension.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint;
+ const isSmallScreenWidth = windowDimension.windowWidth <= variables.mobileResponsiveWidthBreakpoint;
+ const isMediumScreenWidth = !isSmallScreenWidth && windowDimension.windowWidth <= variables.tabletResponsiveWidthBreakpoint;
+ const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth;
+ return {
+ windowHeight: windowDimension.windowHeight + adjustment,
+ windowWidth: windowDimension.windowWidth,
+ isExtraSmallScreenWidth,
+ isSmallScreenWidth,
+ isMediumScreenWidth,
+ isLargeScreenWidth,
+ };
+ }, [windowDimension.windowHeight, windowDimension.windowWidth, adjustment]);
+ return {props.children};
}
WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes;
diff --git a/src/components/withWindowDimensions/index.native.js b/src/components/withWindowDimensions/index.native.js
index e147a20c9f4e..363196b3fd4d 100644
--- a/src/components/withWindowDimensions/index.native.js
+++ b/src/components/withWindowDimensions/index.native.js
@@ -1,7 +1,7 @@
-import React, {forwardRef, createContext, useState, useEffect} from 'react';
+import React, {forwardRef, createContext, useState, useEffect, useMemo} from 'react';
import PropTypes from 'prop-types';
import {Dimensions} from 'react-native';
-import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
import getComponentDisplayName from '../../libs/getComponentDisplayName';
import variables from '../../styles/variables';
import getWindowHeightAdjustment from '../../libs/getWindowHeightAdjustment';
@@ -60,31 +60,20 @@ function WindowDimensionsProvider(props) {
dimensionsEventListener.remove();
};
}, []);
-
- return (
-
- {(insets) => {
- const isExtraSmallScreenWidth = windowDimension.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint;
- const isSmallScreenWidth = true;
- const isMediumScreenWidth = false;
- const isLargeScreenWidth = false;
- return (
-
- {props.children}
-
- );
- }}
-
- );
+ const insets = useSafeAreaInsets();
+ const adjustment = getWindowHeightAdjustment(insets);
+ const contextValue = useMemo(() => {
+ const isExtraSmallScreenWidth = windowDimension.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint;
+ return {
+ windowHeight: windowDimension.windowHeight + adjustment,
+ windowWidth: windowDimension.windowWidth,
+ isExtraSmallScreenWidth,
+ isSmallScreenWidth: true,
+ isMediumScreenWidth: false,
+ isLargeScreenWidth: false,
+ };
+ }, [windowDimension.windowHeight, windowDimension.windowWidth, adjustment]);
+ return {props.children};
}
WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes;
diff --git a/src/hooks/useCopySelectionHelper.js b/src/hooks/useCopySelectionHelper.ts
similarity index 89%
rename from src/hooks/useCopySelectionHelper.js
rename to src/hooks/useCopySelectionHelper.ts
index 42871981e29c..b41bfb3c4aee 100644
--- a/src/hooks/useCopySelectionHelper.js
+++ b/src/hooks/useCopySelectionHelper.ts
@@ -25,10 +25,12 @@ export default function useCopySelectionHelper() {
copyShortcutConfig.shortcutKey,
copySelectionToClipboard,
copyShortcutConfig.descriptionKey,
- copyShortcutConfig.modifiers,
+ [...copyShortcutConfig.modifiers],
false,
);
- return unsubscribeCopyShortcut;
+ return () => {
+ unsubscribeCopyShortcut();
+ };
}, []);
}
diff --git a/src/hooks/useNetwork.js b/src/hooks/useNetwork.ts
similarity index 74%
rename from src/hooks/useNetwork.js
rename to src/hooks/useNetwork.ts
index a4e973d0194d..4405dd7126a5 100644
--- a/src/hooks/useNetwork.js
+++ b/src/hooks/useNetwork.ts
@@ -1,16 +1,17 @@
import {useRef, useContext, useEffect} from 'react';
import {NetworkContext} from '../components/OnyxProvider';
-/**
- * @param {Object} [options]
- * @param {Function} [options.onReconnect]
- * @returns {Object}
- */
-export default function useNetwork({onReconnect = () => {}} = {}) {
+type UseNetworkProps = {
+ onReconnect?: () => void;
+};
+
+type UseNetwork = {isOffline?: boolean};
+
+export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork {
const callback = useRef(onReconnect);
callback.current = onReconnect;
- const {isOffline} = useContext(NetworkContext);
+ const {isOffline} = useContext(NetworkContext) ?? {};
const prevOfflineStatusRef = useRef(isOffline);
useEffect(() => {
// If we were offline before and now we are not offline then we just reconnected
diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js
deleted file mode 100644
index 1c31ffc8bb64..000000000000
--- a/src/hooks/usePermissions.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import _ from 'underscore';
-import {useContext, useMemo} from 'react';
-import Permissions from '../libs/Permissions';
-import {BetasContext} from '../components/OnyxProvider';
-
-export default function usePermissions() {
- const betas = useContext(BetasContext);
- return useMemo(() => {
- const permissions = {};
- _.each(Permissions, (checkerFunction, beta) => {
- permissions[beta] = checkerFunction(betas);
- });
- return permissions;
- }, [betas]);
-}
diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts
new file mode 100644
index 000000000000..09e87554b5c3
--- /dev/null
+++ b/src/hooks/usePermissions.ts
@@ -0,0 +1,24 @@
+import {useContext, useMemo} from 'react';
+import Permissions from '../libs/Permissions';
+import {BetasContext} from '../components/OnyxProvider';
+
+type PermissionKey = keyof typeof Permissions;
+type UsePermissions = Partial>;
+let permissionKey: PermissionKey;
+
+export default function usePermissions(): UsePermissions {
+ const betas = useContext(BetasContext);
+ return useMemo(() => {
+ const permissions: UsePermissions = {};
+
+ for (permissionKey in Permissions) {
+ if (betas) {
+ const checkerFunction = Permissions[permissionKey];
+
+ permissions[permissionKey] = checkerFunction(betas);
+ }
+ }
+
+ return permissions;
+ }, [betas]);
+}
diff --git a/src/hooks/useWindowDimensions/index.native.js b/src/hooks/useWindowDimensions/index.native.ts
similarity index 89%
rename from src/hooks/useWindowDimensions/index.native.js
rename to src/hooks/useWindowDimensions/index.native.ts
index 358e43f1b75d..5b0ec2002201 100644
--- a/src/hooks/useWindowDimensions/index.native.js
+++ b/src/hooks/useWindowDimensions/index.native.ts
@@ -1,17 +1,18 @@
// eslint-disable-next-line no-restricted-imports
import {useWindowDimensions} from 'react-native';
import variables from '../../styles/variables';
+import WindowDimensions from './types';
/**
* A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints.
- * @returns {Object}
*/
-export default function () {
+export default function (): WindowDimensions {
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
const isExtraSmallScreenHeight = windowHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint;
const isSmallScreenWidth = true;
const isMediumScreenWidth = false;
const isLargeScreenWidth = false;
+
return {
windowWidth,
windowHeight,
diff --git a/src/hooks/useWindowDimensions/index.js b/src/hooks/useWindowDimensions/index.ts
similarity index 93%
rename from src/hooks/useWindowDimensions/index.js
rename to src/hooks/useWindowDimensions/index.ts
index 1a1f7eed5a67..f9fee6301d06 100644
--- a/src/hooks/useWindowDimensions/index.js
+++ b/src/hooks/useWindowDimensions/index.ts
@@ -1,12 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import {Dimensions, useWindowDimensions} from 'react-native';
import variables from '../../styles/variables';
+import WindowDimensions from './types';
/**
* A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints.
- * @returns {Object}
*/
-export default function () {
+export default function (): WindowDimensions {
const {width: windowWidth, height: windowHeight} = useWindowDimensions();
// When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight.
const screenHeight = Dimensions.get('screen').height;
@@ -14,6 +14,7 @@ export default function () {
const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint;
const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint;
const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint;
+
return {
windowWidth,
windowHeight,
diff --git a/src/hooks/useWindowDimensions/types.ts b/src/hooks/useWindowDimensions/types.ts
new file mode 100644
index 000000000000..9b59d4968935
--- /dev/null
+++ b/src/hooks/useWindowDimensions/types.ts
@@ -0,0 +1,10 @@
+type WindowDimensions = {
+ windowWidth: number;
+ windowHeight: number;
+ isExtraSmallScreenHeight: boolean;
+ isSmallScreenWidth: boolean;
+ isMediumScreenWidth: boolean;
+ isLargeScreenWidth: boolean;
+};
+
+export default WindowDimensions;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index b321903a9781..d5bbe0e336d8 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -57,7 +57,7 @@ import type {
ConfirmThatParams,
UntilTimeParams,
StepCounterParams,
- UserIsAlreadyMemberOfWorkspaceParams,
+ UserIsAlreadyMemberParams,
GoToRoomParams,
WelcomeNoteParams,
RoomNameReservedErrorParams,
@@ -264,6 +264,7 @@ export default {
recent: 'Recent',
all: 'All',
tbd: 'TBD',
+ selectCurrency: 'Select a currency',
card: 'Card',
},
location: {
@@ -338,10 +339,6 @@ export default {
splitWith: 'Split with',
whatsItFor: "What's it for?",
},
- iOUCurrencySelection: {
- selectCurrency: 'Select a currency',
- allCurrencies: 'All currencies',
- },
optionsSelector: {
nameEmailOrPhoneNumber: 'Name, email, or phone number',
findMember: 'Find a member',
@@ -381,6 +378,14 @@ export default {
termsOfService: 'Terms of Service',
privacy: 'Privacy',
},
+ samlSignIn: {
+ welcomeSAMLEnabled: 'Continue logging in with single sign-on:',
+ orContinueWithMagicCode: 'Or optionally, your company allows signing in with a magic code',
+ useSingleSignOn: 'Use single sign-on',
+ useMagicCode: 'Use magic code',
+ launching: 'Launching...',
+ oneMoment: "One moment while we redirect you to your company's single sign-on portal.",
+ },
reportActionCompose: {
addAction: 'Actions',
dropToUpload: 'Drop to upload',
@@ -542,8 +547,9 @@ export default {
deleteConfirmation: 'Are you sure that you want to delete this request?',
settledExpensify: 'Paid',
settledElsewhere: 'Paid elsewhere',
- settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount} with Expensify`,
+ settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`),
payElsewhere: 'Pay elsewhere',
+ nextSteps: 'Next Steps',
requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`,
requestedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `requested ${formattedAmount}${comment ? ` for ${comment}` : ''}`,
splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`,
@@ -575,6 +581,7 @@ export default {
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',
error: {
invalidAmount: 'Please enter a valid amount before continuing.',
invalidSplit: 'Split amounts do not equal total amount',
@@ -586,6 +593,8 @@ export default {
duplicateWaypointsErrorMessage: 'Please remove duplicate waypoints',
emptyWaypointsErrorMessage: 'Please enter at least two waypoints',
},
+ waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
+ enableWallet: 'Enable Wallet',
},
notificationPreferencesPage: {
header: 'Notification preferences',
@@ -849,12 +858,16 @@ export default {
receiveMoney: 'Receive money in your local currency',
expensifyWallet: 'Expensify Wallet',
sendAndReceiveMoney: 'Send and receive money from your Expensify Wallet.',
+ enableWalletToSendAndReceiveMoney: 'Enable your Expensify Wallet to start sending and receiving money with friends!',
+ enableWallet: 'Enable wallet',
bankAccounts: 'Bank accounts',
addBankAccountToSendAndReceive: 'Add a bank account to send and receive payments directly in the app.',
addBankAccount: 'Add bank account',
assignedCards: 'Assigned cards',
assignedCardsDescription: 'These are cards assigned by a Workspace admin to manage company spend.',
expensifyCard: 'Expensify Card',
+ walletActivationPending: "We're reviewing your information, please check back in a few minutes!",
+ walletActivationFailed: 'Unfortunately your wallet cannot be enabled at this time. Please chat with Concierge for further assistance.',
},
cardPage: {
expensifyCard: 'Expensify Card',
@@ -862,6 +875,10 @@ export default {
virtualCardNumber: 'Virtual card number',
physicalCardNumber: 'Physical card number',
reportFraud: 'Report virtual card fraud',
+ reviewTransaction: 'Review transaction',
+ suspiciousBannerTitle: 'Suspicious transaction',
+ suspiciousBannerDescription: 'We noticed suspicious transaction on your card. Tap below to review.',
+ cardLocked: "Your card is temporarily locked while our team reviews your company's account.",
cardDetails: {
cardNumber: 'Virtual card number',
expiration: 'Expiration',
@@ -869,6 +886,7 @@ export default {
address: 'Address',
revealDetails: 'Reveal details',
copyCardNumber: 'Copy card number',
+ updateAddress: 'Update address',
},
},
reportFraudPage: {
@@ -1035,7 +1053,7 @@ export default {
legalName: 'Legal name',
legalFirstName: 'Legal first name',
legalLastName: 'Legal last name',
- homeAddress: 'Home address',
+ address: 'Address',
error: {
dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`,
dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`,
@@ -1191,7 +1209,7 @@ export default {
messages: {
errorMessageInvalidPhone: `Please enter a valid phone number without brackets or dashes. If you're outside the US please include your country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`,
errorMessageInvalidEmail: 'Invalid email',
- userIsAlreadyMemberOfWorkspace: ({login, workspace}: UserIsAlreadyMemberOfWorkspaceParams) => `${login} is already a member of ${workspace}`,
+ userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} is already a member of ${name}`,
},
onfidoStep: {
acceptTerms: 'By continuing with the request to activate your Expensify wallet, you confirm that you have read, understand and accept ',
@@ -1210,7 +1228,7 @@ export default {
},
additionalDetailsStep: {
headerTitle: 'Additional details',
- helpText: 'We need to confirm the following information before we can process this payment.',
+ helpText: 'We need to confirm the following information before you can send and receive money from your Wallet.',
helpTextIdologyQuestions: 'We need to ask you just a few more questions to finish validating your identity.',
helpLink: 'Learn more about why we need this.',
legalFirstNameLabel: 'Legal first name',
@@ -1581,13 +1599,18 @@ export default {
selectAWorkspace: 'Select a workspace',
growlMessageOnRenameError: 'Unable to rename policy room, please check your connection and try again.',
visibilityOptions: {
- restricted: 'Restricted',
+ restricted: 'Workspace', // the translation for "restricted" visibility is actually workspace. This is so we can display restricted visibility rooms as "workspace" without having to change what's stored.
private: 'Private',
public: 'Public',
// eslint-disable-next-line @typescript-eslint/naming-convention
public_announce: 'Public Announce',
},
},
+ roomMembersPage: {
+ memberNotFound: 'Member not found. To invite a new member to the room, please use the Invite button above.',
+ notAuthorized: `You do not have access to this page. Are you trying to join the room? Please reach out to a member of this room so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`,
+ removeMembersPrompt: 'Are you sure you want to remove the selected members from the room?',
+ },
newTaskPage: {
assignTask: 'Assign task',
assignMe: 'Assign to me',
@@ -1843,7 +1866,7 @@ export default {
},
cardTransactions: {
notActivated: 'Not activated',
- outOfPocketSpend: 'Out-of-pocket spend',
+ outOfPocket: 'Out of pocket',
companySpend: 'Company spend',
},
distance: {
@@ -1865,6 +1888,20 @@ export default {
selectSuggestedAddress: 'Please select a suggested address or use current location',
},
},
+ reportCardLostOrDamaged: {
+ report: 'Report physical card loss / damage',
+ screenTitle: 'Report card lost or damaged',
+ nextButtonLabel: 'Next',
+ reasonTitle: 'Why do you need a new card?',
+ cardDamaged: 'My card was damaged',
+ cardLostOrStolen: 'My card was lost or stolen',
+ confirmAddressTitle: "Please confirm the address below is where you'd like us to send your new card.",
+ currentCardInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.',
+ address: 'Address',
+ deactivateCardButton: 'Deactivate card',
+ addressError: 'Address is required',
+ reasonError: 'Reason is required',
+ },
eReceipt: {
guaranteed: 'Guaranteed eReceipt',
transactionDate: 'Transaction date',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 51d9923a570b..37c72524935d 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -57,7 +57,7 @@ import type {
ConfirmThatParams,
UntilTimeParams,
StepCounterParams,
- UserIsAlreadyMemberOfWorkspaceParams,
+ UserIsAlreadyMemberParams,
GoToRoomParams,
WelcomeNoteParams,
RoomNameReservedErrorParams,
@@ -254,6 +254,7 @@ export default {
recent: 'Reciente',
all: 'Todo',
tbd: 'Por determinar',
+ selectCurrency: 'Selecciona una moneda',
card: 'Tarjeta',
},
location: {
@@ -329,10 +330,6 @@ export default {
splitWith: 'Dividir con',
whatsItFor: '¿Para qué es?',
},
- iOUCurrencySelection: {
- selectCurrency: 'Selecciona una moneda',
- allCurrencies: 'Todas las monedas',
- },
optionsSelector: {
nameEmailOrPhoneNumber: 'Nombre, email o número de teléfono',
findMember: 'Encuentra un miembro',
@@ -372,6 +369,14 @@ export default {
termsOfService: 'Términos de servicio',
privacy: 'Privacidad',
},
+ samlSignIn: {
+ welcomeSAMLEnabled: 'Continua iniciando sesión con el inicio de sesión único:',
+ orContinueWithMagicCode: 'O, opcionalmente, tu empresa te permite iniciar sesión con un código mágico',
+ useSingleSignOn: 'Usar el inicio de sesión único',
+ useMagicCode: 'Usar código mágico',
+ launching: 'Cargando...',
+ oneMoment: 'Un momento mientras te redirigimos al portal de inicio de sesión único de tu empresa.',
+ },
reportActionCompose: {
addAction: 'Acción',
dropToUpload: 'Suelta el archivo aquí para compartirlo',
@@ -534,8 +539,9 @@ export default {
deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?',
settledExpensify: 'Pagado',
settledElsewhere: 'Pagado de otra forma',
- settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount} con Expensify`,
+ settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`),
payElsewhere: 'Pagar de otra forma',
+ nextSteps: 'Pasos Siguientes',
requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`,
requestedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicité ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`,
@@ -569,6 +575,7 @@ export default {
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',
error: {
invalidAmount: 'Por favor ingresa un monto válido antes de continuar.',
invalidSplit: 'La suma de las partes no equivale al monto total',
@@ -580,6 +587,8 @@ export default {
duplicateWaypointsErrorMessage: 'Por favor elimina los puntos de ruta duplicados',
emptyWaypointsErrorMessage: 'Por favor introduce al menos dos puntos de ruta',
},
+ waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `nicio el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`,
+ enableWallet: 'Habilitar Billetera',
},
notificationPreferencesPage: {
header: 'Preferencias de avisos',
@@ -800,7 +809,7 @@ export default {
sharedNoteMessage: 'Guarda notas sobre este chat aquí. Los empleados de Expensify y otros usuarios del dominio team.expensify.com pueden ver estas notas.',
notesUnavailable: 'No se han encontrado notas para el usuario',
composerLabel: 'Notas',
- myNote: 'Mi notas',
+ myNote: 'Mi nota',
},
addDebitCardPage: {
addADebitCard: 'Añadir una tarjeta de débito',
@@ -845,12 +854,16 @@ export default {
receiveMoney: 'Recibe dinero en tu moneda local',
expensifyWallet: 'Billetera Expensify',
sendAndReceiveMoney: 'Envía y recibe dinero desde tu Billetera Expensify.',
+ enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos',
+ enableWallet: 'Habilitar Billetera',
bankAccounts: 'Cuentas bancarias',
addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para enviar y recibir pagos directamente en la aplicación.',
addBankAccount: 'Agregar cuenta bancaria',
assignedCards: 'Tarjetas asignadas',
assignedCardsDescription: 'Son tarjetas asignadas por un administrador del Espacio de Trabajo para gestionar los gastos de la empresa.',
expensifyCard: 'Tarjeta Expensify',
+ walletActivationPending: 'Estamos revisando su información, por favor vuelve en unos minutos.',
+ walletActivationFailed: 'Lamentablemente, no podemos activar tu billetera en este momento. Chatea con Concierge para obtener más ayuda.',
},
cardPage: {
expensifyCard: 'Tarjeta Expensify',
@@ -858,6 +871,10 @@ export default {
virtualCardNumber: 'Número de la tarjeta virtual',
physicalCardNumber: 'Número de la tarjeta física',
reportFraud: 'Reportar fraude con la tarjeta virtual',
+ reviewTransaction: 'Revisar transacción',
+ suspiciousBannerTitle: 'Transacción sospechosa',
+ suspiciousBannerDescription: 'Hemos detectado una transacción sospechosa en la tarjeta. Haga click abajo para revisarla.',
+ cardLocked: 'La tarjeta está temporalmente bloqueada mientras nuestro equipo revisa la cuenta de tu empresa.',
cardDetails: {
cardNumber: 'Número de tarjeta virtual',
expiration: 'Expiración',
@@ -865,6 +882,7 @@ export default {
address: 'Dirección',
revealDetails: 'Revelar detalles',
copyCardNumber: 'Copiar número de la tarjeta',
+ updateAddress: 'Actualizar dirección',
},
},
reportFraudPage: {
@@ -1033,7 +1051,7 @@ export default {
legalName: 'Nombre completo',
legalFirstName: 'Nombre legal',
legalLastName: 'Apellidos legales',
- homeAddress: 'Domicilio',
+ address: 'Dirección',
error: {
dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`,
dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`,
@@ -1209,7 +1227,7 @@ export default {
messages: {
errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`,
errorMessageInvalidEmail: 'Email inválido',
- userIsAlreadyMemberOfWorkspace: ({login, workspace}: UserIsAlreadyMemberOfWorkspaceParams) => `${login} ya es miembro de ${workspace}`,
+ userIsAlreadyMember: ({login, name}: UserIsAlreadyMemberParams) => `${login} ya es miembro de ${name}`,
},
onfidoStep: {
acceptTerms: 'Al continuar con la solicitud para activar su billetera Expensify, confirma que ha leído, comprende y acepta ',
@@ -1229,7 +1247,7 @@ export default {
},
additionalDetailsStep: {
headerTitle: 'Detalles adicionales',
- helpText: 'Necesitamos confirmar la siguiente información antes de que podamos procesar el pago.',
+ helpText: 'Necesitamos confirmar la siguiente información antes de que puedas enviar y recibir dinero desde tu Billetera.',
helpTextIdologyQuestions: 'Tenemos que preguntarte unas preguntas más para terminar de verificar tu identidad',
helpLink: 'Obtén más información sobre por qué necesitamos esto.',
legalFirstNameLabel: 'Primer nombre legal',
@@ -1605,13 +1623,18 @@ export default {
selectAWorkspace: 'Seleccionar un espacio de trabajo',
growlMessageOnRenameError: 'No se ha podido cambiar el nombre del espacio de trabajo, por favor, comprueba tu conexión e inténtalo de nuevo.',
visibilityOptions: {
- restricted: 'Restringida',
+ restricted: 'Espacio de trabajo', // the translation for "restricted" visibility is actually workspace. This is so we can display restricted visibility rooms as "workspace" without having to change what's stored.
private: 'Privada',
public: 'Público',
// eslint-disable-next-line @typescript-eslint/naming-convention
public_announce: 'Anuncio Público',
},
},
+ roomMembersPage: {
+ memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro a la sala de chat, por favor, utiliza el botón Invitar que está más arriba.',
+ notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte a la sala de chat? Comunícate con el propietario de esta sala de chat para que pueda añadirte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`,
+ removeMembersPrompt: '¿Estás seguro de que quieres eliminar a los miembros seleccionados de la sala de chat?',
+ },
newTaskPage: {
assignTask: 'Asignar tarea',
assignMe: 'Asignar a mí mismo',
@@ -2328,7 +2351,7 @@ export default {
},
cardTransactions: {
notActivated: 'No activado',
- outOfPocketSpend: 'Gastos por cuenta propia',
+ outOfPocket: 'Por cuenta propia',
companySpend: 'Gastos de empresa',
},
distance: {
@@ -2350,11 +2373,25 @@ export default {
selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida o usa la ubicación actual.',
},
},
+ reportCardLostOrDamaged: {
+ report: 'Notificar la pérdida / daño de la tarjeta física',
+ screenTitle: 'Notificar la pérdida o deterioro de la tarjeta',
+ nextButtonLabel: 'Siguiente',
+ reasonTitle: '¿Por qué necesitas una tarjeta nueva?',
+ cardDamaged: 'Mi tarjeta está dañada',
+ cardLostOrStolen: 'He perdido o me han robado la tarjeta',
+ confirmAddressTitle: 'Confirma que la dirección que aparece a continuación es a la que deseas que te enviemos tu nueva tarjeta.',
+ currentCardInfo: 'La tarjeta actual se desactivará permanentemente en cuanto se realice el pedido. La mayoría de las tarjetas llegan en unos pocos días laborables.',
+ address: 'Dirección',
+ deactivateCardButton: 'Desactivar tarjeta',
+ addressError: 'La dirección es obligatoria',
+ reasonError: 'Se requiere justificación',
+ },
eReceipt: {
guaranteed: 'eRecibo garantizado',
transactionDate: 'Fecha de transacción',
},
globalNavigationOptions: {
- chats: 'Chats',
+ chats: 'Chats', // "Chats" is the accepted term colloqially in Spanish, this is not a bug!!
},
} satisfies EnglishTranslation;
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 9560cd41b25f..5a1847e31e71 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -1,3 +1,4 @@
+import {ReportAction} from '../types/onyx';
import en from './en';
type AddressLineParams = {
@@ -42,15 +43,15 @@ type LocalTimeParams = {
};
type EditActionParams = {
- action: NonNullable;
+ action: ReportAction | null;
};
type DeleteActionParams = {
- action: NonNullable;
+ action: ReportAction | null;
};
type DeleteConfirmationParams = {
- action: NonNullable;
+ action: ReportAction | null;
};
type BeginningOfChatHistoryDomainRoomPartOneParams = {
@@ -168,7 +169,7 @@ type UntilTimeParams = {time: string};
type StepCounterParams = {step: number; total?: number; text?: string};
-type UserIsAlreadyMemberOfWorkspaceParams = {login: string; workspace: string};
+type UserIsAlreadyMemberParams = {login: string; name: string};
type GoToRoomParams = {roomName: string};
@@ -302,7 +303,7 @@ export type {
ConfirmThatParams,
UntilTimeParams,
StepCounterParams,
- UserIsAlreadyMemberOfWorkspaceParams,
+ UserIsAlreadyMemberParams,
GoToRoomParams,
WelcomeNoteParams,
RoomNameReservedErrorParams,
diff --git a/src/libs/API.js b/src/libs/API.ts
similarity index 64%
rename from src/libs/API.js
rename to src/libs/API.ts
index 2ad1f32347d9..ce3d6bab19bc 100644
--- a/src/libs/API.js
+++ b/src/libs/API.ts
@@ -1,5 +1,5 @@
-import _ from 'underscore';
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
import Log from './Log';
import * as Request from './Request';
import * as Middleware from './Middleware';
@@ -7,6 +7,8 @@ import * as SequentialQueue from './Network/SequentialQueue';
import pkg from '../../package.json';
import CONST from '../CONST';
import * as Pusher from './Pusher/pusher';
+import OnyxRequest from '../types/onyx/Request';
+import Response from '../types/onyx/Response';
// Setup API middlewares. Each request made will pass through a series of middleware functions that will get called in sequence (each one passing the result of the previous to the next).
// Note: The ordering here is intentional as we want to Log, Recheck Connection, Reauthenticate, and Save the Response in Onyx. Errors thrown in one middleware will bubble to the next.
@@ -28,25 +30,34 @@ Request.use(Middleware.HandleUnusedOptimisticID);
// middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state.
Request.use(Middleware.SaveResponseInOnyx);
+type OnyxData = {
+ optimisticData?: OnyxUpdate[];
+ successData?: OnyxUpdate[];
+ failureData?: OnyxUpdate[];
+};
+
+type ApiRequestType = ValueOf;
+
/**
* All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData.
* This is so that if the network is unavailable or the app is closed, we can send the WRITE request later.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
*/
-function write(command, apiCommandParameters = {}, onyxData = {}) {
+function write(command: string, apiCommandParameters: Record = {}, onyxData: OnyxData = {}) {
Log.info('Called API write', false, {command, ...apiCommandParameters});
+ const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
// Optimistically update Onyx
- if (onyxData.optimisticData) {
- Onyx.update(onyxData.optimisticData);
+ if (optimisticData) {
+ Onyx.update(optimisticData);
}
// Assemble the data we'll send to the API
@@ -61,7 +72,7 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
};
// Assemble all the request data we'll be storing in the queue
- const request = {
+ const request: OnyxRequest = {
command,
data: {
...data,
@@ -70,7 +81,7 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
shouldRetry: true,
canCancel: true,
},
- ..._.omit(onyxData, 'optimisticData'),
+ ...onyxDataWithoutOptimisticData,
};
// Write commands can be saved and retried, so push it to the SequentialQueue
@@ -85,24 +96,30 @@ function write(command, apiCommandParameters = {}, onyxData = {}) {
* Using this method is discouraged and will throw an ESLint error. Use it sparingly and only when all other alternatives have been exhausted.
* It is best to discuss it in Slack anytime you are tempted to use this method.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
- * @param {String} [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained
* response back to the caller or to trigger reconnection callbacks when re-authentication is required.
- * @returns {Promise}
+ * @returns
*/
-function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData = {}, apiRequestType = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS) {
+function makeRequestWithSideEffects(
+ command: string,
+ apiCommandParameters = {},
+ onyxData: OnyxData = {},
+ apiRequestType: ApiRequestType = CONST.API_REQUEST_TYPE.MAKE_REQUEST_WITH_SIDE_EFFECTS,
+): Promise {
Log.info('Called API makeRequestWithSideEffects', false, {command, ...apiCommandParameters});
+ const {optimisticData, ...onyxDataWithoutOptimisticData} = onyxData;
// Optimistically update Onyx
- if (onyxData.optimisticData) {
- Onyx.update(onyxData.optimisticData);
+ if (optimisticData) {
+ Onyx.update(optimisticData);
}
// Assemble the data we'll send to the API
@@ -113,10 +130,10 @@ function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData
};
// Assemble all the request data we'll be storing
- const request = {
+ const request: OnyxRequest = {
command,
data,
- ..._.omit(onyxData, 'optimisticData'),
+ ...onyxDataWithoutOptimisticData,
};
// Return a promise containing the response from HTTPS
@@ -126,16 +143,16 @@ function makeRequestWithSideEffects(command, apiCommandParameters = {}, onyxData
/**
* Requests made with this method are not be persisted to disk. If there is no network connectivity, the request is ignored and discarded.
*
- * @param {String} command - Name of API command to call.
- * @param {Object} apiCommandParameters - Parameters to send to the API.
- * @param {Object} onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
+ * @param command - Name of API command to call.
+ * @param apiCommandParameters - Parameters to send to the API.
+ * @param onyxData - Object containing errors, loading states, and optimistic UI data that will be merged
* into Onyx before and after a request is made. Each nested object will be formatted in
* the same way as an API response.
- * @param {Object} [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
- * @param {Object} [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
- * @param {Object} [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
+ * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made.
+ * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200.
+ * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200.
*/
-function read(command, apiCommandParameters, onyxData) {
+function read(command: string, apiCommandParameters: Record, onyxData: OnyxData = {}) {
// Ensure all write requests on the sequential queue have finished responding before running read requests.
// Responses from read requests can overwrite the optimistic data inserted by
// write requests that use the same Onyx keys and haven't responded yet.
diff --git a/src/libs/Authentication.js b/src/libs/Authentication.ts
similarity index 83%
rename from src/libs/Authentication.js
rename to src/libs/Authentication.ts
index 9f1967ecf0d8..cec20504dd04 100644
--- a/src/libs/Authentication.js
+++ b/src/libs/Authentication.ts
@@ -7,20 +7,20 @@ import redirectToSignIn from './actions/SignInRedirect';
import CONST from '../CONST';
import Log from './Log';
import * as ErrorUtils from './ErrorUtils';
+import Response from '../types/onyx/Response';
-/**
- * @param {Object} parameters
- * @param {Boolean} [parameters.useExpensifyLogin]
- * @param {String} parameters.partnerName
- * @param {String} parameters.partnerPassword
- * @param {String} parameters.partnerUserID
- * @param {String} parameters.partnerUserSecret
- * @param {String} [parameters.twoFactorAuthCode]
- * @param {String} [parameters.email]
- * @param {String} [parameters.authToken]
- * @returns {Promise}
- */
-function Authenticate(parameters) {
+type Parameters = {
+ useExpensifyLogin?: boolean;
+ partnerName: string;
+ partnerPassword: string;
+ partnerUserID?: string;
+ partnerUserSecret?: string;
+ twoFactorAuthCode?: string;
+ email?: string;
+ authToken?: string;
+};
+
+function Authenticate(parameters: Parameters): Promise {
const commandName = 'Authenticate';
requireParameters(['partnerName', 'partnerPassword', 'partnerUserID', 'partnerUserSecret'], parameters, commandName);
@@ -48,11 +48,9 @@ function Authenticate(parameters) {
/**
* Reauthenticate using the stored credentials and redirect to the sign in page if unable to do so.
- *
- * @param {String} [command] command name for logging purposes
- * @returns {Promise}
+ * @param [command] command name for logging purposes
*/
-function reauthenticate(command = '') {
+function reauthenticate(command = ''): Promise {
// Prevent any more requests from being processed while authentication happens
NetworkStore.setIsAuthenticating(true);
@@ -61,8 +59,8 @@ function reauthenticate(command = '') {
useExpensifyLogin: false,
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
- partnerUserID: credentials.autoGeneratedLogin,
- partnerUserSecret: credentials.autoGeneratedPassword,
+ partnerUserID: credentials?.autoGeneratedLogin,
+ partnerUserSecret: credentials?.autoGeneratedPassword,
}).then((response) => {
if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) {
// If authentication fails, then the network can be unpaused
@@ -92,7 +90,7 @@ function reauthenticate(command = '') {
// Note: It is important to manually set the authToken that is in the store here since any requests that are hooked into
// reauthenticate .then() will immediate post and use the local authToken. Onyx updates subscribers lately so it is not
// enough to do the updateSessionAuthTokens() call above.
- NetworkStore.setAuthToken(response.authToken);
+ NetworkStore.setAuthToken(response.authToken ?? null);
// The authentication process is finished so the network can be unpaused to continue processing requests
NetworkStore.setIsAuthenticating(false);
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 8df554dd4dbf..52c4f7067acf 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,10 +1,10 @@
import lodash from 'lodash';
import Onyx from 'react-native-onyx';
-import {Card} from '../types/onyx';
import CONST from '../CONST';
import * as Localize from './Localize';
import * as OnyxTypes from '../types/onyx';
import ONYXKEYS, {OnyxValues} from '../ONYXKEYS';
+import {Card} from '../types/onyx';
let allCards: OnyxValues[typeof ONYXKEYS.CARD_LIST] = {};
Onyx.connect({
@@ -47,7 +47,7 @@ function getCardDescription(cardID: number) {
return '';
}
const cardDescriptor = card.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED ? Localize.translateLocal('cardTransactions.notActivated') : card.lastFourPAN;
- return `${card.bank} - ${cardDescriptor}`;
+ return cardDescriptor ? `${card.bank} - ${cardDescriptor}` : `${card.bank}`;
}
/**
@@ -60,20 +60,14 @@ function getYearFromExpirationDateString(expirationDateString: string) {
return cardYear.length === 2 ? `20${cardYear}` : cardYear;
}
-function getCompanyCards(cardList: {string: Card}) {
- if (!cardList) {
- return [];
- }
- return Object.values(cardList).filter((card) => card.bank !== CONST.EXPENSIFY_CARD.BANK);
-}
-
/**
* @param cardList - collection of assigned cards
* @returns collection of assigned cards grouped by domain
*/
function getDomainCards(cardList: Record) {
+ // Check for domainName to filter out personal credit cards.
// eslint-disable-next-line you-dont-need-lodash-underscore/filter
- const activeCards = lodash.filter(cardList, (card) => (CONST.EXPENSIFY_CARD.ACTIVE_STATES as ReadonlyArray).includes(card.state));
+ const activeCards = lodash.filter(cardList, (card) => !!card.domainName && (CONST.EXPENSIFY_CARD.ACTIVE_STATES as ReadonlyArray).includes(card.state));
return lodash.groupBy(activeCards, (card) => card.domainName);
}
@@ -96,4 +90,13 @@ function maskCard(lastFour = ''): string {
return maskedString.replace(/(.{4})/g, '$1 ').trim();
}
-export {isExpensifyCard, getDomainCards, getCompanyCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription};
+/**
+ * Finds physical card in a list of cards
+ *
+ * @returns a physical card object (or undefined if none is found)
+ */
+function findPhysicalCard(cards: Card[]) {
+ return cards.find((card) => !card.isVirtual);
+}
+
+export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription, findPhysicalCard};
diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts
index 21784d450a07..85ba8340c13e 100644
--- a/src/libs/CurrencyUtils.ts
+++ b/src/libs/CurrencyUtils.ts
@@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx';
import ONYXKEYS, {OnyxValues} from '../ONYXKEYS';
import CONST from '../CONST';
import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener';
+import * as Localize from './Localize';
import * as NumberFormatUtils from './NumberFormatUtils';
let currencyList: OnyxValues[typeof ONYXKEYS.CURRENCY_LIST] = {};
@@ -96,8 +97,13 @@ function convertToFrontendAmount(amountAsInt: number): number {
*
* @param amountInCents – should be an integer. Anything after a decimal place will be dropped.
* @param currency - IOU currency
+ * @param shouldFallbackToTbd - whether to return 'TBD' instead of a falsy value (e.g. 0.00)
*/
-function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD): string {
+function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string {
+ if (shouldFallbackToTbd && !amountInCents) {
+ return Localize.translateLocal('common.tbd');
+ }
+
const convertedAmount = convertToFrontendAmount(amountInCents);
return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, {
style: 'currency',
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index a44a69f087ab..05ad1bd3c2ce 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -3,6 +3,8 @@ import {getUnixTime} from 'date-fns';
import Str from 'expensify-common/lib/str';
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
+import lodashMin from 'lodash/min';
+import lodashSum from 'lodash/sum';
import ONYXKEYS from '../ONYXKEYS';
import CONST from '../CONST';
import emojisTrie from './EmojiTrie';
@@ -80,7 +82,7 @@ const getEmojiUnicode = _.memoize((input) => {
const pairs = [];
- // Some Emojis in UTF-16 are stored as pair of 2 Unicode characters (eg Flags)
+ // Some Emojis in UTF-16 are stored as a pair of 2 Unicode characters (e.g. Flags)
// The first char is generally between the range U+D800 to U+DBFF called High surrogate
// & the second char between the range U+DC00 to U+DFFF called low surrogate
// More info in the following links:
@@ -110,6 +112,23 @@ function trimEmojiUnicode(emojiCode) {
return emojiCode.replace(/(fe0f|1f3fb|1f3fc|1f3fd|1f3fe|1f3ff)$/, '').trim();
}
+/**
+ * Validates first character is emoji in text string
+ *
+ * @param {String} message
+ * @returns {Boolean}
+ */
+function isFirstLetterEmoji(message) {
+ const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', '');
+ const match = trimmedMessage.match(CONST.REGEX.EMOJIS);
+
+ if (!match) {
+ return false;
+ }
+
+ return trimmedMessage.indexOf(match[0]) === 0;
+}
+
/**
* Validates that this message contains only emojis
*
@@ -457,7 +476,7 @@ const getPreferredEmojiCode = (emoji, preferredSkinTone) => {
/**
* Given an emoji object and a list of senders it will return an
* array of emoji codes, that represents all used variations of the
- * emoji.
+ * emoji, sorted by the reaction timestamp.
* @param {Object} emojiAsset
* @param {String} emojiAsset.name
* @param {String} emojiAsset.code
@@ -466,16 +485,110 @@ const getPreferredEmojiCode = (emoji, preferredSkinTone) => {
* @return {string[]}
* */
const getUniqueEmojiCodes = (emojiAsset, users) => {
- const uniqueEmojiCodes = [];
- _.each(users, (userSkinTones) => {
- _.each(lodashGet(userSkinTones, 'skinTones'), (createdAt, skinTone) => {
- const emojiCode = getPreferredEmojiCode(emojiAsset, skinTone);
- if (emojiCode && !uniqueEmojiCodes.includes(emojiCode)) {
- uniqueEmojiCodes.push(emojiCode);
+ const emojiCodes = _.reduce(
+ users,
+ (result, userSkinTones) => {
+ _.each(lodashGet(userSkinTones, 'skinTones'), (createdAt, skinTone) => {
+ const emojiCode = getPreferredEmojiCode(emojiAsset, skinTone);
+ if (!!emojiCode && (!result[emojiCode] || createdAt < result[emojiCode])) {
+ // eslint-disable-next-line no-param-reassign
+ result[emojiCode] = createdAt;
+ }
+ });
+ return result;
+ },
+ {},
+ );
+
+ return _.chain(emojiCodes)
+ .pairs()
+ .sortBy((entry) => new Date(entry[1])) // Sort by values (timestamps)
+ .map((entry) => entry[0]) // Extract keys (emoji codes)
+ .value();
+};
+
+/**
+ * Given an emoji reaction object and its name, it populates it with the oldest reaction timestamps.
+ * @param {Object} emoji
+ * @param {String} emojiName
+ * @returns {Object}
+ */
+const enrichEmojiReactionWithTimestamps = (emoji, emojiName) => {
+ let oldestEmojiTimestamp = null;
+
+ const usersWithTimestamps = _.chain(emoji.users)
+ .pick(_.identity)
+ .mapObject((user, id) => {
+ const oldestUserTimestamp = lodashMin(_.values(user.skinTones));
+
+ if (!oldestEmojiTimestamp || oldestUserTimestamp < oldestEmojiTimestamp) {
+ oldestEmojiTimestamp = oldestUserTimestamp;
}
- });
- });
- return uniqueEmojiCodes;
+
+ return {
+ ...user,
+ id,
+ oldestTimestamp: oldestUserTimestamp,
+ };
+ })
+ .value();
+
+ return {
+ ...emoji,
+ users: usersWithTimestamps,
+ // Just in case two emojis have the same timestamp, also combine the timestamp with the
+ // emojiName so that the order will always be the same. Without this, the order can be pretty random
+ // and shift around a little bit.
+ oldestTimestamp: (oldestEmojiTimestamp || emoji.createdAt) + emojiName,
+ };
+};
+
+/**
+ * Returns true if the accountID has reacted to the report action (with the given skin tone).
+ * Uses the NEW FORMAT for "emojiReactions"
+ * @param {String} accountID
+ * @param {Array
diff --git a/src/pages/EnablePayments/userWalletPropTypes.js b/src/pages/EnablePayments/userWalletPropTypes.js
index e6b4206be751..53332479d4ec 100644
--- a/src/pages/EnablePayments/userWalletPropTypes.js
+++ b/src/pages/EnablePayments/userWalletPropTypes.js
@@ -20,9 +20,12 @@ export default PropTypes.shape({
/** Status of wallet - e.g. SILVER or GOLD */
tierName: PropTypes.string,
- /** Whether we should show the ActivateStep success view after the user finished the KYC flow */
- shouldShowWalletActivationSuccess: PropTypes.bool,
+ /** Whether the kyc is pending and is yet to be confirmed */
+ isPendingOnfidoResult: PropTypes.bool,
/** The wallet's programID, used to show the correct terms. */
walletProgramID: PropTypes.string,
+
+ /** Whether the user has failed Onfido completely */
+ hasFailedOnfido: PropTypes.bool,
});
diff --git a/src/pages/EnablePayments/walletTermsPropTypes.js b/src/pages/EnablePayments/walletTermsPropTypes.js
index c5f19cd3a9f2..44d153f3b6ff 100644
--- a/src/pages/EnablePayments/walletTermsPropTypes.js
+++ b/src/pages/EnablePayments/walletTermsPropTypes.js
@@ -1,10 +1,18 @@
import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../../CONST';
/** Prop types related to the Terms step of KYC flow */
export default PropTypes.shape({
/** Any error message to show */
errors: PropTypes.objectOf(PropTypes.string),
+ /** The source that triggered the KYC wall */
+ source: PropTypes.oneOf(_.values(CONST.KYC_WALL_SOURCE)),
+
/** When the user accepts the Wallet's terms in order to pay an IOU, this is the ID of the chatReport the IOU is linked to */
chatReportID: PropTypes.string,
+
+ /** Boolean to indicate whether the submission of wallet terms is being processed */
+ isLoading: PropTypes.bool,
});
diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.js b/src/pages/LogInWithShortLivedAuthTokenPage.js
index 62eff262611d..875cdf7e8072 100644
--- a/src/pages/LogInWithShortLivedAuthTokenPage.js
+++ b/src/pages/LogInWithShortLivedAuthTokenPage.js
@@ -12,8 +12,7 @@ import themeColors from '../styles/themes/default';
import Icon from '../components/Icon';
import * as Expensicons from '../components/Icon/Expensicons';
import * as Illustrations from '../components/Icon/Illustrations';
-import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
-import compose from '../libs/compose';
+import useLocalize from '../hooks/useLocalize';
import TextLink from '../components/TextLink';
import ONYXKEYS from '../ONYXKEYS';
@@ -33,8 +32,6 @@ const propTypes = {
}),
}).isRequired,
- ...withLocalizePropTypes,
-
/** The details about the account that the user is signing in with */
account: PropTypes.shape({
/** Whether a sign is loading */
@@ -49,15 +46,26 @@ const defaultProps = {
};
function LogInWithShortLivedAuthTokenPage(props) {
+ const {translate} = useLocalize();
+
useEffect(() => {
const email = lodashGet(props, 'route.params.email', '');
// We have to check for both shortLivedAuthToken and shortLivedToken, as the old mobile app uses shortLivedToken, and is not being actively updated.
const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', '') || lodashGet(props, 'route.params.shortLivedToken', '');
- if (shortLivedAuthToken) {
+
+ // Try to authenticate using the shortLivedToken if we're not already trying to load the accounts
+ if (shortLivedAuthToken && !props.account.isLoading) {
Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken);
return;
}
+
+ // If an error is returned as part of the route, ensure we set it in the onyxData for the account
+ const error = lodashGet(props, 'route.params.error', '');
+ if (error) {
+ Session.setAccountError(error);
+ }
+
const exitTo = lodashGet(props, 'route.params.exitTo', '');
if (exitTo) {
Navigation.isNavigationReady().then(() => {
@@ -82,10 +90,18 @@ function LogInWithShortLivedAuthTokenPage(props) {
src={Illustrations.RocketBlue}
/>
- {props.translate('deeplinkWrapper.launching')}
+ {translate('deeplinkWrapper.launching')}
- {props.translate('deeplinkWrapper.expired')} Navigation.navigate()}>{props.translate('deeplinkWrapper.signIn')}
+ {translate('deeplinkWrapper.expired')}{' '}
+ {
+ Session.clearSignInData();
+ Navigation.navigate();
+ }}
+ >
+ {translate('deeplinkWrapper.signIn')}
+
@@ -105,9 +121,7 @@ LogInWithShortLivedAuthTokenPage.propTypes = propTypes;
LogInWithShortLivedAuthTokenPage.defaultProps = defaultProps;
LogInWithShortLivedAuthTokenPage.displayName = 'LogInWithShortLivedAuthTokenPage';
-export default compose(
- withLocalize,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
-)(LogInWithShortLivedAuthTokenPage);
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ session: {key: ONYXKEYS.SESSION},
+})(LogInWithShortLivedAuthTokenPage);
diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js
index 64bff8655403..381564b82600 100755
--- a/src/pages/NewChatPage.js
+++ b/src/pages/NewChatPage.js
@@ -21,6 +21,7 @@ import personalDetailsPropType from './personalDetailsPropType';
import reportPropTypes from './reportPropTypes';
import variables from '../styles/variables';
import useNetwork from '../hooks/useNetwork';
+import useDelayedInputFocus from '../hooks/useDelayedInputFocus';
const propTypes = {
/** Beta features list */
@@ -50,6 +51,7 @@ const defaultProps = {
const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE);
function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports}) {
+ const optionSelectorRef = React.createRef(null);
const [searchTerm, setSearchTerm] = useState('');
const [filteredRecentReports, setFilteredRecentReports] = useState([]);
const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]);
@@ -71,13 +73,9 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
const sectionsList = [];
let indexOffset = 0;
- sectionsList.push({
- title: undefined,
- data: selectedOptions,
- shouldShow: !_.isEmpty(selectedOptions),
- indexOffset,
- });
- indexOffset += selectedOptions.length;
+ const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, {}, false, indexOffset);
+ sectionsList.push(formatResults.section);
+ indexOffset = formatResults.newIndexOffset;
if (maxParticipantsReached) {
return sectionsList;
@@ -109,7 +107,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
}
return sectionsList;
- }, [translate, filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, maxParticipantsReached, selectedOptions]);
+ }, [translate, filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, maxParticipantsReached, selectedOptions, searchTerm]);
/**
* Removes a selected option from list if already selected. If not already selected add this option to the list.
@@ -130,7 +128,24 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
recentReports,
personalDetails: newChatPersonalDetails,
userToInvite,
- } = OptionsListUtils.getFilteredOptions(reports, personalDetails, betas, searchTerm, newSelectedOptions, excludedGroupEmails);
+ } = OptionsListUtils.getFilteredOptions(
+ reports,
+ personalDetails,
+ betas,
+ searchTerm,
+ newSelectedOptions,
+ isGroupChat ? excludedGroupEmails : [],
+ false,
+ true,
+ false,
+ {},
+ [],
+ false,
+ {},
+ [],
+ true,
+ true,
+ );
setSelectedOptions(newSelectedOptions);
setFilteredRecentReports(recentReports);
@@ -165,7 +180,24 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
recentReports,
personalDetails: newChatPersonalDetails,
userToInvite,
- } = OptionsListUtils.getFilteredOptions(reports, personalDetails, betas, searchTerm, selectedOptions, isGroupChat ? excludedGroupEmails : []);
+ } = OptionsListUtils.getFilteredOptions(
+ reports,
+ personalDetails,
+ betas,
+ searchTerm,
+ selectedOptions,
+ isGroupChat ? excludedGroupEmails : [],
+ false,
+ true,
+ false,
+ {},
+ [],
+ false,
+ {},
+ [],
+ true,
+ true,
+ );
setFilteredRecentReports(recentReports);
setFilteredPersonalDetails(newChatPersonalDetails);
setFilteredUserToInvite(userToInvite);
@@ -180,6 +212,9 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
}
setSearchTerm(text);
}, []);
+
+ useDelayedInputFocus(optionSelectorRef, 600);
+
return (
0 ? safeAreaPaddingBottomStyle : {}]}>
Report.getDraftPrivateNote(report.reportID) || parser.htmlToMarkdown(lodashGet(report, ['privateNotes', route.params.accountID, 'note'], '')).trim(),
);
/**
@@ -104,7 +104,7 @@ function PrivateNotesEditPage({route, personalDetailsList, session, report}) {
const savePrivateNote = () => {
const originalNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], '');
- const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), originalNote);
+ const editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim());
Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote);
Keyboard.dismiss();
diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js
index 761be71d864a..5f7fce851c85 100644
--- a/src/pages/ReimbursementAccount/ACHContractStep.js
+++ b/src/pages/ReimbursementAccount/ACHContractStep.js
@@ -28,7 +28,7 @@ const propTypes = {
};
function ACHContractStep(props) {
- const [beneficialOwners, setBeneficialOwners] = useState(
+ const [beneficialOwners, setBeneficialOwners] = useState(() =>
lodashGet(props.reimbursementAccountDraft, 'beneficialOwners', lodashGet(props.reimbursementAccount, 'achData.beneficialOwners', [])),
);
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
index a99e3d7332a0..2b3127f31168 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js
@@ -492,7 +492,6 @@ class ReimbursementAccountPage extends React.Component {
{
return errors;
};
-function RequestorStep({reimbursementAccount, shouldShowOnfido, reimbursementAccountDraft, onBackButtonPress, getDefaultStateForField}) {
+function RequestorStep({reimbursementAccount, shouldShowOnfido, onBackButtonPress, getDefaultStateForField}) {
const {translate} = useLocalize();
const defaultValues = useMemo(
@@ -111,9 +109,7 @@ function RequestorStep({reimbursementAccount, shouldShowOnfido, reimbursementAcc
return (
);
}
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index 42a535844c72..71eff57a246d 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -2,6 +2,7 @@ import React, {useMemo} from 'react';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import lodashGet from 'lodash/get';
import {View, ScrollView} from 'react-native';
import RoomHeaderAvatars from '../components/RoomHeaderAvatars';
import compose from '../libs/compose';
@@ -61,7 +62,8 @@ const defaultProps = {
function ReportDetailsPage(props) {
const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]);
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]);
- const shouldUseFullTitle = ReportUtils.isTaskReport(props.report);
+ const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]);
+ const shouldUseFullTitle = useMemo(() => ReportUtils.shouldUseFullTitleToDisplay(props.report), [props.report]);
const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]);
const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]);
const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]);
@@ -93,7 +95,7 @@ function ReportDetailsPage(props) {
return items;
}
- if (participants.length) {
+ if ((!isUserCreatedPolicyRoom && participants.length) || (isUserCreatedPolicyRoom && isPolicyMember)) {
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.MEMBERS,
translationKey: 'common.members',
@@ -101,7 +103,21 @@ function ReportDetailsPage(props) {
subtitle: participants.length,
isAnonymousAction: false,
action: () => {
- Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(props.report.reportID));
+ if (isUserCreatedPolicyRoom && !props.report.parentReportID) {
+ Navigation.navigate(ROUTES.ROOM_MEMBERS.getRoute(props.report.reportID));
+ } else {
+ Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(props.report.reportID));
+ }
+ },
+ });
+ } else if ((!participants.length || !isPolicyMember) && isUserCreatedPolicyRoom && !props.report.parentReportID) {
+ items.push({
+ key: CONST.REPORT_DETAILS_MENU_ITEM.INVITE,
+ translationKey: 'common.invite',
+ icon: Expensicons.Users,
+ isAnonymousAction: false,
+ action: () => {
+ Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(props.report.reportID));
},
});
}
@@ -129,17 +145,18 @@ function ReportDetailsPage(props) {
}
if (isUserCreatedPolicyRoom || canLeaveRoom) {
+ const isWorkspaceMemberLeavingWorkspaceRoom = lodashGet(props.report, 'visibility', '') === CONST.REPORT.VISIBILITY.RESTRICTED && isPolicyMember;
items.push({
key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM,
translationKey: isThread ? 'common.leaveThread' : 'common.leaveRoom',
icon: Expensicons.Exit,
isAnonymousAction: false,
- action: () => Report.leaveRoom(props.report.reportID),
+ action: () => Report.leaveRoom(props.report.reportID, isWorkspaceMemberLeavingWorkspaceRoom),
});
}
return items;
- }, [isArchivedRoom, participants.length, isThread, isMoneyRequestReport, props.report, isUserCreatedPolicyRoom, canLeaveRoom, isGroupDMChat]);
+ }, [props.report, isMoneyRequestReport, participants.length, isArchivedRoom, isThread, isUserCreatedPolicyRoom, canLeaveRoom, isGroupDMChat, isPolicyMember]);
const displayNamesWithTooltips = useMemo(() => {
const hasMultipleParticipants = participants.length > 1;
@@ -160,7 +177,18 @@ function ReportDetailsPage(props) {
return (
-
+ {
+ const topMostReportID = Navigation.getTopmostReportId();
+ if (topMostReportID) {
+ Navigation.goBack(ROUTES.HOME);
+ return;
+ }
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID));
+ }}
+ />
@@ -235,7 +263,7 @@ ReportDetailsPage.defaultProps = defaultProps;
export default compose(
withLocalize,
- withReportOrNotFound,
+ withReportOrNotFound(),
withOnyx({
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js
index 67933ebfe3e4..db56a8006e76 100755
--- a/src/pages/ReportParticipantsPage.js
+++ b/src/pages/ReportParticipantsPage.js
@@ -145,7 +145,7 @@ ReportParticipantsPage.displayName = 'ReportParticipantsPage';
export default compose(
withLocalize,
- withReportOrNotFound,
+ withReportOrNotFound(),
withOnyx({
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.js
index 4602035f45f4..11a9c7784c1d 100644
--- a/src/pages/ReportWelcomeMessagePage.js
+++ b/src/pages/ReportWelcomeMessagePage.js
@@ -46,7 +46,7 @@ const defaultProps = {
function ReportWelcomeMessagePage(props) {
const parser = new ExpensiMark();
- const [welcomeMessage, setWelcomeMessage] = useState(parser.htmlToMarkdown(props.report.welcomeMessage));
+ const [welcomeMessage, setWelcomeMessage] = useState(() => parser.htmlToMarkdown(props.report.welcomeMessage));
const welcomeMessageInputRef = useRef(null);
const focusTimeoutRef = useRef(null);
@@ -123,7 +123,7 @@ ReportWelcomeMessagePage.defaultProps = defaultProps;
export default compose(
withLocalize,
- withReportOrNotFound,
+ withReportOrNotFound(),
withOnyx({
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js
new file mode 100644
index 000000000000..5021ccdc42d7
--- /dev/null
+++ b/src/pages/RoomInvitePage.js
@@ -0,0 +1,265 @@
+import React, {useEffect, useMemo, useState, useCallback} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import ScreenWrapper from '../components/ScreenWrapper';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import Navigation from '../libs/Navigation/Navigation';
+import styles from '../styles/styles';
+import compose from '../libs/compose';
+import ONYXKEYS from '../ONYXKEYS';
+import FormAlertWithSubmitButton from '../components/FormAlertWithSubmitButton';
+import * as OptionsListUtils from '../libs/OptionsListUtils';
+import CONST from '../CONST';
+import {policyDefaultProps, policyPropTypes} from './workspace/withPolicy';
+import withReportOrNotFound from './home/report/withReportOrNotFound';
+import reportPropTypes from './reportPropTypes';
+import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
+import ROUTES from '../ROUTES';
+import * as PolicyUtils from '../libs/PolicyUtils';
+import useLocalize from '../hooks/useLocalize';
+import SelectionList from '../components/SelectionList';
+import * as Report from '../libs/actions/Report';
+import * as ReportUtils from '../libs/ReportUtils';
+import Permissions from '../libs/Permissions';
+import personalDetailsPropType from './personalDetailsPropType';
+import * as Browser from '../libs/Browser';
+
+const propTypes = {
+ /** Beta features list */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
+ /** All of the personal details for everyone */
+ personalDetails: PropTypes.objectOf(personalDetailsPropType),
+
+ /** URL Route params */
+ route: PropTypes.shape({
+ /** Params from the URL path */
+ params: PropTypes.shape({
+ /** policyID passed via route: /workspace/:policyID/invite */
+ policyID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ /** The report currently being looked at */
+ report: reportPropTypes.isRequired,
+
+ /** The policies which the user has access to and which the report could be tied to */
+ policies: PropTypes.shape({
+ /** ID of the policy */
+ id: PropTypes.string,
+ }).isRequired,
+
+ ...policyPropTypes,
+};
+
+const defaultProps = {
+ personalDetails: {},
+ betas: [],
+ ...policyDefaultProps,
+};
+
+function RoomInvitePage(props) {
+ const {translate} = useLocalize();
+ const [searchTerm, setSearchTerm] = useState('');
+ const [selectedOptions, setSelectedOptions] = useState([]);
+ const [personalDetails, setPersonalDetails] = useState([]);
+ const [userToInvite, setUserToInvite] = useState(null);
+
+ // Any existing participants and Expensify emails should not be eligible for invitation
+ const excludedUsers = useMemo(() => [...lodashGet(props.report, 'participants', []), ...CONST.EXPENSIFY_EMAILS], [props.report]);
+
+ useEffect(() => {
+ // Kick the user out if they tried to navigate to this via the URL
+ if (Permissions.canUsePolicyRooms(props.betas)) {
+ return;
+ }
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID));
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ const inviteOptions = OptionsListUtils.getMemberInviteOptions(props.personalDetails, props.betas, searchTerm, excludedUsers);
+
+ // Update selectedOptions with the latest personalDetails information
+ const detailsMap = {};
+ _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = OptionsListUtils.formatMemberForList(detail, false)));
+ const newSelectedOptions = [];
+ _.forEach(selectedOptions, (option) => {
+ newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option);
+ });
+
+ setUserToInvite(inviteOptions.userToInvite);
+ setPersonalDetails(inviteOptions.personalDetails);
+ setSelectedOptions(newSelectedOptions);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change
+ }, [props.personalDetails, props.betas, searchTerm, excludedUsers]);
+
+ const getSections = () => {
+ const sections = [];
+ let indexOffset = 0;
+
+ sections.push({
+ title: undefined,
+ data: selectedOptions,
+ shouldShow: true,
+ indexOffset,
+ });
+ indexOffset += selectedOptions.length;
+
+ // Filtering out selected users from the search results
+ const selectedLogins = _.map(selectedOptions, ({login}) => login);
+ const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login));
+ const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false));
+ const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login);
+
+ sections.push({
+ title: translate('common.contacts'),
+ data: personalDetailsFormatted,
+ shouldShow: !_.isEmpty(personalDetailsFormatted),
+ indexOffset,
+ });
+ indexOffset += personalDetailsFormatted.length;
+
+ if (hasUnselectedUserToInvite) {
+ sections.push({
+ title: undefined,
+ data: [OptionsListUtils.formatMemberForList(userToInvite, false)],
+ shouldShow: true,
+ indexOffset,
+ });
+ }
+
+ return sections;
+ };
+
+ const toggleOption = useCallback(
+ (option) => {
+ const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login);
+
+ let newSelectedOptions;
+ if (isOptionInList) {
+ newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
+ } else {
+ newSelectedOptions = [...selectedOptions, {...option, isSelected: true}];
+ }
+
+ setSelectedOptions(newSelectedOptions);
+ },
+ [selectedOptions],
+ );
+
+ const validate = useCallback(() => {
+ const errors = {};
+ if (selectedOptions.length <= 0) {
+ errors.noUserSelected = true;
+ }
+
+ return _.size(errors) <= 0;
+ }, [selectedOptions]);
+
+ // Non policy members should not be able to view the participants of a room
+ const reportID = props.report.reportID;
+ const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]);
+ const backRoute = useMemo(() => (isPolicyMember ? ROUTES.ROOM_MEMBERS.getRoute(reportID) : ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID)), [isPolicyMember, reportID]);
+ const reportName = useMemo(() => ReportUtils.getReportName(props.report), [props.report]);
+ const inviteUsers = useCallback(() => {
+ if (!validate()) {
+ return;
+ }
+ const invitedEmailsToAccountIDs = {};
+ _.each(selectedOptions, (option) => {
+ const login = option.login || '';
+ const accountID = lodashGet(option, 'accountID', '');
+ if (!login.toLowerCase().trim() || !accountID) {
+ return;
+ }
+ invitedEmailsToAccountIDs[login] = Number(accountID);
+ });
+ Report.inviteToRoom(props.report.reportID, invitedEmailsToAccountIDs);
+ Navigation.navigate(backRoute);
+ }, [selectedOptions, backRoute, props.report.reportID, validate]);
+
+ const headerMessage = useMemo(() => {
+ const searchValue = searchTerm.trim().toLowerCase();
+ if (!userToInvite && CONST.EXPENSIFY_EMAILS.includes(searchValue)) {
+ return translate('messages.errorMessageInvalidEmail');
+ }
+ if (!userToInvite && excludedUsers.includes(searchValue)) {
+ return translate('messages.userIsAlreadyMember', {login: searchValue, name: reportName});
+ }
+ return OptionsListUtils.getHeaderMessage(personalDetails.length !== 0, Boolean(userToInvite), searchValue);
+ }, [excludedUsers, translate, searchTerm, userToInvite, personalDetails, reportName]);
+ return (
+
+ {({didScreenTransitionEnd}) => {
+ const sections = didScreenTransitionEnd ? getSections() : [];
+
+ return (
+ Navigation.goBack(backRoute)}
+ >
+ {
+ Navigation.goBack(backRoute);
+ }}
+ />
+
+
+
+
+
+ );
+ }}
+
+ );
+}
+
+RoomInvitePage.propTypes = propTypes;
+RoomInvitePage.defaultProps = defaultProps;
+RoomInvitePage.displayName = 'RoomInvitePage';
+
+export default compose(
+ withReportOrNotFound(),
+ withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ }),
+)(RoomInvitePage);
diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js
new file mode 100644
index 000000000000..3a6e3b6fd90f
--- /dev/null
+++ b/src/pages/RoomMembersPage.js
@@ -0,0 +1,335 @@
+import React, {useMemo, useState, useCallback, useEffect} from 'react';
+import _ from 'underscore';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import styles from '../styles/styles';
+import compose from '../libs/compose';
+import CONST from '../CONST';
+import ONYXKEYS from '../ONYXKEYS';
+import ROUTES from '../ROUTES';
+import Navigation from '../libs/Navigation/Navigation';
+import ScreenWrapper from '../components/ScreenWrapper';
+import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import ConfirmModal from '../components/ConfirmModal';
+import Button from '../components/Button';
+import SelectionList from '../components/SelectionList';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../components/withWindowDimensions';
+import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
+import withReportOrNotFound from './home/report/withReportOrNotFound';
+import personalDetailsPropType from './personalDetailsPropType';
+import reportPropTypes from './reportPropTypes';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../components/withCurrentUserPersonalDetails';
+import * as PolicyUtils from '../libs/PolicyUtils';
+import * as OptionsListUtils from '../libs/OptionsListUtils';
+import * as UserUtils from '../libs/UserUtils';
+import * as Report from '../libs/actions/Report';
+import * as ReportUtils from '../libs/ReportUtils';
+import Permissions from '../libs/Permissions';
+import Log from '../libs/Log';
+import * as Browser from '../libs/Browser';
+
+const propTypes = {
+ /** All personal details asssociated with user */
+ personalDetails: PropTypes.objectOf(personalDetailsPropType),
+
+ /** Beta features list */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
+ /** The report currently being looked at */
+ report: reportPropTypes.isRequired,
+
+ /** The policies which the user has access to and which the report could be tied to */
+ policies: PropTypes.shape({
+ /** ID of the policy */
+ id: PropTypes.string,
+ }),
+
+ /** URL Route params */
+ route: PropTypes.shape({
+ /** Params from the URL path */
+ params: PropTypes.shape({
+ /** reportID passed via route: /workspace/:reportID/members */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user accountID */
+ accountID: PropTypes.number,
+ }),
+
+ ...withLocalizePropTypes,
+ ...windowDimensionsPropTypes,
+ ...withCurrentUserPersonalDetailsPropTypes,
+};
+
+const defaultProps = {
+ personalDetails: {},
+ session: {
+ accountID: 0,
+ },
+ report: {},
+ policies: {},
+ betas: [],
+ ...withCurrentUserPersonalDetailsDefaultProps,
+};
+
+function RoomMembersPage(props) {
+ const [selectedMembers, setSelectedMembers] = useState([]);
+ const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false);
+ const [searchValue, setSearchValue] = useState('');
+ const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false);
+
+ /**
+ * Get members for the current room
+ */
+ const getRoomMembers = useCallback(() => {
+ Report.openRoomMembersPage(props.report.reportID);
+ setDidLoadRoomMembers(true);
+ }, [props.report.reportID]);
+
+ useEffect(() => {
+ // Kick the user out if they tried to navigate to this via the URL
+ if (!PolicyUtils.isPolicyMember(props.report.policyID, props.policies) || !Permissions.canUsePolicyRooms(props.betas)) {
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID));
+ return;
+ }
+ getRoomMembers();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /**
+ * Open the modal to invite a user
+ */
+ const inviteUser = () => {
+ setSearchValue('');
+ Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(props.report.reportID));
+ };
+
+ /**
+ * Remove selected users from the room
+ */
+ const removeUsers = () => {
+ Report.removeFromRoom(props.report.reportID, selectedMembers);
+ setSelectedMembers([]);
+ setRemoveMembersConfirmModalVisible(false);
+ };
+
+ /**
+ * Add user from the selectedMembers list
+ *
+ * @param {String} login
+ */
+ const addUser = useCallback((accountID) => {
+ setSelectedMembers((prevSelected) => [...prevSelected, accountID]);
+ }, []);
+
+ /**
+ * Remove user from the selectedEmployees list
+ *
+ * @param {String} login
+ */
+ const removeUser = useCallback((accountID) => {
+ setSelectedMembers((prevSelected) => _.without(prevSelected, accountID));
+ }, []);
+
+ /**
+ * Toggle user from the selectedMembers list
+ *
+ * @param {String} accountID
+ * @param {String} pendingAction
+ *
+ */
+ const toggleUser = useCallback(
+ (accountID, pendingAction) => {
+ if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ return;
+ }
+
+ // Add or remove the user if the checkbox is enabled
+ if (_.contains(selectedMembers, Number(accountID))) {
+ removeUser(Number(accountID));
+ } else {
+ addUser(Number(accountID));
+ }
+ },
+ [selectedMembers, addUser, removeUser],
+ );
+
+ /**
+ * Add or remove all users passed from the selectedMembers list
+ * @param {Object} memberList
+ */
+ const toggleAllUsers = (memberList) => {
+ const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
+ const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedMembers, Number(member.keyForList)));
+
+ if (everyoneSelected) {
+ setSelectedMembers([]);
+ } else {
+ const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList));
+ setSelectedMembers(everyAccountId);
+ }
+ };
+
+ /**
+ * Show the modal to confirm removal of the selected members
+ */
+ const askForConfirmationToRemove = () => {
+ setRemoveMembersConfirmModalVisible(true);
+ };
+
+ const getMemberOptions = () => {
+ let result = [];
+
+ _.each(props.report.participantAccountIDs, (accountID) => {
+ const details = props.personalDetails[accountID];
+
+ if (!details) {
+ Log.hmmm(`[RoomMembersPage] no personal details found for room member with accountID: ${accountID}`);
+ return;
+ }
+
+ // If search value is provided, filter out members that don't match the search value
+ if (searchValue.trim()) {
+ let memberDetails = '';
+ if (details.login) {
+ memberDetails += ` ${details.login.toLowerCase()}`;
+ }
+ if (details.firstName) {
+ memberDetails += ` ${details.firstName.toLowerCase()}`;
+ }
+ if (details.lastName) {
+ memberDetails += ` ${details.lastName.toLowerCase()}`;
+ }
+ if (details.displayName) {
+ memberDetails += ` ${details.displayName.toLowerCase()}`;
+ }
+ if (details.phoneNumber) {
+ memberDetails += ` ${details.phoneNumber.toLowerCase()}`;
+ }
+
+ if (!OptionsListUtils.isSearchStringMatch(searchValue.trim(), memberDetails)) {
+ return;
+ }
+ }
+
+ result.push({
+ keyForList: String(accountID),
+ accountID: Number(accountID),
+ isSelected: _.contains(selectedMembers, Number(accountID)),
+ isDisabled: accountID === props.session.accountID,
+ text: props.formatPhoneNumber(details.displayName),
+ alternateText: props.formatPhoneNumber(details.login),
+ icons: [
+ {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: details.login,
+ type: CONST.ICON_TYPE_AVATAR,
+ },
+ ],
+ });
+ });
+
+ result = _.sortBy(result, (value) => value.text.toLowerCase());
+
+ return result;
+ };
+
+ const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]);
+ const data = getMemberOptions();
+ const headerMessage = searchValue.trim() && !data.length ? props.translate('roomMembersPage.memberNotFound') : '';
+ return (
+
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID))}
+ >
+ {
+ setSearchValue('');
+ Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.report.reportID));
+ }}
+ />
+ setRemoveMembersConfirmModalVisible(false)}
+ prompt={props.translate('roomMembersPage.removeMembersPrompt')}
+ confirmText={props.translate('common.remove')}
+ cancelText={props.translate('common.cancel')}
+ />
+
+
+
+
+
+
+ toggleUser(item.keyForList)}
+ onSelectAll={() => toggleAllUsers(data)}
+ showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || !didLoadRoomMembers}
+ showScrollIndicator
+ shouldPreventDefaultFocusOnSelectRow={!Browser.isMobile()}
+ />
+
+
+
+
+ );
+}
+
+RoomMembersPage.propTypes = propTypes;
+RoomMembersPage.defaultProps = defaultProps;
+RoomMembersPage.displayName = 'RoomMembersPage';
+
+export default compose(
+ withLocalize,
+ withWindowDimensions,
+ withReportOrNotFound(),
+ withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ }),
+ withCurrentUserPersonalDetails,
+)(RoomMembersPage);
diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js
index 272fb30de858..c671e7b1a096 100755
--- a/src/pages/SearchPage.js
+++ b/src/pages/SearchPage.js
@@ -219,6 +219,7 @@ class SearchPage extends Component {
SearchPage.propTypes = propTypes;
SearchPage.defaultProps = defaultProps;
+SearchPage.displayName = 'SearchPage';
export default compose(
withLocalize,
diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js
index e6d36ebc7070..f3b683094043 100644
--- a/src/pages/ShareCodePage.js
+++ b/src/pages/ShareCodePage.js
@@ -122,5 +122,6 @@ class ShareCodePage extends React.Component {
ShareCodePage.propTypes = propTypes;
ShareCodePage.defaultProps = defaultProps;
+ShareCodePage.displayName = 'ShareCodePage';
export default compose(withEnvironment, withLocalize, withCurrentUserPersonalDetails)(ShareCodePage);
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index d7f8c3605564..e88f6cd0b756 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -30,9 +30,11 @@ import * as Link from '../../libs/actions/Link';
import * as Report from '../../libs/actions/Report';
import * as Task from '../../libs/actions/Task';
import compose from '../../libs/compose';
+import * as Session from '../../libs/actions/Session';
import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
import reportPropTypes from '../reportPropTypes';
+import reportWithoutHasDraftSelector from '../../libs/OnyxSelectors/reportWithoutHasDraftSelector';
const propTypes = {
/** Toggles the navigationMenu open and closed */
@@ -79,7 +81,8 @@ function HeaderView(props) {
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report);
const isTaskReport = ReportUtils.isTaskReport(props.report);
const reportHeaderData = !isTaskReport && !isChatThread && props.report.parentReportID ? props.parentReport : props.report;
- const title = ReportUtils.getReportName(reportHeaderData);
+ // Use sorted display names for the title for group chats on native small screen widths
+ const title = ReportUtils.isGroupChat(props.report) ? ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips) : ReportUtils.getReportName(reportHeaderData);
const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData);
const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData);
const isConcierge = ReportUtils.hasSingleParticipant(props.report) && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE);
@@ -101,7 +104,7 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Checkmark,
text: props.translate('task.markAsIncomplete'),
- onSelected: () => Task.reopenTask(props.report),
+ onSelected: Session.checkIfActionIsAllowed(() => Task.reopenTask(props.report)),
});
}
@@ -110,7 +113,7 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Trashcan,
text: props.translate('common.cancel'),
- onSelected: () => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum),
+ onSelected: Session.checkIfActionIsAllowed(() => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)),
});
}
}
@@ -120,13 +123,15 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.ChatBubbles,
text: props.translate('common.joinThread'),
- onSelected: () => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false),
+ onSelected: Session.checkIfActionIsAllowed(() =>
+ Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false),
+ ),
});
} else if (props.report.notificationPreference.length) {
threeDotMenuItems.push({
icon: Expensicons.ChatBubbles,
text: props.translate('common.leaveThread'),
- onSelected: () => Report.leaveRoom(props.report.reportID),
+ onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(props.report.reportID)),
});
}
}
@@ -137,24 +142,24 @@ function HeaderView(props) {
threeDotMenuItems.push({
icon: Expensicons.Phone,
text: props.translate('videoChatButtonAndMenu.tooltip'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(props.guideCalendarLink);
- },
+ }),
});
} else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) {
threeDotMenuItems.push({
icon: ZoomIcon,
text: props.translate('videoChatButtonAndMenu.zoom'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL);
- },
+ }),
});
threeDotMenuItems.push({
icon: GoogleMeetIcon,
text: props.translate('videoChatButtonAndMenu.googleMeet'),
- onSelected: () => {
+ onSelected: Session.checkIfActionIsAllowed(() => {
Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL);
- },
+ }),
});
}
@@ -252,6 +257,7 @@ function HeaderView(props) {
)}
@@ -276,6 +282,7 @@ export default compose(
},
parentReport: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`,
+ selector: reportWithoutHasDraftSelector,
},
session: {
key: ONYXKEYS.SESSION,
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 51981d5fe80e..1b6dd9186453 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -26,7 +26,7 @@ import Banner from '../../components/Banner';
import reportPropTypes from '../reportPropTypes';
import reportMetadataPropTypes from '../reportMetadataPropTypes';
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
-import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop';
+import withViewportOffsetTop from '../../components/withViewportOffsetTop';
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
import personalDetailsPropType from '../personalDetailsPropType';
import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible';
@@ -39,6 +39,7 @@ import DragAndDropProvider from '../../components/DragAndDrop/Provider';
import usePrevious from '../../hooks/usePrevious';
import CONST from '../../CONST';
import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDefaultProps} from '../../components/withCurrentReportID';
+import reportWithoutHasDraftSelector from '../../libs/OnyxSelectors/reportWithoutHasDraftSelector';
const propTypes = {
/** Navigation route context info provided by react navigation */
@@ -94,7 +95,7 @@ const propTypes = {
/** Whether user is leaving the current report */
userLeavingStatus: PropTypes.bool,
- ...viewportOffsetTopPropTypes,
+ viewportOffsetTop: PropTypes.number.isRequired,
...withCurrentReportIDPropTypes,
};
@@ -105,8 +106,9 @@ const defaultProps = {
hasOutstandingIOU: false,
},
reportMetadata: {
- isLoadingReportActions: true,
- isLoadingMoreReportActions: false,
+ isLoadingInitialReportActions: true,
+ isLoadingOlderReportActions: false,
+ isLoadingNewerReportActions: false,
},
isComposerFullSize: false,
betas: [],
@@ -127,6 +129,7 @@ const defaultProps = {
* @returns {String}
*/
function getReportID(route) {
+ // // The reportID is used inside a collection key and should not be empty, as an empty reportID will result in the entire collection being returned.
return String(lodashGet(route, 'params.reportID', null));
}
@@ -163,7 +166,7 @@ function ReportScreen({
const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
// There are no reportActions at all to display and we are still in the process of loading the next set of actions.
- const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingReportActions;
+ const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingInitialReportActions;
const isOptimisticDelete = lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.CLOSED;
@@ -258,6 +261,13 @@ function ReportScreen({
const onSubmitComment = useCallback(
(text) => {
Report.addComment(getReportID(route), text);
+
+ // We need to scroll to the bottom of the list after the comment is added
+ const refID = setTimeout(() => {
+ flatListRef.current.scrollToOffset({animated: false, offset: 0});
+ }, 10);
+
+ return () => clearTimeout(refID);
},
[route],
);
@@ -307,12 +317,16 @@ function ReportScreen({
const prevOnyxReportID = prevReport.reportID;
const routeReportID = getReportID(route);
- // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room)
+ // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room)
if (
// non-optimistic case
(!prevUserLeavingStatus && userLeavingStatus) ||
// optimistic case
- (prevOnyxReportID && prevOnyxReportID === routeReportID && !onyxReportID && prevReport.statusNum === CONST.REPORT.STATUS.OPEN && report.statusNum === CONST.REPORT.STATUS.CLOSED)
+ (prevOnyxReportID &&
+ prevOnyxReportID === routeReportID &&
+ !onyxReportID &&
+ prevReport.statusNum === CONST.REPORT.STATUS.OPEN &&
+ (report.statusNum === CONST.REPORT.STATUS.CLOSED || (!report.statusNum && !prevReport.parentReportID)))
) {
Navigation.dismissModal();
if (Navigation.getTopmostReportId() === prevOnyxReportID) {
@@ -340,6 +354,9 @@ function ReportScreen({
}, [route, report, errors, fetchReportIfNeeded, prevReport.reportID, prevUserLeavingStatus, userLeavingStatus, prevReport.statusNum, prevReport.parentReportID]);
useEffect(() => {
+ if (!ReportUtils.isValidReportIDFromPath(reportID)) {
+ return;
+ }
// Ensures subscription event succeeds when the report/workspace room is created optimistically.
// Check if the optimistic `OpenReport` or `AddWorkspaceRoom` has succeeded by confirming
// any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null.
@@ -363,7 +380,7 @@ function ReportScreen({
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundPage = useMemo(
- () => (!firstRenderRef.current && !report.reportID && !isOptimisticDelete && !reportMetadata.isLoadingReportActions && !isLoading && !userLeavingStatus) || shouldHideReport,
+ () => (!firstRenderRef.current && !report.reportID && !isOptimisticDelete && !reportMetadata.isLoadingInitialReportActions && !isLoading && !userLeavingStatus) || shouldHideReport,
[report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus],
);
@@ -419,8 +436,9 @@ function ReportScreen({
@@ -474,12 +492,14 @@ export default compose(
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`,
allowStaleData: true,
+ selector: reportWithoutHasDraftSelector,
},
reportMetadata: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`,
initialValue: {
- isLoadingReportActions: true,
- isLoadingMoreReportActions: false,
+ isLoadingInitialReportActions: true,
+ isLoadingOlderReportActions: false,
+ isLoadingNewerReportActions: false,
},
},
isComposerFullSize: {
diff --git a/src/pages/home/report/AnimatedEmptyStateBackground.js b/src/pages/home/report/AnimatedEmptyStateBackground.js
index 67d9a9584b39..6d85e4d7fc85 100644
--- a/src/pages/home/report/AnimatedEmptyStateBackground.js
+++ b/src/pages/home/report/AnimatedEmptyStateBackground.js
@@ -41,7 +41,6 @@ function AnimatedEmptyStateBackground() {
return (
maxBackgroundWidth ? 'repeat' : 'cover'}
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 6c9970bde796..ec2f08df502d 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -125,10 +125,12 @@ export default [
if (type !== CONTEXT_MENU_TYPES.REPORT_ACTION) {
return false;
}
- const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID);
+ const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT;
const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction);
- return isCommentAction || isReportPreviewAction || isIOUAction;
+ const isModifiedExpenseAction = ReportActionsUtils.isModifiedExpenseAction(reportAction);
+ const isTaskAction = ReportActionsUtils.isTaskAction(reportAction);
+ return (isCommentAction || isReportPreviewAction || isIOUAction || isModifiedExpenseAction || isTaskAction) && !ReportUtils.isThreadFirstChat(reportAction, reportID);
},
onPress: (closePopover, {reportAction, reportID}) => {
if (closePopover) {
diff --git a/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js b/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js
new file mode 100644
index 000000000000..97e79e96dac7
--- /dev/null
+++ b/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import {ActivityIndicator, View} from 'react-native';
+import PropTypes from 'prop-types';
+import ReportActionsSkeletonView from '../../../../components/ReportActionsSkeletonView';
+import CONST from '../../../../CONST';
+import useNetwork from '../../../../hooks/useNetwork';
+import styles, {stylesGenerator} from '../../../../styles/styles';
+import themeColors from '../../../../styles/themes/default';
+
+const propTypes = {
+ /** type of rendered loader. Can be 'header' or 'footer' */
+ type: PropTypes.oneOf([CONST.LIST_COMPONENTS.HEADER, CONST.LIST_COMPONENTS.FOOTER]).isRequired,
+
+ /** Shows if we call fetching older report action */
+ isLoadingOlderReportActions: PropTypes.bool,
+
+ /* Shows if we call initial loading of report action */
+ isLoadingInitialReportActions: PropTypes.bool,
+
+ /** Shows if we call fetching newer report action */
+ isLoadingNewerReportActions: PropTypes.bool,
+
+ /** Name of the last report action */
+ lastReportActionName: PropTypes.string,
+};
+
+const defaultProps = {
+ isLoadingOlderReportActions: false,
+ isLoadingInitialReportActions: false,
+ isLoadingNewerReportActions: false,
+ lastReportActionName: '',
+};
+
+function ListBoundaryLoader({type, isLoadingOlderReportActions, isLoadingInitialReportActions, lastReportActionName, isLoadingNewerReportActions}) {
+ const {isOffline} = useNetwork();
+
+ // we use two different loading components for header and footer to reduce the jumping effect when you scrolling to the newer reports
+ if (type === CONST.LIST_COMPONENTS.FOOTER) {
+ if (isLoadingOlderReportActions) {
+ return ;
+ }
+
+ // Make sure the oldest report action loaded is not the first. This is so we do not show the
+ // skeleton view above the created action in a newly generated optimistic chat or one with not
+ // that many comments.
+ if (isLoadingInitialReportActions && lastReportActionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ return (
+
+ );
+ }
+ }
+ if (type === CONST.LIST_COMPONENTS.HEADER && isLoadingNewerReportActions) {
+ // applied for a header of the list, i.e. when you scroll to the bottom of the list
+ // the styles for android and the rest components are different that's why we use two different components
+ return (
+
+
+
+ );
+ }
+}
+
+ListBoundaryLoader.propTypes = propTypes;
+ListBoundaryLoader.defaultProps = defaultProps;
+ListBoundaryLoader.displayName = 'ListBoundaryLoader';
+
+export default ListBoundaryLoader;
diff --git a/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js
index 9303d7a5bc39..32433cc80ca5 100644
--- a/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js
+++ b/src/pages/home/report/ReactionList/PopoverReactionList/BasePopoverReactionList.js
@@ -4,7 +4,6 @@ import lodashGet from 'lodash/get';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
-import * as Report from '../../../../../libs/actions/Report';
import withLocalize, {withLocalizePropTypes} from '../../../../../components/withLocalize';
import PopoverWithMeasuredContent from '../../../../../components/PopoverWithMeasuredContent';
import BaseReactionList from '../BaseReactionList';
@@ -121,30 +120,27 @@ class BasePopoverReactionList extends React.Component {
* Get the reaction information.
*
* @param {Object} selectedReaction
+ * @param {String} emojiName
* @returns {Object}
*/
- getReactionInformation(selectedReaction) {
+ getReactionInformation(selectedReaction, emojiName) {
if (!selectedReaction) {
return {
emojiName: '',
- emojiCount: 0,
+ reactionCount: 0,
emojiCodes: [],
hasUserReacted: false,
users: [],
};
}
- const reactionUsers = _.pick(selectedReaction.users, _.identity);
- const emojiCount = _.map(reactionUsers, (user) => user).length;
- const userAccountIDs = _.map(reactionUsers, (user, accountID) => Number(accountID));
- const emojiName = selectedReaction.emojiName;
- const emoji = EmojiUtils.findEmojiByName(emojiName);
- const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, selectedReaction.users);
- const hasUserReacted = Report.hasAccountIDEmojiReacted(this.props.currentUserPersonalDetails.accountID, reactionUsers);
+
+ const {emojiCodes, reactionCount, hasUserReacted, userAccountIDs} = EmojiUtils.getEmojiReactionDetails(emojiName, selectedReaction, this.props.currentUserPersonalDetails.accountID);
+
const users = PersonalDetailsUtils.getPersonalDetailsByIDs(userAccountIDs, this.props.currentUserPersonalDetails.accountID, true);
return {
emojiName,
- emojiCount,
emojiCodes,
+ reactionCount,
hasUserReacted,
users,
};
@@ -205,7 +201,7 @@ class BasePopoverReactionList extends React.Component {
render() {
const selectedReaction = this.state.isPopoverVisible ? lodashGet(this.props.emojiReactions, [this.props.emojiName]) : null;
- const {emojiName, emojiCount, emojiCodes, hasUserReacted, users} = this.getReactionInformation(selectedReaction);
+ const {emojiName, emojiCodes, reactionCount, hasUserReacted, users} = this.getReactionInformation(selectedReaction, this.props.emojiName);
return (
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
index 4024cbd7a2c8..36cd9428b738 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
@@ -10,7 +10,7 @@ import AttachmentPicker from '../../../../components/AttachmentPicker';
import * as Report from '../../../../libs/actions/Report';
import PopoverMenu from '../../../../components/PopoverMenu';
import CONST from '../../../../CONST';
-import Tooltip from '../../../../components/Tooltip';
+import Tooltip from '../../../../components/Tooltip/PopoverAnchorTooltip';
import * as Browser from '../../../../libs/Browser';
import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback';
import useLocalize from '../../../../hooks/useLocalize';
@@ -126,15 +126,15 @@ function AttachmentPickerWithMenuItems({
*/
const moneyRequestOptions = useMemo(() => {
const options = {
- [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT]: {
+ [CONST.IOU.TYPE.SPLIT]: {
icon: Expensicons.Receipt,
text: translate('iou.splitBill'),
},
- [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: {
+ [CONST.IOU.TYPE.REQUEST]: {
icon: Expensicons.MoneyCircle,
text: translate('iou.requestMoney'),
},
- [CONST.IOU.MONEY_REQUEST_TYPE.SEND]: {
+ [CONST.IOU.TYPE.SEND]: {
icon: Expensicons.Send,
text: translate('iou.sendMoney'),
},
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
index 849db381a549..fcb49277ef5b 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -34,6 +34,7 @@ import withKeyboardState from '../../../../components/withKeyboardState';
import {propTypes, defaultProps} from './composerWithSuggestionsProps';
import focusWithDelay from '../../../../libs/focusWithDelay';
import useDebounce from '../../../../hooks/useDebounce';
+import updateMultilineInputRange from '../../../../libs/UpdateMultilineInputRange';
import * as InputFocus from '../../../../libs/actions/InputFocus';
const {RNTextInputReset} = NativeModules;
@@ -120,7 +121,8 @@ function ComposerWithSuggestions({
const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
const isEmptyChat = useMemo(() => _.size(reportActions) === 1, [reportActions]);
- const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput;
+ const parentAction = ReportActionsUtils.getParentReportAction(report);
+ const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentAction))) && shouldShowComposeInput;
const valueRef = useRef(value);
valueRef.current = value;
@@ -215,6 +217,10 @@ function ComposerWithSuggestions({
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
if (!_.isEmpty(newEmojis)) {
+ // Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed
+ if (suggestionsRef.current) {
+ suggestionsRef.current.resetSuggestions();
+ }
insertedEmojisRef.current = [...insertedEmojisRef.current, ...newEmojis];
debouncedUpdateFrequentlyUsedEmojis();
}
@@ -223,11 +229,6 @@ function ComposerWithSuggestions({
setIsCommentEmpty(!!newComment.match(/^(\s)*$/));
setValue(newComment);
if (commentValue !== newComment) {
- // Ensure emoji suggestions are hidden even when the selection is not changed (so calculateEmojiSuggestion would not be called).
- if (suggestionsRef.current) {
- suggestionsRef.current.resetSuggestions();
- }
-
const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
setSelection({
start: newComment.length - remainder,
@@ -496,9 +497,13 @@ function ComposerWithSuggestions({
focus();
}, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]);
useEffect(() => {
+ // Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
+ updateMultilineInputRange(textInputRef.current, shouldAutoFocus);
+
if (value.length === 0) {
return;
}
+
Report.setReportWithDraft(reportID, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -562,6 +567,7 @@ function ComposerWithSuggestions({
(
+
+));
+
+ComposerWithSuggestionsWithRef.displayName = 'ComposerWithSuggestionsWithRef';
+
export default compose(
withKeyboardState,
withOnyx({
@@ -614,12 +630,4 @@ export default compose(
initWithStoredValues: false,
},
}),
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+)(ComposerWithSuggestionsWithRef);
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
index 857b7d5e52c2..d18b6fc2892d 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
@@ -248,6 +248,8 @@ const SuggestionEmojiWithRef = React.forwardRef((props, ref) => (
/>
));
+SuggestionEmojiWithRef.displayName = 'SuggestionEmojiWithRef';
+
export default withOnyx({
preferredSkinTone: {
key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
index 84bee9c80c7f..67d6ac0632eb 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
@@ -9,6 +9,7 @@ import * as UserUtils from '../../../../libs/UserUtils';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import * as SuggestionsUtils from '../../../../libs/SuggestionUtils';
import useLocalize from '../../../../hooks/useLocalize';
+import usePrevious from '../../../../hooks/usePrevious';
import ONYXKEYS from '../../../../ONYXKEYS';
import personalDetailsPropType from '../../../personalDetailsPropType';
import * as SuggestionProps from './suggestionProps';
@@ -54,8 +55,10 @@ function SuggestionMention({
forwardedRef,
isAutoSuggestionPickerLarge,
measureParentContainer,
+ isComposerFocused,
}) {
const {translate} = useLocalize();
+ const previousValue = usePrevious(value);
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu;
@@ -181,24 +184,24 @@ function SuggestionMention({
const calculateMentionSuggestion = useCallback(
(selectionEnd) => {
- if (shouldBlockCalc.current || selectionEnd < 1) {
+ if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) {
shouldBlockCalc.current = false;
resetSuggestions();
return;
}
const valueAfterTheCursor = value.substring(selectionEnd);
- const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI);
+ const indexOfFirstSpecialCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.MENTION_BREAKER);
- let indexOfLastNonWhitespaceCharAfterTheCursor;
- if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) {
- // we didn't find a whitespace/emoji after the cursor, so we will use the entire string
- indexOfLastNonWhitespaceCharAfterTheCursor = value.length;
+ let suggestionEndIndex;
+ if (indexOfFirstSpecialCharOrEmojiAfterTheCursor === -1) {
+ // We didn't find a special char/whitespace/emoji after the cursor, so we will use the entire string
+ suggestionEndIndex = value.length;
} else {
- indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + selectionEnd;
+ suggestionEndIndex = indexOfFirstSpecialCharOrEmojiAfterTheCursor + selectionEnd;
}
- const leftString = value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor);
+ const leftString = value.substring(0, suggestionEndIndex);
const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI);
const lastWord = _.last(words);
@@ -229,12 +232,19 @@ function SuggestionMention({
}));
setHighlightedMentionIndex(0);
},
- [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value],
+ [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value, isComposerFocused],
);
useEffect(() => {
+ if (value.length < previousValue.length) {
+ // A workaround to not show the suggestions list when the user deletes a character before the mention.
+ // It is caused by a buggy behavior of the TextInput on iOS. Should be fixed after migration to Fabric.
+ // See: https://github.com/facebook/react-native/pull/36930#issuecomment-1593028467
+ return;
+ }
+
calculateMentionSuggestion(selection.end);
- }, [selection, calculateMentionSuggestion]);
+ }, [selection, value, previousValue, calculateMentionSuggestion]);
const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
setSuggestionValues((prevState) => {
@@ -296,16 +306,18 @@ SuggestionMention.propTypes = propTypes;
SuggestionMention.defaultProps = defaultProps;
SuggestionMention.displayName = 'SuggestionMention';
+const SuggestionMentionWithRef = React.forwardRef((props, ref) => (
+
+));
+
+SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef';
+
export default withOnyx({
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
-})(
- React.forwardRef((props, ref) => (
-
- )),
-);
+})(SuggestionMentionWithRef);
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js
index 60c31efb1446..5365aefe13e6 100644
--- a/src/pages/home/report/ReportActionCompose/Suggestions.js
+++ b/src/pages/home/report/ReportActionCompose/Suggestions.js
@@ -40,6 +40,7 @@ function Suggestions({
resetKeyboardInput,
measureParentContainer,
isAutoSuggestionPickerLarge,
+ isComposerFocused,
}) {
const suggestionEmojiRef = useRef(null);
const suggestionMentionRef = useRef(null);
@@ -103,6 +104,7 @@ function Suggestions({
composerHeight,
isAutoSuggestionPickerLarge,
measureParentContainer,
+ isComposerFocused,
};
return (
@@ -126,10 +128,14 @@ Suggestions.propTypes = propTypes;
Suggestions.defaultProps = defaultProps;
Suggestions.displayName = 'Suggestions';
-export default React.forwardRef((props, ref) => (
+const SuggestionsWithRef = React.forwardRef((props, ref) => (
));
+
+SuggestionsWithRef.displayName = 'SuggestionsWithRef';
+
+export default SuggestionsWithRef;
diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js
index 815a1c5619f5..62c29f3d418e 100644
--- a/src/pages/home/report/ReportActionCompose/suggestionProps.js
+++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js
@@ -24,6 +24,9 @@ const baseProps = {
/** Meaures the parent container's position and dimensions. */
measureParentContainer: PropTypes.func.isRequired,
+
+ /** Report composer focus state */
+ isComposerFocused: PropTypes.bool,
};
const implementationBaseProps = {
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index d0e84499a443..3afdb437a49a 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -28,7 +28,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe
import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu';
import * as ContextMenuActions from './ContextMenu/ContextMenuActions';
import * as EmojiPickerAction from '../../../libs/actions/EmojiPickerAction';
-import {withBlockedFromConcierge, withNetwork, withPersonalDetails, withReportActionsDrafts} from '../../../components/OnyxProvider';
+import {usePersonalDetails, withBlockedFromConcierge, withNetwork, withReportActionsDrafts} from '../../../components/OnyxProvider';
import RenameAction from '../../../components/ReportActionItem/RenameAction';
import InlineSystemMessage from '../../../components/InlineSystemMessage';
import styles from '../../../styles/styles';
@@ -49,7 +49,6 @@ import Icon from '../../../components/Icon';
import * as Expensicons from '../../../components/Icon/Expensicons';
import Text from '../../../components/Text';
import DisplayNames from '../../../components/DisplayNames';
-import personalDetailsPropType from '../../personalDetailsPropType';
import ReportPreview from '../../../components/ReportActionItem/ReportPreview';
import ReportActionItemDraft from './ReportActionItemDraft';
import TaskPreview from '../../../components/ReportActionItem/TaskPreview';
@@ -70,6 +69,10 @@ import themeColors from '../../../styles/themes/default';
import ReportActionItemBasicMessage from './ReportActionItemBasicMessage';
import RenderHTML from '../../../components/RenderHTML';
import ReportAttachmentsContext from './ReportAttachmentsContext';
+import ROUTES from '../../../ROUTES';
+import Navigation from '../../../libs/Navigation/Navigation';
+import KYCWall from '../../../components/KYCWall';
+import userWalletPropTypes from '../../EnablePayments/userWalletPropTypes';
const propTypes = {
...windowDimensionsPropTypes,
@@ -107,28 +110,31 @@ const propTypes = {
...windowDimensionsPropTypes,
emojiReactions: EmojiReactionsPropTypes,
- personalDetailsList: PropTypes.objectOf(personalDetailsPropType),
/** IOU report for this action, if any */
iouReport: reportPropTypes,
/** Flag to show, hide the thread divider line */
shouldHideThreadDividerLine: PropTypes.bool,
+
+ /** The user's wallet account */
+ userWallet: userWalletPropTypes,
};
const defaultProps = {
draftMessage: '',
preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
emojiReactions: {},
- personalDetailsList: {},
shouldShowSubscriptAvatar: false,
hasOutstandingIOU: false,
iouReport: undefined,
shouldHideThreadDividerLine: false,
+ userWallet: {},
};
function ReportActionItem(props) {
- const [isContextMenuActive, setIsContextMenuActive] = useState(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID));
+ const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
+ const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(props.action.reportActionID));
const [isHidden, setIsHidden] = useState(false);
const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED);
const reactionListRef = useContext(ReactionListContext);
@@ -142,6 +148,10 @@ function ReportActionItem(props) {
const isReportActionLinked = props.linkedReportActionID === props.action.reportActionID;
const highlightedBackgroundColorIfNeeded = useMemo(() => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(themeColors.highlightBG) : {}), [isReportActionLinked]);
+ const originalMessage = lodashGet(props.action, 'originalMessage', {});
+
+ // IOUDetails only exists when we are sending money
+ const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails');
// When active action changes, we need to update the `isContextMenuActive` state
const isActiveReportActionForMenu = ReportActionContextMenu.isActiveReportAction(props.action.reportActionID);
@@ -274,6 +284,16 @@ function ReportActionItem(props) {
[props.report, props.action, props.emojiReactions],
);
+ const contextValue = useMemo(
+ () => ({
+ anchor: popoverAnchorRef,
+ report: props.report,
+ action: props.action,
+ checkIfContextMenuActive: toggleContextMenuFromActiveReportAction,
+ }),
+ [props.report, props.action, toggleContextMenuFromActiveReportAction],
+ );
+
/**
* Get the content of ReportActionItem
* @param {Boolean} hovered whether the ReportActionItem is hovered
@@ -283,10 +303,6 @@ function ReportActionItem(props) {
*/
const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false) => {
let children;
- const originalMessage = lodashGet(props.action, 'originalMessage', {});
-
- // IOUDetails only exists when we are sending money
- const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails');
// Show the MoneyRequestPreview for when request was created, bill was split or money was sent
if (
@@ -344,21 +360,51 @@ function ReportActionItem(props) {
/>
);
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) {
- const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetailsList, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail);
- const shouldShowAddCreditBankAccountButton =
- ReportUtils.isCurrentUserSubmitter(props.report.reportID) && !store.hasCreditBankAccount() && !ReportUtils.isSettled(props.report.reportID);
+ const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail);
+ const paymentType = lodashGet(props.action, 'originalMessage.paymentType', '');
+
+ const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(props.report.reportID) && !ReportUtils.isSettled(props.report.reportID);
+ const shouldShowAddCreditBankAccountButton = isSubmitterOfUnsettledReport && !store.hasCreditBankAccount() && paymentType !== CONST.IOU.PAYMENT_TYPE.EXPENSIFY;
+ const shouldShowEnableWalletButton =
+ isSubmitterOfUnsettledReport &&
+ (_.isEmpty(props.userWallet) || props.userWallet.tierName === CONST.WALLET.TIER_NAME.SILVER) &&
+ paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY;
children = (
-
- {shouldShowAddCreditBankAccountButton ? (
- BankAccounts.openPersonalBankAccountSetupView(props.report.reportID)}
- pressOnEnter
- />
- ) : null}
+
+ <>
+ {shouldShowAddCreditBankAccountButton && (
+ BankAccounts.openPersonalBankAccountSetupView(props.report.reportID)}
+ pressOnEnter
+ />
+ )}
+ {shouldShowEnableWalletButton && (
+ Navigation.navigate(ROUTES.ENABLE_PAYMENTS)}
+ enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
+ addBankAccountRoute={ROUTES.BANK_ACCOUNT_PERSONAL}
+ addDebitCardRoute={ROUTES.SETTINGS_ADD_DEBIT_CARD}
+ chatReportID={props.report.reportID}
+ iouReport={props.iouReport}
+ >
+ {(triggerKYCFlow, buttonRef) => (
+
+ )}
+
+ )}
+ >
);
} else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) {
@@ -366,14 +412,7 @@ function ReportActionItem(props) {
} else {
const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision);
children = (
-
+
{!props.draftMessage ? (
@@ -520,14 +559,7 @@ function ReportActionItem(props) {
const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
if (ReportActionsUtils.isTransactionThread(parentReportAction)) {
content = (
-
+ 0;
const isMultipleParticipant = whisperedToAccountIDs.length > 1;
const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedToAccountIDs);
- const whisperedToPersonalDetails = isWhisper ? _.filter(props.personalDetailsList, (details) => _.includes(whisperedToAccountIDs, details.accountID)) : [];
+ const whisperedToPersonalDetails = isWhisper ? _.filter(personalDetails, (details) => _.includes(whisperedToAccountIDs, details.accountID)) : [];
const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : [];
return (
`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`,
initialValue: {},
},
+ userWallet: {
+ key: ONYXKEYS.USER_WALLET,
+ },
}),
)(
memo(
@@ -730,6 +776,7 @@ export default compose(
prevProps.report.managerEmail === nextProps.report.managerEmail &&
prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine &&
lodashGet(prevProps.report, 'total', 0) === lodashGet(nextProps.report, 'total', 0) &&
+ lodashGet(prevProps.report, 'nonReimbursableTotal', 0) === lodashGet(nextProps.report, 'nonReimbursableTotal', 0) &&
prevProps.linkedReportActionID === nextProps.linkedReportActionID,
),
);
diff --git a/src/pages/home/report/ReportActionItemCreated.js b/src/pages/home/report/ReportActionItemCreated.js
index a5df1c37e769..50b4a3eb6148 100644
--- a/src/pages/home/report/ReportActionItemCreated.js
+++ b/src/pages/home/report/ReportActionItemCreated.js
@@ -19,6 +19,7 @@ import PressableWithoutFeedback from '../../../components/Pressable/PressableWit
import MultipleAvatars from '../../../components/MultipleAvatars';
import CONST from '../../../CONST';
import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground';
+import reportWithoutHasDraftSelector from '../../../libs/OnyxSelectors/reportWithoutHasDraftSelector';
const propTypes = {
/** The id of the report */
@@ -106,6 +107,7 @@ export default compose(
withOnyx({
report: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ selector: reportWithoutHasDraftSelector,
},
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index 24501e307759..57b51ef50519 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -18,6 +18,7 @@ import CONST from '../../../CONST';
import editedLabelStyles from '../../../styles/editedLabelStyles';
import UserDetailsTooltip from '../../../components/UserDetailsTooltip';
import avatarPropTypes from '../../../components/avatarPropTypes';
+import ZeroWidthView from '../../../components/ZeroWidthView';
const propTypes = {
/** Users accountID */
@@ -66,6 +67,9 @@ const propTypes = {
/** localization props */
...withLocalizePropTypes,
+
+ /** Should the comment have the appearance of being grouped with the previous comment? */
+ displayAsGroup: PropTypes.bool,
};
const defaultProps = {
@@ -82,6 +86,7 @@ const defaultProps = {
delegateAccountID: 0,
actorIcon: {},
isThreadParentMessage: false,
+ displayAsGroup: false,
};
function ReportActionItemFragment(props) {
@@ -116,6 +121,10 @@ function ReportActionItemFragment(props) {
return (
+
))
) : (
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index f76f884dca52..4b73ce3b21db 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -111,7 +111,7 @@ function ReportActionItemMessageEdit(props) {
}
return initialDraft;
});
- const [selection, setSelection] = useState(getInitialSelection());
+ const [selection, setSelection] = useState(getInitialSelection);
const [isFocused, setIsFocused] = useState(false);
const [hasExceededMaxCommentLength, setHasExceededMaxCommentLength] = useState(false);
const [modal, setModal] = useState(false);
@@ -461,6 +461,7 @@ function ReportActionItemMessageEdit(props) {
setHasExceededMaxCommentLength(hasExceeded)}
/>
>
@@ -471,10 +472,14 @@ ReportActionItemMessageEdit.propTypes = propTypes;
ReportActionItemMessageEdit.defaultProps = defaultProps;
ReportActionItemMessageEdit.displayName = 'ReportActionItemMessageEdit';
-export default React.forwardRef((props, ref) => (
+const ReportActionItemMessageEditWithRef = React.forwardRef((props, ref) => (
));
+
+ReportActionItemMessageEditWithRef.displayName = 'ReportActionItemMessageEditWithRef';
+
+export default ReportActionItemMessageEditWithRef;
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js
index 60de11bdf218..fc189a3aef36 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -9,12 +9,11 @@ import ReportActionItemFragment from './ReportActionItemFragment';
import styles from '../../../styles/styles';
import ReportActionItemDate from './ReportActionItemDate';
import Avatar from '../../../components/Avatar';
-import personalDetailsPropType from '../../personalDetailsPropType';
import compose from '../../../libs/compose';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import {withPersonalDetails} from '../../../components/OnyxProvider';
+import {usePersonalDetails} from '../../../components/OnyxProvider';
import ControlSelection from '../../../libs/ControlSelection';
import * as ReportUtils from '../../../libs/ReportUtils';
import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
@@ -37,9 +36,6 @@ const propTypes = {
/** All the data of the action */
action: PropTypes.shape(reportActionPropTypes).isRequired,
- /** All of the personalDetails */
- personalDetailsList: PropTypes.objectOf(personalDetailsPropType),
-
/** Styles for the outermost View */
// eslint-disable-next-line react/forbid-prop-types
wrapperStyles: PropTypes.arrayOf(PropTypes.object),
@@ -69,7 +65,6 @@ const propTypes = {
};
const defaultProps = {
- personalDetailsList: {},
wrapperStyles: [styles.chatItem],
showHeader: true,
shouldShowSubscriptAvatar: false,
@@ -88,9 +83,10 @@ const showWorkspaceDetails = (reportID) => {
};
function ReportActionItemSingle(props) {
+ const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const actorAccountID = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport ? props.iouReport.managerID : props.action.actorAccountID;
- let {displayName} = props.personalDetailsList[actorAccountID] || {};
- const {avatar, login, pendingFields, status, fallbackIcon} = props.personalDetailsList[actorAccountID] || {};
+ let {displayName} = personalDetails[actorAccountID] || {};
+ const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID] || {};
let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]);
const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(props.report) && (!actorAccountID || displayAllActors);
@@ -100,10 +96,10 @@ function ReportActionItemSingle(props) {
displayName = ReportUtils.getPolicyName(props.report);
actorHint = displayName;
avatarSource = ReportUtils.getWorkspaceAvatar(props.report);
- } else if (props.action.delegateAccountID && props.personalDetailsList[props.action.delegateAccountID]) {
+ } else if (props.action.delegateAccountID && personalDetails[props.action.delegateAccountID]) {
// We replace the actor's email, name, and avatar with the Copilot manually for now. And only if we have their
// details. This will be improved upon when the Copilot feature is implemented.
- const delegateDetails = props.personalDetailsList[props.action.delegateAccountID];
+ const delegateDetails = personalDetails[props.action.delegateAccountID];
const delegateDisplayName = delegateDetails.displayName;
actorHint = `${delegateDisplayName} (${props.translate('reportAction.asCopilot')} ${displayName})`;
displayName = actorHint;
@@ -116,7 +112,7 @@ function ReportActionItemSingle(props) {
if (displayAllActors) {
// The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice
const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID;
- const secondaryUserDetails = props.personalDetailsList[secondaryAccountId] || {};
+ const secondaryUserDetails = personalDetails[secondaryAccountId] || {};
const secondaryDisplayName = lodashGet(secondaryUserDetails, 'displayName', '');
displayName = `${primaryDisplayName} & ${secondaryDisplayName}`;
secondaryAvatar = {
@@ -270,7 +266,6 @@ ReportActionItemSingle.displayName = 'ReportActionItemSingle';
export default compose(
withLocalize,
- withPersonalDetails(),
withOnyx({
betas: {
key: ONYXKEYS.BETAS,
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 438b6e9b68d5..300b1921545d 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -7,11 +7,10 @@ import lodashGet from 'lodash/get';
import CONST from '../../../CONST';
import InvertedFlatList from '../../../components/InvertedFlatList';
import {withPersonalDetails} from '../../../components/OnyxProvider';
-import ReportActionsSkeletonView from '../../../components/ReportActionsSkeletonView';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../../../components/withCurrentUserPersonalDetails';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
-import useLocalize from '../../../hooks/useLocalize';
import useNetwork from '../../../hooks/useNetwork';
+import useLocalize from '../../../hooks/useLocalize';
import useReportScrollManager from '../../../hooks/useReportScrollManager';
import DateUtils from '../../../libs/DateUtils';
import * as ReportUtils from '../../../libs/ReportUtils';
@@ -23,6 +22,8 @@ import reportPropTypes from '../../reportPropTypes';
import FloatingMessageCounter from './FloatingMessageCounter';
import ReportActionsListItemRenderer from './ReportActionsListItemRenderer';
import reportActionPropTypes from './reportActionPropTypes';
+import ListBoundaryLoader from './ListBoundaryLoader/ListBoundaryLoader';
+import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
const propTypes = {
/** The report currently being looked at */
@@ -35,10 +36,13 @@ const propTypes = {
mostRecentIOUReportActionID: PropTypes.string,
/** The report metadata loading states */
- isLoadingReportActions: PropTypes.bool,
+ isLoadingInitialReportActions: PropTypes.bool,
/** Are we loading more report actions? */
- isLoadingMoreReportActions: PropTypes.bool,
+ isLoadingOlderReportActions: PropTypes.bool,
+
+ /** Are we loading newer report actions? */
+ isLoadingNewerReportActions: PropTypes.bool,
/** Callback executed on list layout */
onLayout: PropTypes.func.isRequired,
@@ -47,7 +51,10 @@ const propTypes = {
onScroll: PropTypes.func,
/** Function to load more chats */
- loadMoreChats: PropTypes.func.isRequired,
+ loadOlderChats: PropTypes.func.isRequired,
+
+ /** Function to load newer chats */
+ loadNewerChats: PropTypes.func.isRequired,
/** The policy object for the current route */
policy: PropTypes.shape({
@@ -66,8 +73,9 @@ const defaultProps = {
personalDetails: {},
onScroll: () => {},
mostRecentIOUReportActionID: '',
- isLoadingReportActions: false,
- isLoadingMoreReportActions: false,
+ isLoadingInitialReportActions: false,
+ isLoadingOlderReportActions: false,
+ isLoadingNewerReportActions: false,
...withCurrentUserPersonalDetailsDefaultProps,
};
@@ -97,13 +105,18 @@ function keyExtractor(item) {
}
function isMessageUnread(message, lastReadTime) {
+ if (!lastReadTime) {
+ return Boolean(!ReportActionsUtils.isCreatedAction(message));
+ }
+
return Boolean(message && lastReadTime && message.created && lastReadTime < message.created);
}
function ReportActionsList({
report,
- isLoadingReportActions,
- isLoadingMoreReportActions,
+ isLoadingInitialReportActions,
+ isLoadingOlderReportActions,
+ isLoadingNewerReportActions,
sortedReportActions,
windowHeight,
onScroll,
@@ -112,7 +125,8 @@ function ReportActionsList({
personalDetailsList,
currentUserPersonalDetails,
hasOutstandingIOU,
- loadMoreChats,
+ loadNewerChats,
+ loadOlderChats,
onLayout,
isComposerFullSize,
}) {
@@ -125,8 +139,9 @@ function ReportActionsList({
const [currentUnreadMarker, setCurrentUnreadMarker] = useState(null);
const scrollingVerticalOffset = useRef(0);
const readActionSkipped = useRef(false);
+ const hasHeaderRendered = useRef(false);
+ const hasFooterRendered = useRef(false);
const reportActionSize = useRef(sortedReportActions.length);
- const firstRenderRef = useRef(true);
const linkedReportActionID = lodashGet(route, 'params.reportActionID', '');
// This state is used to force a re-render when the user manually marks a message as unread
@@ -273,50 +288,62 @@ function ReportActionsList({
* This is so that it will not be conflicting with header's separator line.
*/
const shouldHideThreadDividerLine = useMemo(
- () => sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === currentUnreadMarker,
- [sortedReportActions, currentUnreadMarker],
+ () => ReportActionsUtils.getFirstVisibleReportActionID(sortedReportActions, isOffline) === currentUnreadMarker,
+ [sortedReportActions, isOffline, currentUnreadMarker],
);
/**
- * @param {Object} args
- * @param {Number} args.index
- * @returns {React.Component}
+ * Evaluate new unread marker visibility for each of the report actions.
+ * @returns boolean
*/
- const renderItem = useCallback(
- ({item: reportAction, index}) => {
- let shouldDisplayNewMarker = false;
+ const shouldDisplayNewMarker = useCallback(
+ (reportAction, index) => {
+ let shouldDisplay = false;
if (!currentUnreadMarker) {
const nextMessage = sortedReportActions[index + 1];
const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
- shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
-
+ shouldDisplay = isCurrentMessageUnread && (!nextMessage || !isMessageUnread(nextMessage, report.lastReadTime));
if (!messageManuallyMarkedUnread) {
- shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
- }
- const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
-
- if (!currentUnreadMarker && shouldDisplayNewMarker && canDisplayMarker) {
- setCurrentUnreadMarker(reportAction.reportActionID);
+ shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
}
} else {
- shouldDisplayNewMarker = reportAction.reportActionID === currentUnreadMarker;
+ shouldDisplay = reportAction.reportActionID === currentUnreadMarker;
}
- return (
-
- );
+ return shouldDisplay;
},
- [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
+ [currentUnreadMarker, sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread],
+ );
+
+ useEffect(() => {
+ // Iterate through the report actions and set appropriate unread marker.
+ // This is to avoid a warning of:
+ // Cannot update a component (ReportActionsList) while rendering a different component (CellRenderer).
+ _.each(sortedReportActions, (reportAction, index) => {
+ if (!shouldDisplayNewMarker(reportAction, index)) {
+ return;
+ }
+ if (!currentUnreadMarker && currentUnreadMarker !== reportAction.reportActionID) {
+ setCurrentUnreadMarker(reportAction.reportActionID);
+ }
+ });
+ }, [sortedReportActions, report.lastReadTime, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]);
+
+ const renderItem = useCallback(
+ ({item: reportAction, index}) => (
+
+ ),
+ [report, linkedReportActionID, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
@@ -325,28 +352,30 @@ function ReportActionsList({
const hideComposer = ReportUtils.shouldDisableWriteActions(report);
const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize;
- const renderFooter = useCallback(() => {
+ const contentContainerStyle = useMemo(
+ () => [styles.chatContentScrollView, isLoadingNewerReportActions ? styles.chatContentScrollViewWithHeaderLoader : {}],
+ [isLoadingNewerReportActions],
+ );
+
+ const lastReportAction = useMemo(() => _.last(sortedReportActions) || {}, [sortedReportActions]);
+
+ const listFooterComponent = useCallback(() => {
// Skip this hook on the first render, as we are not sure if more actions are going to be loaded
// Therefore showing the skeleton on footer might be misleading
- if (firstRenderRef.current) {
- firstRenderRef.current = false;
+ if (!hasFooterRendered.current) {
+ hasFooterRendered.current = true;
return null;
}
- if (isLoadingMoreReportActions) {
- return ;
- }
-
- // Make sure the oldest report action loaded is not the first. This is so we do not show the
- // skeleton view above the created action in a newly generated optimistic chat or one with not
- // that many comments.
- const lastReportAction = _.last(sortedReportActions) || {};
- if (isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
- return ;
- }
-
- return null;
- }, [isLoadingMoreReportActions, isLoadingReportActions, sortedReportActions, isOffline]);
+ return (
+
+ );
+ }, [isLoadingInitialReportActions, isLoadingOlderReportActions, lastReportAction.actionName]);
const onLayoutInner = useCallback(
(event) => {
@@ -355,6 +384,19 @@ function ReportActionsList({
[onLayout],
);
+ const listHeaderComponent = useCallback(() => {
+ if (!hasHeaderRendered.current) {
+ hasHeaderRendered.current = true;
+ return null;
+ }
+ return (
+
+ );
+ }, [isLoadingNewerReportActions]);
+
return (
<>
>
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index f58c6644cd47..8111cfa4b644 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -29,10 +29,13 @@ const propTypes = {
reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
/** The report metadata loading states */
- isLoadingReportActions: PropTypes.bool,
+ isLoadingInitialReportActions: PropTypes.bool,
/** The report actions are loading more data */
- isLoadingMoreReportActions: PropTypes.bool,
+ isLoadingOlderReportActions: PropTypes.bool,
+
+ /** The report actions are loading newer data */
+ isLoadingNewerReportActions: PropTypes.bool,
/** Whether the composer is full size */
/* eslint-disable-next-line react/no-unused-prop-types */
@@ -57,8 +60,9 @@ const propTypes = {
const defaultProps = {
reportActions: [],
policy: null,
- isLoadingReportActions: false,
- isLoadingMoreReportActions: false,
+ isLoadingInitialReportActions: false,
+ isLoadingOlderReportActions: false,
+ isLoadingNewerReportActions: false,
};
function ReportActionsView(props) {
@@ -66,6 +70,7 @@ function ReportActionsView(props) {
const reactionListRef = useContext(ReactionListContext);
const didLayout = useRef(false);
const didSubscribeToReportTypingEvents = useRef(false);
+ const isFirstRender = useRef(true);
const hasCachedActions = useRef(_.size(props.reportActions) > 0);
const mostRecentIOUReportActionID = useRef(ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions));
@@ -142,9 +147,9 @@ function ReportActionsView(props) {
* Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
* displaying.
*/
- const loadMoreChats = () => {
+ const loadOlderChats = () => {
// Only fetch more if we are not already fetching so that we don't initiate duplicate requests.
- if (props.isLoadingMoreReportActions) {
+ if (props.isLoadingOlderReportActions) {
return;
}
@@ -154,11 +159,42 @@ function ReportActionsView(props) {
if (oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
return;
}
-
// Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments
- Report.readOldestAction(reportID, oldestReportAction.reportActionID);
+ Report.getOlderActions(reportID, oldestReportAction.reportActionID);
};
+ /**
+ * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
+ * displaying.
+ */
+ const loadNewerChats = useMemo(
+ () =>
+ _.throttle(({distanceFromStart}) => {
+ if (props.isLoadingNewerReportActions || props.isLoadingInitialReportActions) {
+ return;
+ }
+
+ // Ideally, we wouldn't need to use the 'distanceFromStart' variable. However, due to the low value set for 'maxToRenderPerBatch',
+ // the component undergoes frequent re-renders. This frequent re-rendering triggers the 'onStartReached' callback multiple times.
+ //
+ // To mitigate this issue, we use 'CONST.CHAT_HEADER_LOADER_HEIGHT' as a threshold. This ensures that 'onStartReached' is not
+ // triggered unnecessarily when the chat is initially opened or when the user has reached the end of the list but hasn't scrolled further.
+ //
+ // Additionally, we use throttling on the 'onStartReached' callback to further reduce the frequency of its invocation.
+ // This should be removed once the issue of frequent re-renders is resolved.
+ //
+ // onStartReached is triggered during the first render. Since we use OpenReport on the first render and are confident about the message ordering, we can safely skip this call
+ if (isFirstRender.current || distanceFromStart <= CONST.CHAT_HEADER_LOADER_HEIGHT) {
+ isFirstRender.current = false;
+ return;
+ }
+
+ const newestReportAction = _.first(props.reportActions);
+ Report.getNewerActions(reportID, newestReportAction.reportActionID);
+ }, 500),
+ [props.isLoadingNewerReportActions, props.isLoadingInitialReportActions, props.reportActions, reportID],
+ );
+
/**
* Runs when the FlatList finishes laying out
*/
@@ -191,9 +227,11 @@ function ReportActionsView(props) {
onLayout={recordTimeToMeasureItemLayout}
sortedReportActions={props.reportActions}
mostRecentIOUReportActionID={mostRecentIOUReportActionID.current}
- isLoadingReportActions={props.isLoadingReportActions}
- isLoadingMoreReportActions={props.isLoadingMoreReportActions}
- loadMoreChats={loadMoreChats}
+ loadOlderChats={loadOlderChats}
+ loadNewerChats={loadNewerChats}
+ isLoadingInitialReportActions={props.isLoadingInitialReportActions}
+ isLoadingOlderReportActions={props.isLoadingOlderReportActions}
+ isLoadingNewerReportActions={props.isLoadingNewerReportActions}
policy={props.policy}
/>
@@ -222,11 +260,15 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
- if (oldProps.isLoadingMoreReportActions !== newProps.isLoadingMoreReportActions) {
+ if (oldProps.isLoadingInitialReportActions !== newProps.isLoadingInitialReportActions) {
+ return false;
+ }
+
+ if (oldProps.isLoadingOlderReportActions !== newProps.isLoadingOlderReportActions) {
return false;
}
- if (oldProps.isLoadingReportActions !== newProps.isLoadingReportActions) {
+ if (oldProps.isLoadingNewerReportActions !== newProps.isLoadingNewerReportActions) {
return false;
}
@@ -278,6 +320,10 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
+ if (lodashGet(newProps, 'report.nonReimbursableTotal') !== lodashGet(oldProps, 'report.nonReimbursableTotal')) {
+ return false;
+ }
+
if (lodashGet(newProps, 'report.writeCapability') !== lodashGet(oldProps, 'report.writeCapability')) {
return false;
}
diff --git a/src/pages/home/report/ReportDetailsShareCodePage.js b/src/pages/home/report/ReportDetailsShareCodePage.js
index 62030f004bc6..7c22726ac82b 100644
--- a/src/pages/home/report/ReportDetailsShareCodePage.js
+++ b/src/pages/home/report/ReportDetailsShareCodePage.js
@@ -28,4 +28,4 @@ function ReportDetailsShareCodePage(props) {
ReportDetailsShareCodePage.propTypes = propTypes;
ReportDetailsShareCodePage.defaultProps = defaultProps;
-export default withReportOrNotFound(ReportDetailsShareCodePage);
+export default withReportOrNotFound()(ReportDetailsShareCodePage);
diff --git a/src/pages/home/report/withReportAndReportActionOrNotFound.js b/src/pages/home/report/withReportAndReportActionOrNotFound.js
index b4346504b327..a14293e71c67 100644
--- a/src/pages/home/report/withReportAndReportActionOrNotFound.js
+++ b/src/pages/home/report/withReportAndReportActionOrNotFound.js
@@ -67,8 +67,9 @@ export default function (WrappedComponent) {
reportActions: {},
report: {},
reportMetadata: {
- isLoadingReportActions: false,
- isLoadingMoreReportActions: false,
+ isLoadingInitialReportActions: false,
+ isLoadingOlderReportActions: false,
+ isLoadingNewerReportActions: false,
},
policies: {},
betas: [],
@@ -102,7 +103,7 @@ export default function (WrappedComponent) {
// Perform all the loading checks
const isLoadingReport = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID);
- const isLoadingReportAction = _.isEmpty(props.reportActions) || (props.reportMetadata.isLoadingReportActions && _.isEmpty(getReportAction()));
+ const isLoadingReportAction = _.isEmpty(props.reportActions) || (props.reportMetadata.isLoadingInitialReportActions && _.isEmpty(getReportAction()));
const shouldHideReport = !isLoadingReport && (_.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas));
if ((isLoadingReport || isLoadingReportAction) && !shouldHideReport) {
@@ -129,7 +130,7 @@ export default function (WrappedComponent) {
WithReportAndReportActionOrNotFound.displayName = `withReportAndReportActionOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
// eslint-disable-next-line rulesdir/no-negated-variables
- const withReportAndReportActionOrNotFound = React.forwardRef((props, ref) => (
+ const WithReportAndReportActionOrNotFoundWithRef = React.forwardRef((props, ref) => (
));
+ WithReportAndReportActionOrNotFoundWithRef.displayName = 'WithReportAndReportActionOrNotFoundWithRef';
+
return compose(
withWindowDimensions,
withOnyx({
@@ -160,5 +163,5 @@ export default function (WrappedComponent) {
canEvict: false,
},
}),
- )(withReportAndReportActionOrNotFound);
+ )(WithReportAndReportActionOrNotFoundWithRef);
}
diff --git a/src/pages/home/report/withReportOrNotFound.js b/src/pages/home/report/withReportOrNotFound.js
index 5829ac7a6015..43f3caa645e7 100644
--- a/src/pages/home/report/withReportOrNotFound.js
+++ b/src/pages/home/report/withReportOrNotFound.js
@@ -9,7 +9,7 @@ import reportPropTypes from '../../reportPropTypes';
import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator';
import * as ReportUtils from '../../../libs/ReportUtils';
-export default function (WrappedComponent) {
+export default function (shouldRequireReportID = true) {
const propTypes = {
/** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component.
* That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */
@@ -29,6 +29,14 @@ export default function (WrappedComponent) {
}),
),
+ /** Route params */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ /** Report ID passed via route */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
/** Beta features list */
betas: PropTypes.arrayOf(PropTypes.string),
@@ -44,67 +52,76 @@ export default function (WrappedComponent) {
isLoadingReportData: true,
};
- // eslint-disable-next-line rulesdir/no-negated-variables
- function WithReportOrNotFound(props) {
- const contentShown = React.useRef(false);
-
- const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID);
+ return (WrappedComponent) => {
// eslint-disable-next-line rulesdir/no-negated-variables
- const shouldShowNotFoundPage = _.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas);
-
- // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen.
- // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
- if (shouldShowNotFoundPage && contentShown.current) {
- return null;
+ function WithReportOrNotFound(props) {
+ const contentShown = React.useRef(false);
+
+ const isReportIdInRoute = !_.isUndefined(props.route.params.reportID);
+
+ // If we should require reportID or we have a reportID in the route, we will check the reportID is valid or not
+ if (shouldRequireReportID || isReportIdInRoute) {
+ const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID);
+ // eslint-disable-next-line rulesdir/no-negated-variables
+ const shouldShowNotFoundPage = _.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas);
+
+ // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen.
+ // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
+ if (shouldShowNotFoundPage && contentShown.current) {
+ return null;
+ }
+
+ if (shouldShowFullScreenLoadingIndicator) {
+ return ;
+ }
+
+ if (shouldShowNotFoundPage) {
+ return ;
+ }
+ }
+
+ if (!contentShown.current) {
+ contentShown.current = true;
+ }
+
+ const rest = _.omit(props, ['forwardedRef']);
+ return (
+
+ );
}
- if (shouldShowFullScreenLoadingIndicator) {
- return ;
- }
+ WithReportOrNotFound.propTypes = propTypes;
+ WithReportOrNotFound.defaultProps = defaultProps;
+ WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
- if (shouldShowNotFoundPage) {
- return ;
- }
-
- if (!contentShown.current) {
- contentShown.current = true;
- }
-
- const rest = _.omit(props, ['forwardedRef']);
- return (
- (
+
- );
- }
-
- WithReportOrNotFound.propTypes = propTypes;
- WithReportOrNotFound.defaultProps = defaultProps;
- WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`;
-
- // eslint-disable-next-line rulesdir/no-negated-variables
- const withReportOrNotFound = React.forwardRef((props, ref) => (
-
- ));
-
- return withOnyx({
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
- },
- isLoadingReportData: {
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- },
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- })(withReportOrNotFound);
+ ));
+
+ WithReportOrNotFoundWithRef.displayName = 'WithReportOrNotFoundWithRef';
+
+ return withOnyx({
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
+ },
+ isLoadingReportData: {
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ },
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ })(WithReportOrNotFoundWithRef);
+ };
}
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index 9dbdde14c50d..394f6c5ddc5a 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -141,6 +141,7 @@ const chatReportSelector = (report) =>
lastVisibleActionCreated: report.lastVisibleActionCreated,
iouReportID: report.iouReportID,
total: report.total,
+ nonReimbursableTotal: report.nonReimbursableTotal,
hasOutstandingIOU: report.hasOutstandingIOU,
isWaitingOnBankAccount: report.isWaitingOnBankAccount,
statusNum: report.statusNum,
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index c87d4f06e1f4..340e7a0ed6a8 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -199,12 +199,12 @@ function FloatingActionButtonAndPopover(props) {
{
icon: Expensicons.MoneyCircle,
text: props.translate('iou.requestMoney'),
- onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)),
+ onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.REQUEST)),
},
{
icon: Expensicons.Send,
text: props.translate('iou.sendMoney'),
- onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.MONEY_REQUEST_TYPE.SEND)),
+ onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND)),
},
...(Permissions.canUseTasks(props.betas)
? [
@@ -257,6 +257,16 @@ FloatingActionButtonAndPopover.propTypes = propTypes;
FloatingActionButtonAndPopover.defaultProps = defaultProps;
FloatingActionButtonAndPopover.displayName = 'FloatingActionButtonAndPopover';
+const FloatingActionButtonAndPopoverWithRef = forwardRef((props, ref) => (
+
+));
+
+FloatingActionButtonAndPopoverWithRef.displayName = 'FloatingActionButtonAndPopoverWithRef';
+
export default compose(
withLocalize,
withNavigation,
@@ -277,12 +287,4 @@ export default compose(
key: ONYXKEYS.DEMO_INFO,
},
}),
-)(
- forwardRef((props, ref) => (
-
- )),
-);
+)(FloatingActionButtonAndPopoverWithRef);
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index cd14dcd25f11..b8154ac4ceda 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -1,4 +1,5 @@
-import React, {useState, useMemo, useCallback, useRef} from 'react';
+import React, {useState, useMemo, useCallback, useRef, useEffect} from 'react';
+import {Keyboard} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -6,7 +7,6 @@ import lodashGet from 'lodash/get';
import Str from 'expensify-common/lib/str';
import ONYXKEYS from '../../ONYXKEYS';
import CONST from '../../CONST';
-import OptionsSelector from '../../components/OptionsSelector';
import Navigation from '../../libs/Navigation/Navigation';
import ScreenWrapper from '../../components/ScreenWrapper';
import HeaderWithBackButton from '../../components/HeaderWithBackButton';
@@ -14,12 +14,11 @@ import compose from '../../libs/compose';
import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import {withNetwork} from '../../components/OnyxProvider';
import * as CurrencyUtils from '../../libs/CurrencyUtils';
+import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
+import * as ReportUtils from '../../libs/ReportUtils';
import ROUTES from '../../ROUTES';
-import themeColors from '../../styles/themes/default';
-import * as Expensicons from '../../components/Icon/Expensicons';
import {iouPropTypes, iouDefaultProps} from './propTypes';
-
-const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success};
+import SelectionList from '../../components/SelectionList';
/**
* IOU Currency selection for selecting currency
@@ -72,12 +71,36 @@ function IOUCurrencySelection(props) {
const [searchValue, setSearchValue] = useState('');
const optionsSelectorRef = useRef();
const selectedCurrencyCode = (lodashGet(props.route, 'params.currency', props.iou.currency) || CONST.CURRENCY.USD).toUpperCase();
- const iouType = lodashGet(props.route, 'params.iouType', CONST.IOU.MONEY_REQUEST_TYPE.REQUEST);
+ const iouType = lodashGet(props.route, 'params.iouType', CONST.IOU.TYPE.REQUEST);
const reportID = lodashGet(props.route, 'params.reportID', '');
+ const threadReportID = lodashGet(props.route, 'params.threadReportID', '');
+
+ // Decides whether to allow or disallow editing a money request
+ useEffect(() => {
+ // Do not dismiss the modal, when it is not the edit flow.
+ if (!threadReportID) {
+ return;
+ }
+
+ const report = ReportUtils.getReport(threadReportID);
+ const parentReportAction = ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID);
+
+ // Do not dismiss the modal, when a current user can edit this currency of this money request.
+ if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, report.parentReportID, CONST.EDIT_REQUEST_FIELD.CURRENCY)) {
+ return;
+ }
+
+ // Dismiss the modal when a current user cannot edit a money request.
+ Navigation.isNavigationReady().then(() => {
+ Navigation.dismissModal();
+ });
+ }, [threadReportID]);
const confirmCurrencySelection = useCallback(
(option) => {
const backTo = lodashGet(props.route, 'params.backTo', '');
+ Keyboard.dismiss();
+
// When we refresh the web, the money request route gets cleared from the navigation stack.
// Navigating to "backTo" will result in forward navigation instead, causing disruption to the currency selection.
// To prevent any negative experience, we have made the decision to simply close the currency selection page.
@@ -99,8 +122,7 @@ function IOUCurrencySelection(props) {
text: `${currencyCode} - ${CurrencyUtils.getLocalizedCurrencySymbol(currencyCode)}`,
currencyCode,
keyForList: currencyCode,
- customIcon: isSelectedCurrency ? greenCheckmark : undefined,
- boldStyle: isSelectedCurrency,
+ isSelected: isSelectedCurrency,
};
});
@@ -117,9 +139,7 @@ function IOUCurrencySelection(props) {
? []
: [
{
- title: translate('iOUCurrencySelection.allCurrencies'),
data: filteredCurrencies,
- shouldShow: true,
indexOffset: 0,
},
],
@@ -133,27 +153,20 @@ function IOUCurrencySelection(props) {
onEntryTransitionEnd={() => optionsSelectorRef.current && optionsSelectorRef.current.focus()}
testID={IOUCurrencySelection.displayName}
>
- {({safeAreaPaddingBottomStyle}) => (
- <>
- Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID))}
- />
-
- >
- )}
+ Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID))}
+ />
+
);
}
diff --git a/src/pages/iou/MoneyRequestCategoryPage.js b/src/pages/iou/MoneyRequestCategoryPage.js
index 5055c9f90f8a..7431a5deed77 100644
--- a/src/pages/iou/MoneyRequestCategoryPage.js
+++ b/src/pages/iou/MoneyRequestCategoryPage.js
@@ -12,6 +12,8 @@ import CategoryPicker from '../../components/CategoryPicker';
import ONYXKEYS from '../../ONYXKEYS';
import reportPropTypes from '../reportPropTypes';
import * as IOU from '../../libs/actions/IOU';
+import styles from '../../styles/styles';
+import Text from '../../components/Text';
import {iouPropTypes, iouDefaultProps} from './propTypes';
const propTypes = {
@@ -70,7 +72,7 @@ function MoneyRequestCategoryPage({route, report, iou}) {
title={translate('common.category')}
onBackButtonPress={navigateBack}
/>
-
+ {translate('iou.categorySelection')} {
@@ -89,7 +98,7 @@ function MoneyRequestSelectorPage(props) {
testID={MoneyRequestSelectorPage.displayName}
>
{({safeAreaPaddingBottomStyle}) => (
-
+
- {iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST || iouType === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT ? (
+ {iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SPLIT ? (
`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
- },
- selectedTab: {
- key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`,
- },
-})(MoneyRequestSelectorPage);
+export default compose(
+ withReportOrNotFound(false),
+ withOnyx({
+ selectedTab: {
+ key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`,
+ },
+ }),
+)(MoneyRequestSelectorPage);
diff --git a/src/pages/iou/NewDistanceRequestPage.js b/src/pages/iou/NewDistanceRequestPage.js
index c6ac7d72d5f8..726350bfca07 100644
--- a/src/pages/iou/NewDistanceRequestPage.js
+++ b/src/pages/iou/NewDistanceRequestPage.js
@@ -22,7 +22,7 @@ const propTypes = {
/** Parameters the route gets */
params: PropTypes.shape({
/** Type of IOU */
- iouType: PropTypes.oneOf(_.values(CONST.IOU.MONEY_REQUEST_TYPE)),
+ iouType: PropTypes.oneOf(_.values(CONST.IOU.TYPE)),
/** Id of the report on which the distance request is being created */
reportID: PropTypes.string,
}),
diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js
index e9cb81003979..2b4ef44dfd8d 100644
--- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js
+++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.js
@@ -72,4 +72,14 @@ NavigationAwareCamera.propTypes = propTypes;
NavigationAwareCamera.displayName = 'NavigationAwareCamera';
NavigationAwareCamera.defaultProps = defaultProps;
-export default NavigationAwareCamera;
+const NavigationAwareCameraWithRef = React.forwardRef((props, ref) => (
+
+));
+
+NavigationAwareCameraWithRef.displayName = 'NavigationAwareCameraWithRef';
+
+export default NavigationAwareCameraWithRef;
diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js
index 9fb420791539..0569a8236140 100644
--- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js
+++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js
@@ -3,6 +3,7 @@ import {Camera} from 'react-native-vision-camera';
import {useTabAnimation} from '@react-navigation/material-top-tabs';
import {useNavigation} from '@react-navigation/native';
import PropTypes from 'prop-types';
+import CONST from '../../../CONST';
const propTypes = {
/* The index of the tab that contains this camera */
@@ -10,13 +11,16 @@ const propTypes = {
/* Whether we're in a tab navigator */
isInTabNavigator: PropTypes.bool.isRequired,
+
+ /** Name of the selected receipt tab */
+ selectedTab: PropTypes.string.isRequired,
};
// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused.
-const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, ...props}, ref) => {
+const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, selectedTab, ...props}, ref) => {
// Get navigation to get initial isFocused value (only needed once during init!)
const navigation = useNavigation();
- const [isCameraActive, setIsCameraActive] = useState(navigation.isFocused());
+ const [isCameraActive, setIsCameraActive] = useState(() => navigation.isFocused());
// Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed.
// Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness.
@@ -31,6 +35,9 @@ const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigato
}
const listenerId = tabPositionAnimation.addListener(({value}) => {
+ if (selectedTab !== CONST.TAB.SCAN) {
+ return;
+ }
// Activate camera as soon the index is animating towards the `cameraTabIndex`
setIsCameraActive(value > cameraTabIndex - 1 && value < cameraTabIndex + 1);
});
@@ -38,7 +45,7 @@ const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigato
return () => {
tabPositionAnimation.removeListener(listenerId);
};
- }, [cameraTabIndex, tabPositionAnimation, isInTabNavigator]);
+ }, [cameraTabIndex, tabPositionAnimation, isInTabNavigator, selectedTab]);
// Note: The useEffect can be removed once VisionCamera V3 is used.
// Its only needed for android, because there is a native cameraX android bug. With out this flow would break the camera:
@@ -73,4 +80,14 @@ const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigato
NavigationAwareCamera.propTypes = propTypes;
NavigationAwareCamera.displayName = 'NavigationAwareCamera';
-export default NavigationAwareCamera;
+const NavigationAwareCameraWithRef = React.forwardRef((props, ref) => (
+
+));
+
+NavigationAwareCameraWithRef.displayName = 'NavigationAwareCameraWithRef';
+
+export default NavigationAwareCameraWithRef;
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
index fba792029914..ca9fe90575e7 100644
--- a/src/pages/iou/ReceiptSelector/index.js
+++ b/src/pages/iou/ReceiptSelector/index.js
@@ -165,7 +165,6 @@ function ReceiptSelector({route, transactionID, iou, report}) {
const panResponder = useRef(
PanResponder.create({
- onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
}),
).current;
@@ -181,7 +180,7 @@ function ReceiptSelector({route, transactionID, iou, report}) {
/>
)}
{cameraPermissionState === 'denied' && (
-
+
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 6503d488e805..649b6ea521f3 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -53,6 +53,9 @@ const propTypes = {
/** Whether or not the receipt selector is in a tab navigator for tab animations */
isInTabNavigator: PropTypes.bool,
+
+ /** Name of the selected receipt tab */
+ selectedTab: PropTypes.string,
};
const defaultProps = {
@@ -60,9 +63,10 @@ const defaultProps = {
iou: iouDefaultProps,
transactionID: '',
isInTabNavigator: true,
+ selectedTab: '',
};
-function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) {
+function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, selectedTab}) {
const devices = useCameraDevices('wide-angle-camera');
const device = devices.back;
@@ -159,7 +163,7 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
return (
{cameraPermissionStatus !== RESULTS.GRANTED && (
-
+
)}
diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js
index 86844ce8c66e..1c48a4f1a44a 100644
--- a/src/pages/iou/SplitBillDetailsPage.js
+++ b/src/pages/iou/SplitBillDetailsPage.js
@@ -14,7 +14,6 @@ import reportActionPropTypes from '../home/report/reportActionPropTypes';
import reportPropTypes from '../reportPropTypes';
import transactionPropTypes from '../../components/transactionPropTypes';
import withReportAndReportActionOrNotFound from '../home/report/withReportAndReportActionOrNotFound';
-import useLocalize from '../../hooks/useLocalize';
import * as TransactionUtils from '../../libs/TransactionUtils';
import * as ReportUtils from '../../libs/ReportUtils';
import * as IOU from '../../libs/actions/IOU';
@@ -23,6 +22,7 @@ import MoneyRequestConfirmationList from '../../components/MoneyRequestConfirmat
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '../../components/HeaderWithBackButton';
import MoneyRequestHeaderStatusBar from '../../components/MoneyRequestHeaderStatusBar';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
/* Onyx Props */
@@ -70,8 +70,8 @@ const defaultProps = {
};
function SplitBillDetailsPage(props) {
- const {translate} = useLocalize();
const {reportID} = props.report;
+ const {translate} = useLocalize();
const reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`];
const participantAccountIDs = reportAction.originalMessage.participantAccountIDs;
@@ -133,7 +133,7 @@ function SplitBillDetailsPage(props) {
iouCreated={splitCreated}
iouMerchant={splitMerchant}
iouCategory={splitCategory}
- iouType={CONST.IOU.MONEY_REQUEST_TYPE.SPLIT}
+ iouType={CONST.IOU.TYPE.SPLIT}
isReadOnly={!isEditingSplitBill}
shouldShowSmartScanFields
receiptPath={props.transaction.receipt && props.transaction.receipt.source}
@@ -145,6 +145,7 @@ function SplitBillDetailsPage(props) {
reportActionID={reportAction.reportActionID}
transactionID={props.transaction.transactionID}
onConfirm={onConfirm}
+ isPolicyExpenseChat={ReportUtils.isPolicyExpenseChat(props.report)}
/>
)}
diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js
index 13bf1883804c..a123976b326e 100644
--- a/src/pages/iou/WaypointEditor.js
+++ b/src/pages/iou/WaypointEditor.js
@@ -1,7 +1,7 @@
import React, {useMemo, useRef, useState} from 'react';
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import {Keyboard, View} from 'react-native';
+import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import {useNavigation} from '@react-navigation/native';
@@ -23,8 +23,6 @@ import * as Transaction from '../../libs/actions/Transaction';
import * as ValidationUtils from '../../libs/ValidationUtils';
import ROUTES from '../../ROUTES';
import transactionPropTypes from '../../components/transactionPropTypes';
-import UserCurrentLocationButton from '../../components/UserCurrentLocationButton';
-import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator';
import * as ErrorUtils from '../../libs/ErrorUtils';
const propTypes = {
@@ -78,7 +76,6 @@ const defaultProps = {
function WaypointEditor({route: {params: {iouType = '', transactionID = '', waypointIndex = '', threadReportID = 0}} = {}, transaction, recentWaypoints}) {
const {windowWidth} = useWindowDimensions();
const [isDeleteStopModalOpen, setIsDeleteStopModalOpen] = useState(false);
- const [isFetchingLocation, setIsFetchingLocation] = useState(false);
const navigation = useNavigation();
const isFocused = navigation.isFocused();
const {translate} = useLocalize();
@@ -176,26 +173,6 @@ function WaypointEditor({route: {params: {iouType = '', transactionID = '', wayp
Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType));
};
- /**
- * Sets user current location as a waypoint
- * @param {Object} geolocationData
- * @param {Object} geolocationData.coords
- * @param {Number} geolocationData.coords.latitude
- * @param {Number} geolocationData.coords.longitude
- * @param {Number} geolocationData.timestamp
- */
- const selectWaypointFromCurrentLocation = (geolocationData) => {
- setIsFetchingLocation(false);
-
- const waypoint = {
- lat: geolocationData.coords.latitude,
- lng: geolocationData.coords.longitude,
- address: CONST.YOUR_LOCATION_TEXT,
- };
-
- selectWaypoint(waypoint);
- };
-
return (
(textInput.current = e)}
hint={!isOffline ? 'distance.errors.selectSuggestedAddress' : ''}
@@ -265,17 +243,6 @@ function WaypointEditor({route: {params: {iouType = '', transactionID = '', wayp
resultTypes=""
/>
- {
- Keyboard.dismiss();
-
- setIsFetchingLocation(true);
- }}
- onLocationError={() => setIsFetchingLocation(false)}
- onLocationFetched={selectWaypointFromCurrentLocation}
- />
- {isFetchingLocation && }
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index d84c4d9e3cd0..55e76727e500 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -299,10 +299,14 @@ MoneyRequestAmountForm.propTypes = propTypes;
MoneyRequestAmountForm.defaultProps = defaultProps;
MoneyRequestAmountForm.displayName = 'MoneyRequestAmountForm';
-export default React.forwardRef((props, ref) => (
+const MoneyRequestAmountFormWithRef = React.forwardRef((props, ref) => (
));
+
+MoneyRequestAmountFormWithRef.displayName = 'MoneyRequestAmountFormWithRef';
+
+export default MoneyRequestAmountFormWithRef;
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 46367e275af4..96212d6f5a01 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -67,6 +67,7 @@ function MoneyRequestConfirmPage(props) {
const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType.current, props.selectedTab);
const isScanRequest = MoneyRequestUtils.isScanRequest(props.selectedTab);
const reportID = useRef(lodashGet(props.route, 'params.reportID', ''));
+ const [receiptFile, setReceiptFile] = useState();
const participants = useMemo(
() =>
_.map(props.iou.participants, (participant) => {
@@ -76,7 +77,7 @@ function MoneyRequestConfirmPage(props) {
[props.iou.participants, props.personalDetails],
);
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(props.report)), [props.report]);
- const isManualRequestDM = props.selectedTab === CONST.TAB.MANUAL && iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST;
+ const isManualRequestDM = props.selectedTab === CONST.TAB.MANUAL && iouType.current === CONST.IOU.TYPE.REQUEST;
useEffect(() => {
IOU.resetMoneyRequestCategory();
@@ -94,6 +95,21 @@ function MoneyRequestConfirmPage(props) {
}
}, [isOffline, participants, props.iou.billable, props.policy]);
+ useEffect(() => {
+ if (!props.iou.receiptPath || !props.iou.receiptFilename) {
+ return;
+ }
+ FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => {
+ if (!file) {
+ Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType.current, reportID.current));
+ } else {
+ const receipt = file;
+ receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY;
+ setReceiptFile(receipt);
+ }
+ });
+ }, [props.iou.receiptPath, props.iou.receiptFilename, isManualRequestDM]);
+
useEffect(() => {
// ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
if (!isDistanceRequest && prevMoneyRequestId.current !== props.iou.id) {
@@ -106,19 +122,19 @@ function MoneyRequestConfirmPage(props) {
// Reset the money request Onyx if the ID in Onyx does not match the ID from params
const moneyRequestId = `${iouType.current}${reportID.current}`;
- const shouldReset = !isDistanceRequest && props.iou.id !== moneyRequestId;
+ const shouldReset = !isDistanceRequest && props.iou.id !== moneyRequestId && !_.isEmpty(reportID.current);
if (shouldReset) {
IOU.resetMoneyRequestInfo(moneyRequestId);
}
- if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset) {
+ if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset || ReportUtils.isArchivedRoom(props.report)) {
Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType.current, reportID.current), true);
}
return () => {
prevMoneyRequestId.current = props.iou.id;
};
- }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest]);
+ }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest, props.report]);
const navigateBack = () => {
let fallback;
@@ -195,7 +211,7 @@ function MoneyRequestConfirmPage(props) {
const trimmedComment = props.iou.comment.trim();
// If we have a receipt let's start the split bill by creating only the action, the transaction, and the group DM if needed
- if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT && props.iou.receiptPath) {
+ if (iouType.current === CONST.IOU.TYPE.SPLIT && props.iou.receiptPath) {
const existingSplitChatReportID = CONST.REGEX.NUMBER.test(reportID.current) ? reportID.current : '';
FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((receipt) => {
IOU.startSplitBill(
@@ -212,7 +228,7 @@ function MoneyRequestConfirmPage(props) {
// IOUs created from a group report will have a reportID param in the route.
// Since the user is already viewing the report, we don't need to navigate them to the report
- if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT && CONST.REGEX.NUMBER.test(reportID.current)) {
+ if (iouType.current === CONST.IOU.TYPE.SPLIT && CONST.REGEX.NUMBER.test(reportID.current)) {
IOU.splitBill(
selectedParticipants,
props.currentUserPersonalDetails.login,
@@ -227,7 +243,7 @@ function MoneyRequestConfirmPage(props) {
}
// If the request is created from the global create menu, we also navigate the user to the group report
- if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT) {
+ if (iouType.current === CONST.IOU.TYPE.SPLIT) {
IOU.splitBillAndOpenReport(
selectedParticipants,
props.currentUserPersonalDetails.login,
@@ -240,12 +256,8 @@ function MoneyRequestConfirmPage(props) {
return;
}
- if (props.iou.receiptPath && props.iou.receiptFilename) {
- FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename).then((file) => {
- const receipt = file;
- receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY;
- requestMoney(selectedParticipants, trimmedComment, receipt);
- });
+ if (receiptFile) {
+ requestMoney(selectedParticipants, trimmedComment, receiptFile);
return;
}
@@ -268,7 +280,7 @@ function MoneyRequestConfirmPage(props) {
isDistanceRequest,
requestMoney,
createDistanceRequest,
- isManualRequestDM,
+ receiptFile,
],
);
@@ -300,11 +312,11 @@ function MoneyRequestConfirmPage(props) {
return props.translate('common.distance');
}
- if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT) {
+ if (iouType.current === CONST.IOU.TYPE.SPLIT) {
return props.translate('iou.split');
}
- if (iouType.current === CONST.IOU.MONEY_REQUEST_TYPE.SEND) {
+ if (iouType.current === CONST.IOU.TYPE.SEND) {
return props.translate('common.send');
}
@@ -333,7 +345,7 @@ function MoneyRequestConfirmPage(props) {
/>
{
@@ -80,7 +80,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
useEffect(() => {
// ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
- if (prevMoneyRequestId.current !== iou.id) {
+ if (prevMoneyRequestId.current !== iou.id && !_.isEmpty(reportID.current)) {
// The ID is cleared on completing a request. In that case, we will do nothing
if (iou.id && !isDistanceRequest && !isSplitRequest) {
navigateBack(true);
@@ -90,7 +90,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
// Reset the money request Onyx if the ID in Onyx does not match the ID from params
const moneyRequestId = `${iouType.current}${reportID.current}`;
- const shouldReset = iou.id !== moneyRequestId;
+ const shouldReset = !_.isEmpty(reportID.current) && iou.id !== moneyRequestId;
if (shouldReset) {
IOU.resetMoneyRequestInfo(moneyRequestId);
}
@@ -121,7 +121,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) {
participants={iou.participants}
onAddParticipants={IOU.setMoneyRequestParticipants}
navigateToRequest={() => navigateToConfirmationStep(iouType.current)}
- navigateToSplit={() => navigateToConfirmationStep(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)}
+ navigateToSplit={() => navigateToConfirmationStep(CONST.IOU.TYPE.SPLIT)}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
iouType={iouType.current}
isDistanceRequest={isDistanceRequest}
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index 547d2b7c363a..7e88ebe7db48 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -104,16 +104,17 @@ function MoneyRequestParticipantsSelector({
const newSections = [];
let indexOffset = 0;
- newSections.push({
- title: undefined,
- data: _.map(participants, (participant) => {
- const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false);
- return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails);
- }),
- shouldShow: true,
+ const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(
+ searchTerm,
+ participants,
+ newChatOptions.recentReports,
+ newChatOptions.personalDetails,
+ personalDetails,
+ true,
indexOffset,
- });
- indexOffset += participants.length;
+ );
+ newSections.push(formatResults.section);
+ indexOffset = formatResults.newIndexOffset;
if (maxParticipantsReached) {
return newSections;
@@ -148,7 +149,7 @@ function MoneyRequestParticipantsSelector({
}
return newSections;
- }, [maxParticipantsReached, newChatOptions, participants, personalDetails, translate]);
+ }, [maxParticipantsReached, newChatOptions, participants, personalDetails, translate, searchTerm]);
/**
* Adds a single participant to the request
@@ -223,10 +224,18 @@ function MoneyRequestParticipantsSelector({
// If we are using this component in the "Request money" flow then we pass the includeOwnedWorkspaceChats argument so that the current user
// sees the option to request money from their admin on their own Workspace Chat.
- iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST,
+ iouType === CONST.IOU.TYPE.REQUEST,
// We don't want to include any P2P options like personal details or reports that are not workspace chats for certain features.
!isDistanceRequest,
+ false,
+ {},
+ [],
+ false,
+ {},
+ [],
+ true,
+ true,
);
setNewChatOptions({
recentReports: chatOptions.recentReports,
@@ -240,7 +249,7 @@ function MoneyRequestParticipantsSelector({
// the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants
const hasPolicyExpenseChatParticipant = _.some(participants, (participant) => participant.isPolicyExpenseChat);
const shouldShowConfirmButton = !(participants.length > 1 && hasPolicyExpenseChatParticipant);
- const isAllowedToSplit = !isDistanceRequest && iouType !== CONST.IOU.MONEY_REQUEST_TYPE.SEND;
+ const isAllowedToSplit = !isDistanceRequest && iouType !== CONST.IOU.TYPE.SEND;
return (
0 ? safeAreaPaddingBottomStyle : {}]}>
@@ -274,6 +283,16 @@ MoneyRequestParticipantsSelector.propTypes = propTypes;
MoneyRequestParticipantsSelector.defaultProps = defaultProps;
MoneyRequestParticipantsSelector.displayName = 'MoneyRequestParticipantsSelector';
+const MoneyRequestParticipantsSelectorWithRef = React.forwardRef((props, ref) => (
+
+));
+
+MoneyRequestParticipantsSelectorWithRef.displayName = 'MoneyRequestParticipantsSelectorWithRef';
+
export default compose(
withLocalize,
withOnyx({
@@ -287,12 +306,4 @@ export default compose(
key: ONYXKEYS.BETAS,
},
}),
-)(
- React.forwardRef((props, ref) => (
-
- )),
-);
+)(MoneyRequestParticipantsSelectorWithRef);
diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js
index ae319f5a73bb..15a2c74d8a95 100644
--- a/src/pages/iou/steps/NewRequestAmountPage.js
+++ b/src/pages/iou/steps/NewRequestAmountPage.js
@@ -8,7 +8,6 @@ import _ from 'underscore';
import ONYXKEYS from '../../../ONYXKEYS';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import * as ReportUtils from '../../../libs/ReportUtils';
import * as CurrencyUtils from '../../../libs/CurrencyUtils';
import reportPropTypes from '../../reportPropTypes';
import * as IOU from '../../../libs/actions/IOU';
@@ -83,14 +82,6 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) {
}, []),
);
- // Check and dismiss modal
- useEffect(() => {
- if (!ReportUtils.shouldDisableWriteActions(report)) {
- return;
- }
- Navigation.dismissModal(reportID);
- }, [report, reportID]);
-
// Because we use Onyx to store IOU info, when we try to make two different money requests from different tabs,
// it can result in an IOU sent with improper values. In such cases we want to reset the flow and redirect the user to the first step of the IOU.
useEffect(() => {
diff --git a/src/pages/nextStepPropTypes.js b/src/pages/nextStepPropTypes.js
new file mode 100644
index 000000000000..b7a9e5b13033
--- /dev/null
+++ b/src/pages/nextStepPropTypes.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+
+const messagePropType = PropTypes.shape({
+ text: PropTypes.string,
+ type: PropTypes.string,
+ action: PropTypes.string,
+});
+
+export default PropTypes.shape({
+ /** The message parts of the next step */
+ message: PropTypes.arrayOf(messagePropType),
+
+ /** The title for the next step */
+ title: PropTypes.string,
+
+ /** Whether the user should take some sort of action in order to unblock the report */
+ requiresUserAction: PropTypes.bool,
+
+ /** The type of next step */
+ type: PropTypes.oneOf(['neutral', 'alert', null]),
+
+ /** If the "Undo submit" button should be visible */
+ showUndoSubmit: PropTypes.bool,
+
+ /** Deprecated - If the next step should be displayed on mobile, related to OldApp */
+ showForMobile: PropTypes.bool,
+
+ /** If the next step should be displayed at the expense level */
+ showForExpense: PropTypes.bool,
+
+ /** An optional alternate message to display on expenses instead of what is provided in the "message" field */
+ expenseMessage: PropTypes.arrayOf(messagePropType),
+
+ /** The next person in the approval chain of the report */
+ nextReceiver: PropTypes.string,
+
+ /** An array of buttons to be displayed next to the next step */
+ buttons: PropTypes.arrayOf(
+ PropTypes.shape({
+ text: PropTypes.string,
+ tooltip: PropTypes.string,
+ disabled: PropTypes.bool,
+ hidden: PropTypes.bool,
+ // eslint-disable-next-line react/forbid-prop-types
+ data: PropTypes.array,
+ }),
+ ),
+});
diff --git a/src/pages/reportMetadataPropTypes.js b/src/pages/reportMetadataPropTypes.js
index a75d71aef7b3..65ed01952977 100644
--- a/src/pages/reportMetadataPropTypes.js
+++ b/src/pages/reportMetadataPropTypes.js
@@ -1,9 +1,12 @@
import PropTypes from 'prop-types';
export default PropTypes.shape({
- /** Are we loading more report actions? */
- isLoadingMoreReportActions: PropTypes.bool,
+ /** Are we loading newer report actions? */
+ isLoadingNewerReportActions: PropTypes.bool,
+
+ /** Are we loading older report actions? */
+ isLoadingOlderReportActions: PropTypes.bool,
/** Flag to check if the report actions data are loading */
- isLoadingReportActions: PropTypes.bool,
+ isLoadingInitialReportActions: PropTypes.bool,
});
diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
index 7e8baba5a9ce..1c9abcf535f6 100644
--- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
+++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js
@@ -335,6 +335,7 @@ class ContactMethodDetailsPage extends Component {
ContactMethodDetailsPage.propTypes = propTypes;
ContactMethodDetailsPage.defaultProps = defaultProps;
+ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage';
export default compose(
withLocalize,
diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js
index cce43117d4f2..480c425a9094 100644
--- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js
+++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js
@@ -122,7 +122,7 @@ function NewContactMethodPage(props) {
ref={(el) => (loginInputRef.current = el)}
inputID="phoneOrEmail"
autoCapitalize="none"
- returnKeyType="done"
+ returnKeyType="go"
maxLength={CONST.LOGIN_CHARACTER_LIMIT}
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
index b029a2085877..3a49b6901035 100644
--- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
@@ -154,11 +154,11 @@ function AddressPage({privatePersonalDetails, route}) {
}, []);
useEffect(() => {
- if (!countryFromUrl || countryFromUrl === currentCountry) {
+ if (!countryFromUrl) {
return;
}
handleAddressChange(countryFromUrl, 'country');
- }, [countryFromUrl, handleAddressChange, currentCountry]);
+ }, [countryFromUrl, handleAddressChange]);
return (
Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)}
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
index 725c3f4ffbb9..046af8204b97 100644
--- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
+++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js
@@ -19,6 +19,7 @@ import Navigation from '../../../../libs/Navigation/Navigation';
import ROUTES from '../../../../ROUTES';
import usePrivatePersonalDetails from '../../../../hooks/usePrivatePersonalDetails';
import FullscreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator';
+import * as ErrorUtils from '../../../../libs/ErrorUtils';
const propTypes = {
/* Onyx Props */
@@ -53,16 +54,22 @@ function LegalNamePage(props) {
const errors = {};
if (!ValidationUtils.isValidLegalName(values.legalFirstName)) {
- errors.legalFirstName = 'privatePersonalDetails.error.hasInvalidCharacter';
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', 'privatePersonalDetails.error.hasInvalidCharacter');
} else if (_.isEmpty(values.legalFirstName)) {
errors.legalFirstName = 'common.error.fieldRequired';
}
+ if (values.legalFirstName.length > CONST.LEGAL_NAME.MAX_LENGTH) {
+ ErrorUtils.addErrorMessage(errors, 'legalFirstName', ['common.error.characterLimitExceedCounter', {length: values.legalFirstName.length, limit: CONST.LEGAL_NAME.MAX_LENGTH}]);
+ }
if (!ValidationUtils.isValidLegalName(values.legalLastName)) {
- errors.legalLastName = 'privatePersonalDetails.error.hasInvalidCharacter';
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', 'privatePersonalDetails.error.hasInvalidCharacter');
} else if (_.isEmpty(values.legalLastName)) {
errors.legalLastName = 'common.error.fieldRequired';
}
+ if (values.legalLastName.length > CONST.LEGAL_NAME.MAX_LENGTH) {
+ ErrorUtils.addErrorMessage(errors, 'legalLastName', ['common.error.characterLimitExceedCounter', {length: values.legalLastName.length, limit: CONST.LEGAL_NAME.MAX_LENGTH}]);
+ }
return errors;
}, []);
@@ -96,7 +103,7 @@ function LegalNamePage(props) {
accessibilityLabel={props.translate('privatePersonalDetails.legalFirstName')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={legalFirstName}
- maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
+ maxLength={CONST.LEGAL_NAME.MAX_LENGTH + CONST.SEARCH_MAX_LENGTH}
spellCheck={false}
/>
@@ -108,7 +115,7 @@ function LegalNamePage(props) {
accessibilityLabel={props.translate('privatePersonalDetails.legalLastName')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={legalLastName}
- maxLength={CONST.DISPLAY_NAME.MAX_LENGTH}
+ maxLength={CONST.LEGAL_NAME.MAX_LENGTH + CONST.SEARCH_MAX_LENGTH}
spellCheck={false}
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
index f639a7eebc15..3b695de3fcb7 100644
--- a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js
@@ -91,7 +91,7 @@ function PersonalDetailsInitialPage(props) {
/>
Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)}
/>
diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js
index 64e6bdfb4b5b..43e346150ca8 100644
--- a/src/pages/settings/Report/NotificationPreferencePage.js
+++ b/src/pages/settings/Report/NotificationPreferencePage.js
@@ -56,4 +56,4 @@ function NotificationPreferencePage(props) {
NotificationPreferencePage.displayName = 'NotificationPreferencePage';
NotificationPreferencePage.propTypes = propTypes;
-export default compose(withLocalize, withReportOrNotFound)(NotificationPreferencePage);
+export default compose(withLocalize, withReportOrNotFound())(NotificationPreferencePage);
diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js
index 5612096207bb..fb88cbd59f25 100644
--- a/src/pages/settings/Report/ReportSettingsPage.js
+++ b/src/pages/settings/Report/ReportSettingsPage.js
@@ -23,6 +23,7 @@ import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescr
import ROUTES from '../../../ROUTES';
import * as Expensicons from '../../../components/Icon/Expensicons';
import MenuItem from '../../../components/MenuItem';
+import DisplayNames from '../../../components/DisplayNames';
const propTypes = {
/** Route params */
@@ -112,12 +113,13 @@ function ReportSettingsPage(props) {
>
{roomNameLabel}
-
- {reportName}
-
+ textStyles={[styles.optionAlternateText, styles.pre]}
+ shouldUseFullTitle
+ />
) : (
{translate('workspace.common.workspace')}
-
- {linkedWorkspace.name}
-
+ textStyles={[styles.optionAlternateText, styles.pre]}
+ shouldUseFullTitle
+ />
)}
{Boolean(report.visibility) && (
@@ -206,7 +209,7 @@ ReportSettingsPage.propTypes = propTypes;
ReportSettingsPage.defaultProps = defaultProps;
ReportSettingsPage.displayName = 'ReportSettingsPage';
export default compose(
- withReportOrNotFound,
+ withReportOrNotFound(),
withOnyx({
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
diff --git a/src/pages/settings/Report/RoomNamePage.js b/src/pages/settings/Report/RoomNamePage.js
index 985d83e7fd95..4ce997533378 100644
--- a/src/pages/settings/Report/RoomNamePage.js
+++ b/src/pages/settings/Report/RoomNamePage.js
@@ -118,7 +118,7 @@ RoomNamePage.displayName = 'RoomNamePage';
export default compose(
withLocalize,
- withReportOrNotFound,
+ withReportOrNotFound(),
withOnyx({
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
diff --git a/src/pages/settings/Report/WriteCapabilityPage.js b/src/pages/settings/Report/WriteCapabilityPage.js
index 1558d98a830a..174cc57d8d18 100644
--- a/src/pages/settings/Report/WriteCapabilityPage.js
+++ b/src/pages/settings/Report/WriteCapabilityPage.js
@@ -67,7 +67,7 @@ WriteCapabilityPage.defaultProps = defaultProps;
export default compose(
withLocalize,
- withReportOrNotFound,
+ withReportOrNotFound(),
withOnyx({
policy: {
key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js
index 3151380dc1f5..763f6c77d774 100644
--- a/src/pages/settings/Security/CloseAccountPage.js
+++ b/src/pages/settings/Security/CloseAccountPage.js
@@ -16,10 +16,11 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
import * as CloseAccount from '../../../libs/actions/CloseAccount';
import ONYXKEYS from '../../../ONYXKEYS';
-import Form from '../../../components/Form';
import CONST from '../../../CONST';
import ConfirmModal from '../../../components/ConfirmModal';
import * as ValidationUtils from '../../../libs/ValidationUtils';
+import FormProvider from '../../../components/Form/FormProvider';
+import InputWrapper from '../../../components/Form/InputWrapper';
const propTypes = {
/** Session of currently logged in user */
@@ -91,7 +92,7 @@ function CloseAccountPage(props) {
title={props.translate('closeAccountPage.closeAccount')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_SECURITY)}
/>
-
+
);
}
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
index 7340a1f64511..ebad8d8bc5d0 100644
--- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
@@ -32,11 +32,12 @@ function CodesStep({account = defaultAccount}) {
const {setStep} = useTwoFactorAuthContext();
useEffect(() => {
- if (account.recoveryCodes) {
+ if (account.requiresTwoFactorAuth || account.recoveryCodes) {
return;
}
Session.toggleTwoFactorAuth(true);
- }, [account.recoveryCodes]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- We want to run this when component mounts
+ }, []);
return (
(
+
+));
+
+BaseTwoFactorAuthFormWithRef.displayName = 'BaseTwoFactorAuthFormWithRef';
+
export default compose(
withLocalize,
withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
}),
-)(
- forwardRef((props, ref) => (
-
- )),
-);
+)(BaseTwoFactorAuthFormWithRef);
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js
index a505ca51f1e3..f1753e74c281 100644
--- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js
@@ -22,6 +22,7 @@ const TwoFactorAuthPropTypes = {
const defaultAccount = {
requiresTwoFactorAuth: false,
twoFactorAuthStep: '',
+ recoveryCodes: '',
};
export {TwoFactorAuthPropTypes, defaultAccount};
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
index 58852c1dba02..df2a4d8e0950 100644
--- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import CodesStep from './Steps/CodesStep';
import DisabledStep from './Steps/DisabledStep';
@@ -40,6 +40,7 @@ function TwoFactorAuthSteps({account = defaultAccount}) {
},
[setAnimationDirection],
);
+ const contextValue = useMemo(() => ({setStep: handleSetStep}), [handleSetStep]);
const renderStep = () => {
switch (currentStep) {
@@ -58,15 +59,7 @@ function TwoFactorAuthSteps({account = defaultAccount}) {
}
};
- return (
-
- {renderStep()}
-
- );
+ return {renderStep()};
}
TwoFactorAuthSteps.propTypes = TwoFactorAuthPropTypes;
diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js
index e7198c009a44..0175f2ceac1f 100644
--- a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js
+++ b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js
@@ -78,7 +78,7 @@ function ActivatePhysicalCardPage({
return;
}
- Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain));
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
}, [cardID, cardList, domain, physicalCard.isLoading]);
useEffect(
@@ -131,7 +131,7 @@ function ActivatePhysicalCardPage({
return (
Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain))}
+ onBackButtonPress={() => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))}
backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.PREFERENCES]}
illustration={LottieAnimations.Magician}
scrollViewContainerStyles={[styles.mnh100]}
diff --git a/src/pages/settings/Wallet/DangerCardSection.js b/src/pages/settings/Wallet/DangerCardSection.js
new file mode 100644
index 000000000000..bd67ba03c43b
--- /dev/null
+++ b/src/pages/settings/Wallet/DangerCardSection.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {View} from 'react-native';
+import styles from '../../../styles/styles';
+import * as Illustrations from '../../../components/Icon/Illustrations';
+import Text from '../../../components/Text';
+
+const propTypes = {
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+};
+
+function DangerCardSection({title, description}) {
+ return (
+
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+ );
+}
+
+DangerCardSection.propTypes = propTypes;
+DangerCardSection.displayName = 'DangerCardSection';
+
+export default DangerCardSection;
diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js
index cfbd26133ced..e198d449d57d 100644
--- a/src/pages/settings/Wallet/ExpensifyCardPage.js
+++ b/src/pages/settings/Wallet/ExpensifyCardPage.js
@@ -18,8 +18,13 @@ import * as Expensicons from '../../../components/Icon/Expensicons';
import * as CardUtils from '../../../libs/CardUtils';
import Button from '../../../components/Button';
import CardDetails from './WalletPage/CardDetails';
+import MenuItem from '../../../components/MenuItem';
import CONST from '../../../CONST';
import assignedCardPropTypes from './assignedCardPropTypes';
+import theme from '../../../styles/themes/default';
+import DotIndicatorMessage from '../../../components/DotIndicatorMessage';
+import * as Link from '../../../libs/actions/Link';
+import DangerCardSection from './DangerCardSection';
const propTypes = {
/* Onyx Props */
@@ -62,6 +67,9 @@ function ExpensifyCardPage({
setShouldShowCardDetails(true);
};
+ const hasDetectedDomainFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN);
+ const hasDetectedIndividualFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL);
+
return (
Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
-
+
-
- {!_.isEmpty(virtualCard) && (
+ {hasDetectedDomainFraud ? (
+
+ ) : null}
+
+ {hasDetectedIndividualFraud && !hasDetectedDomainFraud ? (
<>
- {shouldShowCardDetails ? (
-
- ) : (
-
- }
- />
- )}
+ Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(domain))}
+ brickRoadIndicator="error"
+ onPress={() => Link.openOldDotLink('inbox')}
/>
>
- )}
- {!_.isEmpty(physicalCard) && (
-
- )}
+ ) : null}
+
+ {!hasDetectedDomainFraud ? (
+ <>
+
+ {!_.isEmpty(virtualCard) && (
+ <>
+ {shouldShowCardDetails ? (
+
+ ) : (
+
+ }
+ />
+ )}
+ Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(domain))}
+ />
+ >
+ )}
+ {!_.isEmpty(physicalCard) && (
+ <>
+
+
{physicalCard.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED && (
CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state));
+ const assignedCards = _.chain(cardList)
+ // Filter by physical, active cards associated with a domain
+ .filter((card) => !card.isVirtual && card.domainName && CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state))
+ .sortBy((card) => !CardUtils.isExpensifyCard(card.cardID))
+ .value();
+
+ const numberPhysicalExpensifyCards = _.filter(assignedCards, (card) => CardUtils.isExpensifyCard(card.cardID)).length;
+
return _.map(assignedCards, (card) => {
- const icon = getBankIcon(card.bank);
+ const isExpensifyCard = CardUtils.isExpensifyCard(card.cardID);
+ const icon = getBankIcon(card.bank, true);
+
+ // In the case a user has been assigned multiple physical Expensify Cards under one domain, display the Card with PAN
+ const expensifyCardDescription = numberPhysicalExpensifyCards > 1 ? CardUtils.getCardDescription(card.cardID) : translate('walletPage.expensifyCard');
return {
- key: card.key,
- title: translate('walletPage.expensifyCard'),
+ key: card.cardID,
+ title: isExpensifyCard ? expensifyCardDescription : card.cardName,
description: card.domainName,
- onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(card.domainName)),
+ onPress: isExpensifyCard ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(card.domainName)) : () => {},
+ shouldShowRightIcon: isExpensifyCard,
+ interactive: isExpensifyCard,
+ canDismissError: isExpensifyCard,
+ errors: card.errors,
+ brickRoadIndicator: card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL ? 'error' : null,
...icon,
};
});
@@ -274,6 +291,7 @@ function PaymentMethodList({
pendingAction={item.pendingAction}
errors={item.errors}
errorRowStyles={styles.ph6}
+ canDismissError={item.canDismissError}
>
),
- [filteredPaymentMethods, translate, shouldShowAssignedCards, shouldShowSelectedState, selectedMethodID],
+ [filteredPaymentMethods, translate, shouldShowSelectedState, selectedMethodID],
);
return (
diff --git a/src/pages/settings/Wallet/ReportCardLostPage.js b/src/pages/settings/Wallet/ReportCardLostPage.js
new file mode 100644
index 000000000000..29a588916326
--- /dev/null
+++ b/src/pages/settings/Wallet/ReportCardLostPage.js
@@ -0,0 +1,227 @@
+import React, {useState, useEffect} from 'react';
+import _ from 'underscore';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import ScreenWrapper from '../../../components/ScreenWrapper';
+import Navigation from '../../../libs/Navigation/Navigation';
+import ROUTES from '../../../ROUTES';
+import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
+import styles from '../../../styles/styles';
+import ONYXKEYS from '../../../ONYXKEYS';
+import SingleOptionSelector from '../../../components/SingleOptionSelector';
+import useLocalize from '../../../hooks/useLocalize';
+import Text from '../../../components/Text';
+import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription';
+import usePrivatePersonalDetails from '../../../hooks/usePrivatePersonalDetails';
+import assignedCardPropTypes from './assignedCardPropTypes';
+import * as CardUtils from '../../../libs/CardUtils';
+import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils';
+import NotFoundPage from '../../ErrorPage/NotFoundPage';
+import usePrevious from '../../../hooks/usePrevious';
+import * as FormActions from '../../../libs/actions/FormActions';
+import * as CardActions from '../../../libs/actions/Card';
+import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton';
+
+/** Options for reason selector */
+const OPTIONS = [
+ {
+ key: 'damaged',
+ label: 'reportCardLostOrDamaged.cardDamaged',
+ },
+ {
+ key: 'stolen',
+ label: 'reportCardLostOrDamaged.cardLostOrStolen',
+ },
+];
+
+const propTypes = {
+ /** Onyx form data */
+ formData: PropTypes.shape({
+ isLoading: PropTypes.bool,
+ }),
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ /** User's home address */
+ address: PropTypes.shape({
+ street: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ zip: PropTypes.string,
+ country: PropTypes.string,
+ }),
+ }),
+ /** User's cards list */
+ cardList: PropTypes.objectOf(assignedCardPropTypes),
+ route: PropTypes.shape({
+ /** Each parameter passed via the URL */
+ params: PropTypes.shape({
+ /** Domain string */
+ domain: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ formData: {},
+ privatePersonalDetails: {
+ address: {
+ street: '',
+ street2: '',
+ city: '',
+ state: '',
+ zip: '',
+ country: '',
+ },
+ },
+ cardList: {},
+};
+
+function ReportCardLostPage({
+ privatePersonalDetails,
+ cardList,
+ route: {
+ params: {domain},
+ },
+ formData,
+}) {
+ usePrivatePersonalDetails();
+
+ const domainCards = CardUtils.getDomainCards(cardList)[domain];
+ const physicalCard = CardUtils.findPhysicalCard(domainCards);
+
+ const {translate} = useLocalize();
+
+ const [reason, setReason] = useState();
+ const [isReasonConfirmed, setIsReasonConfirmed] = useState(false);
+ const [shouldShowAddressError, setShouldShowAddressError] = useState(false);
+ const [shouldShowReasonError, setShouldShowReasonError] = useState(false);
+
+ const prevIsLoading = usePrevious(formData.isLoading);
+
+ const formattedAddress = PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails);
+
+ useEffect(() => {
+ if (!_.isEmpty(physicalCard.errors) || !(prevIsLoading && !formData.isLoading)) {
+ return;
+ }
+
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain));
+ }, [domain, formData.isLoading, prevIsLoading, physicalCard.errors]);
+
+ useEffect(() => {
+ if (formData.isLoading && _.isEmpty(physicalCard.errors)) {
+ return;
+ }
+
+ FormActions.setErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, physicalCard.errors);
+ }, [formData.isLoading, physicalCard.errors]);
+
+ if (_.isEmpty(physicalCard)) {
+ return ;
+ }
+
+ const handleSubmitFirstStep = () => {
+ if (!reason) {
+ setShouldShowReasonError(true);
+ return;
+ }
+
+ setIsReasonConfirmed(true);
+ setShouldShowAddressError(false);
+ setShouldShowReasonError(false);
+ };
+
+ const handleSubmitSecondStep = () => {
+ if (!formattedAddress) {
+ setShouldShowAddressError(true);
+ return;
+ }
+
+ CardActions.requestReplacementExpensifyCard(physicalCard.cardID, reason);
+ };
+
+ const handleOptionSelect = (option) => {
+ setReason(option);
+ setShouldShowReasonError(false);
+ };
+
+ const handleBackButtonPress = () => {
+ if (isReasonConfirmed) {
+ setIsReasonConfirmed(false);
+ return;
+ }
+
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
+ };
+
+ return (
+
+
+
+ {isReasonConfirmed ? (
+ <>
+
+ {translate('reportCardLostOrDamaged.confirmAddressTitle')}
+ Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)}
+ numberOfLinesTitle={2}
+ />
+ {translate('reportCardLostOrDamaged.currentCardInfo')}
+
+
+ >
+ ) : (
+ <>
+
+ {translate('reportCardLostOrDamaged.reasonTitle')}
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+ReportCardLostPage.propTypes = propTypes;
+ReportCardLostPage.defaultProps = defaultProps;
+ReportCardLostPage.displayName = 'ReportCardLostPage';
+
+export default withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ cardList: {
+ key: ONYXKEYS.CARD_LIST,
+ },
+ formData: {
+ key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM,
+ },
+})(ReportCardLostPage);
diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
index 2652494aa1c7..1a51fc4d9453 100644
--- a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
+++ b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.js
@@ -63,7 +63,7 @@ function ReportVirtualCardFraudPage({
return;
}
- Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain));
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
}, [domain, formData.isLoading, prevIsLoading, virtualCard.errors]);
if (_.isEmpty(virtualCard)) {
@@ -74,7 +74,7 @@ function ReportVirtualCardFraudPage({
Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain))}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))}
/>
{translate('reportFraudPage.description')}
diff --git a/src/pages/settings/Wallet/TransferBalancePage.js b/src/pages/settings/Wallet/TransferBalancePage.js
index f33c92bea02b..ae54dab569f7 100644
--- a/src/pages/settings/Wallet/TransferBalancePage.js
+++ b/src/pages/settings/Wallet/TransferBalancePage.js
@@ -167,7 +167,9 @@ function TransferBalancePage(props) {
const isButtonDisabled = !isTransferable || !selectedAccount;
const errorMessage = !_.isEmpty(props.walletTransfer.errors) ? _.chain(props.walletTransfer.errors).values().first().value() : '';
- const shouldShowTransferView = PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, props.bankAccountList) && props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD;
+ const shouldShowTransferView =
+ PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, props.bankAccountList) &&
+ _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], props.userWallet.tierName);
return (
diff --git a/src/pages/settings/Wallet/WalletEmptyState.js b/src/pages/settings/Wallet/WalletEmptyState.js
index adfd2cf49cee..f54716e3110a 100644
--- a/src/pages/settings/Wallet/WalletEmptyState.js
+++ b/src/pages/settings/Wallet/WalletEmptyState.js
@@ -9,6 +9,7 @@ import ROUTES from '../../../ROUTES';
import * as Illustrations from '../../../components/Icon/Illustrations';
import FeatureList from '../../../components/FeatureList';
import themeColors from '../../../styles/themes/default';
+import SCREENS from '../../../SCREENS';
const propTypes = {
/** The function that is called when a menu item is pressed */
@@ -34,7 +35,7 @@ function WalletEmptyState({onAddPaymentMethod}) {
const {translate} = useLocalize();
return (
Navigation.goBack(ROUTES.SETTINGS)}
title={translate('common.wallet')}
diff --git a/src/pages/settings/Wallet/WalletPage/CardDetails.js b/src/pages/settings/Wallet/WalletPage/CardDetails.js
index 6b2b6b8bc54f..f38f90fdfcb2 100644
--- a/src/pages/settings/Wallet/WalletPage/CardDetails.js
+++ b/src/pages/settings/Wallet/WalletPage/CardDetails.js
@@ -11,6 +11,9 @@ import ONYXKEYS from '../../../../ONYXKEYS';
import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils';
import PressableWithDelayToggle from '../../../../components/Pressable/PressableWithDelayToggle';
import styles from '../../../../styles/styles';
+import TextLink from '../../../../components/TextLink';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ROUTES from '../../../../ROUTES';
const propTypes = {
/** Card number */
@@ -33,6 +36,9 @@ const propTypes = {
country: PropTypes.string,
}),
}),
+
+ /** Domain name */
+ domain: PropTypes.string.isRequired,
};
const defaultProps = {
@@ -51,7 +57,7 @@ const defaultProps = {
},
};
-function CardDetails({pan, expiration, cvv, privatePersonalDetails}) {
+function CardDetails({pan, expiration, cvv, privatePersonalDetails, domain}) {
usePrivatePersonalDetails();
const {translate} = useLocalize();
@@ -92,6 +98,12 @@ function CardDetails({pan, expiration, cvv, privatePersonalDetails}) {
title={PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails)}
interactive={false}
/>
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.getRoute(domain))}
+ >
+ {translate('cardPage.cardDetails.updateAddress')}
+
>
);
}
diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js
index ec9ff537189e..11974446eea6 100644
--- a/src/pages/settings/Wallet/WalletPage/WalletPage.js
+++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js
@@ -2,16 +2,19 @@ import React, {useCallback, useEffect, useState, useRef} from 'react';
import {ActivityIndicator, View, InteractionManager, ScrollView} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import lodashGet from 'lodash/get';
import PaymentMethodList from '../PaymentMethodList';
import ROUTES from '../../../../ROUTES';
import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
import ScreenWrapper from '../../../../components/ScreenWrapper';
-import Navigation, {navigationRef} from '../../../../libs/Navigation/Navigation';
+import Navigation from '../../../../libs/Navigation/Navigation';
import styles from '../../../../styles/styles';
import compose from '../../../../libs/compose';
import * as BankAccounts from '../../../../libs/actions/BankAccounts';
import Popover from '../../../../components/Popover';
import MenuItem from '../../../../components/MenuItem';
+import Text from '../../../../components/Text';
+import Icon from '../../../../components/Icon';
import * as PaymentMethods from '../../../../libs/actions/PaymentMethods';
import getClickedTargetLocation from '../../../../libs/getClickedTargetLocation';
import CurrentWalletBalance from '../../../../components/CurrentWalletBalance';
@@ -61,10 +64,14 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
const [showConfirmDeleteContent, setShowConfirmDeleteContent] = useState(false);
const hasBankAccount = !_.isEmpty(bankAccountList) || !_.isEmpty(fundList);
- const hasWallet = userWallet.walletLinkedAccountID > 0;
+ const hasWallet = !_.isEmpty(userWallet);
+ const hasActivatedWallet = _.contains([CONST.WALLET.TIER_NAME.GOLD, CONST.WALLET.TIER_NAME.PLATINUM], userWallet.tierName);
const hasAssignedCard = !_.isEmpty(cardList);
const shouldShowEmptyState = !hasBankAccount && !hasWallet && !hasAssignedCard;
+ const isPendingOnfidoResult = lodashGet(userWallet, 'isPendingOnfidoResult', false);
+ const hasFailedOnfido = lodashGet(userWallet, 'hasFailedOnfido', false);
+
const updateShouldShowLoadingSpinner = useCallback(() => {
// In order to prevent a loop, only update state of the spinner if there is a change
const showLoadingSpinner = isLoadingPaymentMethods || false;
@@ -241,8 +248,13 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
}
}, [paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethod.fundID, paymentMethod.selectedPaymentMethodType]);
- const navigateToTransferBalancePage = () => {
- Navigation.navigate(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
+ /**
+ * Navigate to the appropriate page after completing the KYC flow, depending on what initiated it
+ *
+ * @param {String} source
+ */
+ const navigateToWalletOrTransferBalancePage = (source) => {
+ Navigation.navigate(source === CONST.KYC_WALL_SOURCE.ENABLE_WALLET ? ROUTES.SETTINGS_WALLET : ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
};
useEffect(() => {
@@ -293,13 +305,6 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
}
}, [hideDefaultDeleteMenu, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, bankAccountList, fundList, shouldShowDefaultDeleteMenu]);
- useEffect(() => {
- if (!shouldShowEmptyState) {
- return;
- }
- navigationRef.setParams({backgroundColor: themeColors.walletPageBG});
- }, [shouldShowEmptyState]);
-
const shouldShowMakeDefaultButton =
!paymentMethod.isSelectedPaymentMethodDefault &&
Permissions.canUseWallet(betas) &&
@@ -307,6 +312,8 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
// Determines whether or not the modal popup is mounted from the bottom of the screen instead of the side mount on Web or Desktop screens
const isPopoverBottomMount = anchorPosition.anchorPositionTop === 0 || isSmallScreenWidth;
+ const alertTextStyle = [styles.inlineSystemMessage, styles.flexShrink1];
+ const alertViewStyle = [styles.flexRow, styles.alignItemsCenter, styles.w100, styles.ph5];
return (
<>
@@ -330,7 +337,7 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
{hasWallet && (
<>
@@ -351,24 +358,78 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
)}
+
navigateToWalletOrTransferBalancePage(source)}
+ onSelectPaymentMethod={(selectedPaymentMethod) => {
+ if (hasActivatedWallet || selectedPaymentMethod !== CONST.PAYMENT_METHODS.BANK_ACCOUNT) {
+ return;
+ }
+ // To allow upgrading to a gold wallet, continue with the KYC flow after adding a bank account
+ BankAccounts.setPersonalBankAccountContinueKYCOnSuccess(ROUTES.SETTINGS_WALLET);
+ }}
enablePaymentsRoute={ROUTES.SETTINGS_ENABLE_PAYMENTS}
addBankAccountRoute={ROUTES.SETTINGS_ADD_BANK_ACCOUNT}
addDebitCardRoute={ROUTES.SETTINGS_ADD_DEBIT_CARD}
popoverPlacement="bottom"
+ source={hasActivatedWallet ? CONST.KYC_WALL_SOURCE.TRANSFER_BALANCE : CONST.KYC_WALL_SOURCE.ENABLE_WALLET}
+ shouldIncludeDebitCard={hasActivatedWallet}
>
- {(triggerKYCFlow, buttonRef) => (
-
- )}
+ {(triggerKYCFlow, buttonRef) => {
+ if (shouldShowLoadingSpinner) {
+ return null;
+ }
+
+ if (hasActivatedWallet) {
+ return (
+
+ );
+ }
+
+ if (isPendingOnfidoResult) {
+ return (
+
+
+ {translate('walletPage.walletActivationPending')}
+
+ );
+ }
+
+ if (hasFailedOnfido) {
+ return (
+
+
+ {translate('walletPage.walletActivationFailed')}
+
+ );
+ }
+
+ return (
+
+ );
+ }}
>
diff --git a/src/pages/settings/Wallet/assignedCardPropTypes.js b/src/pages/settings/Wallet/assignedCardPropTypes.js
index e45b57a05d31..a8bfc1a2cbd0 100644
--- a/src/pages/settings/Wallet/assignedCardPropTypes.js
+++ b/src/pages/settings/Wallet/assignedCardPropTypes.js
@@ -10,7 +10,7 @@ const assignedCardPropTypes = PropTypes.shape({
domainName: PropTypes.string,
maskedPan: PropTypes.string,
isVirtual: PropTypes.bool,
- fraud: PropTypes.oneOf([CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, CONST.EXPENSIFY_CARD.FRAUD_TYPES.USER, CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE]),
+ fraud: PropTypes.oneOf([CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN, CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL, CONST.EXPENSIFY_CARD.FRAUD_TYPES.NONE]),
cardholderFirstName: PropTypes.string,
cardholderLastName: PropTypes.string,
});
diff --git a/src/pages/signin/ChooseSSOOrMagicCode.js b/src/pages/signin/ChooseSSOOrMagicCode.js
new file mode 100644
index 000000000000..32f0776cdbc9
--- /dev/null
+++ b/src/pages/signin/ChooseSSOOrMagicCode.js
@@ -0,0 +1,108 @@
+import React from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import styles from '../../styles/styles';
+import ONYXKEYS from '../../ONYXKEYS';
+import Text from '../../components/Text';
+import Button from '../../components/Button';
+import * as Session from '../../libs/actions/Session';
+import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink';
+import Terms from './Terms';
+import CONST from '../../CONST';
+import ROUTES from '../../ROUTES';
+import Navigation from '../../libs/Navigation/Navigation';
+import * as ErrorUtils from '../../libs/ErrorUtils';
+import useLocalize from '../../hooks/useLocalize';
+import useNetwork from '../../hooks/useNetwork';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import FormHelpMessage from '../../components/FormHelpMessage';
+
+const propTypes = {
+ /* Onyx Props */
+
+ /** The credentials of the logged in person */
+ credentials: PropTypes.shape({
+ /** The email/phone the user logged in with */
+ login: PropTypes.string,
+ }),
+
+ /** The details about the account that the user is signing in with */
+ account: PropTypes.shape({
+ /** Whether or not a sign on form is loading (being submitted) */
+ isLoading: PropTypes.bool,
+
+ /** Form that is being loaded */
+ loadingForm: PropTypes.oneOf(_.values(CONST.FORMS)),
+
+ /** Whether this account has 2FA enabled or not */
+ requiresTwoFactorAuth: PropTypes.bool,
+
+ /** Server-side errors in the submitted authentication code */
+ errors: PropTypes.objectOf(PropTypes.string),
+ }),
+
+ /** Function that returns whether the user is using SAML or magic codes to log in */
+ setIsUsingMagicCode: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ credentials: {},
+ account: {},
+};
+
+function ChooseSSOOrMagicCode({credentials, account, setIsUsingMagicCode}) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ return (
+ <>
+
+ {translate('samlSignIn.welcomeSAMLEnabled')}
+ {
+ Navigation.navigate(ROUTES.SAML_SIGN_IN);
+ }}
+ />
+
+
+
+ {translate('samlSignIn.orContinueWithMagicCode')}
+
+
+
+ {
+ Session.resendValidateCode(credentials.login);
+ setIsUsingMagicCode(true);
+ }}
+ />
+ {Boolean(account) && !_.isEmpty(account.errors) && }
+ Session.clearSignInData()} />
+
+
+
+
+ >
+ );
+}
+
+ChooseSSOOrMagicCode.propTypes = propTypes;
+ChooseSSOOrMagicCode.defaultProps = defaultProps;
+ChooseSSOOrMagicCode.displayName = 'ChooseSSOOrMagicCode';
+
+export default withOnyx({
+ credentials: {key: ONYXKEYS.CREDENTIALS},
+ account: {key: ONYXKEYS.ACCOUNT},
+})(ChooseSSOOrMagicCode);
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js
index 2c65b5ff5d37..9c597b73fc5c 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.js
+++ b/src/pages/signin/LoginForm/BaseLoginForm.js
@@ -30,7 +30,7 @@ import GoogleSignIn from '../../../components/SignInButtons/GoogleSignIn';
import isInputAutoFilled from '../../../libs/isInputAutoFilled';
import * as PolicyUtils from '../../../libs/PolicyUtils';
import Log from '../../../libs/Log';
-import withNavigationFocus, {withNavigationFocusPropTypes} from '../../../components/withNavigationFocus';
+import withNavigationFocus from '../../../components/withNavigationFocus';
import usePrevious from '../../../hooks/usePrevious';
import * as MemoryOnlyKeys from '../../../libs/actions/MemoryOnlyKeys/MemoryOnlyKeys';
@@ -60,23 +60,33 @@ const propTypes = {
success: PropTypes.string,
}),
+ /** The credentials of the logged in person */
+ credentials: PropTypes.shape({
+ /** The email the user logged in with */
+ login: PropTypes.string,
+ }),
+
/** Props to detect online status */
network: networkPropTypes.isRequired,
/** Whether or not the sign in page is being rendered in the RHP modal */
isInModal: PropTypes.bool,
+ /** Whether navigation is focused */
+ isFocused: PropTypes.bool.isRequired,
+
...windowDimensionsPropTypes,
...withLocalizePropTypes,
...toggleVisibilityViewPropTypes,
-
- ...withNavigationFocusPropTypes,
};
const defaultProps = {
account: {},
+ credentials: {
+ login: '',
+ },
closeAccount: {},
blurOnSubmit: false,
innerRef: () => {},
@@ -85,7 +95,7 @@ const defaultProps = {
function LoginForm(props) {
const input = useRef();
- const [login, setLogin] = useState('');
+ const [login, setLogin] = useState(() => Str.removeSMSDomain(props.credentials.login));
const [formError, setFormError] = useState(false);
const prevIsVisible = usePrevious(props.isVisible);
@@ -163,7 +173,7 @@ function LoginForm(props) {
useEffect(() => {
// Just call clearAccountMessages on the login page (home route), because when the user is in the transition route and not yet authenticated,
// this component will also be mounted, resetting account.isLoading will cause the app to briefly display the session expiration page.
- if (props.isFocused) {
+ if (props.isFocused && props.isVisible) {
Session.clearAccountMessages();
}
if (!canFocusInputOnScreenFocus() || !input.current || !props.isVisible) {
@@ -213,6 +223,7 @@ function LoginForm(props) {
accessibilityLabel={translate('loginForm.phoneOrEmail')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
value={login}
+ returnKeyType="go"
autoCompleteType="username"
textContentType="username"
nativeID="username"
@@ -283,22 +294,25 @@ LoginForm.propTypes = propTypes;
LoginForm.defaultProps = defaultProps;
LoginForm.displayName = 'LoginForm';
+const LoginFormWithRef = forwardRef((props, ref) => (
+
+));
+
+LoginFormWithRef.displayName = 'LoginFormWithRef';
+
export default compose(
withNavigationFocus,
withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
+ credentials: {key: ONYXKEYS.CREDENTIALS},
closeAccount: {key: ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM},
}),
withWindowDimensions,
withLocalize,
withToggleVisibilityView,
withNetwork(),
-)(
- forwardRef((props, ref) => (
-
- )),
-);
+)(LoginFormWithRef);
diff --git a/src/pages/signin/SAMLSignInPage/index.js b/src/pages/signin/SAMLSignInPage/index.js
new file mode 100644
index 000000000000..23ce9b93b8cc
--- /dev/null
+++ b/src/pages/signin/SAMLSignInPage/index.js
@@ -0,0 +1,66 @@
+import React, {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import ONYXKEYS from '../../../ONYXKEYS';
+import CONFIG from '../../../CONFIG';
+import Icon from '../../../components/Icon';
+import Text from '../../../components/Text';
+import * as Expensicons from '../../../components/Icon/Expensicons';
+import * as Illustrations from '../../../components/Icon/Illustrations';
+import styles from '../../../styles/styles';
+import themeColors from '../../../styles/themes/default';
+import useLocalize from '../../../hooks/useLocalize';
+
+const propTypes = {
+ /** The credentials of the logged in person */
+ credentials: PropTypes.shape({
+ /** The email/phone the user logged in with */
+ login: PropTypes.string,
+ }),
+};
+
+const defaultProps = {
+ credentials: {},
+};
+
+function SAMLSignInPage({credentials}) {
+ const {translate} = useLocalize();
+
+ useEffect(() => {
+ window.open(`${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}`, '_self');
+ }, [credentials.login]);
+
+ return (
+
+
+
+
+
+ {translate('samlSignIn.launching')}
+
+ {translate('samlSignIn.oneMoment')}
+
+
+
+
+
+
+ );
+}
+
+SAMLSignInPage.propTypes = propTypes;
+SAMLSignInPage.defaultProps = defaultProps;
+
+export default withOnyx({
+ credentials: {key: ONYXKEYS.CREDENTIALS},
+})(SAMLSignInPage);
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index d5acf8803224..18ed29fa0415 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -19,7 +19,13 @@ import * as StyleUtils from '../../styles/StyleUtils';
import useLocalize from '../../hooks/useLocalize';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import Log from '../../libs/Log';
+import getPlatform from '../../libs/getPlatform';
+import CONST from '../../CONST';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import ChooseSSOOrMagicCode from './ChooseSSOOrMagicCode';
import * as ActiveClientManager from '../../libs/ActiveClientManager';
+import * as Session from '../../libs/actions/Session';
const propTypes = {
/** The details about the account that the user is signing in with */
@@ -38,6 +44,18 @@ const propTypes = {
/** Is this account having trouble receiving emails */
hasEmailDeliveryFailure: PropTypes.bool,
+
+ /** Whether or not a sign on form is loading (being submitted) */
+ isLoading: PropTypes.bool,
+
+ /** Form that is being loaded */
+ loadingForm: PropTypes.oneOf(_.values(CONST.FORMS)),
+
+ /** Whether or not the user has SAML enabled on their account */
+ isSAMLEnabled: PropTypes.bool,
+
+ /** Whether or not SAML is required on the account */
+ isSAMLRequired: PropTypes.bool,
}),
/** The credentials of the person signing in */
@@ -52,6 +70,9 @@ const propTypes = {
/** Whether or not the sign in page is being rendered in the RHP modal */
isInModal: PropTypes.bool,
+
+ /** The user's preferred locale */
+ preferredLocale: PropTypes.string,
};
const defaultProps = {
@@ -59,34 +80,62 @@ const defaultProps = {
credentials: {},
isInModal: false,
activeClients: [],
+ preferredLocale: '',
};
/**
* @param {Boolean} hasLogin
* @param {Boolean} hasValidateCode
+ * @param {Object} account
* @param {Boolean} isPrimaryLogin
- * @param {Boolean} isAccountValidated
+ * @param {Boolean} isUsingMagicCode
+ * @param {Boolean} hasInitiatedSAMLLogin
* @param {Boolean} hasEmailDeliveryFailure
* @returns {Object}
*/
-function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin, isAccountValidated, hasEmailDeliveryFailure, isClientTheLeader}) {
+function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, isUsingMagicCode, hasInitiatedSAMLLogin, isClientTheLeader}) {
+ const hasAccount = !_.isEmpty(account);
+ const isSAMLEnabled = Boolean(account.isSAMLEnabled);
+ const isSAMLRequired = Boolean(account.isSAMLRequired);
+ const hasEmailDeliveryFailure = Boolean(account.hasEmailDeliveryFailure);
+
+ // SAML is temporarily restricted to users on the beta or to users signing in on web and mweb
+ let shouldShowChooseSSOOrMagicCode = false;
+ let shouldInitiateSAMLLogin = false;
+ const platform = getPlatform();
+ if (platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.DESKTOP) {
+ // True if the user has SAML required and we haven't already initiated SAML for their account
+ shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && account.isLoading;
+ shouldShowChooseSSOOrMagicCode = hasAccount && hasLogin && isSAMLEnabled && !isSAMLRequired && !isUsingMagicCode;
+ }
+
+ // SAML required users may reload the login page after having already entered their login details, in which
+ // case we want to clear their sign in data so they don't end up in an infinite loop redirecting back to their
+ // SSO provider's login page
+ if (hasLogin && isSAMLRequired && !shouldInitiateSAMLLogin && !hasInitiatedSAMLLogin && !account.isLoading) {
+ Session.clearSignInData();
+ }
+
const shouldShowLoginForm = isClientTheLeader && !hasLogin && !hasValidateCode;
- const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure;
- const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !isAccountValidated && !hasEmailDeliveryFailure;
- const shouldShowValidateCodeForm = hasAccount && (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && !hasEmailDeliveryFailure;
- const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowValidateCodeForm || isUnvalidatedSecondaryLogin;
- const shouldShowWelcomeText = shouldShowLoginForm || shouldShowValidateCodeForm || !isClientTheLeader;
+ const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure && !shouldShowChooseSSOOrMagicCode && !shouldInitiateSAMLLogin;
+ const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !account.validated && !hasEmailDeliveryFailure;
+ const shouldShowValidateCodeForm =
+ hasAccount && (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && !hasEmailDeliveryFailure && !shouldShowChooseSSOOrMagicCode && !isSAMLRequired;
+ const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowValidateCodeForm || shouldShowChooseSSOOrMagicCode || isUnvalidatedSecondaryLogin;
+ const shouldShowWelcomeText = shouldShowLoginForm || shouldShowValidateCodeForm || shouldShowChooseSSOOrMagicCode || !isClientTheLeader;
return {
shouldShowLoginForm,
shouldShowEmailDeliveryFailurePage,
shouldShowUnlinkLoginForm: isUnvalidatedSecondaryLogin,
shouldShowValidateCodeForm,
+ shouldShowChooseSSOOrMagicCode,
+ shouldInitiateSAMLLogin,
shouldShowWelcomeHeader,
shouldShowWelcomeText,
};
}
-function SignInPage({credentials, account, isInModal, activeClients}) {
+function SignInPage({credentials, account, isInModal, activeClients, preferredLocale}) {
const {translate, formatPhoneNumber} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
const shouldShowSmallScreen = isSmallScreenWidth || isInModal;
@@ -96,24 +145,47 @@ function SignInPage({credentials, account, isInModal, activeClients}) {
* and we need it here since welcome text(`welcomeText`) also depends on it */
const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false);
+ /** This state is needed to keep track of whether the user has opted to use magic codes
+ * instead of signing in via SAML when SAML is enabled and not required */
+ const [isUsingMagicCode, setIsUsingMagicCode] = useState(false);
+
+ /** This state is needed to keep track of whether the user has been directed to their SSO provider's login page and
+ * if we need to clear their sign in details so they can enter a login */
+ const [hasInitiatedSAMLLogin, setHasInitiatedSAMLLogin] = useState(false);
+
+ const isClientTheLeader = activeClients && ActiveClientManager.isClientTheLeader();
+
useEffect(() => Performance.measureTTI(), []);
useEffect(() => {
+ if (preferredLocale) {
+ return;
+ }
App.setLocale(Localize.getDevicePreferredLocale());
- }, []);
+ }, [preferredLocale]);
- const isClientTheLeader = activeClients && ActiveClientManager.isClientTheLeader();
+ const {
+ shouldShowLoginForm,
+ shouldShowEmailDeliveryFailurePage,
+ shouldShowUnlinkLoginForm,
+ shouldShowValidateCodeForm,
+ shouldShowChooseSSOOrMagicCode,
+ shouldInitiateSAMLLogin,
+ shouldShowWelcomeHeader,
+ shouldShowWelcomeText,
+ } = getRenderOptions({
+ hasLogin: Boolean(credentials.login),
+ hasValidateCode: Boolean(credentials.validateCode),
+ account,
+ isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login,
+ isUsingMagicCode,
+ hasInitiatedSAMLLogin,
+ isClientTheLeader,
+ });
- const {shouldShowLoginForm, shouldShowEmailDeliveryFailurePage, shouldShowUnlinkLoginForm, shouldShowValidateCodeForm, shouldShowWelcomeHeader, shouldShowWelcomeText} = getRenderOptions(
- {
- hasLogin: Boolean(credentials.login),
- hasValidateCode: Boolean(credentials.validateCode),
- hasAccount: !_.isEmpty(account),
- isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login,
- isAccountValidated: Boolean(account.validated),
- hasEmailDeliveryFailure: Boolean(account.hasEmailDeliveryFailure),
- isClientTheLeader,
- },
- );
+ if (shouldInitiateSAMLLogin) {
+ setHasInitiatedSAMLLogin(true);
+ Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.SAML_SIGN_IN));
+ }
let welcomeHeader = '';
let welcomeText = '';
@@ -147,14 +219,14 @@ function SignInPage({credentials, account, isInModal, activeClients}) {
: translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay});
}
}
- } else if (shouldShowUnlinkLoginForm || shouldShowEmailDeliveryFailurePage) {
+ } else if (shouldShowUnlinkLoginForm || shouldShowEmailDeliveryFailurePage || shouldShowChooseSSOOrMagicCode) {
welcomeHeader = shouldShowSmallScreen ? headerText : translate('welcomeText.welcomeBack');
// Don't show any welcome text if we're showing the user the email delivery failed view
- if (shouldShowEmailDeliveryFailurePage) {
+ if (shouldShowEmailDeliveryFailurePage || shouldShowChooseSSOOrMagicCode) {
welcomeText = '';
}
- } else {
+ } else if (!shouldInitiateSAMLLogin) {
Log.warn('SignInPage in unexpected state!');
}
@@ -178,14 +250,20 @@ function SignInPage({credentials, account, isInModal, activeClients}) {
blurOnSubmit={account.validated === false}
scrollPageToTop={signInPageLayoutRef.current && signInPageLayoutRef.current.scrollPageToTop}
/>
- {shouldShowValidateCodeForm && (
-
+ {isClientTheLeader && (
+ <>
+ {shouldShowValidateCodeForm && (
+
+ )}
+ {shouldShowUnlinkLoginForm && }
+ {shouldShowChooseSSOOrMagicCode && }
+ {shouldShowEmailDeliveryFailurePage && }
+ >
)}
- {shouldShowUnlinkLoginForm && }
- {shouldShowEmailDeliveryFailurePage && }
);
@@ -206,4 +284,7 @@ export default withOnyx({
We use that function to prevent repeating code that checks which client is the leader.
*/
activeClients: {key: ONYXKEYS.ACTIVE_CLIENTS},
+ preferredLocale: {
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ },
})(SignInPage);
diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js
index fdc88b6ede9e..21a768aac3b1 100644
--- a/src/pages/signin/SignInPageLayout/index.js
+++ b/src/pages/signin/SignInPageLayout/index.js
@@ -185,16 +185,14 @@ SignInPageLayout.propTypes = propTypes;
SignInPageLayout.displayName = 'SignInPageLayout';
SignInPageLayout.defaultProps = defaultProps;
-export default compose(
- withWindowDimensions,
- withSafeAreaInsets,
- withLocalize,
-)(
- forwardRef((props, ref) => (
-
- )),
-);
+const SignInPageLayoutWithRef = forwardRef((props, ref) => (
+
+));
+
+SignInPageLayoutWithRef.displayName = 'SignInPageLayoutWithRef';
+
+export default compose(withWindowDimensions, withSafeAreaInsets, withLocalize)(SignInPageLayoutWithRef);
diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
index 335df7be3188..dc100fffe4f1 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
@@ -38,6 +38,12 @@ const propTypes = {
/** Whether or not a sign on form is loading (being submitted) */
isLoading: PropTypes.bool,
+
+ /** Whether or not the user has SAML enabled on their account */
+ isSAMLEnabled: PropTypes.bool,
+
+ /** Whether or not SAML is required on the account */
+ isSAMLRequired: PropTypes.bool,
}),
/** The credentials of the person signing in */
@@ -64,6 +70,9 @@ const propTypes = {
/** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
setIsUsingRecoveryCode: PropTypes.func.isRequired,
+ /** Function to change `isUsingMagicCode` state when the user goes back to the login page */
+ setIsUsingMagicCode: PropTypes.func.isRequired,
+
...withLocalizePropTypes,
};
@@ -199,6 +208,8 @@ function BaseValidateCodeForm(props) {
* Clears local and Onyx sign in states
*/
const clearSignInData = () => {
+ // Reset the user's preference for signing in with SAML versus magic codes
+ props.setIsUsingMagicCode(false);
clearLocalSignInData();
Session.clearSignInData();
};
diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js
index 668a61526198..236003b23c68 100644
--- a/src/pages/tasks/NewTaskDetailsPage.js
+++ b/src/pages/tasks/NewTaskDetailsPage.js
@@ -10,7 +10,6 @@ import ScreenWrapper from '../../components/ScreenWrapper';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import * as ErrorUtils from '../../libs/ErrorUtils';
-import Form from '../../components/Form';
import TextInput from '../../components/TextInput';
import Permissions from '../../libs/Permissions';
import ROUTES from '../../ROUTES';
@@ -18,6 +17,8 @@ import * as Task from '../../libs/actions/Task';
import CONST from '../../CONST';
import * as Browser from '../../libs/Browser';
import useAutoFocusInput from '../../hooks/useAutoFocusInput';
+import FormProvider from '../../components/Form/FormProvider';
+import InputWrapper from '../../components/Form/InputWrapper';
const propTypes = {
/** Beta features list */
@@ -86,7 +87,7 @@ function NewTaskDetailsPage(props) {
shouldShowBackButton
onBackButtonPress={() => Task.dismissModalAndClearOutTaskInfo()}
/>
-
+
);
}
diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js
index 790bd6ceeb64..f0d2d506c9d8 100644
--- a/src/pages/tasks/NewTaskPage.js
+++ b/src/pages/tasks/NewTaskPage.js
@@ -16,6 +16,7 @@ import ROUTES from '../../ROUTES';
import MenuItemWithTopDescription from '../../components/MenuItemWithTopDescription';
import MenuItem from '../../components/MenuItem';
import reportPropTypes from '../reportPropTypes';
+import * as OptionsListUtils from '../../libs/OptionsListUtils';
import * as Task from '../../libs/actions/Task';
import * as ReportUtils from '../../libs/ReportUtils';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
@@ -64,6 +65,7 @@ const defaultProps = {
function NewTaskPage(props) {
const [assignee, setAssignee] = useState({});
+ const assigneeTooltipDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([props.task.assigneeAccountID], props.personalDetails), false);
const [shareDestination, setShareDestination] = useState({});
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
@@ -185,6 +187,7 @@ function NewTaskPage(props) {
icon={assignee.icons}
onPress={() => Navigation.navigate(ROUTES.NEW_TASK_ASSIGNEE)}
shouldShowRightIcon
+ titleWithTooltips={assigneeTooltipDetails}
/>