diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index aa38a7778f31..e1b1696411b1 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -104,6 +104,11 @@ The GitHub workflows require a large list of secrets to deploy, notify and test
1. `APPLE_DEMO_PASSWORD` - Demo account password used for https://appstoreconnect.apple.com/
1. `BROWSERSTACK` - Used to access Browserstack's API
+### Important note about Secrets
+Secrets are available by default in most workflows. The exception to the rule is callable workflows. If a workflow is triggered by the `workflow_call` event, it will only have access to repo secrets if the workflow that called it passed in the secrets explicitly (for example, using `secrets: inherit`).
+
+Furthermore, secrets are not accessible in actions. If you need to access a secret in an action, you must declare it as an input and pass it in. GitHub _should_ still obfuscate the value of the secret in workflow run logs.
+
## Actions
All these _workflows_ are comprised of atomic _actions_. Most of the time, we can use pre-made and independently maintained actions to create powerful workflows that meet our needs. However, when we want to do something very specific or have a more complex or robust action in mind, we can create our own _actions_.
diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml
index cb4e0f956657..ca7345ef9462 100644
--- a/.github/workflows/deployExpensifyHelp.yml
+++ b/.github/workflows/deployExpensifyHelp.yml
@@ -28,23 +28,27 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8
+
- 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
+
- name: Build with Jekyll
uses: actions/jekyll-build-pages@0143c158f4fa0c5dcd99499a5d00859d79f70b0e
with:
source: ./docs/
destination: ./docs/_site
+
- name: Upload artifact
uses: actions/upload-pages-artifact@64bcae551a7b18bcb9a09042ddf1960979799187
with:
path: ./docs/_site
-
# Deployment job
deploy:
environment:
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index fe364b376e3b..d8f9cad138d9 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -46,6 +46,9 @@ jobs:
git fetch origin tag ${{ steps.getMostRecentRelease.outputs.VERSION }} --no-tags --depth=1
git switch --detach ${{ steps.getMostRecentRelease.outputs.VERSION }}
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
- name: Build APK
if: ${{ !fromJSON(steps.checkForExistingArtifact.outputs.exists) }}
uses: Expensify/App/.github/actions/composite/buildAndroidAPK@main
@@ -112,6 +115,9 @@ jobs:
- name: Checkout "delta ref"
run: git checkout ${{ steps.getDeltaRef.outputs.DELTA_REF }}
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
- name: Build APK
uses: Expensify/App/.github/actions/composite/buildAndroidAPK@main
with:
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index e787b26336c5..84f8373ff247 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -36,6 +36,9 @@ jobs:
steps:
- uses: actions/checkout@v3
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
- uses: Expensify/App/.github/actions/composite/setupNode@main
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
@@ -77,7 +80,7 @@ jobs:
- 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/release/app-release.aab"
+ 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"
env:
BROWSERSTACK: ${{ secrets.BROWSERSTACK }}
@@ -144,6 +147,9 @@ jobs:
steps:
- uses: actions/checkout@v3
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
- uses: Expensify/App/.github/actions/composite/setupNode@main
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fe234bc8373c..e79a02281ae0 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -42,7 +42,9 @@ jobs:
name: Storybook tests
steps:
- uses: actions/checkout@v3
+
- uses: Expensify/App/.github/actions/composite/setupNode@main
+
- name: Storybook run
run: npm run storybook -- --smoke-test --ci
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index e541e2291ae9..16fffcc2c65e 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -11,7 +11,7 @@ on:
branches: ['*ci-test/**']
env:
- DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer
+ DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer
jobs:
validateActor:
@@ -103,6 +103,9 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
- name: Run Fastlane beta test
id: runFastlaneBetaTest
run: bundle exec fastlane android build_internal
@@ -111,6 +114,8 @@ jobs:
S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_BUCKET: ad-hoc-expensify-cash
S3_REGION: us-east-1
+ MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }}
+ MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }}
- uses: actions/upload-artifact@v3
with:
@@ -130,6 +135,9 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }}
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
- name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it
run: |
cp .env.staging .env.adhoc
@@ -138,6 +146,9 @@ jobs:
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ - name: Setup Xcode
+ run: sudo xcode-select -switch /Applications/Xcode_14.2.app
+
- uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7
with:
ruby-version: '2.7'
@@ -151,7 +162,7 @@ jobs:
command: cd ios && bundle exec pod install
- name: Decrypt profile
- run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output chat_expensify_adhoc.mobileprovision chat_expensify_adhoc.mobileprovision.gpg
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output expensify_chat_adhoc.mobileprovision expensify_chat_adhoc.mobileprovision.gpg
env:
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml
index 8b715a7047c4..64188769f0bd 100644
--- a/.github/workflows/verifyPodfile.yml
+++ b/.github/workflows/verifyPodfile.yml
@@ -15,5 +15,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
+
- uses: Expensify/App/.github/actions/composite/setupNode@main
+
- run: ./.github/scripts/verifyPodfile.sh
diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index cf6bdf1dedef..9274dd8c1382 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -32,6 +32,10 @@
"/": "/iou/*",
"comment": "I Owe You reports"
},
+ {
+ "/": "/request/*",
+ "comment": "Money request"
+ },
{
"/": "/enable-payments/*",
"comment": "Payments setup"
diff --git a/README.md b/README.md
index b453a278b29f..f0a94a16855c 100644
--- a/README.md
+++ b/README.md
@@ -50,13 +50,14 @@ For an M1 Mac, read this [SO](https://stackoverflow.com/c/expensify/questions/11
* Install project gems, including cocoapods, using bundler to ensure everyone uses the same versions. In the project root, run: `bundle install`
* If you get the error `Could not find 'bundler'`, install the bundler gem first: `gem install bundler` and try again.
* If you are using MacOS and get the error `Gem::FilePermissionError` when trying to install the bundler gem, you're likely using system Ruby, which requires administrator permission to modify. To get around this, install another version of Ruby with a version manager like [rbenv](https://github.com/rbenv/rbenv#installation).
+* Before installing iOS dependencies, you need to obtain a token from Mapbox to download their SDKs. Please run `npm run configure-mapbox` and follow the instructions.
* To install the iOS dependencies, run: `npm install && npm run pod-install`
* 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 Simulator**: `npm run ios`
* Changes applied to Javascript will be applied automatically, any changes to native code will require a recompile
## Running the Android app 🤖
-* To install the Android dependencies, run: `npm install`
+* 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
* 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)
@@ -418,4 +419,4 @@ In order to compile a production desktop build, run `npm run desktop-build`, thi
In order to compile a production iOS build, run `npm run ios-build`, this will generate a `Chat.ipa` in the root directory of this project.
#### Local production build the Android app
-To build an APK to share run (e.g. via Slack), run `npm run android-build`, this will generate a new APK in the `android/app` folder.
+To build an APK to share run (e.g. via Slack), run `npm run android-build`, this will generate a new APK in the `android/app` folder.
\ No newline at end of file
diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js
index 26a943ce62bc..006d1aee38af 100644
--- a/__mocks__/react-native.js
+++ b/__mocks__/react-native.js
@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import * as ReactNative from 'react-native';
import _ from 'underscore';
-import CONST from '../src/CONST';
jest.doMock('react-native', () => {
let url = 'https://new.expensify.com/';
@@ -15,7 +14,12 @@ jest.doMock('react-native', () => {
// runs against index.native.js source and so anything that is testing a component reliant on withWindowDimensions()
// would be most commonly assumed to be on a mobile phone vs. a tablet or desktop style view. This behavior can be
// overridden by explicitly setting the dimensions inside a test via Dimensions.set()
- let dimensions = CONST.TESTING.SCREEN_SIZE.SMALL;
+ let dimensions = {
+ width: 300,
+ height: 700,
+ scale: 1,
+ fontScale: 1,
+ };
return Object.setPrototypeOf(
{
diff --git a/android/app/build.gradle b/android/app/build.gradle
index baf35c531fd3..c6c2e308bac2 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -22,7 +22,7 @@ react {
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
- // debuggableVariants = ["liteDebug", "prodDebug"]
+ debuggableVariants = ["developmentDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@@ -53,8 +53,12 @@ react {
}
project.ext.envConfigFiles = [
- debug: ".env",
- release: ".env.production",
+ productionDebug: ".env.production",
+ productionRelease: ".env.production",
+ adhocRelease: ".env.adhoc",
+ developmentRelease: ".env",
+ developmentDebug: ".env",
+ e2eRelease: ".env.production"
]
/**
@@ -86,18 +90,39 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001035301
- versionName "1.3.53-1"
+ versionCode 1001035703
+ versionName "1.3.57-3"
+ }
+
+ flavorDimensions "default"
+ productFlavors {
+ // we need to define a production flavor but since it has default config, we can leave it empty
+ production
+ e2e {
+ // If are building a version that won't be uploaded to the play store, we don't have to use production keys
+ // applies all non-production flavors
+ applicationIdSuffix ".adhoc"
+ signingConfig signingConfigs.debug
+ resValue "string", "build_config_package", "com.expensify.chat"
+ }
+ adhoc {
+ applicationIdSuffix ".adhoc"
+ signingConfig signingConfigs.debug
+ resValue "string", "build_config_package", "com.expensify.chat"
+ }
+ development {
+ applicationIdSuffix ".dev"
+ signingConfig signingConfigs.debug
+ resValue "string", "build_config_package", "com.expensify.chat"
+ }
}
signingConfigs {
release {
- if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) {
- storeFile file(MYAPP_UPLOAD_STORE_FILE)
- storePassword System.getenv('MYAPP_UPLOAD_STORE_PASSWORD')
- keyAlias MYAPP_UPLOAD_KEY_ALIAS
- keyPassword System.getenv('MYAPP_UPLOAD_KEY_PASSWORD')
- }
+ storeFile file(MYAPP_UPLOAD_STORE_FILE)
+ storePassword System.getenv('MYAPP_UPLOAD_STORE_PASSWORD')
+ keyAlias MYAPP_UPLOAD_KEY_ALIAS
+ keyPassword System.getenv('MYAPP_UPLOAD_KEY_PASSWORD')
}
debug {
storeFile file('debug.keystore')
@@ -112,19 +137,16 @@ android {
}
release {
signingConfig signingConfigs.release
+ productFlavors.production.signingConfig signingConfigs.release
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
- // We need a custom build type, so we can allow http clear text traffic in a release build:
- e2eRelease {
- initWith release
- matchingFallbacks = ['release']
- signingConfig signingConfigs.debug
- }
- internalRelease {
- initWith release
- matchingFallbacks = ['release']
- signingConfig signingConfigs.debug
+ }
+
+ // since we don't need variants adhocDebug and e2eDebug, we can force gradle to ignore them
+ variantFilter { variant ->
+ if (variant.name == "adhocDebug" || variant.name == "e2eDebug") {
+ setIgnore(true)
}
}
}
@@ -170,7 +192,7 @@ dependencies {
// Fixes a version conflict between airship and react-native-plaid-link-sdk
// This may be fixed by a newer version of the plaid SDK (not working as of 10.0.0)
implementation "androidx.work:work-runtime-ktx:2.8.0"
-
+
// This okhttp3 dependency prevents the app from crashing - See https://github.com/plaid/react-native-plaid-link-sdk/issues/74#issuecomment-648435002
implementation "com.squareup.okhttp3:okhttp-urlconnection:4.+"
@@ -178,8 +200,5 @@ dependencies {
}
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
-def googleServicesFile = rootProject.file('app/google-services.json')
-if (googleServicesFile.exists()) {
- apply plugin: 'com.google.gms.google-services' // Google Play services Gradle plugin
-}
+apply plugin: 'com.google.gms.google-services' // Google Play services Gradle plugin
apply plugin: 'com.google.firebase.crashlytics'
diff --git a/android/app/google-services.json b/android/app/google-services.json
index 545f49d765bf..35f7f5b68921 100644
--- a/android/app/google-services.json
+++ b/android/app/google-services.json
@@ -1,47 +1,143 @@
{
"project_info": {
- "project_number": "921154746561",
- "firebase_url": "https://expensify-chat.firebaseio.com",
- "project_id": "expensify-chat",
- "storage_bucket": "expensify-chat.appspot.com"
+ "project_number": "921154746561",
+ "firebase_url": "https://expensify-chat.firebaseio.com",
+ "project_id": "expensify-chat",
+ "storage_bucket": "expensify-chat.appspot.com"
},
"client": [
- {
- "client_info": {
- "mobilesdk_app_id": "1:921154746561:android:4f04268f25f84eaf027c40",
- "android_client_info": {
- "package_name": "com.expensify.chat"
- }
- },
- "oauth_client": [
- {
- "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyCVwQb9lBI06bDIwHOw10AkdJyquXoMngk"
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:921154746561:android:4f04268f25f84eaf027c40",
+ "android_client_info": {
+ "package_name": "com.expensify.chat"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "921154746561-o0pgqgc84e3e97s9iljlmimcb5nesqad.apps.googleusercontent.com",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.expensify.chat",
+ "certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625"
+ }
+ },
+ {
+ "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzaSyCVwQb9lBI06bDIwHOw10AkdJyquXoMngk"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
+ "client_type": 3
+ },
+ {
+ "client_id": "921154746561-080fav7kvk6s70k6nd70mt50isubgff4.apps.googleusercontent.com",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "com.expensify.chat.adhoc"
}
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
- "client_type": 3
- },
- {
- "client_id": "921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3.apps.googleusercontent.com",
- "client_type": 2,
- "ios_info": {
- "bundle_id": "com.chat.expensify.chat"
- }
- }
- ]
+ }
+ ]
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:921154746561:android:333e293a7fef83a8027c40",
+ "android_client_info": {
+ "package_name": "com.expensify.chat.adhoc"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "921154746561-cbegir0tnc2gan6k1gre5vtn75p60hom.apps.googleusercontent.com",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.expensify.chat.adhoc",
+ "certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625"
+ }
+ },
+ {
+ "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzaSyCVwQb9lBI06bDIwHOw10AkdJyquXoMngk"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
+ "client_type": 3
+ },
+ {
+ "client_id": "921154746561-080fav7kvk6s70k6nd70mt50isubgff4.apps.googleusercontent.com",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "com.expensify.chat.adhoc"
}
+ }
+ ]
+ }
+ }
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:921154746561:android:3b19fdbaedb5b586027c40",
+ "android_client_info": {
+ "package_name": "com.expensify.chat.dev"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "921154746561-svjnccrcn6vet45kn9o7sibb3jemipa6.apps.googleusercontent.com",
+ "client_type": 1,
+ "android_info": {
+ "package_name": "com.expensify.chat.dev",
+ "certificate_hash": "5e8f16062ea3cd2c4a0d547876baa6f38cabf625"
}
+ },
+ {
+ "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzaSyCVwQb9lBI06bDIwHOw10AkdJyquXoMngk"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "921154746561-gpsoaqgqfuqrfsjdf8l7vohfkfj7b9up.apps.googleusercontent.com",
+ "client_type": 3
+ },
+ {
+ "client_id": "921154746561-080fav7kvk6s70k6nd70mt50isubgff4.apps.googleusercontent.com",
+ "client_type": 2,
+ "ios_info": {
+ "bundle_id": "com.expensify.chat.adhoc"
+ }
+ }
+ ]
+ }
}
+ }
],
"configuration_version": "1"
-}
+ }
diff --git a/android/app/src/adhoc/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/adhoc/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000000..80b730f3673e
--- /dev/null
+++ b/android/app/src/adhoc/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/adhoc/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/adhoc/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000000..80b730f3673e
--- /dev/null
+++ b/android/app/src/adhoc/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher.png b/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000000..d76e72f68d43
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..ecf9a8d7648a
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..f8d43cb7dc2d
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/adhoc/res/mipmap-ldpi/ic_launcher.png b/android/app/src/adhoc/res/mipmap-ldpi/ic_launcher.png
new file mode 100644
index 000000000000..30c0e8484309
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher.png b/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000000..6767ae1f2712
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..ba8a2086138c
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..3f0d4a9f6b77
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000000..9a406a263d3d
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..d1e6dbf34a18
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..9ca33d6f0e5c
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000000..819d0456ff8a
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..2bb80c3d622b
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..c343ab0f94a5
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000000..b5d80bc20289
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..576550530857
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..d6df660bc3c3
Binary files /dev/null and b/android/app/src/adhoc/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/adhoc/res/values/ic_launcher_background.xml b/android/app/src/adhoc/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000000..ad6f6d9631c0
--- /dev/null
+++ b/android/app/src/adhoc/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #3DDC84
+
diff --git a/android/app/src/adhoc/res/values/strings.xml b/android/app/src/adhoc/res/values/strings.xml
new file mode 100644
index 000000000000..f59d0656694b
--- /dev/null
+++ b/android/app/src/adhoc/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ New Expensify AdHoc
+
diff --git a/android/app/src/debug/assets/airshipconfig.properties b/android/app/src/development/assets/airshipconfig.properties
similarity index 100%
rename from android/app/src/debug/assets/airshipconfig.properties
rename to android/app/src/development/assets/airshipconfig.properties
diff --git a/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000000..80b730f3673e
--- /dev/null
+++ b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000000..80b730f3673e
--- /dev/null
+++ b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/development/res/mipmap-hdpi/ic_launcher.png b/android/app/src/development/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000000..c1ec7afcfc02
Binary files /dev/null and b/android/app/src/development/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/development/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/development/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..eb5af7cb730f
Binary files /dev/null and b/android/app/src/development/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/development/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..c6aad72d89f6
Binary files /dev/null and b/android/app/src/development/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/development/res/mipmap-ldpi/ic_launcher.png b/android/app/src/development/res/mipmap-ldpi/ic_launcher.png
new file mode 100644
index 000000000000..380afcaa5369
Binary files /dev/null and b/android/app/src/development/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/android/app/src/development/res/mipmap-mdpi/ic_launcher.png b/android/app/src/development/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000000..a06edb8a1406
Binary files /dev/null and b/android/app/src/development/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/development/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/development/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..45ceb6c76b3e
Binary files /dev/null and b/android/app/src/development/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/development/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..a05f7659d0de
Binary files /dev/null and b/android/app/src/development/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/development/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/development/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000000..d115c9cc2613
Binary files /dev/null and b/android/app/src/development/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/development/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/development/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..bde468cb56cf
Binary files /dev/null and b/android/app/src/development/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/development/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..7d9fe85bfce5
Binary files /dev/null and b/android/app/src/development/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/development/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000000..b6a3e55257ce
Binary files /dev/null and b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..fe8e3c4be2c6
Binary files /dev/null and b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..f85391b480a3
Binary files /dev/null and b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000000..a6ba2750e92d
Binary files /dev/null and b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000000..3ab898c20c6b
Binary files /dev/null and b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000000..44aa87a0e8d0
Binary files /dev/null and b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/development/res/values/ic_launcher_background.xml b/android/app/src/development/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000000..ad6f6d9631c0
--- /dev/null
+++ b/android/app/src/development/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #3DDC84
+
diff --git a/android/app/src/development/res/values/strings.xml b/android/app/src/development/res/values/strings.xml
new file mode 100644
index 000000000000..545b4a07a105
--- /dev/null
+++ b/android/app/src/development/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ New Expensify Dev
+
diff --git a/android/app/src/e2eRelease/AndroidManifest.xml b/android/app/src/e2e/AndroidManifest.xml
similarity index 100%
rename from android/app/src/e2eRelease/AndroidManifest.xml
rename to android/app/src/e2e/AndroidManifest.xml
diff --git a/android/app/src/e2eRelease/java/com/expensify/chat/ReactNativeFlipper.java b/android/app/src/e2eRelease/java/com/expensify/chat/ReactNativeFlipper.java
deleted file mode 100644
index d7730e2d4fae..000000000000
--- a/android/app/src/e2eRelease/java/com/expensify/chat/ReactNativeFlipper.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * Copyright (c) Meta Platforms, Inc. and affiliates.
- *
- *
This source code is licensed under the MIT license found in the LICENSE file in the root
- * directory of this source tree.
- */
-package com.expensify.chat;
-
-import android.content.Context;
-import com.facebook.react.ReactInstanceManager;
-
-/**
- * Class responsible of loading Flipper inside your React Native application. This is the release
- * flavor of it so it's empty as we don't want to load Flipper.
- */
-public class ReactNativeFlipper {
- public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
- // Do nothing as we don't want to initialize Flipper on Release.
- }
-}
diff --git a/android/app/src/internalRelease/assets/airshipconfig.properties b/android/app/src/internalRelease/assets/airshipconfig.properties
deleted file mode 100644
index 194c4577de8b..000000000000
--- a/android/app/src/internalRelease/assets/airshipconfig.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-appKey = 55vypj0ARc6cN09MX7ogtQ
-appSecret = EsSaqbdLSvmyC6kSBFJCtQ
-inProduction = true
-
-# Notification Customization
-notificationIcon = ic_notification
-notificationAccentColor = #2EAAE2
\ No newline at end of file
diff --git a/android/app/src/internalRelease/java/com/expensify/chat/ReactNativeFlipper.java b/android/app/src/internalRelease/java/com/expensify/chat/ReactNativeFlipper.java
deleted file mode 100644
index 0e3c02f072e6..000000000000
--- a/android/app/src/internalRelease/java/com/expensify/chat/ReactNativeFlipper.java
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * Copyright (c) Meta Platforms, Inc. and affiliates.
- *
- *
This source code is licensed under the MIT license found in the LICENSE file in the root
- * directory of this source tree.
- */
-package com.expensify.chat;
-
-import android.content.Context;
-import com.facebook.react.ReactInstanceManager;
-
-/**
- * Class responsible of loading Flipper inside your React Native application. This is the release
- * flavor of it so it's empty as we don't want to load Flipper.
- */
-public class ReactNativeFlipper {
- public static void initializeFlipper(Context context, ReactInstanceManager reactInstanceManager) {
- // Do nothing as we don't want to initialize Flipper on Release.
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f7b50f62369c..f1c7f65757d6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -60,6 +60,7 @@
+
@@ -75,6 +76,7 @@
+
diff --git a/android/app/src/e2eRelease/assets/airshipconfig.properties b/android/app/src/main/assets/airshipconfig.properties
similarity index 100%
rename from android/app/src/e2eRelease/assets/airshipconfig.properties
rename to android/app/src/main/assets/airshipconfig.properties
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 036d09bc5fd5..80b730f3673e 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 036d09bc5fd5..80b730f3673e 100644
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,4 +2,4 @@
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
index 4e823a09e75e..ad6f6d9631c0 100644
--- a/android/app/src/main/res/values/ic_launcher_background.xml
+++ b/android/app/src/main/res/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
#3DDC84
-
\ No newline at end of file
+
diff --git a/android/app/src/release/assets/airshipconfig.properties b/android/app/src/release/assets/airshipconfig.properties
deleted file mode 100644
index 194c4577de8b..000000000000
--- a/android/app/src/release/assets/airshipconfig.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-appKey = 55vypj0ARc6cN09MX7ogtQ
-appSecret = EsSaqbdLSvmyC6kSBFJCtQ
-inProduction = true
-
-# Notification Customization
-notificationIcon = ic_notification
-notificationAccentColor = #2EAAE2
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
index c04314a9aa0c..d7e9529ae6dd 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -14,6 +14,10 @@ buildscript {
multiDexEnabled = true
googlePlayServicesVersion = "17.0.0"
kotlinVersion = '1.6.20'
+
+ // This property configures the type of Mapbox SDK used by the @rnmapbox/maps library.
+ // "mapbox" indicates the usage of the Mapbox SDK.
+ RNMapboxMapsImpl = "mapbox"
}
repositories {
google()
@@ -48,5 +52,23 @@ allprojects {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
+ maven {
+ // Mapbox SDK requires authentication to download from Mapbox's private Maven repository.
+ url 'https://api.mapbox.com/downloads/v2/releases/maven'
+ authentication {
+ basic(BasicAuthentication)
+ }
+ credentials {
+ // 'mapbox' is the fixed username for Mapbox's Maven repository.
+ username = 'mapbox'
+
+ // The value for password is read from the 'MAPBOX_DOWNLOADS_TOKEN' gradle property.
+ // Run "npm run setup-mapbox-sdk" to set this property in «USER_HOME»/.gradle/gradle.properties
+
+ // Example gradle.properties entry:
+ // MAPBOX_DOWNLOADS_TOKEN=YOUR_SECRET_TOKEN_HERE
+ password = project.properties['MAPBOX_DOWNLOADS_TOKEN'] ?: ""
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/assets/emojis/index.js b/assets/emojis/index.js
index 3882ac7f0fa6..c8dab36f57d9 100644
--- a/assets/emojis/index.js
+++ b/assets/emojis/index.js
@@ -15,13 +15,18 @@ const emojiNameTable = _.reduce(
{},
);
-const emojiCodeTable = _.reduce(
+const emojiCodeTableWithSkinTones = _.reduce(
emojis,
(prev, cur) => {
const newValue = prev;
if (!cur.header) {
newValue[cur.code] = cur;
}
+ if (cur.types) {
+ cur.types.forEach((type) => {
+ newValue[type] = cur;
+ });
+ }
return newValue;
},
{},
@@ -32,5 +37,5 @@ const localeEmojis = {
es: esEmojis,
};
-export {emojiNameTable, emojiCodeTable, localeEmojis};
+export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis};
export {skinTones, categoryFrequentlyUsed, default} from './common';
diff --git a/assets/images/dot-indicator-unfilled.svg b/assets/images/dot-indicator-unfilled.svg
new file mode 100644
index 000000000000..ae131b1c2cba
--- /dev/null
+++ b/assets/images/dot-indicator-unfilled.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/assets/images/drag-handles.svg b/assets/images/drag-handles.svg
new file mode 100644
index 000000000000..ec4fc4ccc672
--- /dev/null
+++ b/assets/images/drag-handles.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/assets/images/emptystate__routepending.svg b/assets/images/emptystate__routepending.svg
new file mode 100644
index 000000000000..7646917046cc
--- /dev/null
+++ b/assets/images/emptystate__routepending.svg
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/expensify-app-icon.svg b/assets/images/expensify-app-icon.svg
new file mode 100644
index 000000000000..a0adfe7dd952
--- /dev/null
+++ b/assets/images/expensify-app-icon.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/location.svg b/assets/images/location.svg
new file mode 100644
index 000000000000..ad8102051e26
--- /dev/null
+++ b/assets/images/location.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/assets/images/lounge-access.svg b/assets/images/lounge-access.svg
deleted file mode 100644
index 3be9ff00fb7a..000000000000
--- a/assets/images/lounge-access.svg
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/assets/images/new-expensify-adhoc.svg b/assets/images/new-expensify-adhoc.svg
index db2f420c4e0e..26f18c8cc088 100644
--- a/assets/images/new-expensify-adhoc.svg
+++ b/assets/images/new-expensify-adhoc.svg
@@ -1,50 +1,25 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/new-expensify-dark.svg b/assets/images/new-expensify-dark.svg
index 567cc667e972..bcdb3c87f164 100644
--- a/assets/images/new-expensify-dark.svg
+++ b/assets/images/new-expensify-dark.svg
@@ -1 +1,29 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/new-expensify-dev.svg b/assets/images/new-expensify-dev.svg
index 5e36ffebe0d3..8f995412bb0c 100644
--- a/assets/images/new-expensify-dev.svg
+++ b/assets/images/new-expensify-dev.svg
@@ -1,46 +1,25 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/images/signIn/apple-logo.svg b/assets/images/signIn/apple-logo.svg
new file mode 100644
index 000000000000..4e428fc41aed
--- /dev/null
+++ b/assets/images/signIn/apple-logo.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/assets/images/signIn/google-logo.svg b/assets/images/signIn/google-logo.svg
new file mode 100644
index 000000000000..ebdd4be8cade
--- /dev/null
+++ b/assets/images/signIn/google-logo.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md
new file mode 100644
index 000000000000..9032a99dfbbd
--- /dev/null
+++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md
@@ -0,0 +1,272 @@
+# Overview
+
+"Sign in with Apple" and "Sign in with Google" are multi-platform sign-in methods. Both Apple and Google provide official tools, but we have to manage the fact that the behavior, APIs, and constraints for each of those tools varies quite a bit. The architecture of Apple and Google sign-in aims to provide as consistent a user experience and implementation as possible, but our options are limited by Apple and Google. This document will describe the user experience, tooling, and options available on each and why this feature is implemented the way it is.
+
+## Terms
+
+The **client app**, or **client**: this refers to the application that is attempting to access a user's resources hosted by a third party. In this case, this is the Expensify app.
+
+The **third party**: this is any other service that the client app (Expensify) wants to interact with on behalf of a user. In this case, Apple or Google. Since this flow is specifically concerned with authentication, it may also be called the **third-party authentication provider**.
+
+**Third-party sign-in**: a general phrase to refer to either "Sign in with Apple" or "Sign in with Google" (or any future similar features). Any authentication method that involves authentication with a service not provided by Expensify.
+
+## How third-party sign-in works
+
+When the user signs in to the app with a third party like Apple or Google, there is a general flow used by all of them:
+
+1. The user presses a button within the client app to start their preferred sign-in process.
+2. The user is sent to a UI owned by the third-party to sign in (e.g., the Google sign-in web page hosted by Google, or the Sign in with Apple bottom sheet provided by iOS).
+3. When the user successfully signs in with the third party, the third party generates a token and sends it back to the client app.
+4. The client app sends the token to the client backend API, where the token is verified and the user's email is extracted from the token, and the user is signed in.
+
+Both services also require registering a "client ID", along with some configuration we'll explain next. For apps that aren't built using XCode, Apple calls this a "service ID", and it can be configured under "[Services IDs](https://developer.apple.com/account/resources/identifiers/list/serviceId)" in "Certificates, Identifiers & Profiles" in the Apple Developer console. (For apps made using XCode, like the iOS app, the bundle identifier is used as the client ID.) For Google, this configuration is done under "[Credentials](https://console.cloud.google.com/apis/credentials)" in the Google Cloud console.
+
+### On web
+
+We'll cover web details first, because web is treated as the "general use" case for services like this, and then platform-specific tools are built on top of that, which we'll cover afterwards.
+
+Both services also provide official Javascript libraries for integrating their services on web platforms. Using these libraries offers improved security and decreased maintenance burden over using the APIs directly, as Google notes while they heavily discourage using their auth APIs directly; but they also add additional constraints, which will be described later in the document.
+
+How the third party sends the token in step 3 depends on the third party's implementation and the app's configuration. In both Apple and Google's case, there are two main modes: "pop-up", and "redirect".
+
+#### Redirect mode
+
+From the user's perspective, redirect mode will usually look like opening the third party's sign-in page in the same browser window, and then redirecting back to the client app in that window. But re-use of the same window isn't required. The key point is the redirection back to the client app, via the third-party sign-in form making an HTTPS request.
+
+In both the Google and Apple JS libraries, the request endpoint, found at the "redirect URI", must handle a POST request with form data in the body, which contains the token we need to send to the client back-end API. This pattern is not easily implemented with the existing single-page web app, and so we use the other mode: "pop-up mode".
+
+The redirect URI must match a URI in the Google or Apple client ID configuration.
+
+#### Pop-up mode
+
+Pop-up mode opens a pop-up window to show the third-party sign-in form. But it also changes how tokens are given to the client app. Instead of an HTTPS request, they are returned by the JS libraries in memory, either via a callback (Google) or a promise (Apple).
+
+Apple and Google both check that the client app is running on an allowed domain. The sign-in process will fail otherwise. Google allows localhost, but Apple does not, and so testing Apple in development environments requires hosting the client app on a domain that the Apple client ID (or "service ID", in Apple's case) has been configured with.
+
+In the case of Apple, sometimes it will silently fail at the very end of the sign-in process, where the only sign that something is wrong is that the pop-up fails to close. In this case, it's very likely that configuration mismatch is the issue.
+
+In addition, Apple will require a valid redirect URI be provided in the library's configuration, even though it is not used.
+
+### Considerations for non-web platforms
+
+For apps that aren't web-based, there are other options:
+
+Sign in with Google provides libraries on [Android](https://developers.google.com/identity/sign-in/android/start) and [iOS](https://developers.google.com/identity/sign-in/ios/start) to use that will authenticate the mobile app is who it says it is, via app signing. For React Native, we use the [react-native-google-signin](https://github.com/react-native-google-signin/google-signin) wrapper to use these libraries.
+
+The [iOS implementation for Sign in with Apple](https://developer.apple.com/documentation/authenticationservices/implementing_user_authentication_with_sign_in_with_apple?language=objc) can also verify the app's bundle ID and the team who signed it. We use the [react-native-apple-authentication](https://github.com/invertase/react-native-apple-authentication) wrapper library for this.
+
+There is no official library for Sign in with Apple on Android, so it has to work with the web tooling; but Android can't meet the requirements of the official JS library. It isn't hosted on a domain, which is required for pop-up flow, and can't receive an HTTPS request, which is required for redirect flow with the official JS library. To deal with this, react-native-apple-authentication's implementation uses a webview on Android, which can intercept the redirect POST and pass the data directly to the react-native app.
+
+#### Issues with third-party sign-in and Electron
+
+These tools aren't built with Electron or similar desktop apps in mind, and that presents similar challenges as Sign in with Apple for Android:
+
+1. Like mobile platforms, Electron does not have the option of validating the origin of the client app authentication request using a registered HTTPS domain
+2. Unlike many mobile platforms, there are not official tools for Electron or desktop apps in general.
+3. Attempts to get Electron to work like web are either blocked by the third-party authentication provider, broken, or inadvisable.
+
+These are the specific issues we've seen:
+
+1. [Google stopped allowing its sign-in page to render inside embedded browser frameworks](https://security.googleblog.com/2019/04/better-protection-against-man-in-middle.html) such as Electron. This means we can't open the sign-in flow inside the an Electron window. However, opening the sign-in form in the user's default web browser did work.
+2. On the other hand, opening the Sign in with Apple form in the user's default browser instead of Electron does _not_ work, and renders an Apple page with an empty body instead of the sign-in form.
+
+We decided to instead redirect the user to a dedicated page in the web app to sign in. Apple and Google each have their own routes, `/sign-in-with-apple` and `/sign-in-with-google`, where the user is shown another button to click to start the sign-in process on web (since it shows a pop-up, the user must click the button directly, otherwise the pop-up would be blocked). After signing in, the user will be shown a deep link prompt in the browser to open the desktop app, where they will be signed in using a short-lived token from the Expensify API.
+
+Due to Expensify's expectation that a user will be using the same account on web and desktop, we do not go through this process if the user was already signed in, but instead the web app prompts the user to go back to desktop again, which will also sign them in on the desktop app.
+
+## Additional design constraints
+
+### New Google web library limits button style choices
+
+The current Sign in with Google library for web [does not allow arbitrary customization of the sign-in button](https://developers.google.com/identity/gsi/web/guides/offerings#sign_in_with_google_button). (The recently deprecated version of the Sign in with Google for web did offer this capability.)
+
+This means the button is limited in design: there are no offline or hover states, and there can only be a white background for the button. We were able to get the official Apple button options to match, so we used the Google options as the starting point for the design.
+
+### Sign in with Apple does not allow `localhost`
+
+Unlike Google, Apple does not allow `localhost` as a domain to host a pop-up or redirect to. In order to test Sign in with Apple on web or desktop, this means we have to:
+
+1. Use SSH tunneling to host the app on an HTTPS domain
+2. Create a test Apple Service ID configuration in the Apple developer console, to allow testing the sign-in flow from its start until the point Apple sends its token to the Expensify app.
+3. Use token interception on Android to test the web and desktop sign-in flow from the point where the front-end Expensify app has received a token, until the point where the user is signed in to Expensify using that token.
+
+These steps are covered in more detail in the "testing" section below.
+
+# Testing Apple/Google sign-in
+
+Due to some technical constraints, Apple and Google sign-in may require additional configuration to be able to work in the development environment as expected. This document describes any additional steps for each platform.
+
+## Apple
+
+### iOS/Android
+
+The iOS and Android implementations do not require extra steps to test, aside from signing into an Apple account on the iOS device before being able to use Sign in with Apple.
+
+### Web and desktop
+
+#### Render the web Sign In with Apple button in development
+
+The Google Sign In button renders differently in development mode. To prevent confusion
+for developers about a possible regression, we decided to not render third party buttons in
+development mode.
+
+To show the Apple Sign In button in development mode, you can comment out the following code in the
+LoginForm.js file:
+
+```js
+if (CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV) {
+ return;
+}
+```
+
+#### Port requirements
+
+The Sign in with Apple process will break after the user signs in if the pop-up process is not started from a page at an HTTPS domain registered with Apple. To fix this, you could make a new configuration with your own HTTPS domain, but then the Apple configuration won't match that of Expensify's backend.
+
+So to be able to test this, we have two parts:
+1. Create a valid Sign in with Apple token using valid configuration for the Expensify app, by creating and intercepting one on Android
+2. Host the development web app at an HTTPS domain using SSH tunneling, and in the web app use a custom Apple config with this HTTPS domain registered
+
+Requirements:
+1. Authorization on an Apple Development account or team to create new Service IDs
+2. An SSH tunneling tool that provides static HTTPS domains. [ngrok](https://ngrok.com) is a good choice that provides one static HTTPS domain for a free account.
+
+#### Generate the token to use
+
+**Note**: complete this step before changing other configuration to test Apple on web and desktop, as updating those will cause Android to stop working while the configuration is changed.
+
+On an Android build, alter the `AppleSignIn` component to log the token generated, instead of sending it to the Expensify API:
+
+```js
+// .then((token) => Session.beginAppleSignIn(token))
+ .then((token) => console.log("TOKEN: ", token))
+```
+
+If you need to check that you received the correct data, check it on [jwt.io](https://jwt.io), which will decode it if it is a valid JWT token. It will also show when the token expires.
+
+Hardcode this token into `Session.beginAppleSignIn`, and but also verify a valid token was passed into the function, for example:
+
+```
+function beginAppleSignIn(idToken) {
++ // Show that a token was passed in, without logging the token, for privacy
++ window.alert(`ORIGINAL ID TOKEN LENGTH: ${idToken.length}`);
++ const hardcodedToken = '...';
+ const {optimisticData, successData, failureData} = signInAttemptState();
++ API.write('SignInWithApple', {idToken: hardcodedToken}, {optimisticData, successData, failureData});
+- API.write('SignInWithApple', {idToken}, {optimisticData, successData, failureData});
+}
+```
+
+#### Configure the SSH tunneling
+
+You can use any SSH tunneling service that allows you to configure custom subdomains so that we have a consistent address to use. We'll use ngrok in these examples, but ngrok requires a paid account for this. If you need a free option, try serveo.net.
+
+After you've set ngrok up to be able to run on your machine (requires configuring a key with the command line tool, instructions provided by the ngrok website after you create an account), test hosting the web app on a custom subdomain. This example assumes the development web app is running at `localhost:8082`:
+
+```
+ngrok http 8082 --host-header="localhost:8082" --subdomain=mysubdomain
+```
+
+The `--host-header` flag is there to avoid webpack errors with header validation. In addition, add `allowedHosts: 'all'` to the dev server config in `webpack.dev.js`:
+
+```js
+devServer: {
+ ...,
+ allowedHosts: 'all',
+}
+```
+
+#### Configure Apple Service ID
+
+Now that you have an HTTPS address to use, you can create an Apple Service ID configuration that will work with it.
+
+1. Create a new app ID on your Apple development team that can be used to test this, following the instructions [here](https://github.com/invertase/react-native-apple-authentication/blob/main/docs/INITIAL_SETUP.md).
+2. Create a new service ID following the instructions [here](https://github.com/invertase/react-native-apple-authentication/blob/main/docs/ANDROID_EXTRA.md). For allowed domains, enter your SSH tunnel address (e.g., `https://mysubdomain.ngrok-free.app`), and for redirect URLs, just make up an endpoint, it's never actually invoked (e.g., `mysubdomain.ngrok-free.app/appleauth`).
+
+Notes:
+- Depending on your Apple account configuration, you may need additional permissions to access some of the features described in the instructions above.
+- While the Apple Sign In configuration requires a `clientId`, the Apple Developer console calls this a `Service ID`.
+
+Finally, edit `.env` to use your client (service) ID and redirect URL config:
+
+```
+ASI_CLIENTID_OVERRIDE=com.example.test
+ASI_REDIRECTURI_OVERRIDE=https://mysubdomain.ngrok-free.app/appleauth
+```
+
+#### Run the app
+
+Remember that you will need to restart the web server if you make a change to the `.env` file.
+
+### Desktop
+
+Desktop will require the same configuration, with these additional steps:
+
+#### Configure web app URL in .env
+
+Add `NEW_EXPENSIFY_URL` to .env, and set it to the HTTPS URL where the web app can be found, for example:
+
+```
+NEW_EXPENSIFY_URL=https://subdomain.ngrok-free.app
+```
+
+This is required because the desktop app needs to know the address of the web app, and must open it at the HTTPS domain configured to work with Sign in with Apple.
+
+Note that changing this value to a domain that isn't configured for use with Expensify will cause Android to break, as it is still using the real client ID, but now has an incorrect value for `redirectURI`.
+
+#### Set Environment to something other than "Development"
+
+The DeepLinkWrapper component will not handle deep links in the development environment. To be able to test deep linking, you must set the environment to something other than "Development".
+
+Within the `.env` file, set `envName` to something other than "Development", for example:
+
+```
+envName=Staging
+```
+
+Alternatively, within the `DeepLinkWrapper/index.website.js` file you can set the `CONFIG.ENVIRONMENT` to something other than "Development".
+
+#### Handle deep links in dev on MacOS
+
+If developing on MacOS, the development desktop app can't handle deeplinks correctly. To be able to test deeplinking back to the app, follow these steps:
+
+1. Create a "real" build of the desktop app, which can handle deep links, open the build folder, and install the dmg there:
+
+```
+npm run desktop-build --publish=never
+open desktop-build
+# Then double-click "NewExpensify.dmg" in Finder window
+```
+
+2. Even with this build, the deep link may not be handled by the correct app, as the development Electron config seems to intercept it sometimes. To manage this, install [SwiftDefaultApps](https://github.com/Lord-Kamina/SwiftDefaultApps), which adds a preference pane that can be used to configure which app should handle deep links.
+
+## Google
+
+### Web
+
+#### Render the web Sign In with Google button in Development
+
+The Google Sign In button renders differently in development mode. To prevent confusion
+for developers about a possible regression, we decided to not render third party buttons in
+development mode.
+
+To show the Google Sign In button in development mode, you can comment out the following code in the
+LoginForm.js file:
+
+```js
+if (CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV) {
+ return;
+}
+```
+
+#### Port requirements
+
+Google allows the web app to be hosted at localhost, but according to the
+current Google console configuration for the Expensify client ID, it must be
+hosted on port 8082.
+
+### Desktop
+
+#### Set Environment to something other than "Development"
+
+The DeepLinkWrapper component will not handle deep links in the development environment. To be able to test deep linking, you must set the environment to something other than "Development".
diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md
index 6d8d4a446428..0d6774792c45 100644
--- a/contributingGuides/TS_STYLE.md
+++ b/contributingGuides/TS_STYLE.md
@@ -23,6 +23,7 @@
- [1.16 Reusable Types](#reusable-types)
- [1.17 `.tsx`](#tsx)
- [1.18 No inline prop types](#no-inline-prop-types)
+ - [1.19 Satisfies operator](#satisfies-operator)
- [Exception to Rules](#exception-to-rules)
- [Communication Items](#communication-items)
- [Migration Guidelines](#migration-guidelines)
@@ -101,7 +102,7 @@ type Foo = {
-- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exception is the `global.d.ts` file in which third party packages can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation.
+- [1.2](#d-ts-extension) **`d.ts` Extension**: Do not use `d.ts` file extension even when a file contains only type declarations. Only exceptions are `src/types/global.d.ts` and `src/types/modules/*.d.ts` files in which third party packages can be modified using module augmentation. Refer to the [Communication Items](#communication-items) section to learn more about module augmentation.
> Why? Type errors in `d.ts` files are not checked by TypeScript [^1].
@@ -358,7 +359,7 @@ type Foo = {
-- [1.15](#file-organization) **File organization**: In modules with platform-specific implementations, create `types.ts` to define shared types. Import and use shared types in each platform specific files.
+- [1.15](#file-organization) **File organization**: In modules with platform-specific implementations, create `types.ts` to define shared types. Import and use shared types in each platform specific files. Do not use [`satisfies` operator](#satisfies-operator) for platform-specific implementations, always define shared types that complies with all variants.
> Why? To encourage consistent API across platform-specific implementations. If you're migrating module that doesn't have a default implement (i.e. `index.ts`, e.g. `getPlatform`), refer to [Migration Guidelines](#migration-guidelines) for further information.
@@ -458,6 +459,34 @@ type Foo = {
}
```
+
+
+- [1.19](#satisfies-operator) **Satisfies Operator**: Use the `satisfies` operator when you need to validate that the structure of an expression matches a specific type, without affecting the resulting type of the expression.
+
+ > Why? TypeScript developers often want to ensure that an expression aligns with a certain type, but they also want to retain the most specific type possible for inference purposes. The `satisfies` operator assists in doing both.
+
+ ```ts
+ // BAD
+ const sizingStyles = {
+ w50: {
+ width: '50%',
+ },
+ mw100: {
+ maxWidth: '100%',
+ },
+ } as const;
+
+ // GOOD
+ const sizingStyles = {
+ w50: {
+ width: '50%',
+ },
+ mw100: {
+ maxWidth: '100%',
+ },
+ } satisfies Record;
+ ```
+
## Exception to Rules
Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the `#expensify-open-source` Slack channel with your message prefixed with `TS EXCEPTION:`. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily.
@@ -472,9 +501,11 @@ This rule will apply until the migration is done. After the migration, discussio
- I think types definitions in a third party library is incomplete or incorrect
-When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/global.d.ts`.
+When the library indeed contains incorrect or missing type definitions and it cannot be updated, use module augmentation to correct them. All module augmentation code should be contained in `/src/types/modules/*.d.ts`, each library as a separate file.
```ts
+// external-library-name.d.ts
+
declare module "external-library-name" {
interface LibraryComponentProps {
// Add or modify typings
@@ -487,6 +518,8 @@ declare module "external-library-name" {
> This section contains instructions that are applicable during the migration.
+- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`).
+
- If you're migrating a module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), convert `index.website.js` to `index.ts`. Without `index.ts`, TypeScript cannot get type information where the module is imported.
- Deprecate the usage of `underscore`. Use vanilla methods from JS instead. Only use `lodash` when there is no easy vanilla alternative (eg. `lodashMerge`). eslint: [`no-restricted-imports`](https://eslint.org/docs/latest/rules/no-restricted-imports)
diff --git a/desktop/main.js b/desktop/main.js
index 3a153b4d13c5..b19bef060ba9 100644
--- a/desktop/main.js
+++ b/desktop/main.js
@@ -11,7 +11,7 @@ const CONFIG = require('../src/CONFIG').default;
const CONST = require('../src/CONST').default;
const Localize = require('../src/libs/Localize');
-const port = process.env.PORT || 8080;
+const port = process.env.PORT || 8082;
const {DESKTOP_SHORTCUT_ACCELERATOR} = CONST;
app.setName('New Expensify');
diff --git a/docs/Card-Rev-Share-for-Approved-Partners.md b/docs/Card-Rev-Share-for-Approved-Partners.md
new file mode 100644
index 000000000000..9b5647a004d3
--- /dev/null
+++ b/docs/Card-Rev-Share-for-Approved-Partners.md
@@ -0,0 +1,17 @@
+---
+title: Expensify Card revenue share for ExpensifyApproved! partners
+description: Earn money when your clients adopt the Expensify Card
+---
+
+
+# About
+Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. We're offering 0.5% of the total Expensify Card spend of your clients in cashback returned to your firm. The more your clients spend, the more cashback your firm receives!
+ This program is currently only available to US-based ExpensifyApproved! partner accountants.
+
+# How-to
+To benefit from this program, all you need to do is ensure that you are listed as a domain admin on your client's Expensify account. If you're not currently a domain admin, your client can follow the instructions outlined in [our help article](https://community.expensify.com/discussion/5749/how-to-add-and-remove-domain-admins#:~:text=Domain%20Admins%20have%20total%20control,a%20member%20of%20the%20domain.) to assign you this role.
+# FAQ
+- What if my firm is not permitted to accept revenue share from our clients?
+ We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.
+- What if my firm does not wish to participate in the program?
+ Please reach out to your assigned partner manager at new.expensify.com to inform them you would not like to accept the revenue share nor do you want to pass the revenue share to your clients.
diff --git a/docs/_includes/search-toggle.html b/docs/_includes/search-toggle.html
new file mode 100644
index 000000000000..caa11c63c46f
--- /dev/null
+++ b/docs/_includes/search-toggle.html
@@ -0,0 +1,5 @@
+
diff --git a/docs/_includes/sidebar-search.html b/docs/_includes/sidebar-search.html
new file mode 100644
index 000000000000..a365c812e031
--- /dev/null
+++ b/docs/_includes/sidebar-search.html
@@ -0,0 +1,13 @@
+
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index e0ed71b46818..39d62bb0ea9c 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -2,7 +2,7 @@
-
+
Expensify Help
@@ -12,12 +12,18 @@
+
+
+
+
{% seo %}
-
+
+
+
-
-
-
+
+
+
+ {% include search-toggle.html %}
+
+
+ {% include sidebar-search.html id="sidebar-layer" %}
+
{% if page.url == "/" or page.url contains "/hubs/" %}
diff --git a/docs/_sass/_colors.scss b/docs/_sass/_colors.scss
index d9fa10d24b8e..b34a7d13b7f0 100644
--- a/docs/_sass/_colors.scss
+++ b/docs/_sass/_colors.scss
@@ -1,9 +1,14 @@
$color-green400: #03D47C;
$color-green-icons: #8B9C8F;
$color-green-borders: #1A3D32;
+$color-button-background: #1A3D32;
+$color-button-hovered: #2C6755;
$color-green-highlightBG: #07271F;
+$color-green-highlightBG-hover: #06231c;
$color-green-appBG: #061B09;
+$color-green-hover: #00a862;
$color-light-gray-green: #AFBBB0;
$color-blue300: #5AB0FF;
$color-blue200: #B0D9FF;
$color-white: #E7ECE9;
+$color-gray-label: #afbbb0;
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index ebf96476bc0d..720bc95c0732 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -1,9 +1,11 @@
@import 'breakpoints';
@import 'colors';
@import 'fonts';
+@import 'search-bar';
$color-appBG: $color-green-appBG;
$color-highlightBG: $color-green-highlightBG;
+$color-accent : $color-green400;
$color-borders: $color-green-borders;
$color-icons: $color-green-icons;
$color-text: $color-white;
@@ -11,6 +13,8 @@ $color-link: $color-blue300;
$color-link-hovered: $color-blue200;
$color-success: $color-green400;
$color-text-supporting: $color-light-gray-green;
+$color-green-hover: $color-green-hover;
+$color-gray-label: $color-gray-label;
* {
margin: 0;
@@ -182,6 +186,18 @@ button {
align-content: center;
}
+.flex {
+ display: -webkit-box;
+ display: -moz-box;
+ display: -ms-flexbox;
+ display: -moz-flex;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-flow: row wrap;
+ flex-flow: row wrap;
+ align-content: space-between;
+}
+
#lhn {
position: fixed;
background-color: $color-highlightBG;
@@ -524,6 +540,7 @@ button {
.base-icon {
width: 20px;
height: 20px;
+ cursor: pointer;
}
.homepage {
diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss
new file mode 100644
index 000000000000..ce085878af46
--- /dev/null
+++ b/docs/_sass/_search-bar.scss
@@ -0,0 +1,270 @@
+@import 'breakpoints';
+@import 'colors';
+@import 'fonts';
+
+$color-appBG: $color-green-appBG;
+$color-highlightBG: $color-green-highlightBG;
+$color-highlightBG-hover: $color-green-highlightBG-hover;
+$color-accent : $color-green400;
+$color-borders: $color-green-borders;
+$color-icons: $color-green-icons;
+$color-text: $color-white;
+$color-link: $color-blue300;
+$color-link-hovered: $color-blue200;
+$color-success: $color-green400;
+$color-text-supporting: $color-light-gray-green;
+$color-green-hover: $color-green-hover;
+$color-gray-label: $color-gray-label;
+
+.search-icon {
+ margin: auto 0px;
+}
+
+#sidebar-search {
+ background-color: $color-appBG;
+ width: 375px;
+ height: 100vh;
+ position: fixed;
+ display: block;
+ top: 0;
+ right: 0;
+ z-index: 2;
+}
+
+@media only screen and (max-width: $breakpoint-tablet) {
+ #sidebar-search {
+ width: 100%;
+ }
+}
+
+.searchbar-title-wrapper {
+ padding: 20px;
+}
+
+.search-title {
+ font-size: 17px;
+ padding-bottom: 20px;
+}
+
+#toggle-search-close {
+ margin: auto;
+ margin-left: 0px;
+ margin-right: 10px;
+}
+
+/* Sidebar Layer */
+#sidebar-layer {
+ position: fixed;
+
+ /* Sit on top of the page content */
+ display: none;
+
+ /* Hidden by default */
+ width: 100%;
+
+ /* Full width (cover the whole page) */
+ height: 100%;
+
+ /* Full height (cover the whole page) */
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.4);
+ z-index: 1;
+}
+
+/* All gsc id & class are Google Search relate gcse_0 is the search bar parent & gcse_1 is the search result list parent */
+#___gcse_0 {
+ margin-left: 20px;
+}
+
+/* This input is in #___gcse_0 search bar */
+input#gsc-i-id1.gsc-input {
+ background-color: $color-appBG;
+ color: #E7ECE9;
+ font-family: "ExpensifyNeue", "Segoe UI Emoji", "Noto Color Emoji" !important;
+}
+
+/* These below #gsc-iw-id1, .gsc-input-box & .gsib_a are inner wrapper of search bar input */
+#gsc-iw-id1 {
+ background-color: $color-appBG;
+ border-bottom: $color-borders 2px solid;
+ border-bottom-left-radius: 0px;
+
+ &:focus-within {
+ border-bottom: $color-accent 2px solid;
+ }
+}
+
+.gsc-input-box .gsib_a {
+ padding: 5px 9px 4px 0px;
+}
+
+.search-icon {
+ margin-left: auto;
+}
+
+/* This is the close icon on search bar */
+.gsib_b .gsst_a .gscb_a {
+ color: $color-icons;
+
+ &:hover {
+ color: $color-text;
+ }
+}
+
+/* This is to manage hover on parent close icon and make it the same effect on close icon */
+.gsst_a:hover {
+
+ .gscb_a {
+ color: $color-text !important;
+ }
+}
+
+/* Manage Google Search label animation */
+input#gsc-i-id1:focus+label.search-label,
+input#gsc-i-id1:valid+label.search-label,
+input#gsc-i-id1:active+label.search-label {
+ transform: translateY(-100%) scale(0.80);
+}
+
+label.search-label {
+ display: block;
+ position: absolute;
+ margin-top: -20px;
+ font-size: 15px;
+ font-family: "ExpensifyNeue", "Segoe UI Emoji", "Noto Color Emoji";
+ transform: translateY(-50%);
+ left: 20px;
+ color: $color-gray-label;
+ transform-origin: left top;
+ user-select: none;
+ transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1), color 150ms cubic-bezier(0.4, 0, 0.2, 1), top 500ms;
+}
+
+/* Hide the relevance, Ads, Branding, find more button & etc sections */
+.gsc-above-wrapper-area,
+.gsc-webResult.gsc-result .gsc-url-top,
+.gsc-results-wrapper-visible .gsc-adBlock,
+.gcsc-more-maybe-branding-root,
+.gcsc-find-more-on-google-root {
+ display: none;
+}
+
+.gsc-control-cse {
+ background-color: $color-appBG;
+ border: $color-appBG;
+ font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif !important;
+ max-height: 80vh;
+ overflow-y: scroll;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+/* Hide the scrollbar */
+.gsc-control-cse::-webkit-scrollbar {
+ display: none;
+}
+
+.gs-title {
+ font-weight: bold;
+}
+
+/* Change the Google Search Button icon into Expensify icon button */
+.gsc-search-button.gsc-search-button-v2 {
+ padding: 10px;
+ margin-top: -7px;
+ margin-left: 15px;
+ margin-right: 20px;
+ border-radius: 25px;
+ background-color: $color-green400;
+ cursor: pointer;
+ width: 40px;
+ height: 40px;
+}
+
+.gsc-search-button.gsc-search-button-v2:hover {
+ background-color: $color-green-hover;
+}
+
+.gsc-search-button.gsc-search-button-v2 svg {
+ fill: $color-text;
+ height: auto;
+ width: auto;
+}
+
+/* Change the path of the Google Search Button icon into Expensify icon */
+.gsc-search-button.gsc-search-button-v2 svg path {
+ d: path('M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z');
+ fill-rule: evenodd;
+ clip-rule: evenodd;
+}
+
+.gsc-resultsbox-visible .gsc-webResult .gsc-result {
+ border-bottom: none;
+}
+
+
+/* Change Font the Google Search result */
+.gsc-control-cse .gsc-table-result {
+ font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif !important;
+}
+
+/* Change Font result Paragraph color */
+.gsc-results .gs-webResult:not(.gs-no-results-result):not(.gs-error-result) .gs-snippet, .gs-fileFormatType {
+ color: $color-text;
+}
+
+
+/* Change the color of the Google Search Suggestion font */
+.gs-spelling.gs-result {
+ color: $color-text;
+}
+
+/* Pagination related style */
+.gsc-resultsbox-visible .gsc-results .gsc-cursor-box {
+ text-align: center;
+}
+
+.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page {
+ margin: 4px;
+ width: 28px;
+ height: 28px;
+ border-radius: 25px;
+ display: inline-block;
+ line-height: 2.5;
+ background-color: $color-accent;
+ font-weight: bold;
+ font-size: 11px;
+}
+
+
+/* Change the color & background of Google Search Pagination */
+.gsc-cursor-next-page,
+.gsc-cursor-final-page {
+ color: $color-text;
+ background-color: $color-appBG;
+}
+
+/* Change the color & background of Google Search Current Page */
+.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page.gsc-cursor-current-page {
+ background-color: $color-accent;
+ color: $color-text;
+
+ &:hover {
+ text-decoration: none;
+ background-color: $color-accent;
+ }
+}
+
+/* Change the color & background of Google Search of Other Page */
+.gsc-resultsbox-visible .gsc-results .gsc-cursor-box .gsc-cursor-page {
+ background-color: $color-button-background;
+ color: $color-text;
+
+ &:hover {
+ background-color: $color-button-hovered;
+ text-decoration: none;
+ }
+}
diff --git a/docs/annotations.xml b/docs/annotations.xml
new file mode 100644
index 000000000000..adb06b135f25
--- /dev/null
+++ b/docs/annotations.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/docs/articles/other/Card-Rev-Share-for-Approved-Partners.md b/docs/articles/other/Card-Rev-Share-for-Approved-Partners.md
index 9b5647a004d3..44614d506d49 100644
--- a/docs/articles/other/Card-Rev-Share-for-Approved-Partners.md
+++ b/docs/articles/other/Card-Rev-Share-for-Approved-Partners.md
@@ -4,8 +4,7 @@ description: Earn money when your clients adopt the Expensify Card
---
-# About
-Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. We're offering 0.5% of the total Expensify Card spend of your clients in cashback returned to your firm. The more your clients spend, the more cashback your firm receives!
+Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. **In short, your firm gets 0.5% of your clients’ total Expensify Card spend as cash back**. The more your clients spend, the more cashback your firm receives!
This program is currently only available to US-based ExpensifyApproved! partner accountants.
# How-to
diff --git a/docs/articles/other/Enable-Location-Access-on-Web.md b/docs/articles/other/Enable-Location-Access-on-Web.md
new file mode 100644
index 000000000000..6cc0d19e4cde
--- /dev/null
+++ b/docs/articles/other/Enable-Location-Access-on-Web.md
@@ -0,0 +1,55 @@
+---
+title: Enable Location Access on Web
+description: How to enable location access for Expensify websites on your browser
+---
+
+
+# About
+
+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 how to enable location settings on the three most common web browsers below. If your browser is not in the list then please do a web search for your browser and "enable location settings".
+
+# How-to
+
+
+### 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 make sure expensify.com and new.expensify.com are not listed. If they are, click the delete icon next to them to allow location access
+
+[Chrome help page](https://support.google.com/chrome/answer/142065)
+
+### 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
+
+[Firefox help page](https://support.mozilla.org/en-US/kb/permissions-manager-give-ability-store-passwords-set-cookies-more)
+
+### 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"
+
+Ask: The site must ask if it can use your location.
+Deny: The site can’t use your location.
+Allow: The site can always use your location.
+
+[Safari help page](https://support.apple.com/guide/safari/websites-ibrwe2159f50/mac)
diff --git a/docs/articles/other/Everything-About-Chat.md b/docs/articles/other/Everything-About-Chat.md
index 5ab435eb0523..d52932daa5ff 100644
--- a/docs/articles/other/Everything-About-Chat.md
+++ b/docs/articles/other/Everything-About-Chat.md
@@ -65,3 +65,23 @@ You can find your display mode by clicking on your User Icon > Preferences > Pri
If the person you want to chat with doesn’t appear in your contact list, simply type their email or phone number to invite them to chat! From there, they will receive an email with instructions and a link to create an account.
Once they click the link, a new.expensify.com account is set up for them automatically (if they don't have one already), and they can start chatting with you immediately!
+
+## Flagging content as offensive
+In order to maintain a safe community for our users, Expensify provides tools to report offensive content and unwanted behavior in Expensify Chat. If you see a message (or attachment/image) from another user that you’d like our moderators to review, you can flag it by clicking the flag icon in the message context menu (on desktop) or holding down on the message and selecting “Flag as offensive” (on mobile).
+
+![Moderation Context Menu](https://help.expensify.com/assets/images/moderation-context-menu.png){:width="100%"}
+
+Once the flag is selected, you will be asked to categorize the message (such as spam, bullying, and harassment). Select what you feel best represents the issue is with the content, and you’re done - the message will be sent off to our internal team for review.
+
+![Moderation Flagging Options](https://help.expensify.com/assets/images/moderation-flag-page.png){:width="100%"}
+
+Depending on the severity of the offense, messages can be hidden (with an option to reveal) or fully removed, and in extreme cases, the sender of the message can be temporarily or permanently blocked from posting.
+
+You will receive a whisper from Concierge any time your content has been flagged, as well as when you have successfully flagged a piece of content.
+
+![Moderation Reportee Whisper](https://help.expensify.com/assets/images/moderation-reportee-whisper.png){:width="100%"}
+![Moderation Reporter Whisper](https://help.expensify.com/assets/images/moderation-reporter-whisper.png){:width="100%"}
+
+*Note: Any message sent in public chat rooms are automatically reviewed by an automated system looking for offensive content and sent to our moderators for final decisions if it is found.*
+
+
diff --git a/docs/articles/playbooks/Expensify-Chat-Playbook-for-Conferences.md b/docs/articles/playbooks/Expensify-Chat-Playbook-for-Conferences.md
index c5a06a3b5d3e..2c82c2d04273 100644
--- a/docs/articles/playbooks/Expensify-Chat-Playbook-for-Conferences.md
+++ b/docs/articles/playbooks/Expensify-Chat-Playbook-for-Conferences.md
@@ -97,7 +97,7 @@ We find chat to be a powerful way to not only engage your attendees, but direct
- #social: Have your employees in this room sharing fun photos, stoking conversations, and responding to any questions or feedback.
- Speaker rooms: Encourage your employees to jump in to comment on content and nudge attendees to engage with each other during sessions.
-*Protip*: Expensify Chat has moderation tools to help flag comments deemed to be spam, inconsiderate, intimidating, bullying, harassment, assault. On any comment, just click the flag icon to moderate conversation.
+*Protip*: Expensify Chat has [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 7: Follow up with attendees after the event
diff --git a/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md
index d7bdef860cf7..849932a33c2d 100644
--- a/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md
+++ b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md
@@ -100,7 +100,7 @@ This is essentially like setting a daily or individual expense limitation on any
*Receipt Required Amount: $75*
Receipts are important, and in most cases you prefer an itemized receipt. However, Expensify will generate an IRS-compliant electronic receipt (not itemized) for every expense not tied to hotels expense. For this reason, it’s important to enforce a rule where anytime an employee is on the road, and making business-related purchases at hotel (which happens a lot!), they are required to attach a physical receipt.
-![Expense Basics](https://help.expensify.com/assets/images/playbook-expense-basics.png)
+![Expense Basics](https://help.expensify.com/assets/images/playbook-expense-basics.png){:width="100%"}
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).
@@ -117,7 +117,7 @@ Between Expensify's SmartScan technology, automatic categorization, and [DoubleC
Expenses with violations will stay behind for the employee to fix, while expenses that are “in-policy” will move into an approver’s queue to mitigate any potential for delays. Scheduled Submit will ensure all expenses are submitted automatically for approval.
-![Scheduled submit](https://help.expensify.com/assets/images/playbook-scheduled-submit.png)
+![Scheduled submit](https://help.expensify.com/assets/images/playbook-scheduled-submit.png){:width="100%"}
> _We spent twice as much time and effort on expenses without getting nearly as accurate of results as with Expensify._
>
@@ -151,7 +151,7 @@ We recommend you select *Advanced Approval* as your Approval Mode to set up a mi
*Import your employees in bulk via CSV*
Given the amount of employees you have, it’s best you import employees in bulk via CSV. You can learn more about using a CSV file to bulk upload employees with *Advanced Approval [here](https://community.expensify.com/discussion/5735/deep-dive-the-ins-and-outs-of-advanced-approval)*
-![Bulk import your employees](https://help.expensify.com/assets/images/playbook-impoort-employees.png)
+![Bulk import your employees](https://help.expensify.com/assets/images/playbook-impoort-employees.png){:width="100%"}
*Manually Approve All Reports*
In most cases, at this stage, approvers prefer to review all expenses for a few reasons. 1) We want to make sure expense coding is accurate, and 2) We want to make sure there are no bad actors before we export transactions to our accounting system.
@@ -182,7 +182,7 @@ Expensify supports direct card feeds from most financial institutions. Setting u
3. Next, assign the corporate cards to your employees by selecting the employee’s email address and the corresponding card number from the two drop-down menus under the *Assign a Card* section
4. Set a transaction start date (this is really important to avoid pulling in multiple outdated historical expenses that you don’t want employees to submit)
-![If you have existing corporate cards](https://help.expensify.com/assets/images/playbook-existing-corporate-card.png)
+![If you have existing corporate cards](https://help.expensify.com/assets/images/playbook-existing-corporate-card.png){:width="100%"}
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.
@@ -235,7 +235,7 @@ Similarly, you can send bills directly from Expensify as well.
3. At this point, you can also upload an attachment to further validate the bill if necessary
4. Click *Submit*, we’ll forward the newly created bill directly to your Supplier.
-![Send bills directly from Expensify](https://help.expensify.com/assets/images/playbook-new-bill.png)
+![Send bills directly from Expensify](https://help.expensify.com/assets/images/playbook-new-bill.png){:width="100%"}
Reports, invoices, and bills are largely the same, in theory, just with different rules. As such, creating a customer invoice is just like creating an expense report and even a bill.
@@ -258,7 +258,7 @@ We recommend reporting:
- *Quarterly* - for budget comparison reporting. Pull up your BI tool and compare your active budgets with your spend reporting here in Expensify
- *Annually* - Run annual spend trend reports with month-over-month spend analysis, and prepare yourself for the upcoming fiscal year.
-![Expenses!](https://help.expensify.com/assets/images/playbook-expenses.png)
+![Expenses!](https://help.expensify.com/assets/images/playbook-expenses.png){:width="100%"}
### 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.
diff --git a/docs/assets/images/close.svg b/docs/assets/images/close.svg
new file mode 100644
index 000000000000..71e4df7ace0c
--- /dev/null
+++ b/docs/assets/images/close.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/docs/assets/images/moderation-context-menu.png b/docs/assets/images/moderation-context-menu.png
new file mode 100644
index 000000000000..55aa17498cf7
Binary files /dev/null and b/docs/assets/images/moderation-context-menu.png differ
diff --git a/docs/assets/images/moderation-flag-page.png b/docs/assets/images/moderation-flag-page.png
new file mode 100644
index 000000000000..e60e2ccb8776
Binary files /dev/null and b/docs/assets/images/moderation-flag-page.png differ
diff --git a/docs/assets/images/moderation-reportee-whisper.png b/docs/assets/images/moderation-reportee-whisper.png
new file mode 100644
index 000000000000..9235a262bef5
Binary files /dev/null and b/docs/assets/images/moderation-reportee-whisper.png differ
diff --git a/docs/assets/images/moderation-reporter-whisper.png b/docs/assets/images/moderation-reporter-whisper.png
new file mode 100644
index 000000000000..881b25268515
Binary files /dev/null and b/docs/assets/images/moderation-reporter-whisper.png differ
diff --git a/docs/assets/images/search.svg b/docs/assets/images/search.svg
new file mode 100644
index 000000000000..9680cc415454
--- /dev/null
+++ b/docs/assets/images/search.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index d5d462b83e50..01ebb00b288c 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -75,9 +75,108 @@ function injectFooterCopywrite() {
footer.innerHTML = `©2008-${new Date().getFullYear()} Expensify, Inc.`;
}
+function closeSidebar() {
+ document.getElementById('sidebar-layer').style.display = 'none';
+
+ // Make the body scrollable again
+ const body = document.body;
+ const scrollY = body.style.top;
+
+ // Reset the position and top styles of the body element
+ body.style.position = '';
+ body.style.top = '';
+
+ // Scroll to the original scroll position
+ window.scrollTo(0, parseInt(scrollY || '0', 10) * -1);
+}
+
+function closeSidebarOnClickOutside(event) {
+ const sidebarLayer = document.getElementById('sidebar-layer');
+
+ if (event.target !== sidebarLayer) {
+ return;
+ }
+ closeSidebar();
+}
+
+function openSidebar() {
+ document.getElementById('sidebar-layer').style.display = 'block';
+
+ // Make body unscrollable
+ const yAxis = document.documentElement.style.getPropertyValue('y-axis');
+ const body = document.body;
+ body.style.position = 'fixed';
+ body.style.top = `-${yAxis}`;
+
+ // Close the sidebar when clicking sidebar layer (outside the sidebar search)
+ const sidebarLayer = document.getElementById('sidebar-layer');
+ if (sidebarLayer) {
+ sidebarLayer.addEventListener('click', closeSidebarOnClickOutside);
+ }
+}
+
+// Function to adapt & fix cropped SVG viewBox from Google based on viewport (Mobile or Tablet-Desktop)
+function changeSVGViewBoxGoogle() {
+ // Get all inline Google SVG elements on the page
+ const svgsGoogle = document.querySelectorAll('svg');
+
+ // Create a media query for screens wider than tablet
+ const mediaQuery = window.matchMedia('(min-width: 800px)');
+
+ // Check if the viewport is smaller than tablet
+ if (!mediaQuery.matches) {
+ Array.from(svgsGoogle).forEach((svg) => {
+ // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view
+ svg.setAttribute('viewBox', '0 0 13 13');
+ svg.setAttribute('height', '13');
+ svg.setAttribute('width', '13');
+ });
+ } else {
+ Array.from(svgsGoogle).forEach((svg) => {
+ // Set the viewBox attribute to '0 0 20 20' to make the svg fit in the tablet-desktop view
+ svg.setAttribute('viewBox', '0 0 20 20');
+ svg.setAttribute('height', '16');
+ svg.setAttribute('width', '16');
+ });
+ }
+}
+
+// Function to insert element after another
+// In this case, we insert the label element after the Google Search Input so we can have the same label animation effect
+function insertElementAfter(referenceNode, newNode) {
+ referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
+}
+
+// Need to wait up until page is load, so the svg viewBox can be changed
+// And the search label can be inserted
+window.addEventListener('load', () => {
+ changeSVGViewBoxGoogle();
+
+ // Add required into the search input
+ const searchInput = document.getElementById('gsc-i-id1');
+ searchInput.setAttribute('required', '');
+
+ // Insert search label after the search input
+ const searchLabel = document.createElement('label');
+ searchLabel.classList.add('search-label');
+ searchLabel.innerHTML = 'Search for something...';
+ insertElementAfter(searchInput, searchLabel);
+});
+
window.addEventListener('DOMContentLoaded', () => {
injectFooterCopywrite();
+ // Handle open & close the sidebar
+ const buttonOpenSidebar = document.getElementById('toggle-search-open');
+ if (buttonOpenSidebar) {
+ buttonOpenSidebar.addEventListener('click', openSidebar);
+ }
+
+ const buttonCloseSidebar = document.getElementById('toggle-search-close');
+ if (buttonCloseSidebar) {
+ buttonCloseSidebar.addEventListener('click', closeSidebar);
+ }
+
if (window.tocbot) {
window.tocbot.init({
// Where to render the table of contents.
@@ -139,5 +238,8 @@ window.addEventListener('DOMContentLoaded', () => {
const scrollingElement = e.target.scrollingElement;
const scrollPercentageInArticleContent = clamp(scrollingElement.scrollTop - articleContent.offsetTop, 0, articleContent.scrollHeight) / articleContent.scrollHeight;
lhnContent.scrollTop = scrollPercentageInArticleContent * lhnContent.scrollHeight;
+
+ // Count property of y-axis to keep scroll position & reference it later for making the body fixed when sidebar opened
+ document.documentElement.style.setProperty('y-axis', `${window.scrollY}px`);
});
});
diff --git a/docs/context.xml b/docs/context.xml
new file mode 100644
index 000000000000..f62520153883
--- /dev/null
+++ b/docs/context.xml
@@ -0,0 +1,33 @@
+
+
+ Expensify Help Search
+ Help Search configuration details
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 60d60934c2ba..92c61cb81b2c 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -22,7 +22,9 @@ platform :android do
gradle(
project_dir: './android',
- task: ':app:assembleE2eRelease',
+ task: ':app:assemble',
+ flavor: 'e2e',
+ build_type: 'Release',
)
end
@@ -33,6 +35,7 @@ platform :android do
gradle(
project_dir: './android',
task: 'assemble',
+ flavor: 'Production',
build_type: 'Release',
)
end
@@ -44,7 +47,8 @@ platform :android do
gradle(
project_dir: './android',
task: 'assemble',
- build_type: 'InternalRelease',
+ flavor: 'Adhoc',
+ build_type: 'Release',
)
aws_s3(
@@ -67,13 +71,14 @@ platform :android do
gradle(
project_dir: './android',
task: 'bundle',
+ flavor: 'Production',
build_type: 'Release',
)
upload_to_play_store(
package_name: "com.expensify.chat",
json_key: './android/app/android-fastlane-json-key.json',
- aab: './android/app/build/outputs/bundle/release/app-release.aab',
+ aab: './android/app/build/outputs/bundle/productionRelease/app-production-release.aab',
track: 'internal',
rollout: '1.0'
)
@@ -111,7 +116,7 @@ platform :ios do
build_app(
workspace: "./ios/NewExpensify.xcworkspace",
- scheme: "NewExpensify"
+ scheme: "New Expensify"
)
end
@@ -138,19 +143,19 @@ platform :ios do
)
install_provisioning_profile(
- path: "./ios/chat_expensify_adhoc.mobileprovision"
+ path: "./ios/expensify_chat_adhoc.mobileprovision"
)
build_app(
workspace: "./ios/NewExpensify.xcworkspace",
skip_profile_detection: true,
- scheme: "NewExpensify",
- xcargs: { :PROVISIONING_PROFILE_SPECIFIER => "chat_expensify_adhoc", },
+ scheme: "New Expensify AdHoc",
+ xcargs: { :PROVISIONING_PROFILE_SPECIFIER => "expensify_chat_adhoc", },
export_method: "ad-hoc",
export_options: {
method: "ad-hoc",
provisioningProfiles: {
- "com.chat.expensify.chat" => "chat_expensify_adhoc",
+ "com.expensify.chat.adhoc" => "expensify_chat_adhoc",
},
manageAppVersionAndBuildNumber: false
}
@@ -197,7 +202,8 @@ platform :ios do
build_app(
workspace: "./ios/NewExpensify.xcworkspace",
- scheme: "NewExpensify",
+ scheme: "New Expensify",
+ output_name: "New Expensify.ipa",
export_options: {
manageAppVersionAndBuildNumber: false
}
diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj
index 414ad71ab217..d87226269a8b 100644
--- a/ios/NewExpensify.xcodeproj/project.pbxproj
+++ b/ios/NewExpensify.xcodeproj/project.pbxproj
@@ -21,12 +21,12 @@
26AF3C3540374A9FACB6C19E /* ExpensifyMono-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */; };
2A9F8CDA983746B0B9204209 /* ExpensifyNeue-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = 52796131E6554494B2DDB056 /* ExpensifyNeue-Bold.otf */; };
30581EA8AAFD4FCE88C5D191 /* ExpensifyNeue-Italic.otf in Resources */ = {isa = PBXBuildFile; fileRef = BF6A4C5167244B9FB8E4D4E3 /* ExpensifyNeue-Italic.otf */; };
+ 34FF0B164B1D8ED1054BFBB6 /* libPods-NewExpensify-NewExpensifyTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6FB387B20AE4E6E98858B6AA /* libPods-NewExpensify-NewExpensifyTests.a */; };
374FB8D728A133FE000D84EF /* OriginImageRequestHandler.mm in Sources */ = {isa = PBXBuildFile; fileRef = 374FB8D628A133FE000D84EF /* OriginImageRequestHandler.mm */; };
+ 5A464BC8112CDB1DE1E38F1C /* libPods-NewExpensify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AEFE6CD54912D427D19133C7 /* libPods-NewExpensify.a */; };
7041848526A8E47D00E09F4D /* RCTStartupTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 7041848426A8E47D00E09F4D /* RCTStartupTimer.m */; };
7041848626A8E47D00E09F4D /* RCTStartupTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 7041848426A8E47D00E09F4D /* RCTStartupTimer.m */; };
70CF6E82262E297300711ADC /* BootSplash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 70CF6E81262E297300711ADC /* BootSplash.storyboard */; };
- 9A65F0F374EC04ABB5FF40AF /* libPods-NewExpensify.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2FD35F00FB84D9FCF60D56A7 /* libPods-NewExpensify.a */; };
- B54A7C3AA98189A600323C02 /* libPods-NewExpensify-NewExpensifyTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F679A86058F8C4B331D239C3 /* libPods-NewExpensify-NewExpensifyTests.a */; };
BDB853621F354EBB84E619C2 /* ExpensifyNewKansas-MediumItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */; };
DD79042B2792E76D004484B4 /* RCTBootSplash.m in Sources */ = {isa = PBXBuildFile; fileRef = DD79042A2792E76D004484B4 /* RCTBootSplash.m */; };
E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
@@ -50,41 +50,53 @@
008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = "
"; };
00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NewExpensifyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 02BE6CF80ED1BD2445267F92 /* Pods-NewExpensify.release development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.release development.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.release development.xcconfig"; sourceTree = ""; };
+ 0B09CE5BDAF34DD3573AB4E2 /* Pods-NewExpensify.debug adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.debug adhoc.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.debug adhoc.xcconfig"; sourceTree = ""; };
0CDA8E33287DD650004ECBEC /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = NewExpensify/AppDelegate.mm; sourceTree = ""; };
0CDA8E36287DD6A0004ECBEC /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = NewExpensify/Images.xcassets; sourceTree = ""; };
+ 0E27AA27706D894246E7946D /* Pods-NewExpensify.debug production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.debug production.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.debug production.xcconfig"; sourceTree = ""; };
0F5BE0CD252686320097D869 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
0F5E534E263B73D5004CA14F /* EnvironmentChecker.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EnvironmentChecker.h; sourceTree = ""; };
0F5E534F263B73FD004CA14F /* EnvironmentChecker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EnvironmentChecker.m; sourceTree = ""; };
- 13B07F961A680F5B00A75B9A /* New Expensify.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "New Expensify.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 13B07F961A680F5B00A75B9A /* New Expensify Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "New Expensify Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = NewExpensify/AppDelegate.h; sourceTree = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = NewExpensify/Info.plist; sourceTree = ""; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = NewExpensify/main.m; sourceTree = ""; };
18D050DF262400AF000D658B /* BridgingFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BridgingFile.swift; sourceTree = ""; };
- 2FD35F00FB84D9FCF60D56A7 /* libPods-NewExpensify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 1DDE5449979A136852B939B5 /* Pods-NewExpensify.release adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.release adhoc.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.release adhoc.xcconfig"; sourceTree = ""; };
+ 34A8FDD1F9AA58B8F15C8380 /* Pods-NewExpensify.release production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.release production.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.release production.xcconfig"; sourceTree = ""; };
374FB8D528A133A7000D84EF /* OriginImageRequestHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = OriginImageRequestHandler.h; path = NewExpensify/OriginImageRequestHandler.h; sourceTree = ""; };
374FB8D628A133FE000D84EF /* OriginImageRequestHandler.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = OriginImageRequestHandler.mm; path = NewExpensify/OriginImageRequestHandler.mm; sourceTree = ""; };
- 37F6DD6E91B4C55BD8DDC895 /* Pods-NewExpensify.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.debug.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.debug.xcconfig"; sourceTree = ""; };
- 391B5D1DB6CFBAC16FD11DC4 /* Pods-NewExpensify.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.release.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.release.xcconfig"; sourceTree = ""; };
+ 3D393D7ABC1092F1DE91397F /* Pods-NewExpensify-NewExpensifyTests.debug staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.debug staging.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.debug staging.xcconfig"; sourceTree = ""; };
+ 432FF5842B766535509FC547 /* Pods-NewExpensify-NewExpensifyTests.debug adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.debug adhoc.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.debug adhoc.xcconfig"; sourceTree = ""; };
44BF435285B94E5B95F90994 /* ExpensifyNewKansas-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNewKansas-Medium.otf"; path = "../assets/fonts/native/ExpensifyNewKansas-Medium.otf"; sourceTree = ""; };
- 52796131E6554494B2DDB056 /* ExpensifyNeue-Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Bold.otf"; path = "../assets/fonts/native/ExpensifyNeue-Bold.otf"; sourceTree = ""; };
+ 52796131E6554494B2DDB056 /* ExpensifyNeue-Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Bold.otf"; path = "../assets/fonts/native/ExpensifyNeue-Bold.otf"; sourceTree = ""; };
+ 6B5211DB0EEB46E12DF4AD2D /* Pods-NewExpensify-NewExpensifyTests.release adhoc.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.release adhoc.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.release adhoc.xcconfig"; sourceTree = ""; };
+ 6BE16DA6EFF88513DB1CD47B /* Pods-NewExpensify-NewExpensifyTests.release staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.release staging.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.release staging.xcconfig"; sourceTree = ""; };
+ 6FB387B20AE4E6E98858B6AA /* libPods-NewExpensify-NewExpensifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify-NewExpensifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
7041848326A8E40900E09F4D /* RCTStartupTimer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RCTStartupTimer.h; path = NewExpensify/RCTStartupTimer.h; sourceTree = ""; };
7041848426A8E47D00E09F4D /* RCTStartupTimer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = RCTStartupTimer.m; path = NewExpensify/RCTStartupTimer.m; sourceTree = ""; };
70CF6E81262E297300711ADC /* BootSplash.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = BootSplash.storyboard; path = NewExpensify/BootSplash.storyboard; sourceTree = ""; };
- 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-BoldItalic.otf"; path = "../assets/fonts/native/ExpensifyNeue-BoldItalic.otf"; sourceTree = ""; };
- B37C757CE02B734BFED38097 /* Pods-NewExpensify-NewExpensifyTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.debug.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.debug.xcconfig"; sourceTree = ""; };
- BF6A4C5167244B9FB8E4D4E3 /* ExpensifyNeue-Italic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Italic.otf"; path = "../assets/fonts/native/ExpensifyNeue-Italic.otf"; sourceTree = ""; };
- CA3A3642AEED7CF2D4CD3716 /* Pods-NewExpensify-NewExpensifyTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.release.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.release.xcconfig"; sourceTree = ""; };
+ 75CABB0D0ABB0082FE0EB600 /* Pods-NewExpensify.release staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.release staging.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.release staging.xcconfig"; sourceTree = ""; };
+ 8B28D84EF339436DBD42A203 /* ExpensifyNeue-BoldItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-BoldItalic.otf"; path = "../assets/fonts/native/ExpensifyNeue-BoldItalic.otf"; sourceTree = ""; };
+ 8D3B36BF88E773E3C1A383FA /* Pods-NewExpensify.debug staging.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.debug staging.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.debug staging.xcconfig"; sourceTree = ""; };
+ 96552D489D9F09B6A5ABD81B /* Pods-NewExpensify-NewExpensifyTests.release production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.release production.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.release production.xcconfig"; sourceTree = ""; };
+ AEFE6CD54912D427D19133C7 /* libPods-NewExpensify.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ BD6E1BA27D6ABE0AC9D70586 /* Pods-NewExpensify-NewExpensifyTests.release development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.release development.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.release development.xcconfig"; sourceTree = ""; };
+ BF6A4C5167244B9FB8E4D4E3 /* ExpensifyNeue-Italic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Italic.otf"; path = "../assets/fonts/native/ExpensifyNeue-Italic.otf"; sourceTree = ""; };
+ CECC4CBB97A55705A33BEA9E /* Pods-NewExpensify.debug development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify.debug development.xcconfig"; path = "Target Support Files/Pods-NewExpensify/Pods-NewExpensify.debug development.xcconfig"; sourceTree = ""; };
D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNewKansas-MediumItalic.otf"; path = "../assets/fonts/native/ExpensifyNewKansas-MediumItalic.otf"; sourceTree = ""; };
- DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyMono-Bold.otf"; path = "../assets/fonts/native/ExpensifyMono-Bold.otf"; sourceTree = ""; };
+ DB76E0D5C670190A0997C71E /* Pods-NewExpensify-NewExpensifyTests.debug production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.debug production.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.debug production.xcconfig"; sourceTree = ""; };
+ DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyMono-Bold.otf"; path = "../assets/fonts/native/ExpensifyMono-Bold.otf"; sourceTree = ""; };
DD7904292792E76D004484B4 /* RCTBootSplash.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RCTBootSplash.h; path = NewExpensify/RCTBootSplash.h; sourceTree = ""; };
DD79042A2792E76D004484B4 /* RCTBootSplash.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = RCTBootSplash.m; path = NewExpensify/RCTBootSplash.m; sourceTree = ""; };
- E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyMono-Regular.otf"; path = "../assets/fonts/native/ExpensifyMono-Regular.otf"; sourceTree = ""; };
+ E2F1036F70CBFE39E9352674 /* Pods-NewExpensify-NewExpensifyTests.debug development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NewExpensify-NewExpensifyTests.debug development.xcconfig"; path = "Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests.debug development.xcconfig"; sourceTree = ""; };
+ E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyMono-Regular.otf"; path = "../assets/fonts/native/ExpensifyMono-Regular.otf"; sourceTree = ""; };
E9DF872C2525201700607FDC /* AirshipConfig.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = AirshipConfig.plist; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; };
F0C450E92705020500FD2970 /* colors.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = colors.json; path = ../colors.json; sourceTree = ""; };
- F4F8A052A22040339996324B /* ExpensifyNeue-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Regular.otf"; path = "../assets/fonts/native/ExpensifyNeue-Regular.otf"; sourceTree = ""; };
- F679A86058F8C4B331D239C3 /* libPods-NewExpensify-NewExpensifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify-NewExpensifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ F4F8A052A22040339996324B /* ExpensifyNeue-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "ExpensifyNeue-Regular.otf"; path = "../assets/fonts/native/ExpensifyNeue-Regular.otf"; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -92,7 +104,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- B54A7C3AA98189A600323C02 /* libPods-NewExpensify-NewExpensifyTests.a in Frameworks */,
+ 34FF0B164B1D8ED1054BFBB6 /* libPods-NewExpensify-NewExpensifyTests.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -101,7 +113,7 @@
buildActionMask = 2147483647;
files = (
E51DC681C7DEE40AEBDDFBFE /* BuildFile in Frameworks */,
- 9A65F0F374EC04ABB5FF40AF /* libPods-NewExpensify.a in Frameworks */,
+ 5A464BC8112CDB1DE1E38F1C /* libPods-NewExpensify.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -147,8 +159,8 @@
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
ED2971642150620600B7C4FE /* JavaScriptCore.framework */,
- F679A86058F8C4B331D239C3 /* libPods-NewExpensify-NewExpensifyTests.a */,
- 2FD35F00FB84D9FCF60D56A7 /* libPods-NewExpensify.a */,
+ AEFE6CD54912D427D19133C7 /* libPods-NewExpensify.a */,
+ 6FB387B20AE4E6E98858B6AA /* libPods-NewExpensify-NewExpensifyTests.a */,
);
name = Frameworks;
sourceTree = "";
@@ -187,7 +199,7 @@
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
- 13B07F961A680F5B00A75B9A /* New Expensify.app */,
+ 13B07F961A680F5B00A75B9A /* New Expensify Dev.app */,
00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */,
);
name = Products;
@@ -211,10 +223,22 @@
EC29677F0A49C2946A495A33 /* Pods */ = {
isa = PBXGroup;
children = (
- 37F6DD6E91B4C55BD8DDC895 /* Pods-NewExpensify.debug.xcconfig */,
- 391B5D1DB6CFBAC16FD11DC4 /* Pods-NewExpensify.release.xcconfig */,
- B37C757CE02B734BFED38097 /* Pods-NewExpensify-NewExpensifyTests.debug.xcconfig */,
- CA3A3642AEED7CF2D4CD3716 /* Pods-NewExpensify-NewExpensifyTests.release.xcconfig */,
+ CECC4CBB97A55705A33BEA9E /* Pods-NewExpensify.debug development.xcconfig */,
+ 0B09CE5BDAF34DD3573AB4E2 /* Pods-NewExpensify.debug adhoc.xcconfig */,
+ 8D3B36BF88E773E3C1A383FA /* Pods-NewExpensify.debug staging.xcconfig */,
+ 0E27AA27706D894246E7946D /* Pods-NewExpensify.debug production.xcconfig */,
+ 02BE6CF80ED1BD2445267F92 /* Pods-NewExpensify.release development.xcconfig */,
+ 1DDE5449979A136852B939B5 /* Pods-NewExpensify.release adhoc.xcconfig */,
+ 75CABB0D0ABB0082FE0EB600 /* Pods-NewExpensify.release staging.xcconfig */,
+ 34A8FDD1F9AA58B8F15C8380 /* Pods-NewExpensify.release production.xcconfig */,
+ E2F1036F70CBFE39E9352674 /* Pods-NewExpensify-NewExpensifyTests.debug development.xcconfig */,
+ 432FF5842B766535509FC547 /* Pods-NewExpensify-NewExpensifyTests.debug adhoc.xcconfig */,
+ 3D393D7ABC1092F1DE91397F /* Pods-NewExpensify-NewExpensifyTests.debug staging.xcconfig */,
+ DB76E0D5C670190A0997C71E /* Pods-NewExpensify-NewExpensifyTests.debug production.xcconfig */,
+ BD6E1BA27D6ABE0AC9D70586 /* Pods-NewExpensify-NewExpensifyTests.release development.xcconfig */,
+ 6B5211DB0EEB46E12DF4AD2D /* Pods-NewExpensify-NewExpensifyTests.release adhoc.xcconfig */,
+ 6BE16DA6EFF88513DB1CD47B /* Pods-NewExpensify-NewExpensifyTests.release staging.xcconfig */,
+ 96552D489D9F09B6A5ABD81B /* Pods-NewExpensify-NewExpensifyTests.release production.xcconfig */,
);
path = Pods;
sourceTree = "";
@@ -226,12 +250,12 @@
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "NewExpensifyTests" */;
buildPhases = (
- 534FB136F5054EABB5B78C45 /* [CP] Check Pods Manifest.lock */,
+ EA9511689FED50580B0F3DE7 /* [CP] Check Pods Manifest.lock */,
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
- 7026C9E151743F743B66758C /* [CP] Embed Pods Frameworks */,
- D86F192E7E836C985A6C3887 /* [CP] Copy Pods Resources */,
+ 6B9E07408E1D6715FDAB0C98 /* [CP] Embed Pods Frameworks */,
+ DCBD600FEEE485201447211A /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -247,16 +271,16 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "NewExpensify" */;
buildPhases = (
- 76D95BA26FBE16FB4160466A /* [CP] Check Pods Manifest.lock */,
+ 7E666D03089C35260C905B4A /* [CP] Check Pods Manifest.lock */,
FD10A7F022414F080027D42C /* Start Packager */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
- 0DD7756DAF1D223C57F4D186 /* [CP] Embed Pods Frameworks */,
- 1243CB50053E5462B0B69043 /* [CP] Copy Pods Resources */,
- BC051F7DE694DE52DA3FEB70 /* [CP-User] [RNFB] Core Configuration */,
- AD029A24A9ACE21B4D2AE31D /* [CP-User] [RNFB] Crashlytics Configuration */,
+ 3792B4E76B24FC8F78B7FEB6 /* [CP] Embed Pods Frameworks */,
+ 5259EE1448507A682C02026F /* [CP] Copy Pods Resources */,
+ 5E34288ECB69FCFA24851234 /* [CP-User] [RNFB] Core Configuration */,
+ E10553ABAB7762D41AC85C09 /* [CP-User] [RNFB] Crashlytics Configuration */,
);
buildRules = (
);
@@ -264,7 +288,7 @@
);
name = NewExpensify;
productName = NewExpensify;
- productReference = 13B07F961A680F5B00A75B9A /* New Expensify.app */;
+ productReference = 13B07F961A680F5B00A75B9A /* New Expensify Dev.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -277,6 +301,7 @@
TargetAttributes = {
00E356ED1AD99517003FC87E = {
CreatedOnToolsVersion = 6.2;
+ DevelopmentTeam = 368M544MTT;
ProvisioningStyle = Automatic;
TestTargetID = 13B07F861A680F5B00A75B9A;
};
@@ -352,15 +377,20 @@
shellPath = /bin/sh;
shellScript = "export NODE_BINARY=node\nexport EXTRA_PACKAGER_ARGS=\"--sourcemap-output $(pwd)/../main.jsbundle.map\"\n\n../node_modules/react-native/scripts/react-native-xcode.sh\n";
};
- 0DD7756DAF1D223C57F4D186 /* [CP] Embed Pods Frameworks */ = {
+ 3792B4E76B24FC8F78B7FEB6 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-NewExpensify/Pods-NewExpensify-frameworks.sh",
+ "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework",
+ "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-Glog/glog.framework/glog",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Onfido/Onfido.framework/Onfido",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Plaid/LinkKit.framework/LinkKit",
@@ -368,8 +398,13 @@
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/glog.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Onfido.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LinkKit.framework",
@@ -380,7 +415,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NewExpensify/Pods-NewExpensify-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- 1243CB50053E5462B0B69043 /* [CP] Copy Pods Resources */ = {
+ 5259EE1448507A682C02026F /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -392,6 +427,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipExtendedActionsResources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipMessageCenterResources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle",
);
name = "[CP] Copy Pods Resources";
@@ -401,6 +437,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipExtendedActionsResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipMessageCenterResources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
@@ -408,37 +445,33 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NewExpensify/Pods-NewExpensify-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- 534FB136F5054EABB5B78C45 /* [CP] Check Pods Manifest.lock */ = {
+ 5E34288ECB69FCFA24851234 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
- inputFileListPaths = (
- );
inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
- );
- name = "[CP] Check Pods Manifest.lock";
- outputFileListPaths = (
- );
- outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-NewExpensify-NewExpensifyTests-checkManifestLockResult.txt",
+ "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
+ name = "[CP-User] [RNFB] Core Configuration";
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
- showEnvVarsInLog = 0;
+ shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n _JSON_OUTPUT_BASE64=$(python -c 'import json,sys,base64;print(base64.b64encode(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"').read())['${_JSON_ROOT}'])))' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n";
};
- 7026C9E151743F743B66758C /* [CP] Embed Pods Frameworks */ = {
+ 6B9E07408E1D6715FDAB0C98 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-frameworks.sh",
+ "${BUILT_PRODUCTS_DIR}/MapboxMaps/MapboxMaps.framework",
+ "${BUILT_PRODUCTS_DIR}/Turf/Turf.framework",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-Glog/glog.framework/glog",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCommon/MapboxCommon.framework/MapboxCommon",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxCoreMaps/MapboxCoreMaps.framework/MapboxCoreMaps",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/MapboxMobileEvents/MapboxMobileEvents.framework/MapboxMobileEvents",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Onfido/Onfido.framework/Onfido",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Plaid/LinkKit.framework/LinkKit",
@@ -446,8 +479,13 @@
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMaps.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Turf.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/glog.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCommon.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxCoreMaps.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MapboxMobileEvents.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Onfido.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LinkKit.framework",
@@ -458,7 +496,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- 76D95BA26FBE16FB4160466A /* [CP] Check Pods Manifest.lock */ = {
+ 7E666D03089C35260C905B4A /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -480,59 +518,70 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- AD029A24A9ACE21B4D2AE31D /* [CP-User] [RNFB] Crashlytics Configuration */ = {
+ DCBD600FEEE485201447211A /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
- "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
- "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
+ "${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-resources.sh",
+ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipAutomationResources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipCoreResources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipExtendedActionsResources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipMessageCenterResources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle",
+ );
+ name = "[CP] Copy Pods Resources";
+ outputPaths = (
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipAutomationResources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipCoreResources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipExtendedActionsResources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipMessageCenterResources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle",
);
- name = "[CP-User] [RNFB] Crashlytics Configuration";
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-resources.sh\"\n";
+ showEnvVarsInLog = 0;
};
- BC051F7DE694DE52DA3FEB70 /* [CP-User] [RNFB] Core Configuration */ = {
+ E10553ABAB7762D41AC85C09 /* [CP-User] [RNFB] Crashlytics Configuration */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
- "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
+ "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
+ "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
- name = "[CP-User] [RNFB] Core Configuration";
+ name = "[CP-User] [RNFB] Crashlytics Configuration";
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n _JSON_OUTPUT_BASE64=$(python -c 'import json,sys,base64;print(base64.b64encode(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"').read())['${_JSON_ROOT}'])))' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n";
+ shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n";
};
- D86F192E7E836C985A6C3887 /* [CP] Copy Pods Resources */ = {
+ EA9511689FED50580B0F3DE7 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
+ inputFileListPaths = (
+ );
inputPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-resources.sh",
- "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipAutomationResources.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipCoreResources.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipExtendedActionsResources.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipMessageCenterResources.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/Airship/AirshipPreferenceCenterResources.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle",
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
);
- name = "[CP] Copy Pods Resources";
outputPaths = (
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipAutomationResources.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipCoreResources.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipExtendedActionsResources.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipMessageCenterResources.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AirshipPreferenceCenterResources.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle",
+ "$(DERIVED_FILE_DIR)/Pods-NewExpensify-NewExpensifyTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-NewExpensify-NewExpensifyTests/Pods-NewExpensify-NewExpensifyTests-resources.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
FD10A7F022414F080027D42C /* Start Packager */ = {
@@ -592,9 +641,9 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
- 00E356F61AD99517003FC87E /* Debug */ = {
+ 00E356F61AD99517003FC87E /* Debug Development */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = B37C757CE02B734BFED38097 /* Pods-NewExpensify-NewExpensifyTests.debug.xcconfig */;
+ baseConfigurationReference = E2F1036F70CBFE39E9352674 /* Pods-NewExpensify-NewExpensifyTests.debug development.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -615,11 +664,11 @@
PRODUCT_NAME = "$(TARGET_NAME)";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NewExpensify.app/NewExpensify";
};
- name = Debug;
+ name = "Debug Development";
};
- 00E356F71AD99517003FC87E /* Release */ = {
+ 00E356F71AD99517003FC87E /* Release Development */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = CA3A3642AEED7CF2D4CD3716 /* Pods-NewExpensify-NewExpensifyTests.release.xcconfig */;
+ baseConfigurationReference = BD6E1BA27D6ABE0AC9D70586 /* Pods-NewExpensify-NewExpensifyTests.release development.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)";
@@ -638,13 +687,14 @@
PRODUCT_NAME = "$(TARGET_NAME)";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NewExpensify.app/NewExpensify";
};
- name = Release;
+ name = "Release Development";
};
- 13B07F941A680F5B00A75B9A /* Debug */ = {
+ 13B07F941A680F5B00A75B9A /* Debug Development */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 37F6DD6E91B4C55BD8DDC895 /* Pods-NewExpensify.debug.xcconfig */;
+ baseConfigurationReference = CECC4CBB97A55705A33BEA9E /* Pods-NewExpensify.debug development.xcconfig */;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = NewExpensify/Chat.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution";
@@ -661,21 +711,22 @@
"-ObjC",
"-lc++",
);
- PRODUCT_BUNDLE_IDENTIFIER = com.chat.expensify.chat;
- PRODUCT_NAME = "New Expensify";
- PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore;
+ PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.dev;
+ PRODUCT_NAME = "New Expensify Dev";
+ PROVISIONING_PROFILE_SPECIFIER = expensify_chat_dev;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
- name = Debug;
+ name = "Debug Development";
};
- 13B07F951A680F5B00A75B9A /* Release */ = {
+ 13B07F951A680F5B00A75B9A /* Release Development */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 391B5D1DB6CFBAC16FD11DC4 /* Pods-NewExpensify.release.xcconfig */;
+ baseConfigurationReference = 02BE6CF80ED1BD2445267F92 /* Pods-NewExpensify.release development.xcconfig */;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = NewExpensify/Chat.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution";
@@ -691,21 +742,267 @@
"-ObjC",
"-lc++",
);
+ PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.dev;
+ PRODUCT_NAME = "New Expensify Dev";
+ PROVISIONING_PROFILE_SPECIFIER = expensify_chat_dev;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = "Release Development";
+ };
+ 83CBBA201A601CBA00E9B192 /* Debug Development */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_SYMBOLS_PRIVATE_EXTERN = NO;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.1;
+ LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
+ LIBRARY_SEARCH_PATHS = (
+ "$(SDKROOT)/usr/lib/swift",
+ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
+ "\"$(inherited)\"",
+ );
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ OTHER_CFLAGS = "$(inherited)";
+ OTHER_CPLUSPLUSFLAGS = "$(inherited)";
+ PRODUCT_BUNDLE_IDENTIFIER = "";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ SDKROOT = iphoneos;
+ };
+ name = "Debug Development";
+ };
+ 83CBBA211A601CBA00E9B192 /* Release Development */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = YES;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.1;
+ LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
+ LIBRARY_SEARCH_PATHS = (
+ "$(SDKROOT)/usr/lib/swift",
+ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
+ "\"$(inherited)\"",
+ );
+ MTL_ENABLE_DEBUG_INFO = NO;
+ OTHER_CFLAGS = "$(inherited)";
+ OTHER_CPLUSPLUSFLAGS = "$(inherited)";
+ PRODUCT_BUNDLE_IDENTIFIER = "";
+ PRODUCT_NAME = "";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ SDKROOT = iphoneos;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = "Release Development";
+ };
+ CF9AF93E29EE9276001FA527 /* Debug Production */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_SYMBOLS_PRIVATE_EXTERN = NO;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.1;
+ LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
+ LIBRARY_SEARCH_PATHS = (
+ "$(SDKROOT)/usr/lib/swift",
+ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
+ "\"$(inherited)\"",
+ );
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ OTHER_CFLAGS = "$(inherited)";
+ OTHER_CPLUSPLUSFLAGS = "$(inherited)";
+ PRODUCT_BUNDLE_IDENTIFIER = "";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ SDKROOT = iphoneos;
+ };
+ name = "Debug Production";
+ };
+ CF9AF93F29EE9276001FA527 /* Debug Production */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 0E27AA27706D894246E7946D /* Pods-NewExpensify.debug production.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = NewExpensify/Chat.entitlements;
+ CODE_SIGN_IDENTITY = "iPhone Distribution";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 3;
+ DEVELOPMENT_TEAM = 368M544MTT;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = "$(SRCROOT)/NewExpensify/Info.plist";
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MARKETING_VERSION = 1.0.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ "-lc++",
+ );
PRODUCT_BUNDLE_IDENTIFIER = com.chat.expensify.chat;
PRODUCT_NAME = "New Expensify";
PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
- name = Release;
+ name = "Debug Production";
};
- 83CBBA201A601CBA00E9B192 /* Debug */ = {
+ CF9AF94029EE9276001FA527 /* Debug Production */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = DB76E0D5C670190A0997C71E /* Pods-NewExpensify-NewExpensifyTests.debug production.xcconfig */;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = NewExpensifyTests/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 11.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ OTHER_LDFLAGS = (
+ "-ObjC",
+ "-lc++",
+ "$(inherited)",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NewExpensify.app/NewExpensify";
+ };
+ name = "Debug Production";
+ };
+ CF9AF94429EE927A001FA527 /* Debug AdHoc */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
- CLANG_CXX_LANGUAGE_STANDARD = "c++17";
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
@@ -759,17 +1056,75 @@
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = "$(inherited)";
+ PRODUCT_BUNDLE_IDENTIFIER = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
};
- name = Debug;
+ name = "Debug AdHoc";
+ };
+ CF9AF94529EE927A001FA527 /* Debug AdHoc */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 0B09CE5BDAF34DD3573AB4E2 /* Pods-NewExpensify.debug adhoc.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIconAdHoc;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = NewExpensify/Chat.entitlements;
+ CODE_SIGN_IDENTITY = "iPhone Distribution";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 3;
+ DEVELOPMENT_TEAM = 368M544MTT;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = "$(SRCROOT)/NewExpensify/Info.plist";
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MARKETING_VERSION = 1.0.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ "-lc++",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.adhoc;
+ PRODUCT_NAME = "New Expensify AdHoc";
+ PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = "Debug AdHoc";
};
- 83CBBA211A601CBA00E9B192 /* Release */ = {
+ CF9AF94629EE927A001FA527 /* Debug AdHoc */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 432FF5842B766535509FC547 /* Pods-NewExpensify-NewExpensifyTests.debug adhoc.xcconfig */;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = NewExpensifyTests/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 11.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ OTHER_LDFLAGS = (
+ "-ObjC",
+ "-lc++",
+ "$(inherited)",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NewExpensify.app/NewExpensify";
+ };
+ name = "Debug AdHoc";
+ };
+ CF9AF94729EE928E001FA527 /* Release Production */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
- CLANG_CXX_LANGUAGE_STANDARD = "c++17";
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
@@ -815,11 +1170,178 @@
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = "$(inherited)";
+ PRODUCT_BUNDLE_IDENTIFIER = "";
+ PRODUCT_NAME = "";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
- name = Release;
+ name = "Release Production";
+ };
+ CF9AF94829EE928E001FA527 /* Release Production */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 34A8FDD1F9AA58B8F15C8380 /* Pods-NewExpensify.release production.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = NewExpensify/Chat.entitlements;
+ CODE_SIGN_IDENTITY = "iPhone Distribution";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 3;
+ DEVELOPMENT_TEAM = 368M544MTT;
+ INFOPLIST_FILE = NewExpensify/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MARKETING_VERSION = 1.0.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ "-lc++",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.chat.expensify.chat;
+ PRODUCT_NAME = "New Expensify";
+ PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = "Release Production";
+ };
+ CF9AF94929EE928E001FA527 /* Release Production */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 96552D489D9F09B6A5ABD81B /* Pods-NewExpensify-NewExpensifyTests.release production.xcconfig */;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEVELOPMENT_TEAM = 368M544MTT;
+ INFOPLIST_FILE = NewExpensifyTests/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 11.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ OTHER_LDFLAGS = (
+ "-ObjC",
+ "-lc++",
+ "$(inherited)",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NewExpensify.app/NewExpensify";
+ };
+ name = "Release Production";
+ };
+ CF9AF94D29EE9293001FA527 /* Release AdHoc */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = YES;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.1;
+ LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
+ LIBRARY_SEARCH_PATHS = (
+ "$(SDKROOT)/usr/lib/swift",
+ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
+ "\"$(inherited)\"",
+ );
+ MTL_ENABLE_DEBUG_INFO = NO;
+ OTHER_CFLAGS = "$(inherited)";
+ OTHER_CPLUSPLUSFLAGS = "$(inherited)";
+ PRODUCT_BUNDLE_IDENTIFIER = "";
+ PRODUCT_NAME = "";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ SDKROOT = iphoneos;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = "Release AdHoc";
+ };
+ CF9AF94E29EE9293001FA527 /* Release AdHoc */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1DDE5449979A136852B939B5 /* Pods-NewExpensify.release adhoc.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIconAdHoc;
+ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = NewExpensify/Chat.entitlements;
+ CODE_SIGN_IDENTITY = "iPhone Distribution";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 3;
+ DEVELOPMENT_TEAM = 368M544MTT;
+ INFOPLIST_FILE = NewExpensify/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+ MARKETING_VERSION = 1.0.0;
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-ObjC",
+ "-lc++",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = com.expensify.chat.adhoc;
+ PRODUCT_NAME = "New Expensify AdHoc";
+ PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = "Release AdHoc";
+ };
+ CF9AF94F29EE9293001FA527 /* Release AdHoc */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 6B5211DB0EEB46E12DF4AD2D /* Pods-NewExpensify-NewExpensifyTests.release adhoc.xcconfig */;
+ buildSettings = {
+ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEVELOPMENT_TEAM = 368M544MTT;
+ INFOPLIST_FILE = NewExpensifyTests/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 11.0;
+ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
+ OTHER_LDFLAGS = (
+ "-ObjC",
+ "-lc++",
+ "$(inherited)",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NewExpensify.app/NewExpensify";
+ };
+ name = "Release AdHoc";
};
/* End XCBuildConfiguration section */
@@ -827,29 +1349,41 @@
00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "NewExpensifyTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
- 00E356F61AD99517003FC87E /* Debug */,
- 00E356F71AD99517003FC87E /* Release */,
+ 00E356F61AD99517003FC87E /* Debug Development */,
+ CF9AF94629EE927A001FA527 /* Debug AdHoc */,
+ CF9AF94029EE9276001FA527 /* Debug Production */,
+ 00E356F71AD99517003FC87E /* Release Development */,
+ CF9AF94F29EE9293001FA527 /* Release AdHoc */,
+ CF9AF94929EE928E001FA527 /* Release Production */,
);
defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
+ defaultConfigurationName = "Debug Development";
};
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "NewExpensify" */ = {
isa = XCConfigurationList;
buildConfigurations = (
- 13B07F941A680F5B00A75B9A /* Debug */,
- 13B07F951A680F5B00A75B9A /* Release */,
+ 13B07F941A680F5B00A75B9A /* Debug Development */,
+ CF9AF94529EE927A001FA527 /* Debug AdHoc */,
+ CF9AF93F29EE9276001FA527 /* Debug Production */,
+ 13B07F951A680F5B00A75B9A /* Release Development */,
+ CF9AF94E29EE9293001FA527 /* Release AdHoc */,
+ CF9AF94829EE928E001FA527 /* Release Production */,
);
defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
+ defaultConfigurationName = "Debug Development";
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "NewExpensify" */ = {
isa = XCConfigurationList;
buildConfigurations = (
- 83CBBA201A601CBA00E9B192 /* Debug */,
- 83CBBA211A601CBA00E9B192 /* Release */,
+ 83CBBA201A601CBA00E9B192 /* Debug Development */,
+ CF9AF94429EE927A001FA527 /* Debug AdHoc */,
+ CF9AF93E29EE9276001FA527 /* Debug Production */,
+ 83CBBA211A601CBA00E9B192 /* Release Development */,
+ CF9AF94D29EE9293001FA527 /* Release AdHoc */,
+ CF9AF94729EE928E001FA527 /* Release Production */,
);
defaultConfigurationIsVisible = 0;
- defaultConfigurationName = Release;
+ defaultConfigurationName = "Debug Development";
};
/* End XCConfigurationList section */
};
diff --git a/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify AdHoc.xcscheme b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify AdHoc.xcscheme
new file mode 100644
index 000000000000..0e0fad6399a0
--- /dev/null
+++ b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify AdHoc.xcscheme
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/NewExpensify.xcscheme b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme
similarity index 88%
rename from ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/NewExpensify.xcscheme
rename to ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme
index 87aa0146de0d..77f512242f67 100644
--- a/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/NewExpensify.xcscheme
+++ b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify Dev.xcscheme
@@ -15,7 +15,7 @@
@@ -23,7 +23,7 @@
@@ -41,7 +41,7 @@
+ buildConfiguration = "Debug Development">
diff --git a/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify.xcscheme b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify.xcscheme
new file mode 100644
index 000000000000..f68be2705527
--- /dev/null
+++ b/ios/NewExpensify.xcodeproj/xcshareddata/xcschemes/New Expensify.xcscheme
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/NewExpensify/Chat.entitlements b/ios/NewExpensify/Chat.entitlements
index 33bb7f9feff8..5300e35eadbf 100644
--- a/ios/NewExpensify/Chat.entitlements
+++ b/ios/NewExpensify/Chat.entitlements
@@ -4,6 +4,10 @@
aps-environment
development
+ com.apple.developer.applesignin
+
+ Default
+
com.apple.developer.associated-domains
applinks:new.expensify.com
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@2x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@2x.png
new file mode 100644
index 000000000000..b6f81e21850a
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@2x~ipad.png
new file mode 100644
index 000000000000..b6f81e21850a
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@3x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@3x.png
new file mode 100644
index 000000000000..827df2594c05
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20~ipad.png
new file mode 100644
index 000000000000..e15f81d06823
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-20~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29.png
new file mode 100644
index 000000000000..c09f9e98e00e
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@2x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@2x.png
new file mode 100644
index 000000000000..6e8d7eb5977a
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@2x~ipad.png
new file mode 100644
index 000000000000..6e8d7eb5977a
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@3x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@3x.png
new file mode 100644
index 000000000000..ea1de90cebb9
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29~ipad.png
new file mode 100644
index 000000000000..c09f9e98e00e
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-29~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@2x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@2x.png
new file mode 100644
index 000000000000..405c9d06c2e7
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@2x~ipad.png
new file mode 100644
index 000000000000..405c9d06c2e7
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@3x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@3x.png
new file mode 100644
index 000000000000..f7d677f601cd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40~ipad.png
new file mode 100644
index 000000000000..b6f81e21850a
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-40~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-60@2x~car.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-60@2x~car.png
new file mode 100644
index 000000000000..f7d677f601cd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-60@2x~car.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-60@3x~car.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-60@3x~car.png
new file mode 100644
index 000000000000..ba5cbd6d0418
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-60@3x~car.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-83.5@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-83.5@2x~ipad.png
new file mode 100644
index 000000000000..bc4a8fad1305
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon-83.5@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@2x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@2x.png
new file mode 100644
index 000000000000..f7d677f601cd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@2x~ipad.png
new file mode 100644
index 000000000000..3c4738b4e0d9
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@3x.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@3x.png
new file mode 100644
index 000000000000..ba5cbd6d0418
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon~ios-marketing.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon~ios-marketing.png
new file mode 100644
index 000000000000..ba9980fe553d
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon~ios-marketing.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon~ipad.png
new file mode 100644
index 000000000000..d6902de513a8
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/AppIcon~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/Contents.json b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/Contents.json
new file mode 100644
index 000000000000..bd04914aec96
--- /dev/null
+++ b/ios/NewExpensify/Images.xcassets/AppIconAdHoc.appiconset/Contents.json
@@ -0,0 +1,134 @@
+{
+ "images": [
+ {
+ "filename": "AppIcon@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "76x76"
+ },
+ {
+ "filename": "AppIcon@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "76x76"
+ },
+ {
+ "filename": "AppIcon-83.5@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "83.5x83.5"
+ },
+ {
+ "filename": "AppIcon-40@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-20@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-29.png",
+ "idiom": "iphone",
+ "scale": "1x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-60@2x~car.png",
+ "idiom": "car",
+ "scale": "2x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon-60@3x~car.png",
+ "idiom": "car",
+ "scale": "3x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon~ios-marketing.png",
+ "idiom": "ios-marketing",
+ "scale": "1x",
+ "size": "1024x1024"
+ }
+ ],
+ "info": {
+ "author": "iconkitchen",
+ "version": 1
+ }
+}
\ No newline at end of file
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@2x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@2x.png
new file mode 100644
index 000000000000..827df9a2ad1f
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@2x~ipad.png
new file mode 100644
index 000000000000..827df9a2ad1f
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@3x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@3x.png
new file mode 100644
index 000000000000..b7e326a153f0
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20~ipad.png
new file mode 100644
index 000000000000..0b96abb2496d
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-20~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29.png
new file mode 100644
index 000000000000..3a0648282861
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@2x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@2x.png
new file mode 100644
index 000000000000..a89052bf5818
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@2x~ipad.png
new file mode 100644
index 000000000000..a89052bf5818
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@3x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@3x.png
new file mode 100644
index 000000000000..4234a1b8bc7d
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29~ipad.png
new file mode 100644
index 000000000000..3a0648282861
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-29~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@2x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@2x.png
new file mode 100644
index 000000000000..535d2ea95841
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@2x~ipad.png
new file mode 100644
index 000000000000..535d2ea95841
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@3x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@3x.png
new file mode 100644
index 000000000000..1ce8ff1c5a4e
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40~ipad.png
new file mode 100644
index 000000000000..827df9a2ad1f
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-40~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-60@2x~car.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-60@2x~car.png
new file mode 100644
index 000000000000..1ce8ff1c5a4e
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-60@2x~car.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-60@3x~car.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-60@3x~car.png
new file mode 100644
index 000000000000..3306f28e9cfd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-60@3x~car.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-83.5@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-83.5@2x~ipad.png
new file mode 100644
index 000000000000..c92d9c97b673
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon-83.5@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@2x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@2x.png
new file mode 100644
index 000000000000..1ce8ff1c5a4e
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@2x~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@2x~ipad.png
new file mode 100644
index 000000000000..5ad52fc70033
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@2x~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@3x.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@3x.png
new file mode 100644
index 000000000000..3306f28e9cfd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon~ios-marketing.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon~ios-marketing.png
new file mode 100644
index 000000000000..431307ca66b4
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon~ios-marketing.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon~ipad.png b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon~ipad.png
new file mode 100644
index 000000000000..9aff8b53fb0e
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/AppIcon~ipad.png differ
diff --git a/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/Contents.json b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/Contents.json
new file mode 100644
index 000000000000..bd04914aec96
--- /dev/null
+++ b/ios/NewExpensify/Images.xcassets/AppIconDev.appiconset/Contents.json
@@ -0,0 +1,134 @@
+{
+ "images": [
+ {
+ "filename": "AppIcon@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "76x76"
+ },
+ {
+ "filename": "AppIcon@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "76x76"
+ },
+ {
+ "filename": "AppIcon-83.5@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "83.5x83.5"
+ },
+ {
+ "filename": "AppIcon-40@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-40@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "40x40"
+ },
+ {
+ "filename": "AppIcon-20@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-20@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "20x20"
+ },
+ {
+ "filename": "AppIcon-29.png",
+ "idiom": "iphone",
+ "scale": "1x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29@2x.png",
+ "idiom": "iphone",
+ "scale": "2x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29@3x.png",
+ "idiom": "iphone",
+ "scale": "3x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29~ipad.png",
+ "idiom": "ipad",
+ "scale": "1x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-29@2x~ipad.png",
+ "idiom": "ipad",
+ "scale": "2x",
+ "size": "29x29"
+ },
+ {
+ "filename": "AppIcon-60@2x~car.png",
+ "idiom": "car",
+ "scale": "2x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon-60@3x~car.png",
+ "idiom": "car",
+ "scale": "3x",
+ "size": "60x60"
+ },
+ {
+ "filename": "AppIcon~ios-marketing.png",
+ "idiom": "ios-marketing",
+ "scale": "1x",
+ "size": "1024x1024"
+ }
+ ],
+ "info": {
+ "author": "iconkitchen",
+ "version": 1
+ }
+}
\ No newline at end of file
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/Contents.json b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/Contents.json
index 570652dfdaa0..a8927aa86e2b 100644
--- a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/Contents.json
+++ b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/Contents.json
@@ -1,23 +1,23 @@
{
- "images": [
+ "images" : [
{
- "idiom": "universal",
- "filename": "bootsplash_logo.png",
- "scale": "1x"
+ "filename" : "bootsplash_logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
},
{
- "idiom": "universal",
- "filename": "bootsplash_logo@2x.png",
- "scale": "2x"
+ "filename" : "bootsplash_logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
},
{
- "idiom": "universal",
- "filename": "bootsplash_logo@3x.png",
- "scale": "3x"
+ "filename" : "bootsplash_logo@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
}
],
- "info": {
- "version": 1,
- "author": "xcode"
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
}
}
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/Contents.json b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/Contents.json
new file mode 100644
index 000000000000..a8927aa86e2b
--- /dev/null
+++ b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "bootsplash_logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "bootsplash_logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "bootsplash_logo@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo.png b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo.png
new file mode 100644
index 000000000000..8fbef1c5ab06
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo.png differ
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo@2x.png b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo@2x.png
new file mode 100644
index 000000000000..186a2f85e1dd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo@3x.png b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo@3x.png
new file mode 100644
index 000000000000..e208d1e0f8ab
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/BootSplashLogoAdHoc.imageset/bootsplash_logo@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/Contents.json b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/Contents.json
new file mode 100644
index 000000000000..a8927aa86e2b
--- /dev/null
+++ b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "bootsplash_logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "bootsplash_logo@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "bootsplash_logo@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo.png b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo.png
new file mode 100644
index 000000000000..8fbef1c5ab06
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo.png differ
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo@2x.png b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo@2x.png
new file mode 100644
index 000000000000..186a2f85e1dd
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo@2x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo@3x.png b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo@3x.png
new file mode 100644
index 000000000000..e208d1e0f8ab
Binary files /dev/null and b/ios/NewExpensify/Images.xcassets/BootSplashLogoDev.imageset/bootsplash_logo@3x.png differ
diff --git a/ios/NewExpensify/Images.xcassets/Contents.json b/ios/NewExpensify/Images.xcassets/Contents.json
index 2d92bd53fdb2..73c00596a7fc 100644
--- a/ios/NewExpensify/Images.xcassets/Contents.json
+++ b/ios/NewExpensify/Images.xcassets/Contents.json
@@ -1,6 +1,6 @@
{
"info" : {
- "version" : 1,
- "author" : "xcode"
+ "author" : "xcode",
+ "version" : 1
}
}
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 4c769a9f1bbd..66186890d68f 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.53
+ 1.3.57
CFBundleSignature
????
CFBundleURLTypes
@@ -30,9 +30,17 @@
new-expensify
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ com.googleusercontent.apps.921154746561-s3uqn2oe4m85tufi6mqflbfbuajrm2i3
+
+
CFBundleVersion
- 1.3.53.1
+ 1.3.57.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index eb8e731a3b94..d74d2f154b38 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.3.53
+ 1.3.57
CFBundleSignature
????
CFBundleVersion
- 1.3.53.1
+ 1.3.57.3
diff --git a/ios/Podfile b/ios/Podfile
index 3261d68fd27d..6445685db014 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,3 +1,7 @@
+# Set the type of Mapbox SDK to use
+# This value is used by $RNMapboxMaps
+$RNMapboxMapsImpl = 'mapbox'
+
# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
'require.resolve(
@@ -18,7 +22,7 @@ prepare_react_native_project!
# dependencies: {
# ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}),
# ```
-flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled
+flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled(['Debug Production', 'Debug Development', 'Debug AdHoc'])
linkage = ENV['USE_FRAMEWORKS']
if linkage != nil
@@ -41,9 +45,22 @@ def __apply_Xcode_14_3_RC_post_install_workaround(installer)
end
end
+# Configure Mapbox before installing dependencies
+pre_install do |installer|
+ $RNMapboxMaps.pre_install(installer)
+end
+
target 'NewExpensify' do
permissions_path = '../node_modules/react-native-permissions/ios'
+ project 'NewExpensify',
+ 'Debug Development' => :debug,
+ 'Debug AdHoc' => :debug,
+ 'Debug Production' => :debug,
+ 'Release Development' => :release,
+ 'Release AdHoc' => :release,
+ 'Release Production' => :release
+
pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy"
pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways"
pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse"
@@ -75,6 +92,9 @@ target 'NewExpensify' do
end
post_install do |installer|
+ # Configure Mapbox after installation
+ $RNMapboxMaps.post_install(installer)
+
# https://github.com/facebook/react-native/blob/main/scripts/react_native_pods.rb#L197-L202
react_native_post_install(
installer,
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index a486465b0a29..16ed1e05dc64 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -20,7 +20,15 @@ PODS:
- Airship (= 16.11.3)
- Airship/MessageCenter (= 16.11.3)
- Airship/PreferenceCenter (= 16.11.3)
+ - AppAuth (1.6.2):
+ - AppAuth/Core (= 1.6.2)
+ - AppAuth/ExternalUserAgent (= 1.6.2)
+ - AppAuth/Core (1.6.2)
+ - AppAuth/ExternalUserAgent (1.6.2):
+ - AppAuth/Core
- boost (1.76.0)
+ - BVLinearGradient (2.8.1):
+ - React-Core
- CocoaAsyncSocket (7.6.5)
- DoubleConversion (1.1.6)
- FBLazyVector (0.72.3)
@@ -184,6 +192,10 @@ PODS:
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
+ - GoogleSignIn (7.0.0):
+ - AppAuth (~> 1.5)
+ - GTMAppAuth (< 3.0, >= 1.3)
+ - GTMSessionFetcher/Core (< 4.0, >= 1.1)
- GoogleUtilities/AppDelegateSwizzler (7.11.1):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
@@ -204,6 +216,10 @@ PODS:
- GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.11.1):
- GoogleUtilities/Logger
+ - GTMAppAuth (2.0.0):
+ - AppAuth/Core (~> 1.6)
+ - GTMSessionFetcher/Core (< 4.0, >= 1.5)
+ - GTMSessionFetcher/Core (3.1.1)
- hermes-engine (0.72.3):
- hermes-engine/Pre-built (= 0.72.3)
- hermes-engine/Pre-built (0.72.3)
@@ -221,6 +237,15 @@ PODS:
- lottie-react-native (5.1.6):
- lottie-ios (~> 3.4.0)
- React-Core
+ - MapboxCommon (23.6.0)
+ - MapboxCoreMaps (10.14.0):
+ - MapboxCommon (~> 23.6)
+ - MapboxMaps (10.14.0):
+ - MapboxCommon (= 23.6.0)
+ - MapboxCoreMaps (= 10.14.0)
+ - MapboxMobileEvents (= 1.0.10)
+ - Turf (~> 2.0)
+ - MapboxMobileEvents (1.0.10)
- nanopb (2.30908.0):
- nanopb/decode (= 2.30908.0)
- nanopb/encode (= 2.30908.0)
@@ -699,6 +724,8 @@ PODS:
- React-jsi (= 0.72.3)
- React-logger (= 0.72.3)
- React-perflogger (= 0.72.3)
+ - RNAppleAuthentication (2.2.2):
+ - React-Core
- RNCAsyncStorage (1.17.11):
- React-Core
- RNCClipboard (1.5.1):
@@ -736,8 +763,22 @@ PODS:
- React-Core
- RNGestureHandler (2.12.0):
- React-Core
+ - RNGoogleSignin (10.0.1):
+ - GoogleSignIn (~> 7.0)
+ - React-Core
- RNLocalize (2.2.6):
- React-Core
+ - rnmapbox-maps (10.0.11):
+ - MapboxMaps (~> 10.14.0)
+ - React
+ - React-Core
+ - rnmapbox-maps/DynamicLibrary (= 10.0.11)
+ - Turf
+ - rnmapbox-maps/DynamicLibrary (10.0.11):
+ - MapboxMaps (~> 10.14.0)
+ - React
+ - React-Core
+ - Turf
- RNPermissions (3.6.1):
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
@@ -783,6 +824,7 @@ PODS:
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- SocketRocket (0.6.1)
+ - Turf (2.6.1)
- VisionCamera (2.15.4):
- React
- React-callinvoker
@@ -793,6 +835,7 @@ PODS:
DEPENDENCIES:
- boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
+ - BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
- FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`)
@@ -879,6 +922,7 @@ DEPENDENCIES:
- React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
+ - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)"
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)"
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
@@ -892,7 +936,9 @@ DEPENDENCIES:
- "RNFBPerf (from `../node_modules/@react-native-firebase/perf`)"
- RNFS (from `../node_modules/react-native-fs`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
+ - "RNGoogleSignin (from `../node_modules/@react-native-google-signin/google-signin`)"
- RNLocalize (from `../node_modules/react-native-localize`)
+ - "rnmapbox-maps (from `../node_modules/@rnmapbox/maps`)"
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
@@ -905,6 +951,7 @@ SPEC REPOS:
trunk:
- Airship
- AirshipFrameworkProxy
+ - AppAuth
- CocoaAsyncSocket
- Firebase
- FirebaseABTesting
@@ -926,10 +973,17 @@ SPEC REPOS:
- fmt
- GoogleAppMeasurement
- GoogleDataTransport
+ - GoogleSignIn
- GoogleUtilities
+ - GTMAppAuth
+ - GTMSessionFetcher
- libevent
- libwebp
- lottie-ios
+ - MapboxCommon
+ - MapboxCoreMaps
+ - MapboxMaps
+ - MapboxMobileEvents
- nanopb
- Onfido
- OpenSSL-Universal
@@ -938,11 +992,14 @@ SPEC REPOS:
- SDWebImage
- SDWebImageWebPCoder
- SocketRocket
+ - Turf
- YogaKit
EXTERNAL SOURCES:
boost:
:podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
+ BVLinearGradient:
+ :path: "../node_modules/react-native-linear-gradient"
DoubleConversion:
:podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
FBLazyVector:
@@ -1068,6 +1125,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/react/utils"
ReactCommon:
:path: "../node_modules/react-native/ReactCommon"
+ RNAppleAuthentication:
+ :path: "../node_modules/@invertase/react-native-apple-authentication"
RNCAsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
RNCClipboard:
@@ -1094,8 +1153,12 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-fs"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
+ RNGoogleSignin:
+ :path: "../node_modules/@react-native-google-signin/google-signin"
RNLocalize:
:path: "../node_modules/react-native-localize"
+ rnmapbox-maps:
+ :path: "../node_modules/@rnmapbox/maps"
RNPermissions:
:path: "../node_modules/react-native-permissions"
RNReactNativeHapticFeedback:
@@ -1114,7 +1177,9 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Airship: c70eed50e429f97f5adb285423c7291fb7a032ae
AirshipFrameworkProxy: 7bc4130c668c6c98e2d4c60fe4c9eb61a999be99
+ AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570
boost: 57d2868c099736d80fcd648bf211b4431e51a558
+ BVLinearGradient: 421743791a59d259aec53f4c58793aad031da2ca
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54
FBLazyVector: 4cce221dd782d3ff7c4172167bba09d58af67ccb
@@ -1140,12 +1205,19 @@ SPEC CHECKSUMS:
glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b
GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91
GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd
+ GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842
GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749
+ GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae
+ GTMSessionFetcher: e8647203b65cee28c5f73d0f473d096653945e72
hermes-engine: 10fbd3f62405c41ea07e71973ea61e1878d07322
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
lottie-ios: 8f97d3271e155c2d688875c29cd3c74908aef5f8
lottie-react-native: 8f9d4be452e23f6e5ca0fdc11669dc99ab52be81
+ MapboxCommon: 4a0251dd470ee37e7fadda8e285c01921a5e1eb0
+ MapboxCoreMaps: eb07203bbb0b1509395db5ab89cd3ad6c2e3c04c
+ MapboxMaps: af50ec61a7eb3b032c3f7962c6bd671d93d2a209
+ MapboxMobileEvents: de50b3a4de180dd129c326e09cd12c8adaaa46d6
nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96
Onfido: e36f284b865adcf99d9c905590a64ac09d4a576b
onfido-react-native-sdk: 4ecde1a97435dcff9f00a878e3f8d1eb14fabbdc
@@ -1207,6 +1279,7 @@ SPEC CHECKSUMS:
React-runtimescheduler: 837c1bebd2f84572db17698cd702ceaf585b0d9a
React-utils: bcb57da67eec2711f8b353f6e3d33bd8e4b2efa3
ReactCommon: 3ccb8fb14e6b3277e38c73b0ff5e4a1b8db017a9
+ RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
RNCPicker: 0b65be85fe7954fbb2062ef079e3d1cde252d888
@@ -1220,7 +1293,9 @@ SPEC CHECKSUMS:
RNFBPerf: 389914cda4000fe0d996a752532a591132cbf3f9
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: dec4645026e7401a0899f2846d864403478ff6a5
+ RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0
RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81
+ rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
RNReanimated: 020859659f64be2d30849a1fe88c821a7c3e0cbf
@@ -1229,10 +1304,11 @@ SPEC CHECKSUMS:
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
+ Turf: 469ce2c3d22e5e8e4818d5a3b254699a5c89efa4
VisionCamera: d3ec8883417a6a4a0e3a6ba37d81d22db7611601
Yoga: 8796b55dba14d7004f980b54bcc9833ee45b28ce
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
-PODFILE CHECKSUM: bc8161c6bfffeec6e6eaf84be18de5041ddcacf6
+PODFILE CHECKSUM: 845537d35601574adcd0794e17003ba7dbccdbfd
COCOAPODS: 1.12.1
diff --git a/ios/tmp.xcconfig b/ios/tmp.xcconfig
new file mode 100644
index 000000000000..8b137891791f
--- /dev/null
+++ b/ios/tmp.xcconfig
@@ -0,0 +1 @@
+
diff --git a/jest.config.js b/jest.config.js
index 02597af9c9f2..1f540a679b9a 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -22,7 +22,7 @@ module.exports = {
doNotFake: ['nextTick'],
},
testEnvironment: 'jsdom',
- setupFiles: ['/jest/setup.js'],
+ setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'],
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
cacheDirectory: '/.jest-cache',
};
diff --git a/package-lock.json b/package-lock.json
index 75736f2c68ac..2b3df7229b67 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.53-1",
+ "version": "1.3.57-3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.53-1",
+ "version": "1.3.57-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -17,6 +17,7 @@
"@formatjs/intl-numberformat": "^8.5.0",
"@formatjs/intl-pluralrules": "^5.2.2",
"@gorhom/portal": "^1.0.14",
+ "@invertase/react-native-apple-authentication": "^2.2.2",
"@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52",
"@onfido/react-native-sdk": "7.4.0",
"@react-native-async-storage/async-storage": "^1.17.10",
@@ -28,11 +29,13 @@
"@react-native-firebase/app": "^12.3.0",
"@react-native-firebase/crashlytics": "^12.3.0",
"@react-native-firebase/perf": "^12.3.0",
+ "@react-native-google-signin/google-signin": "^10.0.1",
"@react-native-picker/picker": "^2.4.3",
"@react-navigation/material-top-tabs": "^6.6.3",
"@react-navigation/native": "6.1.6",
"@react-navigation/stack": "6.3.16",
"@react-ng/bounds-observer": "^0.2.1",
+ "@rnmapbox/maps": "^10.0.11",
"@ua/react-native-airship": "^15.2.6",
"awesome-phonenumber": "^5.4.0",
"babel-plugin-transform-remove-console": "^6.9.4",
@@ -42,14 +45,14 @@
"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#b60e464ca23e452eacffb93d471abed977b9abf0",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
+ "idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
- "localforage": "^1.10.0",
- "localforage-removeitems": "^1.4.0",
"lodash": "4.17.21",
"lottie-react-native": "^5.1.6",
+ "mapbox-gl": "^2.15.0",
"metro-config": "^0.71.3",
"moment": "^2.29.4",
"moment-timezone": "^0.5.31",
@@ -62,6 +65,7 @@
"react-collapse": "^5.1.0",
"react-content-loader": "^6.1.0",
"react-dom": "18.1.0",
+ "react-map-gl": "^7.1.3",
"react-native": "0.72.3",
"react-native-blob-util": "^0.17.3",
"react-native-collapsible": "^1.6.0",
@@ -78,9 +82,10 @@
"react-native-image-picker": "^5.1.0",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
"react-native-key-command": "^1.0.1",
+ "react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.59",
+ "react-native-onyx": "1.0.63",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.6.2",
"react-native-performance": "^4.0.0",
@@ -95,16 +100,19 @@
"react-native-screens": "3.21.0",
"react-native-svg": "^13.9.0",
"react-native-tab-view": "^3.5.2",
+ "react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^3.6.0",
"react-native-vision-camera": "^2.15.4",
+ "react-native-web-linear-gradient": "^1.1.2",
"react-native-web-lottie": "^1.4.4",
"react-native-webview": "^11.17.2",
+ "react-native-x-maps": "1.0.10",
"react-pdf": "^6.2.2",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
"react-window": "^1.8.9",
"save": "^2.4.0",
- "semver": "^7.3.8",
+ "semver": "^7.5.2",
"shim-keyboard-event-key": "^1.0.3",
"underscore": "^1.13.1"
},
@@ -143,6 +151,7 @@
"@types/jest-when": "^3.5.2",
"@types/js-yaml": "^4.0.5",
"@types/lodash": "^4.14.195",
+ "@types/mapbox-gl": "^2.7.13",
"@types/mock-fs": "^4.13.1",
"@types/pusher-js": "^5.1.0",
"@types/react": "^18.2.12",
@@ -184,7 +193,6 @@
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-storybook": "^0.5.13",
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0",
- "flipper-plugin-bridgespy-client": "^0.1.9",
"html-webpack-plugin": "^5.5.0",
"jest": "29.4.1",
"jest-circus": "29.4.1",
@@ -207,7 +215,7 @@
"style-loader": "^2.0.0",
"time-analytics-webpack-plugin": "^0.1.17",
"type-fest": "^3.12.0",
- "typescript": "^4.8.4",
+ "typescript": "^5.1.6",
"wait-port": "^0.2.9",
"webpack": "^5.76.0",
"webpack-bundle-analyzer": "^4.5.0",
@@ -3967,6 +3975,11 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/@invertase/react-native-apple-authentication": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/@invertase/react-native-apple-authentication/-/react-native-apple-authentication-2.2.2.tgz",
+ "integrity": "sha512-uNZcUn9WbAQP5zSOFXI1+kEUokLwZG9imUulFdt5t22CU2ozGq6zyPm+BAVVg8D5eUUXduX/dJFhbuOpJxiEhQ=="
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5449,6 +5462,31 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/@mapbox/geojson-rewind": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
+ "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
+ "dependencies": {
+ "get-stream": "^6.0.1",
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "geojson-rewind": "geojson-rewind"
+ }
+ },
+ "node_modules/@mapbox/jsonlint-lines-primitives": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
+ "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@mapbox/mapbox-gl-supported": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz",
+ "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ=="
+ },
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz",
@@ -5516,6 +5554,55 @@
"node": ">=6"
}
},
+ "node_modules/@mapbox/point-geometry": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
+ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
+ },
+ "node_modules/@mapbox/tiny-sdf": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
+ "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA=="
+ },
+ "node_modules/@mapbox/unitbezier": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
+ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
+ "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
+ "dependencies": {
+ "@mapbox/point-geometry": "~0.1.0"
+ }
+ },
+ "node_modules/@mapbox/whoots-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
+ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.0.tgz",
+ "integrity": "sha512-ZbhX9CTV+Z7vHwkRIasDOwTSzr76e8Q6a55RMsAibjyX6+P0ZNL1qAKNzOjjBDP3+aEfNMl7hHo5knuY6pTAUQ==",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^3.0.0",
+ "minimist": "^1.2.8",
+ "rw": "^1.3.3",
+ "sort-object": "^3.0.3"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
"node_modules/@mdx-js/mdx": {
"version": "1.6.22",
"dev": true,
@@ -8509,6 +8596,21 @@
"@react-native-firebase/app": "12.9.3"
}
},
+ "node_modules/@react-native-google-signin/google-signin": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-10.0.1.tgz",
+ "integrity": "sha512-oZoU2lfKyn0s0GqqdFsi4v2FSENrxQYQU9DD/RSkxDdkIQ49Wwo6p5LKlgXY04GwZEVdYMuvZN3G89gQW0ig2g==",
+ "peerDependencies": {
+ "expo": ">=47.0.0",
+ "react": "*",
+ "react-native": "*"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@react-native-picker/picker": {
"version": "2.4.4",
"license": "MIT",
@@ -9415,6 +9517,34 @@
"loose-envify": "^1.1.0"
}
},
+ "node_modules/@rnmapbox/maps": {
+ "version": "10.0.11",
+ "resolved": "https://registry.npmjs.org/@rnmapbox/maps/-/maps-10.0.11.tgz",
+ "integrity": "sha512-CqaAOEV2nYjZzAwSd7RceGIVVIyDO0G/Vqdvgen20LDuejX9N9Yqw7BrMH8MgIH3FNFxtjwyXiw6aVtybpke0w==",
+ "dependencies": {
+ "@turf/along": "6.5.0",
+ "@turf/distance": "6.5.0",
+ "@turf/helpers": "6.5.0",
+ "@turf/length": "6.5.0",
+ "@turf/nearest-point-on-line": "6.5.0",
+ "@types/geojson": "^7946.0.7",
+ "debounce": "^1.2.0"
+ },
+ "peerDependencies": {
+ "expo": ">=47.0.0",
+ "mapbox-gl": "^2.9.0",
+ "react": ">=16.6.1",
+ "react-native": ">=0.59.9"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ },
+ "mapbox-gl": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@sentry/browser": {
"version": "7.11.1",
"license": "BSD-3-Clause",
@@ -19749,6 +19879,157 @@
"node": ">=10.13.0"
}
},
+ "node_modules/@turf/along": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz",
+ "integrity": "sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw==",
+ "dependencies": {
+ "@turf/bearing": "^6.5.0",
+ "@turf/destination": "^6.5.0",
+ "@turf/distance": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/bbox": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz",
+ "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/bearing": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz",
+ "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/destination": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz",
+ "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/distance": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
+ "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/helpers": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz",
+ "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==",
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/invariant": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz",
+ "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/length": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/length/-/length-6.5.0.tgz",
+ "integrity": "sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig==",
+ "dependencies": {
+ "@turf/distance": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/line-intersect": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz",
+ "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/line-segment": "^6.5.0",
+ "@turf/meta": "^6.5.0",
+ "geojson-rbush": "3.x"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/line-segment": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz",
+ "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/meta": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz",
+ "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/nearest-point-on-line": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz",
+ "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==",
+ "dependencies": {
+ "@turf/bearing": "^6.5.0",
+ "@turf/destination": "^6.5.0",
+ "@turf/distance": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/line-intersect": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
"node_modules/@types/acorn": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz",
@@ -19937,6 +20218,11 @@
"@types/node": "*"
}
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.10",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
+ "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
+ },
"node_modules/@types/glob": {
"version": "7.2.0",
"dev": true,
@@ -20092,6 +20378,14 @@
"version": "4.0.2",
"license": "MIT"
},
+ "node_modules/@types/mapbox-gl": {
+ "version": "2.7.13",
+ "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-2.7.13.tgz",
+ "integrity": "sha512-qNffhTdYkeFl8QG9Q1zPPJmcs8PvHgmLa1PcwP1rxvcfMsIgcFr/FnrCttG0ZnH7Kzdd7xfECSRNTWSr4jC3PQ==",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/mdast": {
"version": "3.0.10",
"dev": true,
@@ -21988,7 +22282,6 @@
},
"node_modules/arr-union": {
"version": "3.1.0",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -22181,7 +22474,6 @@
},
"node_modules/assign-symbols": {
"version": "1.0.0",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23874,6 +24166,23 @@
"node": ">= 0.8"
}
},
+ "node_modules/bytewise": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
+ "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==",
+ "dependencies": {
+ "bytewise-core": "^1.2.2",
+ "typewise": "^1.0.3"
+ }
+ },
+ "node_modules/bytewise-core": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz",
+ "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==",
+ "dependencies": {
+ "typewise-core": "^1.2"
+ }
+ },
"node_modules/c8": {
"version": "7.12.0",
"dev": true,
@@ -24924,6 +25233,19 @@
"typescript": "^4.0.2"
}
},
+ "node_modules/config-file-ts/node_modules/typescript": {
+ "version": "4.9.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
+ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=4.2.0"
+ }
+ },
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"dev": true,
@@ -25931,6 +26253,11 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/csscolorparser": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
+ "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w=="
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"dev": true,
@@ -26041,6 +26368,11 @@
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz",
"integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA=="
},
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="
+ },
"node_modules/debug": {
"version": "4.3.4",
"license": "MIT",
@@ -26774,6 +27106,11 @@
"stream-shift": "^1.0.0"
}
},
+ "node_modules/earcut": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
+ "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -29206,8 +29543,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#b60e464ca23e452eacffb93d471abed977b9abf0",
- "integrity": "sha512-SA+1PDrST90MoWKNuqyfw7vT1c3S14JrrHCuk5l5m77k2T1Khu1lHPAw7sCUt0Yeoceq7JHL7zC4ZPhqVzDXwQ==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
+ "integrity": "sha512-nNYAweSE5bwjKyFTi9tz+p1z+gxkytCnIa8M11vnseV60ZzJespcwB/2SbWkdaAL5wpvcgHLlFTTGbPUwIiTvw==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -29389,7 +29726,6 @@
},
"node_modules/extend-shallow": {
"version": "3.0.2",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"assign-symbols": "^1.0.0",
@@ -29979,16 +30315,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/flipper-plugin-bridgespy-client": {
- "version": "0.1.9",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "react-native": ">=0.62.0",
- "react-native-flipper": ">=0.54.0 <1.0.0",
- "typescript": ">=3.5.0 <5.0.0"
- }
- },
"node_modules/flow-enums-runtime": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.5.tgz",
@@ -30397,6 +30723,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/geojson-rbush": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz",
+ "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==",
+ "dependencies": {
+ "@turf/bbox": "*",
+ "@turf/helpers": "6.x",
+ "@turf/meta": "6.x",
+ "@types/geojson": "7946.0.8",
+ "rbush": "^3.0.1"
+ }
+ },
+ "node_modules/geojson-rbush/node_modules/@types/geojson": {
+ "version": "7946.0.8",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz",
+ "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA=="
+ },
+ "node_modules/geojson-vt": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz",
+ "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg=="
+ },
"node_modules/get-caller-file": {
"version": "2.0.5",
"license": "ISC",
@@ -30466,7 +30814,6 @@
},
"node_modules/get-value": {
"version": "2.0.6",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -30485,6 +30832,11 @@
"integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==",
"dev": true
},
+ "node_modules/gl-matrix": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
+ "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
+ },
"node_modules/glob": {
"version": "7.1.6",
"license": "ISC",
@@ -30639,6 +30991,11 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true
},
+ "node_modules/grid-index": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
+ "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA=="
+ },
"node_modules/gzip-size": {
"version": "6.0.0",
"dev": true,
@@ -31787,6 +32144,11 @@
"node": ">= 6"
}
},
+ "node_modules/idb-keyval": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
+ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"funding": [
@@ -32345,7 +32707,6 @@
},
"node_modules/is-extendable": {
"version": "1.0.1",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"is-plain-object": "^2.0.4"
@@ -32356,7 +32717,6 @@
},
"node_modules/is-extendable/node_modules/is-plain-object": {
"version": "2.0.4",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
@@ -35630,6 +35990,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stringify-pretty-compact": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
+ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA=="
+ },
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"dev": true,
@@ -35683,6 +36048,11 @@
"node": ">=8"
}
},
+ "node_modules/kdbush": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
+ },
"node_modules/kebab-case": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",
@@ -35893,12 +36263,6 @@
"lie": "3.1.1"
}
},
- "node_modules/localforage-removeitems": {
- "version": "1.4.0",
- "dependencies": {
- "localforage": ">=1.4.0"
- }
- },
"node_modules/locate-path": {
"version": "6.0.0",
"license": "MIT",
@@ -36592,6 +36956,35 @@
"node": ">=0.10.0"
}
},
+ "node_modules/mapbox-gl": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-2.15.0.tgz",
+ "integrity": "sha512-fjv+aYrd5TIHiL7wRa+W7KjtUqKWziJMZUkK5hm8TvJ3OLeNPx4NmW/DgfYhd/jHej8wWL+QJBDbdMMAKvNC0A==",
+ "dependencies": {
+ "@mapbox/geojson-rewind": "^0.5.2",
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/mapbox-gl-supported": "^2.0.1",
+ "@mapbox/point-geometry": "^0.1.0",
+ "@mapbox/tiny-sdf": "^2.0.6",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^1.3.1",
+ "@mapbox/whoots-js": "^3.1.0",
+ "csscolorparser": "~1.0.3",
+ "earcut": "^2.2.4",
+ "geojson-vt": "^3.2.1",
+ "gl-matrix": "^3.4.3",
+ "grid-index": "^1.1.0",
+ "kdbush": "^4.0.1",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^3.2.1",
+ "potpack": "^2.0.0",
+ "quickselect": "^2.0.0",
+ "rw": "^1.3.3",
+ "supercluster": "^8.0.0",
+ "tinyqueue": "^2.0.3",
+ "vt-pbf": "^3.1.3"
+ }
+ },
"node_modules/markdown-builder": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/markdown-builder/-/markdown-builder-0.9.0.tgz",
@@ -39459,8 +39852,12 @@
}
},
"node_modules/minimist": {
- "version": "1.2.6",
- "license": "MIT"
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
},
"node_modules/minipass": {
"version": "3.3.4",
@@ -39737,6 +40134,11 @@
"multicast-dns": "cli.js"
}
},
+ "node_modules/murmurhash-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
+ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
+ },
"node_modules/mute-stream": {
"version": "0.0.8",
"dev": true,
@@ -41196,6 +41598,18 @@
"through": "~2.3"
}
},
+ "node_modules/pbf": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
+ "integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==",
+ "dependencies": {
+ "ieee754": "^1.1.12",
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/pbkdf2": {
"version": "3.1.2",
"license": "MIT",
@@ -41587,6 +42001,11 @@
"version": "4.2.0",
"license": "MIT"
},
+ "node_modules/potpack": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz",
+ "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
+ },
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
@@ -41793,6 +42212,11 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"dev": true,
@@ -42124,6 +42548,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/quickselect": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
+ },
"node_modules/ramda": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.0.tgz",
@@ -42208,6 +42637,14 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
+ "node_modules/rbush": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
+ "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
+ "dependencies": {
+ "quickselect": "^2.0.0"
+ }
+ },
"node_modules/react": {
"version": "18.2.0",
"license": "MIT",
@@ -42344,6 +42781,29 @@
"version": "16.13.1",
"license": "MIT"
},
+ "node_modules/react-map-gl": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-7.1.3.tgz",
+ "integrity": "sha512-uMiwk3x/XxYqSxWWBPgqitvLh8y+O9AabEIBeg3tLjoCVBEyFiZko7tAcRqA7WLT079KX/lyDL1N2zALqJb/MQ==",
+ "dependencies": {
+ "@maplibre/maplibre-gl-style-spec": "^19.2.1",
+ "@types/mapbox-gl": ">=1.0.0"
+ },
+ "peerDependencies": {
+ "mapbox-gl": ">=1.13.0",
+ "maplibre-gl": ">=1.13.0",
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ },
+ "peerDependenciesMeta": {
+ "mapbox-gl": {
+ "optional": true
+ },
+ "maplibre-gl": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-native": {
"version": "0.72.3",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.72.3.tgz",
@@ -42615,6 +43075,15 @@
"react-native-web": "^0.18.1"
}
},
+ "node_modules/react-native-linear-gradient": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz",
+ "integrity": "sha512-934R4Bnjo7mYT38W9ypS1Dq/YW6TgyGdkHg+w72HNxN0ZDKG1GqAnZ6XlicMUYJDh7ViiJAKN8eOF3Ho0N4J0Q==",
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-localize": {
"version": "2.2.6",
"license": "MIT",
@@ -42646,9 +43115,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "1.0.59",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.59.tgz",
- "integrity": "sha512-eDFRT3lGol651gjGmS7HMZVPJf/wrnLa3lQUWOeK5oD0D93I+e71brrHuUu0WoSiQVT6icp+0Wx3kEdds3+spw==",
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.63.tgz",
+ "integrity": "sha512-GJc4vlhx/+vnM+xRZqT7aq/BEYMAFcPxFF5TW5OKS7j5Ba/SKMmooZB5zAutsbVq5tfh+Cfh3L2O4rNRXNjKEg==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -42659,17 +43128,13 @@
"npm": "8.11.0"
},
"peerDependencies": {
- "localforage": "^1.10.0",
- "localforage-removeitems": "^1.4.0",
+ "idb-keyval": "^6.2.1",
"react": ">=18.1.0",
"react-native-performance": "^4.0.0",
"react-native-quick-sqlite": "^8.0.0-beta.2"
},
"peerDependenciesMeta": {
- "localforage": {
- "optional": true
- },
- "localforage-removeitems": {
+ "idb-keyval": {
"optional": true
},
"react-native-performance": {
@@ -42894,6 +43359,17 @@
"react-native-pager-view": "*"
}
},
+ "node_modules/react-native-url-polyfill": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
+ "integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==",
+ "dependencies": {
+ "whatwg-url-without-unicode": "8.0.0-3"
+ },
+ "peerDependencies": {
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-view-shot": {
"version": "3.6.0",
"license": "MIT",
@@ -42929,6 +43405,14 @@
"react-dom": "^17.0.2 || ^18.0.0"
}
},
+ "node_modules/react-native-web-linear-gradient": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/react-native-web-linear-gradient/-/react-native-web-linear-gradient-1.1.2.tgz",
+ "integrity": "sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==",
+ "peerDependencies": {
+ "react-native-web": "*"
+ }
+ },
"node_modules/react-native-web-lottie": {
"version": "1.4.4",
"license": "MIT",
@@ -42958,6 +43442,18 @@
"node": ">=8"
}
},
+ "node_modules/react-native-x-maps": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.10.tgz",
+ "integrity": "sha512-jBRl5JzP3QmGY6tj5CR9UwbREZ3tnuSa7puZozai3bRFrN68k3W6x1p6O8SGp91VvcQlaqJUPFZ+bkYiY3XRvA==",
+ "peerDependencies": {
+ "@rnmapbox/maps": "^10.0.11",
+ "mapbox-gl": "^2.15.0",
+ "react": "^18.2.0",
+ "react-map-gl": "^7.1.3",
+ "react-native": "^0.72.3"
+ }
+ },
"node_modules/react-native/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -44744,6 +45240,14 @@
"version": "2.2.0",
"license": "MIT"
},
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/resolve-url": {
"version": "0.2.1",
"devOptional": true,
@@ -44910,6 +45414,11 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
+ },
"node_modules/rxjs": {
"version": "6.6.7",
"dev": true,
@@ -45279,7 +45788,6 @@
},
"node_modules/set-value": {
"version": "2.0.1",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
@@ -45293,7 +45801,6 @@
},
"node_modules/set-value/node_modules/extend-shallow": {
"version": "2.0.1",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
@@ -45304,7 +45811,6 @@
},
"node_modules/set-value/node_modules/is-extendable": {
"version": "0.1.1",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -45312,7 +45818,6 @@
},
"node_modules/set-value/node_modules/is-plain-object": {
"version": "2.0.4",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
@@ -45888,6 +46393,46 @@
"node": ">= 10"
}
},
+ "node_modules/sort-asc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
+ "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-desc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz",
+ "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-object": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz",
+ "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==",
+ "dependencies": {
+ "bytewise": "^1.1.0",
+ "get-value": "^2.0.2",
+ "is-extendable": "^0.1.1",
+ "sort-asc": "^0.2.0",
+ "sort-desc": "^0.2.0",
+ "union-value": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sort-object/node_modules/is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-list-map": {
"version": "2.0.1",
"license": "MIT"
@@ -46031,7 +46576,6 @@
},
"node_modules/split-string": {
"version": "3.1.0",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"extend-shallow": "^3.0.0"
@@ -46589,6 +47133,14 @@
"node": ">= 8.0"
}
},
+ "node_modules/supercluster": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+ "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+ "dependencies": {
+ "kdbush": "^4.0.2"
+ }
+ },
"node_modules/superstruct": {
"version": "0.6.2",
"license": "MIT",
@@ -47187,6 +47739,11 @@
"version": "1.0.3",
"license": "MIT"
},
+ "node_modules/tinyqueue": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
+ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="
+ },
"node_modules/tmp": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
@@ -47551,18 +48108,31 @@
"license": "MIT"
},
"node_modules/typescript": {
- "version": "4.8.4",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
- "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+ "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
- "node": ">=4.2.0"
+ "node": ">=14.17"
}
},
+ "node_modules/typewise": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz",
+ "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==",
+ "dependencies": {
+ "typewise-core": "^1.2.0"
+ }
+ },
+ "node_modules/typewise-core": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz",
+ "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg=="
+ },
"node_modules/ua-parser-js": {
"version": "0.7.31",
"funding": [
@@ -47705,7 +48275,6 @@
},
"node_modules/union-value": {
"version": "1.0.1",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"arr-union": "^3.1.0",
@@ -47719,7 +48288,6 @@
},
"node_modules/union-value/node_modules/is-extendable": {
"version": "0.1.1",
- "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -48352,6 +48920,16 @@
"version": "1.1.2",
"license": "MIT"
},
+ "node_modules/vt-pbf": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
+ "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
+ "dependencies": {
+ "@mapbox/point-geometry": "0.1.0",
+ "@mapbox/vector-tile": "^1.3.1",
+ "pbf": "^3.2.1"
+ }
+ },
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"dev": true,
@@ -49459,6 +50037,27 @@
"node": ">=12"
}
},
+ "node_modules/whatwg-url-without-unicode": {
+ "version": "8.0.0-3",
+ "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
+ "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==",
+ "dependencies": {
+ "buffer": "^5.4.3",
+ "punycode": "^2.1.1",
+ "webidl-conversions": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
+ "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"license": "ISC",
@@ -52460,6 +53059,11 @@
"version": "1.2.1",
"dev": true
},
+ "@invertase/react-native-apple-authentication": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/@invertase/react-native-apple-authentication/-/react-native-apple-authentication-2.2.2.tgz",
+ "integrity": "sha512-uNZcUn9WbAQP5zSOFXI1+kEUokLwZG9imUulFdt5t22CU2ozGq6zyPm+BAVVg8D5eUUXduX/dJFhbuOpJxiEhQ=="
+ },
"@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -53487,6 +54091,25 @@
"tmp-promise": "^3.0.2"
}
},
+ "@mapbox/geojson-rewind": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
+ "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
+ "requires": {
+ "get-stream": "^6.0.1",
+ "minimist": "^1.2.6"
+ }
+ },
+ "@mapbox/jsonlint-lines-primitives": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
+ "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ=="
+ },
+ "@mapbox/mapbox-gl-supported": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz",
+ "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ=="
+ },
"@mapbox/node-pre-gyp": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz",
@@ -53540,6 +54163,47 @@
}
}
},
+ "@mapbox/point-geometry": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
+ "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
+ },
+ "@mapbox/tiny-sdf": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz",
+ "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA=="
+ },
+ "@mapbox/unitbezier": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
+ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
+ },
+ "@mapbox/vector-tile": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
+ "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
+ "requires": {
+ "@mapbox/point-geometry": "~0.1.0"
+ }
+ },
+ "@mapbox/whoots-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
+ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q=="
+ },
+ "@maplibre/maplibre-gl-style-spec": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.0.tgz",
+ "integrity": "sha512-ZbhX9CTV+Z7vHwkRIasDOwTSzr76e8Q6a55RMsAibjyX6+P0ZNL1qAKNzOjjBDP3+aEfNMl7hHo5knuY6pTAUQ==",
+ "requires": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^3.0.0",
+ "minimist": "^1.2.8",
+ "rw": "^1.3.3",
+ "sort-object": "^3.0.3"
+ }
+ },
"@mdx-js/mdx": {
"version": "1.6.22",
"dev": true,
@@ -55620,6 +56284,12 @@
"@expo/config-plugins": "^4.0.3"
}
},
+ "@react-native-google-signin/google-signin": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-10.0.1.tgz",
+ "integrity": "sha512-oZoU2lfKyn0s0GqqdFsi4v2FSENrxQYQU9DD/RSkxDdkIQ49Wwo6p5LKlgXY04GwZEVdYMuvZN3G89gQW0ig2g==",
+ "requires": {}
+ },
"@react-native-picker/picker": {
"version": "2.4.4",
"requires": {}
@@ -56318,6 +56988,20 @@
}
}
},
+ "@rnmapbox/maps": {
+ "version": "10.0.11",
+ "resolved": "https://registry.npmjs.org/@rnmapbox/maps/-/maps-10.0.11.tgz",
+ "integrity": "sha512-CqaAOEV2nYjZzAwSd7RceGIVVIyDO0G/Vqdvgen20LDuejX9N9Yqw7BrMH8MgIH3FNFxtjwyXiw6aVtybpke0w==",
+ "requires": {
+ "@turf/along": "6.5.0",
+ "@turf/distance": "6.5.0",
+ "@turf/helpers": "6.5.0",
+ "@turf/length": "6.5.0",
+ "@turf/nearest-point-on-line": "6.5.0",
+ "@types/geojson": "^7946.0.7",
+ "debounce": "^1.2.0"
+ }
+ },
"@sentry/browser": {
"version": "7.11.1",
"requires": {
@@ -63414,6 +64098,121 @@
"version": "0.2.0",
"dev": true
},
+ "@turf/along": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz",
+ "integrity": "sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw==",
+ "requires": {
+ "@turf/bearing": "^6.5.0",
+ "@turf/destination": "^6.5.0",
+ "@turf/distance": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ }
+ },
+ "@turf/bbox": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.5.0.tgz",
+ "integrity": "sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==",
+ "requires": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ }
+ },
+ "@turf/bearing": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz",
+ "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==",
+ "requires": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ }
+ },
+ "@turf/destination": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz",
+ "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==",
+ "requires": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ }
+ },
+ "@turf/distance": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
+ "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+ "requires": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ }
+ },
+ "@turf/helpers": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz",
+ "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw=="
+ },
+ "@turf/invariant": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz",
+ "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==",
+ "requires": {
+ "@turf/helpers": "^6.5.0"
+ }
+ },
+ "@turf/length": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/length/-/length-6.5.0.tgz",
+ "integrity": "sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig==",
+ "requires": {
+ "@turf/distance": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ }
+ },
+ "@turf/line-intersect": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz",
+ "integrity": "sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA==",
+ "requires": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/line-segment": "^6.5.0",
+ "@turf/meta": "^6.5.0",
+ "geojson-rbush": "3.x"
+ }
+ },
+ "@turf/line-segment": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/line-segment/-/line-segment-6.5.0.tgz",
+ "integrity": "sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw==",
+ "requires": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ }
+ },
+ "@turf/meta": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz",
+ "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==",
+ "requires": {
+ "@turf/helpers": "^6.5.0"
+ }
+ },
+ "@turf/nearest-point-on-line": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz",
+ "integrity": "sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg==",
+ "requires": {
+ "@turf/bearing": "^6.5.0",
+ "@turf/destination": "^6.5.0",
+ "@turf/distance": "^6.5.0",
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/line-intersect": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ }
+ },
"@types/acorn": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz",
@@ -63586,6 +64385,11 @@
"@types/node": "*"
}
},
+ "@types/geojson": {
+ "version": "7946.0.10",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz",
+ "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA=="
+ },
"@types/glob": {
"version": "7.2.0",
"dev": true,
@@ -63717,6 +64521,14 @@
"@types/long": {
"version": "4.0.2"
},
+ "@types/mapbox-gl": {
+ "version": "2.7.13",
+ "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-2.7.13.tgz",
+ "integrity": "sha512-qNffhTdYkeFl8QG9Q1zPPJmcs8PvHgmLa1PcwP1rxvcfMsIgcFr/FnrCttG0ZnH7Kzdd7xfECSRNTWSr4jC3PQ==",
+ "requires": {
+ "@types/geojson": "*"
+ }
+ },
"@types/mdast": {
"version": "3.0.10",
"dev": true,
@@ -65061,8 +65873,7 @@
"devOptional": true
},
"arr-union": {
- "version": "3.1.0",
- "devOptional": true
+ "version": "3.1.0"
},
"array-find-index": {
"version": "1.0.2",
@@ -65188,8 +65999,7 @@
"optional": true
},
"assign-symbols": {
- "version": "1.0.0",
- "devOptional": true
+ "version": "1.0.0"
},
"ast-types": {
"version": "0.14.2",
@@ -66361,6 +67171,23 @@
"bytes": {
"version": "3.0.0"
},
+ "bytewise": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
+ "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==",
+ "requires": {
+ "bytewise-core": "^1.2.2",
+ "typewise": "^1.0.3"
+ }
+ },
+ "bytewise-core": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz",
+ "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==",
+ "requires": {
+ "typewise-core": "^1.2"
+ }
+ },
"c8": {
"version": "7.12.0",
"dev": true,
@@ -67047,6 +67874,14 @@
"requires": {
"glob": "^7.1.6",
"typescript": "^4.0.2"
+ },
+ "dependencies": {
+ "typescript": {
+ "version": "4.9.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
+ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
+ "dev": true
+ }
}
},
"confusing-browser-globals": {
@@ -67721,6 +68556,11 @@
"css-what": {
"version": "6.1.0"
},
+ "csscolorparser": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
+ "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w=="
+ },
"cssesc": {
"version": "3.0.0",
"dev": true
@@ -67796,6 +68636,11 @@
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz",
"integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA=="
},
+ "debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="
+ },
"debug": {
"version": "4.3.4",
"requires": {
@@ -68305,6 +69150,11 @@
"stream-shift": "^1.0.0"
}
},
+ "earcut": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
+ "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="
+ },
"eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -69963,9 +70813,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#b60e464ca23e452eacffb93d471abed977b9abf0",
- "integrity": "sha512-SA+1PDrST90MoWKNuqyfw7vT1c3S14JrrHCuk5l5m77k2T1Khu1lHPAw7sCUt0Yeoceq7JHL7zC4ZPhqVzDXwQ==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#b60e464ca23e452eacffb93d471abed977b9abf0",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
+ "integrity": "sha512-nNYAweSE5bwjKyFTi9tz+p1z+gxkytCnIa8M11vnseV60ZzJespcwB/2SbWkdaAL5wpvcgHLlFTTGbPUwIiTvw==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -70096,7 +70946,6 @@
},
"extend-shallow": {
"version": "3.0.2",
- "devOptional": true,
"requires": {
"assign-symbols": "^1.0.0",
"is-extendable": "^1.0.1"
@@ -70506,11 +71355,6 @@
"version": "3.2.6",
"dev": true
},
- "flipper-plugin-bridgespy-client": {
- "version": "0.1.9",
- "dev": true,
- "requires": {}
- },
"flow-enums-runtime": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.5.tgz",
@@ -70762,6 +71606,30 @@
"gensync": {
"version": "1.0.0-beta.2"
},
+ "geojson-rbush": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/geojson-rbush/-/geojson-rbush-3.2.0.tgz",
+ "integrity": "sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w==",
+ "requires": {
+ "@turf/bbox": "*",
+ "@turf/helpers": "6.x",
+ "@turf/meta": "6.x",
+ "@types/geojson": "7946.0.8",
+ "rbush": "^3.0.1"
+ },
+ "dependencies": {
+ "@types/geojson": {
+ "version": "7946.0.8",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz",
+ "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA=="
+ }
+ }
+ },
+ "geojson-vt": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz",
+ "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg=="
+ },
"get-caller-file": {
"version": "2.0.5"
},
@@ -70797,8 +71665,7 @@
}
},
"get-value": {
- "version": "2.0.6",
- "devOptional": true
+ "version": "2.0.6"
},
"getenv": {
"version": "1.0.0"
@@ -70809,6 +71676,11 @@
"integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==",
"dev": true
},
+ "gl-matrix": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
+ "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
+ },
"glob": {
"version": "7.1.6",
"requires": {
@@ -70912,6 +71784,11 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true
},
+ "grid-index": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
+ "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA=="
+ },
"gzip-size": {
"version": "6.0.0",
"dev": true,
@@ -71705,6 +72582,11 @@
"postcss": "^7.0.14"
}
},
+ "idb-keyval": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
+ "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg=="
+ },
"ieee754": {
"version": "1.2.1"
},
@@ -72020,14 +72902,12 @@
},
"is-extendable": {
"version": "1.0.1",
- "devOptional": true,
"requires": {
"is-plain-object": "^2.0.4"
},
"dependencies": {
"is-plain-object": {
"version": "2.0.4",
- "devOptional": true,
"requires": {
"isobject": "^3.0.1"
}
@@ -74177,6 +75057,11 @@
"version": "1.0.1",
"dev": true
},
+ "json-stringify-pretty-compact": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
+ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA=="
+ },
"json-stringify-safe": {
"version": "5.0.1",
"dev": true
@@ -74208,6 +75093,11 @@
"version": "3.1.0",
"dev": true
},
+ "kdbush": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
+ },
"kebab-case": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz",
@@ -74353,12 +75243,6 @@
"lie": "3.1.1"
}
},
- "localforage-removeitems": {
- "version": "1.4.0",
- "requires": {
- "localforage": ">=1.4.0"
- }
- },
"locate-path": {
"version": "6.0.0",
"requires": {
@@ -74845,6 +75729,35 @@
"object-visit": "^1.0.0"
}
},
+ "mapbox-gl": {
+ "version": "2.15.0",
+ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-2.15.0.tgz",
+ "integrity": "sha512-fjv+aYrd5TIHiL7wRa+W7KjtUqKWziJMZUkK5hm8TvJ3OLeNPx4NmW/DgfYhd/jHej8wWL+QJBDbdMMAKvNC0A==",
+ "requires": {
+ "@mapbox/geojson-rewind": "^0.5.2",
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/mapbox-gl-supported": "^2.0.1",
+ "@mapbox/point-geometry": "^0.1.0",
+ "@mapbox/tiny-sdf": "^2.0.6",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^1.3.1",
+ "@mapbox/whoots-js": "^3.1.0",
+ "csscolorparser": "~1.0.3",
+ "earcut": "^2.2.4",
+ "geojson-vt": "^3.2.1",
+ "gl-matrix": "^3.4.3",
+ "grid-index": "^1.1.0",
+ "kdbush": "^4.0.1",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^3.2.1",
+ "potpack": "^2.0.0",
+ "quickselect": "^2.0.0",
+ "rw": "^1.3.3",
+ "supercluster": "^8.0.0",
+ "tinyqueue": "^2.0.3",
+ "vt-pbf": "^3.1.3"
+ }
+ },
"markdown-builder": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/markdown-builder/-/markdown-builder-0.9.0.tgz",
@@ -76847,7 +77760,9 @@
}
},
"minimist": {
- "version": "1.2.6"
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
},
"minipass": {
"version": "3.3.4",
@@ -77041,6 +77956,11 @@
"thunky": "^1.0.2"
}
},
+ "murmurhash-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
+ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
+ },
"mute-stream": {
"version": "0.0.8",
"dev": true
@@ -78022,6 +78942,15 @@
"through": "~2.3"
}
},
+ "pbf": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
+ "integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==",
+ "requires": {
+ "ieee754": "^1.1.12",
+ "resolve-protobuf-schema": "^2.1.0"
+ }
+ },
"pbkdf2": {
"version": "3.1.2",
"requires": {
@@ -78284,6 +79213,11 @@
"postcss-value-parser": {
"version": "4.2.0"
},
+ "potpack": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz",
+ "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
+ },
"preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
@@ -78415,6 +79349,11 @@
"xtend": "^4.0.0"
}
},
+ "protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
+ },
"proxy-addr": {
"version": "2.0.7",
"dev": true,
@@ -78632,6 +79571,11 @@
"version": "5.1.1",
"dev": true
},
+ "quickselect": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+ "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
+ },
"ramda": {
"version": "0.29.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.0.tgz",
@@ -78685,6 +79629,14 @@
"schema-utils": "^3.0.0"
}
},
+ "rbush": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
+ "integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
+ "requires": {
+ "quickselect": "^2.0.0"
+ }
+ },
"react": {
"version": "18.2.0",
"requires": {
@@ -78771,6 +79723,15 @@
"react-is": {
"version": "16.13.1"
},
+ "react-map-gl": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-7.1.3.tgz",
+ "integrity": "sha512-uMiwk3x/XxYqSxWWBPgqitvLh8y+O9AabEIBeg3tLjoCVBEyFiZko7tAcRqA7WLT079KX/lyDL1N2zALqJb/MQ==",
+ "requires": {
+ "@maplibre/maplibre-gl-style-spec": "^19.2.1",
+ "@types/mapbox-gl": ">=1.0.0"
+ }
+ },
"react-native": {
"version": "0.72.3",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.72.3.tgz",
@@ -79108,6 +80069,12 @@
"underscore": "^1.13.4"
}
},
+ "react-native-linear-gradient": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz",
+ "integrity": "sha512-934R4Bnjo7mYT38W9ypS1Dq/YW6TgyGdkHg+w72HNxN0ZDKG1GqAnZ6XlicMUYJDh7ViiJAKN8eOF3Ho0N4J0Q==",
+ "requires": {}
+ },
"react-native-localize": {
"version": "2.2.6",
"requires": {}
@@ -79120,9 +80087,9 @@
}
},
"react-native-onyx": {
- "version": "1.0.59",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.59.tgz",
- "integrity": "sha512-eDFRT3lGol651gjGmS7HMZVPJf/wrnLa3lQUWOeK5oD0D93I+e71brrHuUu0WoSiQVT6icp+0Wx3kEdds3+spw==",
+ "version": "1.0.63",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.63.tgz",
+ "integrity": "sha512-GJc4vlhx/+vnM+xRZqT7aq/BEYMAFcPxFF5TW5OKS7j5Ba/SKMmooZB5zAutsbVq5tfh+Cfh3L2O4rNRXNjKEg==",
"requires": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -79263,6 +80230,14 @@
"use-latest-callback": "^0.1.5"
}
},
+ "react-native-url-polyfill": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
+ "integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==",
+ "requires": {
+ "whatwg-url-without-unicode": "8.0.0-3"
+ }
+ },
"react-native-view-shot": {
"version": "3.6.0",
"requires": {}
@@ -79286,6 +80261,12 @@
"styleq": "^0.1.2"
}
},
+ "react-native-web-linear-gradient": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/react-native-web-linear-gradient/-/react-native-web-linear-gradient-1.1.2.tgz",
+ "integrity": "sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==",
+ "requires": {}
+ },
"react-native-web-lottie": {
"version": "1.4.4",
"requires": {
@@ -79304,6 +80285,12 @@
}
}
},
+ "react-native-x-maps": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/react-native-x-maps/-/react-native-x-maps-1.0.10.tgz",
+ "integrity": "sha512-jBRl5JzP3QmGY6tj5CR9UwbREZ3tnuSa7puZozai3bRFrN68k3W6x1p6O8SGp91VvcQlaqJUPFZ+bkYiY3XRvA==",
+ "requires": {}
+ },
"react-pdf": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-6.2.2.tgz",
@@ -80383,6 +81370,14 @@
"resolve-pathname": {
"version": "2.2.0"
},
+ "resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "requires": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"resolve-url": {
"version": "0.2.1",
"devOptional": true
@@ -80483,6 +81478,11 @@
}
}
},
+ "rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
+ },
"rxjs": {
"version": "6.6.7",
"dev": true,
@@ -80757,7 +81757,6 @@
},
"set-value": {
"version": "2.0.1",
- "devOptional": true,
"requires": {
"extend-shallow": "^2.0.1",
"is-extendable": "^0.1.1",
@@ -80767,18 +81766,15 @@
"dependencies": {
"extend-shallow": {
"version": "2.0.1",
- "devOptional": true,
"requires": {
"is-extendable": "^0.1.0"
}
},
"is-extendable": {
- "version": "0.1.1",
- "devOptional": true
+ "version": "0.1.1"
},
"is-plain-object": {
"version": "2.0.4",
- "devOptional": true,
"requires": {
"isobject": "^3.0.1"
}
@@ -81189,6 +82185,36 @@
"socks": "^2.6.2"
}
},
+ "sort-asc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
+ "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA=="
+ },
+ "sort-desc": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz",
+ "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w=="
+ },
+ "sort-object": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz",
+ "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==",
+ "requires": {
+ "bytewise": "^1.1.0",
+ "get-value": "^2.0.2",
+ "is-extendable": "^0.1.1",
+ "sort-asc": "^0.2.0",
+ "sort-desc": "^0.2.0",
+ "union-value": "^1.0.1"
+ },
+ "dependencies": {
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="
+ }
+ }
+ },
"source-list-map": {
"version": "2.0.1"
},
@@ -81296,7 +82322,6 @@
},
"split-string": {
"version": "3.1.0",
- "devOptional": true,
"requires": {
"extend-shallow": "^3.0.0"
}
@@ -81675,6 +82700,14 @@
"debug": "^4.1.0"
}
},
+ "supercluster": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+ "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+ "requires": {
+ "kdbush": "^4.0.2"
+ }
+ },
"superstruct": {
"version": "0.6.2",
"requires": {
@@ -82073,6 +83106,11 @@
"tiny-warning": {
"version": "1.0.3"
},
+ "tinyqueue": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
+ "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="
+ },
"tmp": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
@@ -82312,11 +83350,24 @@
"dev": true
},
"typescript": {
- "version": "4.8.4",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz",
- "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==",
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+ "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
"dev": true
},
+ "typewise": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz",
+ "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==",
+ "requires": {
+ "typewise-core": "^1.2.0"
+ }
+ },
+ "typewise-core": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz",
+ "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg=="
+ },
"ua-parser-js": {
"version": "0.7.31"
},
@@ -82403,7 +83454,6 @@
},
"union-value": {
"version": "1.0.1",
- "devOptional": true,
"requires": {
"arr-union": "^3.1.0",
"get-value": "^2.0.6",
@@ -82412,8 +83462,7 @@
},
"dependencies": {
"is-extendable": {
- "version": "0.1.1",
- "devOptional": true
+ "version": "0.1.1"
}
}
},
@@ -82832,6 +83881,16 @@
"vm-browserify": {
"version": "1.1.2"
},
+ "vt-pbf": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
+ "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
+ "requires": {
+ "@mapbox/point-geometry": "0.1.0",
+ "@mapbox/vector-tile": "^1.3.1",
+ "pbf": "^3.2.1"
+ }
+ },
"w3c-hr-time": {
"version": "1.0.2",
"dev": true,
@@ -83552,6 +84611,23 @@
"webidl-conversions": "^7.0.0"
}
},
+ "whatwg-url-without-unicode": {
+ "version": "8.0.0-3",
+ "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
+ "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==",
+ "requires": {
+ "buffer": "^5.4.3",
+ "punycode": "^2.1.1",
+ "webidl-conversions": "^5.0.0"
+ },
+ "dependencies": {
+ "webidl-conversions": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
+ "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA=="
+ }
+ }
+ },
"which": {
"version": "2.0.2",
"requires": {
diff --git a/package.json b/package.json
index 4f45b9d18310..17dd83bffe35 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,20 @@
{
"name": "new.expensify",
- "version": "1.3.53-1",
+ "version": "1.3.57-3",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
"license": "MIT",
"private": true,
"scripts": {
+ "configure-mapbox": "scripts/setup-mapbox-sdk-walkthrough.sh",
"postinstall": "scripts/postInstall.sh",
"clean": "npx react-native clean-project-auto",
- "android": "scripts/set-pusher-suffix.sh && npx react-native run-android",
- "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios",
+ "android": "scripts/set-pusher-suffix.sh && npx react-native run-android --variant=developmentDebug --appId=com.expensify.chat.dev",
+ "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios --configuration=\"Debug Development\" --scheme=\"New Expensify Dev\"",
"pod-install": "cd ios && bundle exec pod install",
- "ipad": "concurrently \"npx react-native run-ios --simulator=\"iPad Pro (12.9-inch) (4th generation)\"\"",
- "ipad-sm": "concurrently \"npx react-native run-ios --simulator=\"iPad Pro (9.7-inch)\"\"",
+ "ipad": "concurrently \"npx react-native run-ios --simulator=\\\"iPad Pro (12.9-inch) (6th generation)\\\" --configuration=\\\"Debug Development\\\" --scheme=\\\"New Expensify Dev\\\"\"",
+ "ipad-sm": "concurrently \"npx react-native run-ios --simulator=\\\"iPad Pro (11-inch) (4th generation)\\\" --configuration=\\\"Debug Development\\\" --scheme=\\\"New Expensify Dev\\\"\"",
"start": "npx react-native start",
"web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server",
"web-proxy": "node web/proxy.js",
@@ -56,6 +57,7 @@
"@formatjs/intl-numberformat": "^8.5.0",
"@formatjs/intl-pluralrules": "^5.2.2",
"@gorhom/portal": "^1.0.14",
+ "@invertase/react-native-apple-authentication": "^2.2.2",
"@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52",
"@onfido/react-native-sdk": "7.4.0",
"@react-native-async-storage/async-storage": "^1.17.10",
@@ -67,11 +69,13 @@
"@react-native-firebase/app": "^12.3.0",
"@react-native-firebase/crashlytics": "^12.3.0",
"@react-native-firebase/perf": "^12.3.0",
+ "@react-native-google-signin/google-signin": "^10.0.1",
"@react-native-picker/picker": "^2.4.3",
"@react-navigation/material-top-tabs": "^6.6.3",
"@react-navigation/native": "6.1.6",
"@react-navigation/stack": "6.3.16",
"@react-ng/bounds-observer": "^0.2.1",
+ "@rnmapbox/maps": "^10.0.11",
"@ua/react-native-airship": "^15.2.6",
"awesome-phonenumber": "^5.4.0",
"babel-plugin-transform-remove-console": "^6.9.4",
@@ -81,14 +85,14 @@
"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#b60e464ca23e452eacffb93d471abed977b9abf0",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4cc5f72b69bd77d2c8052a3c167d039e502a2796",
"fbjs": "^3.0.2",
"htmlparser2": "^7.2.0",
+ "idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
- "localforage": "^1.10.0",
- "localforage-removeitems": "^1.4.0",
"lodash": "4.17.21",
"lottie-react-native": "^5.1.6",
+ "mapbox-gl": "^2.15.0",
"metro-config": "^0.71.3",
"moment": "^2.29.4",
"moment-timezone": "^0.5.31",
@@ -101,6 +105,7 @@
"react-collapse": "^5.1.0",
"react-content-loader": "^6.1.0",
"react-dom": "18.1.0",
+ "react-map-gl": "^7.1.3",
"react-native": "0.72.3",
"react-native-blob-util": "^0.17.3",
"react-native-collapsible": "^1.6.0",
@@ -117,9 +122,10 @@
"react-native-image-picker": "^5.1.0",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b",
"react-native-key-command": "^1.0.1",
+ "react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.59",
+ "react-native-onyx": "1.0.63",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.6.2",
"react-native-performance": "^4.0.0",
@@ -134,16 +140,19 @@
"react-native-screens": "3.21.0",
"react-native-svg": "^13.9.0",
"react-native-tab-view": "^3.5.2",
+ "react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^3.6.0",
"react-native-vision-camera": "^2.15.4",
+ "react-native-web-linear-gradient": "^1.1.2",
"react-native-web-lottie": "^1.4.4",
"react-native-webview": "^11.17.2",
+ "react-native-x-maps": "1.0.10",
"react-pdf": "^6.2.2",
"react-plaid-link": "3.3.2",
"react-web-config": "^1.0.0",
"react-window": "^1.8.9",
"save": "^2.4.0",
- "semver": "^7.3.8",
+ "semver": "^7.5.2",
"shim-keyboard-event-key": "^1.0.3",
"underscore": "^1.13.1"
},
@@ -182,6 +191,7 @@
"@types/jest-when": "^3.5.2",
"@types/js-yaml": "^4.0.5",
"@types/lodash": "^4.14.195",
+ "@types/mapbox-gl": "^2.7.13",
"@types/mock-fs": "^4.13.1",
"@types/pusher-js": "^5.1.0",
"@types/react": "^18.2.12",
@@ -223,7 +233,6 @@
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-storybook": "^0.5.13",
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0",
- "flipper-plugin-bridgespy-client": "^0.1.9",
"html-webpack-plugin": "^5.5.0",
"jest": "29.4.1",
"jest-circus": "29.4.1",
@@ -246,7 +255,7 @@
"style-loader": "^2.0.0",
"time-analytics-webpack-plugin": "^0.1.17",
"type-fest": "^3.12.0",
- "typescript": "^4.8.4",
+ "typescript": "^5.1.6",
"wait-port": "^0.2.9",
"webpack": "^5.76.0",
"webpack-bundle-analyzer": "^4.5.0",
diff --git a/patches/@react-navigation+native+6.1.6.patch b/patches/@react-navigation+native+6.1.6.patch
new file mode 100644
index 000000000000..61e5eb9892e1
--- /dev/null
+++ b/patches/@react-navigation+native+6.1.6.patch
@@ -0,0 +1,269 @@
+diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js
+index 16fdbef..bc2c96a 100644
+--- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js
++++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js
+@@ -1,8 +1,23 @@
+ import { nanoid } from 'nanoid/non-secure';
++import { findFocusedRouteKey } from './findFocusedRouteKey';
+ export default function createMemoryHistory() {
+ let index = 0;
+ let items = [];
+-
++ const log = () => {
++ console.log(JSON.stringify({
++ index,
++ indexGetter: history.index,
++ items: items.map((item, i) => {
++ var _item$state;
++ return {
++ selected: history.index === i ? '<<<<<<<' : undefined,
++ path: item.path,
++ id: item.id,
++ state: ((_item$state = item.state) === null || _item$state === void 0 ? void 0 : _item$state.key) || null
++ };
++ })
++ }, null, 4));
++ };
+ // Pending callbacks for `history.go(n)`
+ // We might modify the callback stored if it was interrupted, so we have a ref to identify it
+ const pending = [];
+@@ -16,6 +31,9 @@ export default function createMemoryHistory() {
+ });
+ };
+ const history = {
++ get items() {
++ return items;
++ },
+ get index() {
+ var _window$history$state;
+ // We store an id in the state instead of an index
+@@ -32,12 +50,13 @@ export default function createMemoryHistory() {
+ },
+ backIndex(_ref) {
+ let {
+- path
++ path,
++ state
+ } = _ref;
+ // We need to find the index from the element before current to get closest path to go back to
+ for (let i = index - 1; i >= 0; i--) {
+ const item = items[i];
+- if (item.path === path) {
++ if (item.path === path && findFocusedRouteKey(item.state) === findFocusedRouteKey(state)) {
+ return i;
+ }
+ }
+@@ -68,7 +87,9 @@ export default function createMemoryHistory() {
+ window.history.pushState({
+ id
+ }, '', path);
++ // log();
+ },
++
+ replace(_ref3) {
+ var _window$history$state2;
+ let {
+@@ -108,7 +129,9 @@ export default function createMemoryHistory() {
+ window.history.replaceState({
+ id
+ }, '', pathWithHash);
++ // log();
+ },
++
+ // `history.go(n)` is asynchronous, there are couple of things to keep in mind:
+ // - it won't do anything if we can't go `n` steps, the `popstate` event won't fire.
+ // - each `history.go(n)` call will trigger a separate `popstate` event with correct location.
+@@ -175,20 +198,17 @@ export default function createMemoryHistory() {
+ // But on Firefox, it seems to take much longer, around 50ms from our testing
+ // We're using a hacky timeout since there doesn't seem to be way to know for sure
+ const timer = setTimeout(() => {
+- const index = pending.findIndex(it => it.ref === done);
+- if (index > -1) {
+- pending[index].cb();
+- pending.splice(index, 1);
++ const foundIndex = pending.findIndex(it => it.ref === done);
++ if (foundIndex > -1) {
++ pending[foundIndex].cb();
++ pending.splice(foundIndex, 1);
+ }
++ index = this.index;
+ }, 100);
+ const onPopState = () => {
+- var _window$history$state3;
+- const id = (_window$history$state3 = window.history.state) === null || _window$history$state3 === void 0 ? void 0 : _window$history$state3.id;
+- const currentIndex = items.findIndex(item => item.id === id);
+-
+ // Fix createMemoryHistory.index variable's value
+ // as it may go out of sync when navigating in the browser.
+- index = Math.max(currentIndex, 0);
++ index = this.index;
+ const last = pending.pop();
+ window.removeEventListener('popstate', onPopState);
+ last === null || last === void 0 ? void 0 : last.cb();
+@@ -202,12 +222,17 @@ export default function createMemoryHistory() {
+ // Here we normalize it so that only external changes (e.g. user pressing back/forward) trigger the listener
+ listen(listener) {
+ const onPopState = () => {
++ // Fix createMemoryHistory.index variable's value
++ // as it may go out of sync when navigating in the browser.
++ index = this.index;
+ if (pending.length) {
+ // This was triggered by `history.go(n)`, we shouldn't call the listener
+ return;
+ }
+ listener();
++ // log();
+ };
++
+ window.addEventListener('popstate', onPopState);
+ return () => window.removeEventListener('popstate', onPopState);
+ }
+diff --git a/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js b/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js
+new file mode 100644
+index 0000000..16da117
+--- /dev/null
++++ b/node_modules/@react-navigation/native/lib/module/findFocusedRouteKey.js
+@@ -0,0 +1,7 @@
++import { findFocusedRoute } from '@react-navigation/core';
++export const findFocusedRouteKey = state => {
++ var _findFocusedRoute;
++ // @ts-ignore
++ return (_findFocusedRoute = findFocusedRoute(state)) === null || _findFocusedRoute === void 0 ? void 0 : _findFocusedRoute.key;
++};
++//# sourceMappingURL=findFocusedRouteKey.js.map
+\ No newline at end of file
+diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js
+index 5bf2a88..a6d0670 100644
+--- a/node_modules/@react-navigation/native/lib/module/useLinking.js
++++ b/node_modules/@react-navigation/native/lib/module/useLinking.js
+@@ -2,6 +2,7 @@ import { findFocusedRoute, getActionFromState as getActionFromStateDefault, getP
+ import isEqual from 'fast-deep-equal';
+ import * as React from 'react';
+ import createMemoryHistory from './createMemoryHistory';
++import { findFocusedRouteKey } from './findFocusedRouteKey';
+ import ServerContext from './ServerContext';
+ /**
+ * Find the matching navigation state that changed between 2 navigation states
+@@ -60,6 +61,44 @@ const series = cb => {
+ return callback;
+ };
+ let linkingHandlers = [];
++const getAllStateKeys = state => {
++ let current = state;
++ const keys = [];
++ if (current.routes) {
++ for (let route of current.routes) {
++ keys.push(route.key);
++ if (route.state) {
++ // @ts-ignore
++ keys.push(...getAllStateKeys(route.state));
++ }
++ }
++ }
++ return keys;
++};
++const getStaleHistoryDiff = (items, newState) => {
++ const newStateKeys = getAllStateKeys(newState);
++ for (let i = items.length - 1; i >= 0; i--) {
++ const itemFocusedKey = findFocusedRouteKey(items[i].state);
++ if (newStateKeys.includes(itemFocusedKey)) {
++ return items.length - i - 1;
++ }
++ }
++ return -1;
++};
++const getHistoryDeltaByKeys = (focusedState, previousFocusedState) => {
++ const focusedStateKeys = focusedState.routes.map(r => r.key);
++ const previousFocusedStateKeys = previousFocusedState.routes.map(r => r.key);
++ const minLength = Math.min(focusedStateKeys.length, previousFocusedStateKeys.length);
++ let matchingKeys = 0;
++ for (let i = 0; i < minLength; i++) {
++ if (focusedStateKeys[i] === previousFocusedStateKeys[i]) {
++ matchingKeys++;
++ } else {
++ break;
++ }
++ }
++ return -(previousFocusedStateKeys.length - matchingKeys);
++};
+ export default function useLinking(ref, _ref) {
+ let {
+ independent,
+@@ -251,6 +290,9 @@ export default function useLinking(ref, _ref) {
+ // Otherwise it's likely a change triggered by `popstate`
+ path !== pendingPath) {
+ const historyDelta = (focusedState.history ? focusedState.history.length : focusedState.routes.length) - (previousFocusedState.history ? previousFocusedState.history.length : previousFocusedState.routes.length);
++
++ // The historyDelta and historyDeltaByKeys may differ if the new state has an entry that didn't exist in previous state
++ const historyDeltaByKeys = getHistoryDeltaByKeys(focusedState, previousFocusedState);
+ if (historyDelta > 0) {
+ // If history length is increased, we should pushState
+ // Note that path might not actually change here, for example, drawer open should pushState
+@@ -262,34 +304,55 @@ export default function useLinking(ref, _ref) {
+ // If history length is decreased, i.e. entries were removed, we want to go back
+
+ const nextIndex = history.backIndex({
+- path
++ path,
++ state
+ });
+ const currentIndex = history.index;
+ try {
+ if (nextIndex !== -1 && nextIndex < currentIndex) {
+ // An existing entry for this path exists and it's less than current index, go back to that
+ await history.go(nextIndex - currentIndex);
++ history.replace({
++ path,
++ state
++ });
+ } else {
+ // We couldn't find an existing entry to go back to, so we'll go back by the delta
+ // This won't be correct if multiple routes were pushed in one go before
+ // Usually this shouldn't happen and this is a fallback for that
+- await history.go(historyDelta);
++ await history.go(historyDeltaByKeys);
++ if (historyDeltaByKeys + 1 === historyDelta) {
++ history.push({
++ path,
++ state
++ });
++ } else {
++ history.replace({
++ path,
++ state
++ });
++ }
+ }
+-
+- // Store the updated state as well as fix the path if incorrect
+- history.replace({
+- path,
+- state
+- });
+ } catch (e) {
+ // The navigation was interrupted
+ }
+ } else {
+ // If history length is unchanged, we want to replaceState
+- history.replace({
+- path,
+- state
+- });
++ // and remove any entries from history which focued route no longer exists in state
++ // That may happen if we replace a whole navigator.
++ const staleHistoryDiff = getStaleHistoryDiff(history.items.slice(0, history.index + 1), state);
++ if (staleHistoryDiff <= 0) {
++ history.replace({
++ path,
++ state
++ });
++ } else {
++ await history.go(-staleHistoryDiff);
++ history.push({
++ path,
++ state
++ });
++ }
+ }
+ } else {
+ // If no common navigation state was found, assume it's a replace
diff --git a/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch b/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch
new file mode 100644
index 000000000000..84a233894f94
--- /dev/null
+++ b/patches/react-native+0.72.3+004+ModalKeyboardFlashing.patch
@@ -0,0 +1,18 @@
+diff --git a/node_modules/react-native/React/Views/RCTModalHostViewManager.m b/node_modules/react-native/React/Views/RCTModalHostViewManager.m
+index 4b9f9ad..b72984c 100644
+--- a/node_modules/react-native/React/Views/RCTModalHostViewManager.m
++++ b/node_modules/react-native/React/Views/RCTModalHostViewManager.m
+@@ -79,6 +79,13 @@ RCT_EXPORT_MODULE()
+ if (self->_presentationBlock) {
+ self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock);
+ } else {
++ // In our App, If an input is blurred and a modal is opened, the rootView will become the firstResponder, which
++ // will cause system to retain a wrong keyboard state, and then the keyboard to flicker when the modal is closed.
++ // We first resign the rootView to avoid this problem.
++ UIWindow *window = RCTKeyWindow();
++ if (window && window.rootViewController && [window.rootViewController.view isFirstResponder]) {
++ [window.rootViewController.view resignFirstResponder];
++ }
+ [[modalHostView reactViewController] presentViewController:viewController
+ animated:animated
+ completion:completionBlock];
diff --git a/scripts/setup-mapbox-sdk-walkthrough.sh b/scripts/setup-mapbox-sdk-walkthrough.sh
new file mode 100755
index 000000000000..20b79641fc42
--- /dev/null
+++ b/scripts/setup-mapbox-sdk-walkthrough.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+
+# Mapbox SDK Credential Setup Script
+# ==================================
+#
+# Purpose:
+# --------
+# This script assists users in setting up the necessary credentials to utilize
+# Mapbox's closed-source SDKs for iOS and Android. It provides step-by-step
+# guidance for obtaining a secret token from Mapbox and subsequently invokes
+# the "setup-mapbox-sdk.sh" script to configure the development environment.
+#
+# Background:
+# -----------
+# To use the Mapbox SDKs for iOS and Android development, a secret token
+# must be obtained from Mapbox's account page. This token is essential for
+# authenticating downloads of the closed-source SDKs during the build process.
+#
+# Usage:
+# ------
+# To configure Mapbox, invoke this script by running the following command from the project's root directory:
+# npm run configure-mapbox
+
+# Use functions and varaibles from the utils script
+source scripts/shellUtils.sh
+
+# Intro message
+title "This script helps you set up the credential needed to use Mapbox's closed-sourced SDKs for iOS and Android."
+echo -e "\n"
+
+echo -e "1. Visit: https://account.mapbox.com/access-tokens/\n"
+echo -e "2. If you don't have a Mapbox account, create one.\n"
+echo -e "3. Create a secret token needed to download Mapbox SDKs. If you haven't done this yet:"
+echo -e " - Click the \"Create a token\" button."
+echo -e " - Provide a descriptive name for the token (e.g., Token for SDK downloads)."
+echo -e " - Ensure the checkbox next to \"Downloads:Read\" under \"Secret scopes\" is ticked."
+echo -e " - All checkboxes under the \"Public scopes\" should be ticked by default. Leave them as they are."
+echo -e " - Click the \"Create token\" button at the bottom of the page."
+echo -e " - IMPORTANT: Copy the value of the newly created token. This is your only opportunity to do so."
+echo -e "\nOnce you've done the above steps, please paste the token value below.\n"
+
+# Reading the secret token
+read -r -s -p "Secret download token: " SECRET_TOKEN
+echo -e "\n"
+
+if [[ -z "$SECRET_TOKEN" ]]; then
+ error "Token is empty. Please run the script again and provide a valid token."
+ exit 1
+fi
+
+success "Thank you for providing the token. Setting these credentials in relevant files..."
+echo -e "\n"
+
+# Execute the configuration script
+./scripts/setup-mapbox-sdk.sh "$SECRET_TOKEN"
diff --git a/scripts/setup-mapbox-sdk.sh b/scripts/setup-mapbox-sdk.sh
new file mode 100755
index 000000000000..06fd75fba299
--- /dev/null
+++ b/scripts/setup-mapbox-sdk.sh
@@ -0,0 +1,140 @@
+#!/bin/bash
+
+# Mapbox SDK Configuration Script for iOS and Android
+# ===================================================
+#
+# Purpose:
+# --------
+# This script configures the development environment to download Mapbox SDKs
+# for both iOS and Android builds. We use Mapbox to display maps in the App. As Mapbox SDKs
+# are closed-sourced, we need to authenticate with Mapbox during the download.
+#
+# Background:
+# -----------
+# Engineers are required to obtain a secret token from Mapbox and store it on
+# their development machine. This allows tools like CocoaPods for iOS or Gradle for Android
+# to access the Mapbox SDK during the build process.
+#
+# The `.netrc` file for iOS Configuration:
+# ----------------------------------------
+# The token for iOS is stored in the `.netrc` file located in the user's home directory.
+# This file is used in Unix-like systems to store credentials for remote machine access.
+#
+# The `gradle.properties` file for Android Configuration:
+# -------------------------------------------------------
+# The token for Android is stored in the `gradle.properties` file located in the .gradle directory
+# in the user's home. This is accessed by the Android build system during the SDK download.
+#
+# How this script helps:
+# ----------------------
+# This script streamlines the process of adding the credential to both the `.netrc` and
+# `gradle.properties` files. When executed, it prompts the user for the secret token and
+# then saves it to the respective files along with other necessary information.\n
+#
+# Usage:
+# ------
+# To run this script, pass the secret Mapbox access token as a command-line argument:
+# ./scriptname.sh YOUR_MAPBOX_ACCESS_TOKEN
+
+# Use functions and varaibles from the utils script
+source scripts/shellUtils.sh
+
+NETRC_PATH="$HOME/.netrc"
+GRADLE_PROPERTIES_PATH="$HOME/.gradle/gradle.properties"
+
+# This function provides a user-friendly error message when the script encounters an error.
+# It informs the user about probable permission issues and suggests commands to resolve them.
+handleError() {
+ echo -e "\n"
+
+ error "The script failed."
+ echo "The most probable reason is permissions."
+ echo -e "Please ensure you have read/write permissions for the following:\n"
+
+ echo -e "1. \033[1m$NETRC_PATH\033[0m"
+ echo -e "2. \033[1m$GRADLE_PROPERTIES_PATH\033[0m"
+ echo -e "\nYou can grant permissions using the commands:"
+ echo -e "\033[1mchmod u+rw $NETRC_PATH\033[0m"
+ echo -e "\033[1mchmod u+rw $GRADLE_PROPERTIES_PATH\033[0m"
+
+ echo -e "\n"
+ exit 1
+}
+
+# Set a trap to call the handleError function when any of the commands fail
+trap handleError ERR
+
+# Take the token as a command-line argument
+TOKEN="$1"
+
+# Check if the token was provided
+if [ -z "$TOKEN" ]; then
+ echo "Usage: $0 "
+ echo "No token provided. Exiting."
+ exit 1
+fi
+
+# -----------------------------------------------
+# iOS Configuration for .netrc
+# -----------------------------------------------
+info "Configuring $NETRC_PATH for Mapbox iOS SDK download"
+
+# Check for existing Mapbox entries in .netrc
+if grep -q "api.mapbox.com" "$NETRC_PATH"; then
+ # Extract the current token from .netrc
+ CURRENT_TOKEN=$(grep -A2 "api.mapbox.com" "$NETRC_PATH" | grep "password" | awk '{print $2}')
+
+ # Compare the current token to the entered token
+ if [ "$CURRENT_TOKEN" == "$TOKEN" ]; then
+ echo -e "\nThe entered token matches the existing token in $NETRC_PATH. No changes made."
+ else
+ # Use sed to replace the old token with the new one
+ sed -i.bak "/api.mapbox.com/,+2s/password $CURRENT_TOKEN/password $TOKEN/" "$NETRC_PATH"
+ echo -e "\nToken updated in $NETRC_PATH"
+ fi
+else
+ # If no existing entry, append the new credentials
+ {
+ echo "machine api.mapbox.com"
+ echo "login mapbox"
+ echo "password $TOKEN"
+ } >> "$NETRC_PATH"
+
+ # Set the permissions of the .netrc file to ensure it's kept private
+ chmod 600 "$NETRC_PATH"
+
+ echo -e "\n$NETRC_PATH has been updated with new credentials"
+fi
+
+# -----------------------------------------------
+# Android Configuration for gradle.properties
+# -----------------------------------------------
+echo -e "\n"
+info "Configuring $GRADLE_PROPERTIES_PATH for Mapbox Android SDK download"
+
+# Ensure the .gradle directory exists
+if [ ! -d "$HOME/.gradle" ]; then
+ mkdir "$HOME/.gradle"
+fi
+
+# Check if gradle.properties exists. If not, create one.
+if [ ! -f "$GRADLE_PROPERTIES_PATH" ]; then
+ touch "$GRADLE_PROPERTIES_PATH"
+fi
+
+# Check if MAPBOX_DOWNLOADS_TOKEN already exists in the file
+if grep -q "MAPBOX_DOWNLOADS_TOKEN" "$GRADLE_PROPERTIES_PATH"; then
+ # Extract the current token from gradle.properties
+ CURRENT_ANDROID_TOKEN=$(grep "MAPBOX_DOWNLOADS_TOKEN" "$GRADLE_PROPERTIES_PATH" | cut -d'=' -f2)
+
+ # Compare the current token to the entered token
+ if [ "$CURRENT_ANDROID_TOKEN" == "$TOKEN" ]; then
+ echo -e "\nThe entered token matches the existing token in $GRADLE_PROPERTIES_PATH. No changes made."
+ else
+ sed -i.bak "s/MAPBOX_DOWNLOADS_TOKEN=$CURRENT_ANDROID_TOKEN/MAPBOX_DOWNLOADS_TOKEN=$TOKEN/" "$GRADLE_PROPERTIES_PATH"
+ echo -e "\nToken updated in $GRADLE_PROPERTIES_PATH"
+ fi
+else
+ echo "MAPBOX_DOWNLOADS_TOKEN=$TOKEN" >> "$GRADLE_PROPERTIES_PATH"
+ echo -e "\n$GRADLE_PROPERTIES_PATH has been updated with new credentials"
+fi
diff --git a/src/App.js b/src/App.js
index d8faa911f86b..c432a0b666c8 100644
--- a/src/App.js
+++ b/src/App.js
@@ -23,6 +23,7 @@ import ThemeStylesProvider from './styles/ThemeStylesProvider';
import {CurrentReportIDContextProvider} from './components/withCurrentReportID';
import {EnvironmentProvider} from './components/withEnvironment';
import * as Session from './libs/actions/Session';
+import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
// For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx
if (window && Environment.isDevelopment()) {
@@ -40,6 +41,7 @@ LogBox.ignoreLogs([
const fill = {flex: 1};
function App() {
+ useDefaultDragAndDrop();
return (
{
if (level === 'alert') {
@@ -163,10 +167,16 @@ function Expensify(props) {
appStateChangeListener.current = AppState.addEventListener('change', initializeClient);
// If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
- Linking.getInitialURL().then((url) => Report.openReportFromDeepLink(url, isAuthenticated));
+ Linking.getInitialURL().then((url) => {
+ DemoActions.runDemoByURL(url);
+ Report.openReportFromDeepLink(url, isAuthenticated);
+ });
// Open chat report from a deep link (only mobile native)
- Linking.addEventListener('url', (state) => Report.openReportFromDeepLink(state.url, isAuthenticated));
+ Linking.addEventListener('url', (state) => {
+ DemoActions.runDemoByURL(state.url);
+ Report.openReportFromDeepLink(state.url, isAuthenticated);
+ });
return () => {
if (!appStateChangeListener.current) {
@@ -183,12 +193,13 @@ function Expensify(props) {
}
return (
-
+
{shouldInit && (
<>
+
{/* We include the modal for showing a new update at the top level so the option is always present. */}
{props.updateAvailable ? : null}
@@ -206,6 +217,7 @@ function Expensify(props) {
>
)}
+
{hasAttemptedToOpenPublicRoom && (
;
+type OnyxKey = DeepValueOf>;
+
+type OnyxValues = {
+ [ONYXKEYS.ACCOUNT]: OnyxTypes.Account;
+ [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string;
+ [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean;
+ [ONYXKEYS.ACTIVE_CLIENTS]: string[];
+ [ONYXKEYS.DEVICE_ID]: string;
+ [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean;
+ [ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER]: boolean;
+ [ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[];
+ [ONYXKEYS.QUEUED_ONYX_UPDATES]: OnyxTypes.QueuedOnyxUpdates;
+ [ONYXKEYS.CURRENT_DATE]: string;
+ [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials;
+ [ONYXKEYS.IOU]: OnyxTypes.IOU;
+ [ONYXKEYS.MODAL]: OnyxTypes.Modal;
+ [ONYXKEYS.NETWORK]: OnyxTypes.Network;
+ [ONYXKEYS.CUSTOM_STATUS_DRAFT]: OnyxTypes.CustomStatusDraft;
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: Record;
+ [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails;
+ [ONYXKEYS.TASK]: OnyxTypes.Task;
+ [ONYXKEYS.CURRENCY_LIST]: Record;
+ [ONYXKEYS.UPDATE_AVAILABLE]: boolean;
+ [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest;
+ [ONYXKEYS.COUNTRY_CODE]: number;
+ [ONYXKEYS.COUNTRY]: string;
+ [ONYXKEYS.USER]: OnyxTypes.User;
+ [ONYXKEYS.LOGIN_LIST]: OnyxTypes.Login;
+ [ONYXKEYS.SESSION]: OnyxTypes.Session;
+ [ONYXKEYS.BETAS]: OnyxTypes.Beta[];
+ [ONYXKEYS.PAYPAL]: OnyxTypes.Paypal;
+ [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf;
+ [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge;
+ [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string;
+ [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: Record;
+ [ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean;
+ [ONYXKEYS.PLAID_DATA]: OnyxTypes.PlaidData;
+ [ONYXKEYS.IS_PLAID_DISABLED]: boolean;
+ [ONYXKEYS.PLAID_LINK_TOKEN]: string;
+ [ONYXKEYS.ONFIDO_TOKEN]: string;
+ [ONYXKEYS.NVP_PREFERRED_LOCALE]: ValueOf;
+ [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet;
+ [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido;
+ [ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails;
+ [ONYXKEYS.WALLET_TERMS]: OnyxTypes.WalletTerms;
+ [ONYXKEYS.BANK_ACCOUNT_LIST]: Record;
+ [ONYXKEYS.FUND_LIST]: Record;
+ [ONYXKEYS.WALLET_STATEMENT]: OnyxTypes.WalletStatement;
+ [ONYXKEYS.PERSONAL_BANK_ACCOUNT]: OnyxTypes.PersonalBankAccount;
+ [ONYXKEYS.REIMBURSEMENT_ACCOUNT]: OnyxTypes.ReimbursementAccount;
+ [ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT]: OnyxTypes.ReimbursementAccountDraft;
+ [ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE]: string | number;
+ [ONYXKEYS.FREQUENTLY_USED_EMOJIS]: OnyxTypes.FrequentlyUsedEmoji[];
+ [ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID]: string;
+ [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean;
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean;
+ [ONYXKEYS.IS_SHORTCUTS_MODAL_OPEN]: boolean;
+ [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean;
+ [ONYXKEYS.WALLET_TRANSFER]: OnyxTypes.WalletTransfer;
+ [ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID]: string;
+ [ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: boolean;
+ [ONYXKEYS.IS_BETA]: boolean;
+ [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean;
+ [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record;
+ [ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID]: string;
+ [ONYXKEYS.PREFERRED_THEME]: ValueOf;
+ [ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS]: boolean;
+ [ONYXKEYS.SELECTED_TAB]: string;
+ [ONYXKEYS.RECEIPT_MODAL]: OnyxTypes.ReceiptModal;
+ [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken;
+ [ONYXKEYS.ONYX_UPDATES_FROM_SERVER]: number;
+ [ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT]: number;
+
+ // Collections
+ [ONYXKEYS.COLLECTION.DOWNLOAD]: OnyxTypes.Download;
+ [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy;
+ [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: unknown;
+ [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMember;
+ [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMember;
+ [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record;
+ [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report;
+ [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportAction;
+ [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: string;
+ [ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions;
+ [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT]: string;
+ [ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES]: number;
+ [ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE]: boolean;
+ [ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING]: boolean;
+ [ONYXKEYS.COLLECTION.SECURITY_GROUP]: OnyxTypes.SecurityGroup;
+ [ONYXKEYS.COLLECTION.TRANSACTION]: OnyxTypes.Transaction;
+
+ // Forms
+ [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: OnyxTypes.AddDebitCardForm;
+ [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.ROOM_NAME_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.NEW_TASK_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.EDIT_TASK_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.PAYPAL_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WAYPOINT_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.WAYPOINT_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form;
+};
+
+export default ONYXKEYS;
+export type {OnyxKey, OnyxCollectionKey, OnyxValues};
diff --git a/src/ROUTES.js b/src/ROUTES.js
index 6c0365e40568..bf1beaecb3c3 100644
--- a/src/ROUTES.js
+++ b/src/ROUTES.js
@@ -20,7 +20,10 @@ export default {
BANK_ACCOUNT_NEW: 'bank-account/new',
BANK_ACCOUNT_WITH_STEP_TO_OPEN: 'bank-account/:stepToOpen?',
BANK_ACCOUNT_PERSONAL: 'bank-account/personal',
- getBankAccountRoute: (stepToOpen = '', policyID = '') => `bank-account/${stepToOpen}?policyID=${policyID}`,
+ getBankAccountRoute: (stepToOpen = '', policyID = '', backTo = '') => {
+ const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : '';
+ return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`;
+ },
HOME: '',
SETTINGS: 'settings',
SETTINGS_PROFILE: 'settings/profile',
@@ -39,14 +42,14 @@ export default {
SETTINGS_CLOSE: 'settings/security/closeAccount',
SETTINGS_ABOUT: 'settings/about',
SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links',
- SETTINGS_PAYMENTS: 'settings/payments',
- SETTINGS_ADD_PAYPAL_ME: 'settings/payments/add-paypal-me',
- SETTINGS_ADD_DEBIT_CARD: 'settings/payments/add-debit-card',
- SETTINGS_ADD_BANK_ACCOUNT: 'settings/payments/add-bank-account',
- SETTINGS_ENABLE_PAYMENTS: 'settings/payments/enable-payments',
+ SETTINGS_WALLET: 'settings/wallet',
+ SETTINGS_ADD_PAYPAL_ME: 'settings/wallet/add-paypal-me',
+ 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',
getSettingsAddLoginRoute: (type) => `settings/addlogin/${type}`,
- SETTINGS_PAYMENTS_TRANSFER_BALANCE: 'settings/payments/transfer-balance',
- SETTINGS_PAYMENTS_CHOOSE_TRANSFER_ACCOUNT: 'settings/payments/choose-transfer-account',
+ SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance',
+ SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account',
SETTINGS_PERSONAL_DETAILS,
SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: `${SETTINGS_PERSONAL_DETAILS}/legal-name`,
SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: `${SETTINGS_PERSONAL_DETAILS}/date-of-birth`,
@@ -55,11 +58,7 @@ export default {
SETTINGS_CONTACT_METHOD_DETAILS: `${SETTINGS_CONTACT_METHODS}/:contactMethod/details`,
getEditContactMethodRoute: (contactMethod) => `${SETTINGS_CONTACT_METHODS}/${encodeURIComponent(contactMethod)}/details`,
SETTINGS_NEW_CONTACT_METHOD: `${SETTINGS_CONTACT_METHODS}/new`,
- SETTINGS_2FA_IS_ENABLED: 'settings/security/two-factor-auth/enabled',
- SETTINGS_2FA_DISABLE: 'settings/security/two-factor-auth/disable',
- SETTINGS_2FA_CODES: 'settings/security/two-factor-auth/codes',
- SETTINGS_2FA_VERIFY: 'settings/security/two-factor-auth/verify',
- SETTINGS_2FA_SUCCESS: 'settings/security/two-factor-auth/success',
+ SETTINGS_2FA: 'settings/security/two-factor-auth',
SETTINGS_STATUS,
SETTINGS_STATUS_SET,
NEW_GROUP: 'new/group',
@@ -69,6 +68,8 @@ export default {
REPORT_WITH_ID: 'r/:reportID?',
EDIT_REQUEST: 'r/:threadReportID/edit/:field',
getEditRequestRoute: (threadReportID, field) => `r/${threadReportID}/edit/${field}`,
+ EDIT_CURRENCY_REQUEST: 'r/:threadReportID/edit/currency',
+ getEditRequestCurrencyRoute: (threadReportID, currency, backTo) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`,
getReportRoute: (reportID) => `r/${reportID}`,
REPORT_WITH_ID_DETAILS_SHARE_CODE: 'r/:reportID/details/shareCode',
getReportShareCodeRoute: (reportID) => `r/${reportID}/details/shareCode`,
@@ -87,11 +88,15 @@ export default {
MONEY_REQUEST_AMOUNT: ':iouType/new/amount/:reportID?',
MONEY_REQUEST_PARTICIPANTS: ':iouType/new/participants/:reportID?',
MONEY_REQUEST_CONFIRMATION: ':iouType/new/confirmation/:reportID?',
+ MONEY_REQUEST_DATE: ':iouType/new/date/:reportID?',
MONEY_REQUEST_CURRENCY: ':iouType/new/currency/:reportID?',
MONEY_REQUEST_DESCRIPTION: ':iouType/new/description/:reportID?',
+ MONEY_REQUEST_CATEGORY: ':iouType/new/category/:reportID?',
+ MONEY_REQUEST_MERCHANT: ':iouType/new/merchant/:reportID?',
MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual',
MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan',
MONEY_REQUEST_DISTANCE_TAB: ':iouType/new/:reportID?/distance',
+ MONEY_REQUEST_WAYPOINT: ':iouType/new/waypoint/:waypointIndex',
IOU_SEND_ADD_BANK_ACCOUNT: `${IOU_SEND}/add-bank-account`,
IOU_SEND_ADD_DEBIT_CARD: `${IOU_SEND}/add-debit-card`,
IOU_SEND_ENABLE_PAYMENTS: `${IOU_SEND}/enable-payments`,
@@ -99,8 +104,13 @@ export default {
getMoneyRequestAmountRoute: (iouType, reportID = '') => `${iouType}/new/amount/${reportID}`,
getMoneyRequestParticipantsRoute: (iouType, reportID = '') => `${iouType}/new/participants/${reportID}`,
getMoneyRequestConfirmationRoute: (iouType, reportID = '') => `${iouType}/new/confirmation/${reportID}`,
+ getMoneyRequestCreatedRoute: (iouType, reportID = '') => `${iouType}/new/date/${reportID}`,
getMoneyRequestCurrencyRoute: (iouType, reportID = '', currency, backTo) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`,
getMoneyRequestDescriptionRoute: (iouType, reportID = '') => `${iouType}/new/description/${reportID}`,
+ getMoneyRequestCategoryRoute: (iouType, reportID = '') => `${iouType}/new/category/${reportID}`,
+ getMoneyRequestMerchantRoute: (iouType, reportID = '') => `${iouType}/new/merchant/${reportID}`,
+ getMoneyRequestDistanceTabRoute: (iouType, reportID = '') => `${iouType}/new/${reportID}/distance`,
+ getMoneyRequestWaypointRoute: (iouType, waypointIndex) => `${iouType}/new/waypoint/${waypointIndex}`,
SPLIT_BILL_DETAILS: `r/:reportID/split/:reportActionID`,
getSplitBillDetailsRoute: (reportID, reportActionID) => `r/${reportID}/split/${reportActionID}`,
getNewTaskRoute: (reportID) => `${NEW_TASK}/${reportID}`,
@@ -122,7 +132,10 @@ export default {
DETAILS: 'details',
getDetailsRoute: (login) => `details?login=${encodeURIComponent(login)}`,
PROFILE: 'a/:accountID',
- getProfileRoute: (accountID) => `a/${accountID}`,
+ getProfileRoute: (accountID, backTo = '') => {
+ const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : '';
+ return `a/${accountID}${backToParam}`;
+ },
REPORT_PARTICIPANTS: 'r/:reportID/participants',
getReportParticipantsRoute: (reportID) => `r/${reportID}/participants`,
REPORT_WITH_ID_DETAILS: 'r/:reportID/details',
@@ -143,6 +156,10 @@ export default {
getGetAssistanceRoute: (taskID) => `get-assistance/${taskID}`,
UNLINK_LOGIN: 'u/:accountID/:validateCode',
+ APPLE_SIGN_IN: 'sign-in-with-apple',
+ GOOGLE_SIGN_IN: 'sign-in-with-google',
+ DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect',
+
// 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',
@@ -173,6 +190,10 @@ export default {
getWorkspaceTravelRoute: (policyID) => `workspace/${policyID}/travel`,
getWorkspaceMembersRoute: (policyID) => `workspace/${policyID}/members`,
+ // These are some on-off routes that will be removed once they're no longer needed (see GH issues for details)
+ SAASTR: 'saastr',
+ SBE: 'sbe',
+
/**
* @param {String} route
* @returns {Object}
@@ -194,4 +215,5 @@ export default {
isSubReportPageRoute: pathSegments.length > 2,
};
},
+ SIGN_IN_MODAL: 'sign-in-modal',
};
diff --git a/src/SCREENS.js b/src/SCREENS.ts
similarity index 81%
rename from src/SCREENS.js
rename to src/SCREENS.ts
index 2e42250a8631..bcb3a02cebb4 100644
--- a/src/SCREENS.js
+++ b/src/SCREENS.ts
@@ -13,4 +13,6 @@ export default {
PREFERENCES: 'Settings_Preferences',
WORKSPACES: 'Settings_Workspaces',
},
-};
+ SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
+ SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
+} as const;
diff --git a/src/TIMEZONES.js b/src/TIMEZONES.js
new file mode 100644
index 000000000000..2a596b51e8b3
--- /dev/null
+++ b/src/TIMEZONES.js
@@ -0,0 +1,421 @@
+// All timezones were taken from: https://raw.githubusercontent.com/leon-do/Timezones/main/timezone.json
+export default [
+ 'Africa/Abidjan',
+ 'Africa/Accra',
+ 'Africa/Addis_Ababa',
+ 'Africa/Algiers',
+ 'Africa/Asmara',
+ 'Africa/Bamako',
+ 'Africa/Bangui',
+ 'Africa/Banjul',
+ 'Africa/Bissau',
+ 'Africa/Blantyre',
+ 'Africa/Brazzaville',
+ 'Africa/Bujumbura',
+ 'Africa/Cairo',
+ 'Africa/Casablanca',
+ 'Africa/Ceuta',
+ 'Africa/Conakry',
+ 'Africa/Dakar',
+ 'Africa/Dar_es_Salaam',
+ 'Africa/Djibouti',
+ 'Africa/Douala',
+ 'Africa/El_Aaiun',
+ 'Africa/Freetown',
+ 'Africa/Gaborone',
+ 'Africa/Harare',
+ 'Africa/Johannesburg',
+ 'Africa/Juba',
+ 'Africa/Kampala',
+ 'Africa/Khartoum',
+ 'Africa/Kigali',
+ 'Africa/Kinshasa',
+ 'Africa/Lagos',
+ 'Africa/Libreville',
+ 'Africa/Lome',
+ 'Africa/Luanda',
+ 'Africa/Lubumbashi',
+ 'Africa/Lusaka',
+ 'Africa/Malabo',
+ 'Africa/Maputo',
+ 'Africa/Maseru',
+ 'Africa/Mbabane',
+ 'Africa/Mogadishu',
+ 'Africa/Monrovia',
+ 'Africa/Nairobi',
+ 'Africa/Ndjamena',
+ 'Africa/Niamey',
+ 'Africa/Nouakchott',
+ 'Africa/Ouagadougou',
+ 'Africa/Porto-Novo',
+ 'Africa/Sao_Tome',
+ 'Africa/Tripoli',
+ 'Africa/Tunis',
+ 'Africa/Windhoek',
+ 'America/Adak',
+ 'America/Anchorage',
+ 'America/Anguilla',
+ 'America/Antigua',
+ 'America/Araguaina',
+ 'America/Argentina/Buenos_Aires',
+ 'America/Argentina/Catamarca',
+ 'America/Argentina/Cordoba',
+ 'America/Argentina/Jujuy',
+ 'America/Argentina/La_Rioja',
+ 'America/Argentina/Mendoza',
+ 'America/Argentina/Rio_Gallegos',
+ 'America/Argentina/Salta',
+ 'America/Argentina/San_Juan',
+ 'America/Argentina/San_Luis',
+ 'America/Argentina/Tucuman',
+ 'America/Argentina/Ushuaia',
+ 'America/Aruba',
+ 'America/Asuncion',
+ 'America/Atikokan',
+ 'America/Bahia',
+ 'America/Bahia_Banderas',
+ 'America/Barbados',
+ 'America/Belem',
+ 'America/Belize',
+ 'America/Blanc-Sablon',
+ 'America/Boa_Vista',
+ 'America/Bogota',
+ 'America/Boise',
+ 'America/Cambridge_Bay',
+ 'America/Campo_Grande',
+ 'America/Cancun',
+ 'America/Caracas',
+ 'America/Cayenne',
+ 'America/Cayman',
+ 'America/Chicago',
+ 'America/Chihuahua',
+ 'America/Ciudad_Juarez',
+ 'America/Costa_Rica',
+ 'America/Creston',
+ 'America/Cuiaba',
+ 'America/Curacao',
+ 'America/Danmarkshavn',
+ 'America/Dawson',
+ 'America/Dawson_Creek',
+ 'America/Denver',
+ 'America/Detroit',
+ 'America/Dominica',
+ 'America/Edmonton',
+ 'America/Eirunepe',
+ 'America/El_Salvador',
+ 'America/Fort_Nelson',
+ 'America/Fortaleza',
+ 'America/Glace_Bay',
+ 'America/Goose_Bay',
+ 'America/Grand_Turk',
+ 'America/Grenada',
+ 'America/Guadeloupe',
+ 'America/Guatemala',
+ 'America/Guayaquil',
+ 'America/Guyana',
+ 'America/Halifax',
+ 'America/Havana',
+ 'America/Hermosillo',
+ 'America/Indiana/Indianapolis',
+ 'America/Indiana/Knox',
+ 'America/Indiana/Marengo',
+ 'America/Indiana/Petersburg',
+ 'America/Indiana/Tell_City',
+ 'America/Indiana/Vevay',
+ 'America/Indiana/Vincennes',
+ 'America/Indiana/Winamac',
+ 'America/Inuvik',
+ 'America/Iqaluit',
+ 'America/Jamaica',
+ 'America/Juneau',
+ 'America/Kentucky/Louisville',
+ 'America/Kentucky/Monticello',
+ 'America/Kralendijk',
+ 'America/La_Paz',
+ 'America/Lima',
+ 'America/Los_Angeles',
+ 'America/Lower_Princes',
+ 'America/Maceio',
+ 'America/Managua',
+ 'America/Manaus',
+ 'America/Marigot',
+ 'America/Martinique',
+ 'America/Matamoros',
+ 'America/Mazatlan',
+ 'America/Menominee',
+ 'America/Merida',
+ 'America/Metlakatla',
+ 'America/Mexico_City',
+ 'America/Miquelon',
+ 'America/Moncton',
+ 'America/Monterrey',
+ 'America/Montevideo',
+ 'America/Montserrat',
+ 'America/Nassau',
+ 'America/New_York',
+ 'America/Nome',
+ 'America/Noronha',
+ 'America/North_Dakota/Beulah',
+ 'America/North_Dakota/Center',
+ 'America/North_Dakota/New_Salem',
+ 'America/Nuuk',
+ 'America/Ojinaga',
+ 'America/Panama',
+ 'America/Paramaribo',
+ 'America/Phoenix',
+ 'America/Port-au-Prince',
+ 'America/Port_of_Spain',
+ 'America/Porto_Velho',
+ 'America/Puerto_Rico',
+ 'America/Punta_Arenas',
+ 'America/Rankin_Inlet',
+ 'America/Recife',
+ 'America/Regina',
+ 'America/Resolute',
+ 'America/Rio_Branco',
+ 'America/Santarem',
+ 'America/Santiago',
+ 'America/Santo_Domingo',
+ 'America/Sao_Paulo',
+ 'America/Scoresbysund',
+ 'America/Sitka',
+ 'America/St_Barthelemy',
+ 'America/St_Johns',
+ 'America/St_Kitts',
+ 'America/St_Lucia',
+ 'America/St_Thomas',
+ 'America/St_Vincent',
+ 'America/Swift_Current',
+ 'America/Tegucigalpa',
+ 'America/Thule',
+ 'America/Tijuana',
+ 'America/Toronto',
+ 'America/Tortola',
+ 'America/Vancouver',
+ 'America/Whitehorse',
+ 'America/Winnipeg',
+ 'America/Yakutat',
+ 'Antarctica/Casey',
+ 'Antarctica/Davis',
+ 'Antarctica/DumontDUrville',
+ 'Antarctica/Macquarie',
+ 'Antarctica/Mawson',
+ 'Antarctica/McMurdo',
+ 'Antarctica/Palmer',
+ 'Antarctica/Rothera',
+ 'Antarctica/Syowa',
+ 'Antarctica/Troll',
+ 'Antarctica/Vostok',
+ 'Arctic/Longyearbyen',
+ 'Asia/Aden',
+ 'Asia/Almaty',
+ 'Asia/Amman',
+ 'Asia/Anadyr',
+ 'Asia/Aqtau',
+ 'Asia/Aqtobe',
+ 'Asia/Ashgabat',
+ 'Asia/Atyrau',
+ 'Asia/Baghdad',
+ 'Asia/Bahrain',
+ 'Asia/Baku',
+ 'Asia/Bangkok',
+ 'Asia/Barnaul',
+ 'Asia/Beirut',
+ 'Asia/Bishkek',
+ 'Asia/Brunei',
+ 'Asia/Chita',
+ 'Asia/Choibalsan',
+ 'Asia/Colombo',
+ 'Asia/Damascus',
+ 'Asia/Dhaka',
+ 'Asia/Dili',
+ 'Asia/Dubai',
+ 'Asia/Dushanbe',
+ 'Asia/Famagusta',
+ 'Asia/Gaza',
+ 'Asia/Hebron',
+ 'Asia/Ho_Chi_Minh',
+ 'Asia/Hong_Kong',
+ 'Asia/Hovd',
+ 'Asia/Irkutsk',
+ 'Asia/Jakarta',
+ 'Asia/Jayapura',
+ 'Asia/Jerusalem',
+ 'Asia/Kabul',
+ 'Asia/Kamchatka',
+ 'Asia/Karachi',
+ 'Asia/Kathmandu',
+ 'Asia/Khandyga',
+ 'Asia/Kolkata',
+ 'Asia/Krasnoyarsk',
+ 'Asia/Kuala_Lumpur',
+ 'Asia/Kuching',
+ 'Asia/Kuwait',
+ 'Asia/Macau',
+ 'Asia/Magadan',
+ 'Asia/Makassar',
+ 'Asia/Manila',
+ 'Asia/Muscat',
+ 'Asia/Nicosia',
+ 'Asia/Novokuznetsk',
+ 'Asia/Novosibirsk',
+ 'Asia/Omsk',
+ 'Asia/Oral',
+ 'Asia/Phnom_Penh',
+ 'Asia/Pontianak',
+ 'Asia/Pyongyang',
+ 'Asia/Qatar',
+ 'Asia/Qostanay',
+ 'Asia/Qyzylorda',
+ 'Asia/Riyadh',
+ 'Asia/Sakhalin',
+ 'Asia/Samarkand',
+ 'Asia/Seoul',
+ 'Asia/Shanghai',
+ 'Asia/Singapore',
+ 'Asia/Srednekolymsk',
+ 'Asia/Taipei',
+ 'Asia/Tashkent',
+ 'Asia/Tbilisi',
+ 'Asia/Tehran',
+ 'Asia/Thimphu',
+ 'Asia/Tokyo',
+ 'Asia/Tomsk',
+ 'Asia/Ulaanbaatar',
+ 'Asia/Urumqi',
+ 'Asia/Ust-Nera',
+ 'Asia/Vientiane',
+ 'Asia/Vladivostok',
+ 'Asia/Yakutsk',
+ 'Asia/Yangon',
+ 'Asia/Yekaterinburg',
+ 'Asia/Yerevan',
+ 'Atlantic/Azores',
+ 'Atlantic/Bermuda',
+ 'Atlantic/Canary',
+ 'Atlantic/Cape_Verde',
+ 'Atlantic/Faroe',
+ 'Atlantic/Madeira',
+ 'Atlantic/Reykjavik',
+ 'Atlantic/South_Georgia',
+ 'Atlantic/St_Helena',
+ 'Atlantic/Stanley',
+ 'Australia/Adelaide',
+ 'Australia/Brisbane',
+ 'Australia/Broken_Hill',
+ 'Australia/Darwin',
+ 'Australia/Eucla',
+ 'Australia/Hobart',
+ 'Australia/Lindeman',
+ 'Australia/Lord_Howe',
+ 'Australia/Melbourne',
+ 'Australia/Perth',
+ 'Australia/Sydney',
+ 'Europe/Amsterdam',
+ 'Europe/Andorra',
+ 'Europe/Astrakhan',
+ 'Europe/Athens',
+ 'Europe/Belgrade',
+ 'Europe/Berlin',
+ 'Europe/Bratislava',
+ 'Europe/Brussels',
+ 'Europe/Bucharest',
+ 'Europe/Budapest',
+ 'Europe/Busingen',
+ 'Europe/Chisinau',
+ 'Europe/Copenhagen',
+ 'Europe/Dublin',
+ 'Europe/Gibraltar',
+ 'Europe/Guernsey',
+ 'Europe/Helsinki',
+ 'Europe/Isle_of_Man',
+ 'Europe/Istanbul',
+ 'Europe/Jersey',
+ 'Europe/Kaliningrad',
+ 'Europe/Kirov',
+ 'Europe/Kyiv',
+ 'Europe/Lisbon',
+ 'Europe/Ljubljana',
+ 'Europe/London',
+ 'Europe/Luxembourg',
+ 'Europe/Madrid',
+ 'Europe/Malta',
+ 'Europe/Mariehamn',
+ 'Europe/Minsk',
+ 'Europe/Monaco',
+ 'Europe/Moscow',
+ 'Europe/Oslo',
+ 'Europe/Paris',
+ 'Europe/Podgorica',
+ 'Europe/Prague',
+ 'Europe/Riga',
+ 'Europe/Rome',
+ 'Europe/Samara',
+ 'Europe/San_Marino',
+ 'Europe/Sarajevo',
+ 'Europe/Saratov',
+ 'Europe/Simferopol',
+ 'Europe/Skopje',
+ 'Europe/Sofia',
+ 'Europe/Stockholm',
+ 'Europe/Tallinn',
+ 'Europe/Tirane',
+ 'Europe/Ulyanovsk',
+ 'Europe/Vaduz',
+ 'Europe/Vatican',
+ 'Europe/Vienna',
+ 'Europe/Vilnius',
+ 'Europe/Volgograd',
+ 'Europe/Warsaw',
+ 'Europe/Zagreb',
+ 'Europe/Zurich',
+ 'Indian/Antananarivo',
+ 'Indian/Chagos',
+ 'Indian/Christmas',
+ 'Indian/Cocos',
+ 'Indian/Comoro',
+ 'Indian/Kerguelen',
+ 'Indian/Mahe',
+ 'Indian/Maldives',
+ 'Indian/Mauritius',
+ 'Indian/Mayotte',
+ 'Indian/Reunion',
+ 'Pacific/Apia',
+ 'Pacific/Auckland',
+ 'Pacific/Bougainville',
+ 'Pacific/Chatham',
+ 'Pacific/Chuuk',
+ 'Pacific/Easter',
+ 'Pacific/Efate',
+ 'Pacific/Fakaofo',
+ 'Pacific/Fiji',
+ 'Pacific/Funafuti',
+ 'Pacific/Galapagos',
+ 'Pacific/Gambier',
+ 'Pacific/Guadalcanal',
+ 'Pacific/Guam',
+ 'Pacific/Honolulu',
+ 'Pacific/Kanton',
+ 'Pacific/Kiritimati',
+ 'Pacific/Kosrae',
+ 'Pacific/Kwajalein',
+ 'Pacific/Majuro',
+ 'Pacific/Marquesas',
+ 'Pacific/Midway',
+ 'Pacific/Nauru',
+ 'Pacific/Niue',
+ 'Pacific/Norfolk',
+ 'Pacific/Noumea',
+ 'Pacific/Pago_Pago',
+ 'Pacific/Palau',
+ 'Pacific/Pitcairn',
+ 'Pacific/Pohnpei',
+ 'Pacific/Port_Moresby',
+ 'Pacific/Rarotonga',
+ 'Pacific/Saipan',
+ 'Pacific/Tahiti',
+ 'Pacific/Tarawa',
+ 'Pacific/Tongatapu',
+ 'Pacific/Wake',
+ 'Pacific/Wallis',
+];
diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js
index ff97c9be24a6..d50fad0bd2f0 100644
--- a/src/components/AddPlaidBankAccount.js
+++ b/src/components/AddPlaidBankAccount.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import React from 'react';
+import React, {useEffect, useRef, useCallback} from 'react';
import {ActivityIndicator, View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -10,17 +10,16 @@ import * as BankAccounts from '../libs/actions/BankAccounts';
import ONYXKEYS from '../ONYXKEYS';
import styles from '../styles/styles';
import themeColors from '../styles/themes/default';
-import compose from '../libs/compose';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
import Picker from './Picker';
import {plaidDataPropTypes} from '../pages/ReimbursementAccount/plaidDataPropTypes';
import Text from './Text';
import getBankIcon from './Icon/BankIcons';
import Icon from './Icon';
import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView';
-import {withNetwork} from './OnyxProvider';
import CONST from '../CONST';
import KeyboardShortcut from '../libs/KeyboardShortcut';
+import useLocalize from '../hooks/useLocalize';
+import useNetwork from '../hooks/useNetwork';
const propTypes = {
/** Contains plaid data */
@@ -52,8 +51,6 @@ const propTypes = {
/** Are we adding a withdrawal account? */
allowDebit: PropTypes.bool,
-
- ...withLocalizePropTypes,
};
const defaultProps = {
@@ -68,172 +65,163 @@ const defaultProps = {
bankAccountID: 0,
};
-class AddPlaidBankAccount extends React.Component {
- constructor(props) {
- super(props);
-
- this.getPlaidLinkToken = this.getPlaidLinkToken.bind(this);
- this.subscribedKeyboardShortcuts = [];
- }
-
- componentDidMount() {
- this.subscribeToNavigationShortcuts();
-
- // If we're coming from Plaid OAuth flow then we need to reuse the existing plaidLinkToken
- if (this.isAuthenticatedWithPlaid()) {
- return;
- }
-
- BankAccounts.openPlaidBankLogin(this.props.allowDebit, this.props.bankAccountID);
- }
-
- componentDidUpdate(prevProps) {
- if (!prevProps.network.isOffline || this.props.network.isOffline || this.isAuthenticatedWithPlaid()) {
- return;
- }
-
- // If we are coming back from offline and we haven't authenticated with Plaid yet, we need to re-run our call to kick off Plaid
- BankAccounts.openPlaidBankLogin(this.props.allowDebit, this.props.bankAccountID);
- }
+function AddPlaidBankAccount({plaidData, selectedPlaidAccountID, plaidLinkToken, onExitPlaid, onSelect, text, receivedRedirectURI, plaidLinkOAuthToken, bankAccountID, allowDebit}) {
+ const subscribedKeyboardShortcuts = useRef([]);
+ const previousNetworkState = useRef();
- componentWillUnmount() {
- this.unsubscribeToNavigationShortcuts();
- }
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
/**
* @returns {String}
*/
- getPlaidLinkToken() {
- if (this.props.plaidLinkToken) {
- return this.props.plaidLinkToken;
+ const getPlaidLinkToken = () => {
+ if (plaidLinkToken) {
+ return plaidLinkToken;
}
- if (this.props.receivedRedirectURI && this.props.plaidLinkOAuthToken) {
- return this.props.plaidLinkOAuthToken;
+ if (receivedRedirectURI && plaidLinkOAuthToken) {
+ return plaidLinkOAuthToken;
}
- }
+ };
/**
* @returns {Boolean}
+ * I'm using useCallback so the useEffect which uses this function doesn't run on every render.
*/
- isAuthenticatedWithPlaid() {
- return (
- (this.props.receivedRedirectURI && this.props.plaidLinkOAuthToken) ||
- !_.isEmpty(lodashGet(this.props.plaidData, 'bankAccounts')) ||
- !_.isEmpty(lodashGet(this.props.plaidData, 'errors'))
- );
- }
+ const isAuthenticatedWithPlaid = useCallback(
+ () => (receivedRedirectURI && plaidLinkOAuthToken) || !_.isEmpty(lodashGet(plaidData, 'bankAccounts')) || !_.isEmpty(lodashGet(plaidData, 'errors')),
+ [plaidData, plaidLinkOAuthToken, receivedRedirectURI],
+ );
/**
* Blocks the keyboard shortcuts that can navigate
*/
- subscribeToNavigationShortcuts() {
+ const subscribeToNavigationShortcuts = () => {
// find and block the shortcuts
const shortcutsToBlock = _.filter(CONST.KEYBOARD_SHORTCUTS, (x) => x.type === CONST.KEYBOARD_SHORTCUTS_TYPES.NAVIGATION_SHORTCUT);
- this.subscribedKeyboardShortcuts = _.map(shortcutsToBlock, (shortcut) =>
+ subscribedKeyboardShortcuts.current = _.map(shortcutsToBlock, (shortcut) =>
KeyboardShortcut.subscribe(
shortcut.shortcutKey,
() => {}, // do nothing
shortcut.descriptionKey,
shortcut.modifiers,
false,
- () => lodashGet(this.props.plaidData, 'bankAccounts', []).length > 0, // start bubbling when there are bank accounts
+ () => lodashGet(plaidData, 'bankAccounts', []).length > 0, // start bubbling when there are bank accounts
),
);
- }
+ };
/**
* Unblocks the keyboard shortcuts that can navigate
*/
- unsubscribeToNavigationShortcuts() {
- _.each(this.subscribedKeyboardShortcuts, (unsubscribe) => unsubscribe());
- this.subscribedKeyboardShortcuts = [];
- }
+ const unsubscribeToNavigationShortcuts = () => {
+ _.each(subscribedKeyboardShortcuts.current, (unsubscribe) => unsubscribe());
+ subscribedKeyboardShortcuts.current = [];
+ };
- render() {
- const plaidBankAccounts = lodashGet(this.props.plaidData, 'bankAccounts') || [];
- const token = this.getPlaidLinkToken();
- const options = _.map(plaidBankAccounts, (account) => ({
- value: account.plaidAccountID,
- label: `${account.addressName} ${account.mask}`,
- }));
- const {icon, iconSize} = getBankIcon();
- const plaidErrors = lodashGet(this.props.plaidData, 'errors');
- const plaidDataErrorMessage = !_.isEmpty(plaidErrors) ? _.chain(plaidErrors).values().first().value() : '';
- const bankName = lodashGet(this.props.plaidData, 'bankName');
-
- // Plaid Link view
- if (!plaidBankAccounts.length) {
- return (
-
- {lodashGet(this.props.plaidData, 'isLoading') && (
-
-
-
- )}
- {Boolean(plaidDataErrorMessage) && {plaidDataErrorMessage} }
- {Boolean(token) && !bankName && (
- {
- Log.info('[PlaidLink] Success!');
- BankAccounts.openPlaidBankAccountSelector(publicToken, metadata.institution.name, this.props.allowDebit, this.props.bankAccountID);
- }}
- onError={(error) => {
- Log.hmmm('[PlaidLink] Error: ', error.message);
- }}
- // User prematurely exited the Plaid flow
- // eslint-disable-next-line react/jsx-props-no-multi-spaces
- onExit={this.props.onExitPlaid}
- receivedRedirectURI={this.props.receivedRedirectURI}
- />
- )}
-
- );
+ useEffect(() => {
+ subscribeToNavigationShortcuts();
+
+ // If we're coming from Plaid OAuth flow then we need to reuse the existing plaidLinkToken
+ if (isAuthenticatedWithPlaid()) {
+ return unsubscribeToNavigationShortcuts;
}
+ BankAccounts.openPlaidBankLogin(allowDebit, bankAccountID);
+ return unsubscribeToNavigationShortcuts;
- // Plaid bank accounts view
+ // disabling this rule, as we want this to run only on the first render
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ // If we are coming back from offline and we haven't authenticated with Plaid yet, we need to re-run our call to kick off Plaid
+ // previousNetworkState.current also makes sure that this doesn't run on the first render.
+ if (previousNetworkState.current && !isOffline && !isAuthenticatedWithPlaid()) {
+ BankAccounts.openPlaidBankLogin(allowDebit, bankAccountID);
+ }
+ previousNetworkState.current = isOffline;
+ }, [allowDebit, bankAccountID, isAuthenticatedWithPlaid, isOffline]);
+
+ const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts') || [];
+ const token = getPlaidLinkToken();
+ const options = _.map(plaidBankAccounts, (account) => ({
+ value: account.plaidAccountID,
+ label: `${account.addressName} ${account.mask}`,
+ }));
+ const {icon, iconSize} = getBankIcon();
+ const plaidErrors = lodashGet(plaidData, 'errors');
+ const plaidDataErrorMessage = !_.isEmpty(plaidErrors) ? _.chain(plaidErrors).values().first().value() : '';
+ const bankName = lodashGet(plaidData, 'bankName');
+
+ // Plaid Link view
+ if (!plaidBankAccounts.length) {
return (
- {!_.isEmpty(this.props.text) && {this.props.text} }
-
-
- {bankName}
-
-
-
+
+
+ )}
+ {Boolean(plaidDataErrorMessage) && {plaidDataErrorMessage} }
+ {Boolean(token) && !bankName && (
+ {
+ Log.info('[PlaidLink] Success!');
+ BankAccounts.openPlaidBankAccountSelector(publicToken, metadata.institution.name, allowDebit, bankAccountID);
+ }}
+ onError={(error) => {
+ Log.hmmm('[PlaidLink] Error: ', error.message);
}}
- value={this.props.selectedPlaidAccountID}
+ // User prematurely exited the Plaid flow
+ // eslint-disable-next-line react/jsx-props-no-multi-spaces
+ onExit={onExitPlaid}
+ receivedRedirectURI={receivedRedirectURI}
/>
-
+ )}
);
}
+
+ // Plaid bank accounts view
+ return (
+
+ {!_.isEmpty(text) && {text} }
+
+
+ {bankName}
+
+
+
+
+
+ );
}
AddPlaidBankAccount.propTypes = propTypes;
AddPlaidBankAccount.defaultProps = defaultProps;
-
-export default compose(
- withLocalize,
- withNetwork(),
- withOnyx({
- plaidLinkToken: {
- key: ONYXKEYS.PLAID_LINK_TOKEN,
- initWithStoredValues: false,
- },
- }),
-)(AddPlaidBankAccount);
+AddPlaidBankAccount.displayName = 'AddPlaidBankAccount';
+
+export default withOnyx({
+ plaidLinkToken: {
+ key: ONYXKEYS.PLAID_LINK_TOKEN,
+ initWithStoredValues: false,
+ },
+})(AddPlaidBankAccount);
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index e8a41ec35435..204333474849 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -1,9 +1,10 @@
import _ from 'underscore';
import React, {useMemo, useRef, useState} from 'react';
import PropTypes from 'prop-types';
-import {LogBox, ScrollView, View} from 'react-native';
+import {LogBox, ScrollView, View, Text} from 'react-native';
import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete';
import lodashGet from 'lodash/get';
+import compose from '../../libs/compose';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
@@ -14,6 +15,8 @@ import CONST from '../../CONST';
import * as StyleUtils from '../../styles/StyleUtils';
import resetDisplayListViewBorderOnBlur from './resetDisplayListViewBorderOnBlur';
import variables from '../../styles/variables';
+import {withNetwork} from '../OnyxProvider';
+import networkPropTypes from '../networkPropTypes';
// The error that's being thrown below will be ignored until we fork the
// react-native-google-places-autocomplete repo and replace the
@@ -48,6 +51,9 @@ const propTypes = {
/** A callback function when the value of this field has changed */
onInputChange: PropTypes.func.isRequired,
+ /** A callback function when an address has been auto-selected */
+ onPress: PropTypes.func,
+
/** Customize the TextInput container */
// eslint-disable-next-line react/forbid-prop-types
containerStyles: PropTypes.arrayOf(PropTypes.object),
@@ -58,14 +64,20 @@ const propTypes = {
/** A map of inputID key names */
renamedInputKeys: PropTypes.shape({
street: PropTypes.string,
+ street2: PropTypes.string,
city: PropTypes.string,
state: PropTypes.string,
+ lat: PropTypes.string,
+ lng: PropTypes.string,
zipCode: PropTypes.string,
}),
/** Maximum number of characters allowed in search input */
maxInputLength: PropTypes.number,
+ /** Information about the network */
+ network: networkPropTypes.isRequired,
+
...withLocalizePropTypes,
};
@@ -73,6 +85,7 @@ const defaultProps = {
inputID: undefined,
shouldSaveDraft: false,
onBlur: () => {},
+ onPress: () => {},
errorText: '',
hint: '',
value: undefined,
@@ -81,9 +94,12 @@ const defaultProps = {
isLimitedToUSA: true,
renamedInputKeys: {
street: 'addressStreet',
+ street2: 'addressStreet2',
city: 'addressCity',
state: 'addressState',
zipCode: 'addressZipCode',
+ lat: 'addressLat',
+ lng: 'addressLng',
},
maxInputLength: undefined,
};
@@ -166,6 +182,9 @@ function AddressSearch(props) {
zipCode,
country: '',
state: state || stateAutoCompleteFallback,
+ lat: lodashGet(details, 'geometry.location.lat', 0),
+ lng: lodashGet(details, 'geometry.location.lng', 0),
+ address: lodashGet(details, 'formatted_address', ''),
};
// If the address is not in the US, use the full length state name since we're displaying the address's
@@ -194,11 +213,16 @@ function AddressSearch(props) {
if (props.inputID) {
_.each(values, (value, key) => {
const inputKey = lodashGet(props.renamedInputKeys, key, key);
+ if (!inputKey) {
+ return;
+ }
props.onInputChange(value, inputKey);
});
} else {
props.onInputChange(values);
}
+
+ props.onPress(values);
};
return (
@@ -226,6 +250,11 @@ function AddressSearch(props) {
fetchDetails
suppressDefaultStyles
enablePoweredByContainer={false}
+ ListEmptyComponent={
+ props.network.isOffline ? null : (
+ {props.translate('common.noResultsFound')}
+ )
+ }
onPress={(data, details) => {
saveLocationDetails(data, details);
@@ -306,7 +335,10 @@ AddressSearch.propTypes = propTypes;
AddressSearch.defaultProps = defaultProps;
AddressSearch.displayName = 'AddressSearch';
-export default withLocalize(
+export default compose(
+ withNetwork(),
+ withLocalize,
+)(
React.forwardRef((props, ref) => (
{
+ if (!linkProps.onPress) {
+ return;
+ }
+
+ event.preventDefault();
+ linkProps.onPress();
+ }}
onPressIn={onPressIn}
onPressOut={onPressOut}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.LINK}
@@ -80,14 +87,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '',
target: isEmail || !linkProps.href ? '_self' : target,
}}
href={linkProps.href || href}
- onPress={(event) => {
- if (!linkProps.onPress) {
- return;
- }
-
- event.preventDefault();
- linkProps.onPress();
- }}
+ suppressHighlighting
// Add testID so it gets selected as an anchor tag by SelectionScraper
testID="a"
// eslint-disable-next-line react/jsx-props-no-spreading
diff --git a/src/components/AnimatedStep/AnimatedStepContext.js b/src/components/AnimatedStep/AnimatedStepContext.js
new file mode 100644
index 000000000000..30377147fdb8
--- /dev/null
+++ b/src/components/AnimatedStep/AnimatedStepContext.js
@@ -0,0 +1,5 @@
+import {createContext} from 'react';
+
+const AnimatedStepContext = createContext();
+
+export default AnimatedStepContext;
diff --git a/src/components/AnimatedStep/AnimatedStepProvider.js b/src/components/AnimatedStep/AnimatedStepProvider.js
new file mode 100644
index 000000000000..280fbd1a2776
--- /dev/null
+++ b/src/components/AnimatedStep/AnimatedStepProvider.js
@@ -0,0 +1,17 @@
+import React, {useState} from 'react';
+import PropTypes from 'prop-types';
+import AnimatedStepContext from './AnimatedStepContext';
+import CONST from '../../CONST';
+
+const propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+function AnimatedStepProvider({children}) {
+ const [animationDirection, setAnimationDirection] = useState(CONST.ANIMATION_DIRECTION.IN);
+
+ return {children} ;
+}
+
+AnimatedStepProvider.propTypes = propTypes;
+export default AnimatedStepProvider;
diff --git a/src/components/AnimatedStep.js b/src/components/AnimatedStep/index.js
similarity index 58%
rename from src/components/AnimatedStep.js
rename to src/components/AnimatedStep/index.js
index dce06cb33760..a8b9b80fcc0e 100644
--- a/src/components/AnimatedStep.js
+++ b/src/components/AnimatedStep/index.js
@@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as Animatable from 'react-native-animatable';
-import CONST from '../CONST';
-import styles from '../styles/styles';
+import CONST from '../../CONST';
+import styles from '../../styles/styles';
const propTypes = {
/** Children to wrap in AnimatedStep. */
@@ -14,27 +14,37 @@ const propTypes = {
/** Whether we're animating the step in or out */
direction: PropTypes.oneOf(['in', 'out']),
+
+ /** Callback to fire when the animation ends */
+ onAnimationEnd: PropTypes.func,
};
const defaultProps = {
direction: 'in',
style: [],
+ onAnimationEnd: () => {},
};
-function AnimatedStep(props) {
- function getAnimationStyle(direction) {
- let animationStyle;
-
- if (direction === 'in') {
- animationStyle = styles.makeSlideInTranslation('translateX', CONST.ANIMATED_TRANSITION_FROM_VALUE);
- } else if (direction === 'out') {
- animationStyle = styles.makeSlideInTranslation('translateX', -CONST.ANIMATED_TRANSITION_FROM_VALUE);
- }
- return animationStyle;
+function getAnimationStyle(direction) {
+ let transitionValue;
+
+ if (direction === 'in') {
+ transitionValue = CONST.ANIMATED_TRANSITION_FROM_VALUE;
+ } else if (direction === 'out') {
+ transitionValue = -CONST.ANIMATED_TRANSITION_FROM_VALUE;
}
+ return styles.makeSlideInTranslation('translateX', transitionValue);
+}
+function AnimatedStep(props) {
return (
{
+ if (!props.onAnimationEnd) {
+ return;
+ }
+ props.onAnimationEnd();
+ }}
duration={CONST.ANIMATED_TRANSITION}
animation={getAnimationStyle(props.direction)}
useNativeDriver
diff --git a/src/components/AnimatedStep/useAnimatedStepContext.js b/src/components/AnimatedStep/useAnimatedStepContext.js
new file mode 100644
index 000000000000..e2af9514e20e
--- /dev/null
+++ b/src/components/AnimatedStep/useAnimatedStepContext.js
@@ -0,0 +1,12 @@
+import {useContext} from 'react';
+import AnimatedStepContext from './AnimatedStepContext';
+
+function useAnimatedStepContext() {
+ const context = useContext(AnimatedStepContext);
+ if (!context) {
+ throw new Error('useAnimatedStepContext must be used within an AnimatedStepContextProvider');
+ }
+ return context;
+}
+
+export default useAnimatedStepContext;
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 665123852b77..c07a4474a68b 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -25,6 +25,7 @@ import HeaderGap from './HeaderGap';
import SafeAreaConsumer from './SafeAreaConsumer';
import addEncryptedAuthTokenToURL from '../libs/addEncryptedAuthTokenToURL';
import reportPropTypes from '../pages/reportPropTypes';
+import tryResolveUrlFromApiRoot from '../libs/tryResolveUrlFromApiRoot';
/**
* Modal render prop component that exposes modal launching triggers that can be used
@@ -71,6 +72,9 @@ const propTypes = {
...withLocalizePropTypes,
...windowDimensionsPropTypes,
+
+ /** Denotes whether it is a workspace avatar or not */
+ isWorkspaceAvatar: PropTypes.bool,
};
const defaultProps = {
@@ -86,6 +90,7 @@ const defaultProps = {
onModalShow: () => {},
onModalHide: () => {},
onCarouselAttachmentChange: () => {},
+ isWorkspaceAvatar: false,
};
function AttachmentModal(props) {
@@ -93,12 +98,14 @@ function AttachmentModal(props) {
const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false);
const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
const [isAuthTokenRequired, setIsAuthTokenRequired] = useState(props.isAuthTokenRequired);
+ const [isAttachmentReceipt, setIsAttachmentReceipt] = useState(false);
const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState('');
const [attachmentInvalidReason, setAttachmentInvalidReason] = useState(null);
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 [shouldShowDownloadButton, setShouldShowDownloadButton] = React.useState(true);
const [file, setFile] = useState(
props.originalFileName
? {
@@ -112,12 +119,13 @@ function AttachmentModal(props) {
/**
* Keeps the attachment source in sync with the attachment displayed currently in the carousel.
- * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string } }} attachment
+ * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string }, isReceipt: Boolean }} attachment
*/
const onNavigate = useCallback(
(attachment) => {
setSource(attachment.source);
setFile(attachment.file);
+ setIsAttachmentReceipt(attachment.isReceipt);
setIsAuthTokenRequired(attachment.isAuthTokenRequired);
onCarouselAttachmentChange(attachment);
},
@@ -138,6 +146,16 @@ function AttachmentModal(props) {
[translate],
);
+ const setDownloadButtonVisibility = useCallback(
+ (shouldShowButton) => {
+ if (shouldShowDownloadButton === shouldShowButton) {
+ return;
+ }
+ setShouldShowDownloadButton(shouldShowButton);
+ },
+ [shouldShowDownloadButton],
+ );
+
/**
* Download the currently viewed attachment.
*/
@@ -298,6 +316,7 @@ function AttachmentModal(props) {
}, []);
const sourceForAttachmentView = props.source || source;
+
return (
<>
{props.isSmallScreenWidth && }
downloadAttachment(source)}
shouldShowCloseButton={!props.isSmallScreenWidth}
shouldShowBackButton={props.isSmallScreenWidth}
@@ -332,9 +351,10 @@ function AttachmentModal(props) {
) : (
Boolean(sourceForAttachmentView) &&
@@ -345,6 +365,7 @@ function AttachmentModal(props) {
isAuthTokenRequired={isAuthTokenRequired}
file={file}
onToggleKeyboard={updateConfirmButtonVisibility}
+ isWorkspaceAvatar={props.isWorkspaceAvatar}
/>
)
)}
diff --git a/src/components/AttachmentPicker/index.js b/src/components/AttachmentPicker/index.js
index e7653df2b4d0..9ea94ae53d42 100644
--- a/src/components/AttachmentPicker/index.js
+++ b/src/components/AttachmentPicker/index.js
@@ -27,6 +27,8 @@ function getAcceptableFileTypes(type) {
function AttachmentPicker(props) {
const fileInput = useRef();
const onPicked = useRef();
+ const onCanceled = useRef(() => {});
+
return (
<>
e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (!fileInput.current) {
+ return;
+ }
+ fileInput.current.addEventListener('cancel', () => onCanceled.current(), {once: true});
+ }}
accept={getAcceptableFileTypes(props.type)}
/>
{props.children({
- openPicker: ({onPicked: newOnPicked}) => {
+ openPicker: ({onPicked: newOnPicked, onCanceled: newOnCanceled = () => {}}) => {
onPicked.current = newOnPicked;
fileInput.current.click();
+ onCanceled.current = newOnCanceled;
},
})}
>
diff --git a/src/components/AttachmentPicker/index.native.js b/src/components/AttachmentPicker/index.native.js
index b4b7d0b04c4e..8b1bb54da920 100644
--- a/src/components/AttachmentPicker/index.native.js
+++ b/src/components/AttachmentPicker/index.native.js
@@ -1,30 +1,24 @@
-/**
- * The react native image/document pickers work for iOS/Android, but we want to wrap them both within AttachmentPicker
- */
import _ from 'underscore';
-import React, {Component} from 'react';
-import {Alert, Linking, View} from 'react-native';
-import {launchImageLibrary} from 'react-native-image-picker';
+import React, {useState, useRef, useCallback, useMemo} from 'react';
+import {View, Alert, Linking} from 'react-native';
import RNDocumentPicker from 'react-native-document-picker';
import RNFetchBlob from 'react-native-blob-util';
+import {launchImageLibrary} from 'react-native-image-picker';
import {propTypes as basePropTypes, defaultProps} from './attachmentPickerPropTypes';
-import styles from '../../styles/styles';
-import Popover from '../Popover';
-import MenuItem from '../MenuItem';
-import * as Expensicons from '../Icon/Expensicons';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
-import withLocalize, {withLocalizePropTypes} from '../withLocalize';
-import compose from '../../libs/compose';
-import launchCamera from './launchCamera';
import CONST from '../../CONST';
import * as FileUtils from '../../libs/fileDownload/FileUtils';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
-import KeyboardShortcut from '../../libs/KeyboardShortcut';
+import * as Expensicons from '../Icon/Expensicons';
+import launchCamera from './launchCamera';
+import Popover from '../Popover';
+import MenuItem from '../MenuItem';
+import styles from '../../styles/styles';
+import useLocalize from '../../hooks/useLocalize';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
+import useArrowKeyFocusManager from '../../hooks/useArrowKeyFocusManager';
const propTypes = {
...basePropTypes,
- ...windowDimensionsPropTypes,
- ...withLocalizePropTypes,
};
/**
@@ -43,14 +37,14 @@ const imagePickerOptions = {
* @param {String} type
* @returns {Object}
*/
-function getImagePickerOptions(type) {
+const getImagePickerOptions = (type) => {
// mediaType property is one of the ImagePicker configuration to restrict types'
const mediaType = type === CONST.ATTACHMENT_PICKER_TYPE.IMAGE ? 'photo' : 'mixed';
return {
mediaType,
...imagePickerOptions,
};
-}
+};
/**
* See https://github.com/rnmods/react-native-document-picker#options for DocumentPicker configuration options
@@ -67,7 +61,7 @@ const documentPickerOptions = {
* @param {Object} fileData
* @return {Promise}
*/
-function getDataForUpload(fileData) {
+const getDataForUpload = (fileData) => {
const fileName = fileData.fileName || fileData.name || 'chat_attachment';
const fileResult = {
name: FileUtils.cleanFileName(fileName),
@@ -86,141 +80,53 @@ function getDataForUpload(fileData) {
fileResult.size = stats.size;
return fileResult;
});
-}
+};
/**
* This component renders a function as a child and
* returns a "show attachment picker" method that takes
* a callback. This is the ios/android implementation
* opening a modal with attachment options
+ * @param {propTypes} props
+ * @returns {JSX.Element}
*/
-class AttachmentPicker extends Component {
- constructor(...args) {
- super(...args);
+function AttachmentPicker({type, children}) {
+ const [isVisible, setIsVisible] = useState(false);
- this.state = {
- isVisible: false,
- focusedIndex: -1,
- };
+ const completeAttachmentSelection = useRef();
+ const onModalHide = useRef();
+ const onCanceled = useRef();
- this.menuItemData = [
- {
- icon: Expensicons.Camera,
- textTranslationKey: 'attachmentPicker.takePhoto',
- pickAttachment: () => this.showImagePicker(launchCamera),
- },
- {
- icon: Expensicons.Gallery,
- textTranslationKey: 'attachmentPicker.chooseFromGallery',
- pickAttachment: () => this.showImagePicker(launchImageLibrary),
- },
- ];
-
- // When selecting an image on a native device, it would be redundant to have a second option for choosing a document,
- // so it is excluded in this case.
- if (this.props.type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE) {
- this.menuItemData.push({
- icon: Expensicons.Paperclip,
- textTranslationKey: 'attachmentPicker.chooseDocument',
- pickAttachment: () => this.showDocumentPicker(),
- });
- }
-
- this.close = this.close.bind(this);
- this.pickAttachment = this.pickAttachment.bind(this);
- this.removeKeyboardListener = this.removeKeyboardListener.bind(this);
- this.attachKeyboardListener = this.attachKeyboardListener.bind(this);
- }
-
- componentDidUpdate(prevState) {
- if (this.state.isVisible === prevState.isVisible) {
- return;
- }
-
- if (this.state.isVisible) {
- this.attachKeyboardListener();
- } else {
- this.removeKeyboardListener();
- }
- }
-
- componentWillUnmount() {
- this.removeKeyboardListener();
- }
-
- attachKeyboardListener() {
- const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ENTER;
- this.unsubscribeEnterKey = KeyboardShortcut.subscribe(
- shortcutConfig.shortcutKey,
- () => {
- if (this.state.focusedIndex === -1) {
- return;
- }
- this.selectItem(this.menuItemData[this.state.focusedIndex]);
- this.setState({focusedIndex: -1}); // Reset the focusedIndex on selecting any menu
- },
- shortcutConfig.descriptionKey,
- shortcutConfig.modifiers,
- true,
- );
- }
-
- removeKeyboardListener() {
- if (!this.unsubscribeEnterKey) {
- return;
- }
- this.unsubscribeEnterKey();
- }
-
- /**
- * Handles the image/document picker result and
- * sends the selected attachment to the caller (parent component)
- *
- * @param {Array} attachments
- * @returns {Promise}
- */
- pickAttachment(attachments = []) {
- if (attachments.length === 0) {
- return;
- }
-
- const fileData = _.first(attachments);
-
- if (fileData.width === -1 || fileData.height === -1) {
- this.showImageCorruptionAlert();
- return;
- }
-
- return getDataForUpload(fileData)
- .then((result) => {
- this.completeAttachmentSelection(result);
- })
- .catch((error) => {
- this.showGeneralAlert(error.message);
- throw error;
- });
- }
+ const {translate} = useLocalize();
+ const {isSmallScreenWidth} = useWindowDimensions();
/**
* Inform the users when they need to grant camera access and guide them to settings
*/
- showPermissionsAlert() {
+ const showPermissionsAlert = useCallback(() => {
Alert.alert(
- this.props.translate('attachmentPicker.cameraPermissionRequired'),
- this.props.translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'),
+ translate('attachmentPicker.cameraPermissionRequired'),
+ translate('attachmentPicker.expensifyDoesntHaveAccessToCamera'),
[
{
- text: this.props.translate('common.cancel'),
+ text: translate('common.cancel'),
style: 'cancel',
},
{
- text: this.props.translate('common.settings'),
+ text: translate('common.settings'),
onPress: () => Linking.openSettings(),
},
],
{cancelable: false},
);
- }
+ }, [translate]);
+
+ /**
+ * A generic handling when we don't know the exact reason for an error
+ */
+ const showGeneralAlert = useCallback(() => {
+ Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingAttachment'));
+ }, [translate]);
/**
* Common image picker handling
@@ -228,89 +134,136 @@ class AttachmentPicker extends Component {
* @param {function} imagePickerFunc - RNImagePicker.launchCamera or RNImagePicker.launchImageLibrary
* @returns {Promise}
*/
- showImagePicker(imagePickerFunc) {
- return new Promise((resolve, reject) => {
- imagePickerFunc(getImagePickerOptions(this.props.type), (response) => {
- if (response.didCancel) {
- // When the user cancelled resolve with no attachment
- return resolve();
- }
- if (response.errorCode) {
- switch (response.errorCode) {
- case 'permission':
- this.showPermissionsAlert();
- return resolve();
- default:
- this.showGeneralAlert();
- break;
+ const showImagePicker = useCallback(
+ (imagePickerFunc) =>
+ new Promise((resolve, reject) => {
+ imagePickerFunc(getImagePickerOptions(type), (response) => {
+ if (response.didCancel) {
+ // When the user cancelled resolve with no attachment
+ return resolve();
+ }
+ if (response.errorCode) {
+ switch (response.errorCode) {
+ case 'permission':
+ showPermissionsAlert();
+ return resolve();
+ default:
+ showGeneralAlert();
+ break;
+ }
+
+ return reject(new Error(`Error during attachment selection: ${response.errorMessage}`));
}
- return reject(new Error(`Error during attachment selection: ${response.errorMessage}`));
- }
-
- return resolve(response.assets);
- });
- });
- }
+ return resolve(response.assets);
+ });
+ }),
+ [showGeneralAlert, showPermissionsAlert, type],
+ );
/**
- * A generic handling when we don't know the exact reason for an error
+ * Launch the DocumentPicker. Results are in the same format as ImagePicker
*
+ * @returns {Promise}
*/
- showGeneralAlert() {
- Alert.alert(this.props.translate('attachmentPicker.attachmentError'), this.props.translate('attachmentPicker.errorWhileSelectingAttachment'));
- }
+ const showDocumentPicker = useCallback(
+ () =>
+ RNDocumentPicker.pick(documentPickerOptions).catch((error) => {
+ if (RNDocumentPicker.isCancel(error)) {
+ return;
+ }
+
+ showGeneralAlert(error.message);
+ throw error;
+ }),
+ [showGeneralAlert],
+ );
+
+ const menuItemData = useMemo(() => {
+ const data = [
+ {
+ icon: Expensicons.Camera,
+ textTranslationKey: 'attachmentPicker.takePhoto',
+ pickAttachment: () => showImagePicker(launchCamera),
+ },
+ {
+ icon: Expensicons.Gallery,
+ textTranslationKey: 'attachmentPicker.chooseFromGallery',
+ pickAttachment: () => showImagePicker(launchImageLibrary),
+ },
+ ];
+
+ if (type !== CONST.ATTACHMENT_PICKER_TYPE.IMAGE) {
+ data.push({
+ icon: Expensicons.Paperclip,
+ textTranslationKey: 'attachmentPicker.chooseDocument',
+ pickAttachment: showDocumentPicker,
+ });
+ }
+
+ return data;
+ }, [showDocumentPicker, showImagePicker, type]);
+
+ const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible});
/**
* An attachment error dialog when user selected malformed images
*/
- showImageCorruptionAlert() {
- Alert.alert(this.props.translate('attachmentPicker.attachmentError'), this.props.translate('attachmentPicker.errorWhileSelectingCorruptedImage'));
- }
+ const showImageCorruptionAlert = useCallback(() => {
+ Alert.alert(translate('attachmentPicker.attachmentError'), translate('attachmentPicker.errorWhileSelectingCorruptedImage'));
+ }, [translate]);
/**
- * Launch the DocumentPicker. Results are in the same format as ImagePicker
+ * Opens the attachment modal
*
- * @returns {Promise}
+ * @param {function} onPickedHandler A callback that will be called with the selected attachment
+ * @param {function} onCanceledHandler A callback that will be called without a selected attachment
*/
- showDocumentPicker() {
- return RNDocumentPicker.pick(documentPickerOptions).catch((error) => {
- if (RNDocumentPicker.isCancel(error)) {
- return;
- }
-
- this.showGeneralAlert(error.message);
- throw error;
- });
- }
+ const open = (onPickedHandler, onCanceledHandler = () => {}) => {
+ completeAttachmentSelection.current = onPickedHandler;
+ onCanceled.current = onCanceledHandler;
+ setIsVisible(true);
+ };
/**
- * Triggers the `onPicked` callback with the selected attachment
+ * Closes the attachment modal
*/
- completeAttachmentSelection() {
- if (!this.state.result) {
- return;
- }
-
- this.state.onPicked(this.state.result);
- }
+ const close = () => {
+ setIsVisible(false);
+ };
/**
- * Opens the attachment modal
+ * Handles the image/document picker result and
+ * sends the selected attachment to the caller (parent component)
*
- * @param {function} onPicked A callback that will be called with the selected attachment
+ * @param {Array} attachments
+ * @returns {Promise}
*/
- open(onPicked) {
- this.completeAttachmentSelection = onPicked;
- this.setState({isVisible: true});
- }
+ const pickAttachment = useCallback(
+ (attachments = []) => {
+ if (attachments.length === 0) {
+ onCanceled.current();
+ return Promise.resolve();
+ }
- /**
- * Closes the attachment modal
- */
- close() {
- this.setState({isVisible: false});
- }
+ const fileData = _.first(attachments);
+
+ if (fileData.width === -1 || fileData.height === -1) {
+ showImageCorruptionAlert();
+ return Promise.resolve();
+ }
+
+ return getDataForUpload(fileData)
+ .then((result) => {
+ completeAttachmentSelection.current(result);
+ })
+ .catch((error) => {
+ showGeneralAlert(error.message);
+ throw error;
+ });
+ },
+ [showGeneralAlert, showImageCorruptionAlert],
+ );
/**
* Setup native attachment selection to start after this popover closes
@@ -318,68 +271,80 @@ class AttachmentPicker extends Component {
* @param {Object} item - an item from this.menuItemData
* @param {Function} item.pickAttachment
*/
- selectItem(item) {
- /* setTimeout delays execution to the frame after the modal closes
- * without this on iOS closing the modal closes the gallery/camera as well */
- this.onModalHide = () =>
- setTimeout(
- () =>
- item
- .pickAttachment()
- .then(this.pickAttachment)
- .catch(console.error)
- .finally(() => delete this.onModalHide),
- 200,
- );
-
- this.close();
- }
+ const selectItem = useCallback(
+ (item) => {
+ /* setTimeout delays execution to the frame after the modal closes
+ * without this on iOS closing the modal closes the gallery/camera as well */
+ onModalHide.current = () =>
+ setTimeout(
+ () =>
+ item
+ .pickAttachment()
+ .then(pickAttachment)
+ .catch(console.error)
+ .finally(() => delete onModalHide.current),
+ 200,
+ );
+
+ close();
+ },
+ [pickAttachment],
+ );
+
+ useKeyboardShortcut(
+ CONST.KEYBOARD_SHORTCUTS.ENTER,
+ () => {
+ if (focusedIndex === -1) {
+ return;
+ }
+ selectItem(menuItemData[focusedIndex]);
+ setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu
+ },
+ {
+ isActive: isVisible,
+ },
+ );
/**
* Call the `children` renderProp with the interface defined in propTypes
*
* @returns {React.ReactNode}
*/
- renderChildren() {
- return this.props.children({
- openPicker: ({onPicked}) => this.open(onPicked),
+ const renderChildren = () =>
+ children({
+ openPicker: ({onPicked, onCanceled: newOnCanceled}) => open(onPicked, newOnCanceled),
});
- }
- render() {
- return (
- <>
-
-
- this.setState({focusedIndex: index})}
- >
- {_.map(this.menuItemData, (item, menuIndex) => (
- this.selectItem(item)}
- focused={this.state.focusedIndex === menuIndex}
- />
- ))}
-
-
-
- {this.renderChildren()}
- >
- );
- }
+ return (
+ <>
+ {
+ close();
+ onCanceled.current();
+ }}
+ isVisible={isVisible}
+ anchorPosition={styles.createMenuPosition}
+ onModalHide={onModalHide.current}
+ >
+
+ {_.map(menuItemData, (item, menuIndex) => (
+ selectItem(item)}
+ focused={focusedIndex === menuIndex}
+ />
+ ))}
+
+
+ {renderChildren()}
+ >
+ );
}
AttachmentPicker.propTypes = propTypes;
AttachmentPicker.defaultProps = defaultProps;
+AttachmentPicker.displayName = 'AttachmentPicker';
-export default compose(withWindowDimensions, withLocalize)(AttachmentPicker);
+export default AttachmentPicker;
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
index e19f7617bb28..d33659fd04ae 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselButtons.js
@@ -40,7 +40,7 @@ function CarouselButtons({page, attachments, shouldShowArrows, onBack, onForward
const isForwardDisabled = page === _.size(attachments) - 1;
const {translate} = useLocalize();
- const {isSmallScreenWidth} = useWindowDimensions;
+ const {isSmallScreenWidth} = useWindowDimensions();
return shouldShowArrows ? (
<>
diff --git a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
index a63b0f23d1ab..81f22f684243 100644
--- a/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
+++ b/src/components/Attachments/AttachmentCarousel/attachmentCarouselPropTypes.js
@@ -12,6 +12,9 @@ const propTypes = {
/** Callback to close carousel when user swipes down (on native) */
onClose: PropTypes.func,
+ /** Function to change the download button Visibility */
+ setDownloadButtonVisibility: PropTypes.func,
+
/** Object of report actions for this report */
reportActions: PropTypes.shape(reportActionPropTypes),
@@ -24,6 +27,7 @@ const defaultProps = {
reportActions: {},
onNavigate: () => {},
onClose: () => {},
+ setDownloadButtonVisibility: () => {},
};
export {propTypes, defaultProps};
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
index 047a016674b7..b967d5ab0066 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js
@@ -1,20 +1,21 @@
import {Parser as HtmlParser} from 'htmlparser2';
import _ from 'underscore';
+import lodashGet from 'lodash/get';
import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
+import * as TransactionUtils from '../../../libs/TransactionUtils';
+import * as ReceiptUtils from '../../../libs/ReceiptUtils';
import CONST from '../../../CONST';
import tryResolveUrlFromApiRoot from '../../../libs/tryResolveUrlFromApiRoot';
-import Navigation from '../../../libs/Navigation/Navigation';
/**
* Constructs the initial component state from report actions
* @param {Object} report
* @param {Array} reportActions
- * @param {String} source
- * @returns {{attachments: Array, initialPage: Number, initialItem: Object, initialActiveSource: String}}
+ * @returns {Array}
*/
-function extractAttachmentsFromReport(report, reportActions, source) {
+function extractAttachmentsFromReport(report, reportActions) {
const actions = [ReportActionsUtils.getParentReportAction(report), ...ReportActionsUtils.getSortedReportActions(_.values(reportActions))];
- let attachments = [];
+ const attachments = [];
const htmlParser = new HtmlParser({
onopentag: (name, attribs) => {
@@ -30,6 +31,7 @@ function extractAttachmentsFromReport(report, reportActions, source) {
source: tryResolveUrlFromApiRoot(expensifySource || attribs.src),
isAuthTokenRequired: Boolean(expensifySource),
file: {name: attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE]},
+ isReceipt: false,
});
},
});
@@ -38,31 +40,33 @@ function extractAttachmentsFromReport(report, reportActions, source) {
if (!ReportActionsUtils.shouldReportActionBeVisible(action, key)) {
return;
}
- htmlParser.write(_.get(action, ['message', 0, 'html']));
- });
- htmlParser.end();
- attachments = attachments.reverse();
+ // We're handling receipts differently here because receipt images are not
+ // part of the report action message, the images are constructed client-side
+ if (ReportActionsUtils.isMoneyRequestAction(action)) {
+ const transactionID = lodashGet(action, ['originalMessage', 'IOUTransactionID']);
+ if (!transactionID) {
+ return;
+ }
- const initialPage = _.findIndex(attachments, (a) => a.source === source);
- if (initialPage === -1) {
- Navigation.dismissModal();
- return {
- attachments: [],
- initialPage: 0,
- initialItem: undefined,
- initialActiveSource: null,
- };
- }
+ const transaction = TransactionUtils.getTransaction(transactionID);
+ if (TransactionUtils.hasReceipt(transaction)) {
+ const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ attachments.unshift({
+ source: tryResolveUrlFromApiRoot(image),
+ isAuthTokenRequired: true,
+ file: {name: transaction.filename},
+ isReceipt: true,
+ });
+ return;
+ }
+ }
- const initialItem = attachments[initialPage];
+ htmlParser.write(_.get(action, ['message', 0, 'html']));
+ });
+ htmlParser.end();
- return {
- attachments,
- initialPage,
- initialItem,
- initialActiveSource: initialItem.source,
- };
+ return attachments.reverse();
}
export default extractAttachmentsFromReport;
diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js
index 564e60b65dd1..cec5f54508cb 100644
--- a/src/components/Attachments/AttachmentCarousel/index.js
+++ b/src/components/Attachments/AttachmentCarousel/index.js
@@ -1,4 +1,4 @@
-import React, {useRef, useCallback, useState, useEffect, useMemo} from 'react';
+import React, {useRef, useCallback, useState, useEffect} from 'react';
import {View, FlatList, PixelRatio, Keyboard} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -15,6 +15,10 @@ import withLocalize from '../../withLocalize';
import compose from '../../../libs/compose';
import useCarouselArrows from './useCarouselArrows';
import useWindowDimensions from '../../../hooks/useWindowDimensions';
+import Navigation from '../../../libs/Navigation/Navigation';
+import BlockingView from '../../BlockingViews/BlockingView';
+import * as Illustrations from '../../Icon/Illustrations';
+import variables from '../../../styles/variables';
const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
const viewabilityConfig = {
@@ -23,24 +27,37 @@ const viewabilityConfig = {
itemVisiblePercentThreshold: 95,
};
-function AttachmentCarousel({report, reportActions, source, onNavigate}) {
+function AttachmentCarousel({report, reportActions, source, onNavigate, setDownloadButtonVisibility, translate}) {
const scrollRef = useRef(null);
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
- const {attachments, initialPage, initialActiveSource, initialItem} = useMemo(() => extractAttachmentsFromReport(report, reportActions, source), [report, reportActions, source]);
+ const [containerWidth, setContainerWidth] = useState(0);
+ const [page, setPage] = useState(0);
+ const [attachments, setAttachments] = useState([]);
+ const [activeSource, setActiveSource] = useState(source);
+ const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
useEffect(() => {
- // Update the parent modal's state with the source and name from the mapped attachments
- if (!initialItem) return;
- onNavigate(initialItem);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [initialItem]);
+ const attachmentsFromReport = extractAttachmentsFromReport(report, reportActions);
- const [containerWidth, setContainerWidth] = useState(0);
- const [page, setPage] = useState(initialPage);
- const [activeSource, setActiveSource] = useState(initialActiveSource);
- const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+ const initialPage = _.findIndex(attachmentsFromReport, (a) => a.source === source);
+
+ // Dismiss the modal when deleting an attachment during its display in preview.
+ if (initialPage === -1 && _.find(attachments, (a) => a.source === source)) {
+ Navigation.dismissModal();
+ } else {
+ setPage(initialPage);
+ setAttachments(attachmentsFromReport);
+
+ // Update the download button visibility in the parent modal
+ setDownloadButtonVisibility(initialPage !== -1);
+
+ // Update the parent modal's state with the source and name from the mapped attachments
+ if (!_.isUndefined(attachmentsFromReport[initialPage])) onNavigate(attachmentsFromReport[initialPage]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [report, reportActions, source]);
/**
* Updates the page state when the user navigates between attachments
@@ -153,49 +170,60 @@ function AttachmentCarousel({report, reportActions, source, onNavigate}) {
onMouseEnter={() => !canUseTouchScreen && setShouldShowArrows(true)}
onMouseLeave={() => !canUseTouchScreen && setShouldShowArrows(false)}
>
- cycleThroughAttachments(-1)}
- onForward={() => cycleThroughAttachments(1)}
- autoHideArrow={autoHideArrows}
- cancelAutoHideArrow={cancelAutoHideArrows}
- />
-
- {containerWidth > 0 && (
- item.source}
- viewabilityConfig={viewabilityConfig}
- onViewableItemsChanged={updatePage.current}
+ {page === -1 ? (
+
+ ) : (
+ <>
+ cycleThroughAttachments(-1)}
+ onForward={() => cycleThroughAttachments(1)}
+ autoHideArrow={autoHideArrows}
+ cancelAutoHideArrow={cancelAutoHideArrows}
+ />
+
+ {containerWidth > 0 && (
+ item.source}
+ viewabilityConfig={viewabilityConfig}
+ onViewableItemsChanged={updatePage.current}
+ />
+ )}
+
+
+ >
)}
-
-
);
}
diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js
index 58e248d514e1..4162cfae88e9 100644
--- a/src/components/Attachments/AttachmentCarousel/index.native.js
+++ b/src/components/Attachments/AttachmentCarousel/index.native.js
@@ -1,6 +1,7 @@
-import React, {useCallback, useEffect, useRef, useState, useMemo} from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View, Keyboard, PixelRatio} from 'react-native';
import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
import AttachmentCarouselPager from './Pager';
import styles from '../../../styles/styles';
import CarouselButtons from './CarouselButtons';
@@ -9,24 +10,44 @@ import ONYXKEYS from '../../../ONYXKEYS';
import {propTypes, defaultProps} from './attachmentCarouselPropTypes';
import extractAttachmentsFromReport from './extractAttachmentsFromReport';
import useCarouselArrows from './useCarouselArrows';
+import Navigation from '../../../libs/Navigation/Navigation';
+import BlockingView from '../../BlockingViews/BlockingView';
+import * as Illustrations from '../../Icon/Illustrations';
+import variables from '../../../styles/variables';
+import compose from '../../../libs/compose';
+import withLocalize from '../../withLocalize';
-function AttachmentCarousel({report, reportActions, source, onNavigate, onClose}) {
- const {attachments, initialPage, initialActiveSource, initialItem} = useMemo(() => extractAttachmentsFromReport(report, reportActions, source), [report, reportActions, source]);
-
- useEffect(() => {
- // Update the parent modal's state with the source and name from the mapped attachments
- onNavigate(initialItem);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [initialItem]);
-
+function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, setDownloadButtonVisibility, translate}) {
const pagerRef = useRef(null);
const [containerDimensions, setContainerDimensions] = useState({width: 0, height: 0});
- const [page, setPage] = useState(initialPage);
- const [activeSource, setActiveSource] = useState(initialActiveSource);
+ const [page, setPage] = useState(0);
+ const [attachments, setAttachments] = useState([]);
+ const [activeSource, setActiveSource] = useState(source);
const [isPinchGestureRunning, setIsPinchGestureRunning] = useState(true);
const [shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows] = useCarouselArrows();
+ useEffect(() => {
+ const attachmentsFromReport = extractAttachmentsFromReport(report, reportActions);
+
+ const initialPage = _.findIndex(attachmentsFromReport, (a) => a.source === source);
+
+ // Dismiss the modal when deleting an attachment during its display in preview.
+ if (initialPage === -1 && _.find(attachments, (a) => a.source === source)) {
+ Navigation.dismissModal();
+ } else {
+ setPage(initialPage);
+ setAttachments(attachmentsFromReport);
+
+ // Update the download button visibility in the parent modal
+ setDownloadButtonVisibility(initialPage !== -1);
+
+ // Update the parent modal's state with the source and name from the mapped attachments
+ if (!_.isUndefined(attachmentsFromReport[initialPage])) onNavigate(attachmentsFromReport[initialPage]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [report, reportActions, source]);
+
/**
* Updates the page state when the user navigates between attachments
* @param {Object} item
@@ -90,31 +111,42 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose}
onMouseEnter={() => setShouldShowArrows(true)}
onMouseLeave={() => setShouldShowArrows(false)}
>
- cycleThroughAttachments(-1)}
- onForward={() => cycleThroughAttachments(1)}
- autoHideArrow={autoHideArrows}
- cancelAutoHideArrow={cancelAutoHideArrows}
- />
-
- {containerDimensions.width > 0 && containerDimensions.height > 0 && (
- updatePage(newPage)}
- onPinchGestureChange={(newIsPinchGestureRunning) => {
- setIsPinchGestureRunning(newIsPinchGestureRunning);
- if (!newIsPinchGestureRunning && !shouldShowArrows) setShouldShowArrows(true);
- }}
- onSwipeDown={onClose}
- containerWidth={containerDimensions.width}
- containerHeight={containerDimensions.height}
- ref={pagerRef}
+ {page === -1 ? (
+
+ ) : (
+ <>
+ cycleThroughAttachments(-1)}
+ onForward={() => cycleThroughAttachments(1)}
+ autoHideArrow={autoHideArrows}
+ cancelAutoHideArrow={cancelAutoHideArrows}
+ />
+
+ {containerDimensions.width > 0 && containerDimensions.height > 0 && (
+ updatePage(newPage)}
+ onPinchGestureChange={(newIsPinchGestureRunning) => {
+ setIsPinchGestureRunning(newIsPinchGestureRunning);
+ if (!newIsPinchGestureRunning && !shouldShowArrows) setShouldShowArrows(true);
+ }}
+ onSwipeDown={onClose}
+ containerWidth={containerDimensions.width}
+ containerHeight={containerDimensions.height}
+ ref={pagerRef}
+ />
+ )}
+ >
)}
);
@@ -122,9 +154,12 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose}
AttachmentCarousel.propTypes = propTypes;
AttachmentCarousel.defaultProps = defaultProps;
-export default withOnyx({
- reportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
- canEvict: false,
- },
-})(AttachmentCarousel);
+export default compose(
+ withOnyx({
+ reportActions: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ canEvict: false,
+ },
+ }),
+ withLocalize,
+)(AttachmentCarousel);
diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
index dc329a9fd3fd..bf777f41945e 100644
--- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
+++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js
@@ -1,4 +1,4 @@
-import React, {memo, useCallback, useContext} from 'react';
+import React, {memo, useCallback, useContext, useEffect} from 'react';
import styles from '../../../../styles/styles';
import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes';
import PDFView from '../../../PDFView';
@@ -7,6 +7,11 @@ import AttachmentCarouselPagerContext from '../../AttachmentCarousel/Pager/Attac
function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete}) {
const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext);
+ useEffect(() => {
+ attachmentCarouselPagerContext.onPinchGestureChange(false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- we just want to call this function when component is mounted
+ }, []);
+
const onScaleChanged = useCallback(
(scale) => {
onScaleChangedProp();
@@ -15,6 +20,8 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse
if (isUsedInCarousel) {
const shouldPagerScroll = scale === 1;
+ attachmentCarouselPagerContext.onPinchGestureChange(!shouldPagerScroll);
+
if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) return;
attachmentCarouselPagerContext.shouldPagerScroll.value = shouldPagerScroll;
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index 3ad643d34bcd..47353d915060 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -15,7 +15,7 @@ import variables from '../../../styles/variables';
import AttachmentViewImage from './AttachmentViewImage';
import AttachmentViewPdf from './AttachmentViewPdf';
import addEncryptedAuthTokenToURL from '../../../libs/addEncryptedAuthTokenToURL';
-
+import * as StyleUtils from '../../../styles/StyleUtils';
import {attachmentViewPropTypes, attachmentViewDefaultProps} from './propTypes';
const propTypes = {
@@ -34,6 +34,9 @@ const propTypes = {
/** Extra styles to pass to View wrapper */
// eslint-disable-next-line react/forbid-prop-types
containerStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Denotes whether it is a workspace avatar or not */
+ isWorkspaceAvatar: PropTypes.bool,
};
const defaultProps = {
@@ -42,6 +45,7 @@ const defaultProps = {
shouldShowLoadingSpinnerIcon: false,
onToggleKeyboard: () => {},
containerStyles: [],
+ isWorkspaceAvatar: false,
};
function AttachmentView({
@@ -57,16 +61,27 @@ function AttachmentView({
onToggleKeyboard,
translate,
isFocused,
+ isWorkspaceAvatar,
}) {
const [loadComplete, setLoadComplete] = useState(false);
// Handles case where source is a component (ex: SVG)
if (_.isFunction(source)) {
+ let iconFillColor = '';
+ let additionalStyles = [];
+ if (isWorkspaceAvatar) {
+ const defaultWorkspaceAvatarColor = StyleUtils.getDefaultWorkspaceAvatarColor(file.name);
+ iconFillColor = defaultWorkspaceAvatarColor.fill;
+ additionalStyles = [defaultWorkspaceAvatarColor];
+ }
+
return (
);
}
diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
index 6ff330d839c6..ad3e2babb1cc 100644
--- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
+++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js
@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
+import refPropType from '../refPropTypes';
const propTypes = {
/** Array of suggestions */
@@ -27,8 +28,17 @@ const propTypes = {
/** create accessibility label for each item */
accessibilityLabelExtractor: PropTypes.func.isRequired,
+
+ /** Ref of the container enclosing the menu.
+ * This is needed to render the menu in correct position inside a portal
+ */
+ parentContainerRef: refPropType,
};
-const defaultProps = {};
+const defaultProps = {
+ parentContainerRef: {
+ current: null,
+ },
+};
export {propTypes, defaultProps};
diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js
index 9e1951d9a1d5..b37fcd7181d9 100644
--- a/src/components/AutoCompleteSuggestions/index.js
+++ b/src/components/AutoCompleteSuggestions/index.js
@@ -1,7 +1,11 @@
import React from 'react';
+import {View} from 'react-native';
+import ReactDOM from 'react-dom';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import {propTypes} from './autoCompleteSuggestionsPropTypes';
+import * as StyleUtils from '../../styles/StyleUtils';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
/**
* On the mobile-web platform, when long-pressing on auto-complete suggestions,
@@ -10,8 +14,14 @@ import {propTypes} from './autoCompleteSuggestionsPropTypes';
* On the native platform, tapping on auto-complete suggestions will not blur the main input.
*/
-function AutoCompleteSuggestions(props) {
+function AutoCompleteSuggestions({parentContainerRef, ...props}) {
const containerRef = React.useRef(null);
+ const {windowHeight, windowWidth} = useWindowDimensions();
+ const [{width, left, bottom}, setContainerState] = React.useState({
+ width: 0,
+ left: 0,
+ bottom: 0,
+ });
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
@@ -26,13 +36,26 @@ function AutoCompleteSuggestions(props) {
return () => (container.onpointerdown = null);
}, []);
- return (
+ React.useEffect(() => {
+ if (!parentContainerRef || !parentContainerRef.current) {
+ return;
+ }
+ parentContainerRef.current.measureInWindow((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w}));
+ }, [parentContainerRef, windowHeight, windowWidth]);
+
+ const componentToRender = (
);
+
+ if (!width) {
+ return componentToRender;
+ }
+
+ return ReactDOM.createPortal({componentToRender} , document.querySelector('body'));
}
AutoCompleteSuggestions.propTypes = propTypes;
diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.js
index 22af774bd4fc..514cec6cd844 100644
--- a/src/components/AutoCompleteSuggestions/index.native.js
+++ b/src/components/AutoCompleteSuggestions/index.native.js
@@ -2,7 +2,7 @@ import React from 'react';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
import {propTypes} from './autoCompleteSuggestionsPropTypes';
-function AutoCompleteSuggestions(props) {
+function AutoCompleteSuggestions({parentContainerRef, ...props}) {
// eslint-disable-next-line react/jsx-props-no-spreading
return ;
}
diff --git a/src/components/AutoUpdateTime.js b/src/components/AutoUpdateTime.js
index a522a3e6dcdc..cb15cb20b4ea 100644
--- a/src/components/AutoUpdateTime.js
+++ b/src/components/AutoUpdateTime.js
@@ -27,21 +27,13 @@ function AutoUpdateTime(props) {
* @returns {moment} Returns the locale moment object
*/
const getCurrentUserLocalTime = useCallback(
- () => DateUtils.getLocalMomentFromDatetime(props.preferredLocale, null, props.timezone.selected),
+ () => DateUtils.getLocalDateFromDatetime(props.preferredLocale, null, props.timezone.selected),
[props.preferredLocale, props.timezone.selected],
);
const [currentUserLocalTime, setCurrentUserLocalTime] = useState(getCurrentUserLocalTime);
const minuteRef = useRef(new Date().getMinutes());
- const timezoneName = useMemo(() => {
- // With non-GMT timezone, moment.zoneAbbr() will return the name of that timezone, so we can use it directly.
- if (Number.isNaN(Number(currentUserLocalTime.zoneAbbr()))) {
- return currentUserLocalTime.zoneAbbr();
- }
-
- // With GMT timezone, moment.zoneAbbr() will return a number, so we need to display it as GMT {abbreviations} format, e.g.: GMT +07
- return `GMT ${currentUserLocalTime.zoneAbbr()}`;
- }, [currentUserLocalTime]);
+ const timezoneName = useMemo(() => DateUtils.getZoneAbbreviation(currentUserLocalTime, props.timezone.selected), [currentUserLocalTime, props.timezone.selected]);
useEffect(() => {
// If the any of the props that getCurrentUserLocalTime depends on change, we want to update the displayed time immediately
@@ -68,7 +60,7 @@ function AutoUpdateTime(props) {
{props.translate('detailsPage.localTime')}
- {currentUserLocalTime.format('LT')} {timezoneName}
+ {DateUtils.formatToLocalTime(currentUserLocalTime)} {timezoneName}
);
diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js
index 99262bf12938..baa958106f84 100644
--- a/src/components/AvatarCropModal/AvatarCropModal.js
+++ b/src/components/AvatarCropModal/AvatarCropModal.js
@@ -22,6 +22,7 @@ import HeaderGap from '../HeaderGap';
import * as StyleUtils from '../../styles/StyleUtils';
import Tooltip from '../Tooltip';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
+import ScreenWrapper from '../ScreenWrapper';
const propTypes = {
/** Link to image for cropping */
@@ -361,79 +362,85 @@ function AvatarCropModal(props) {
type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED}
onModalHide={resetState}
>
- {props.isSmallScreenWidth && }
-
- {props.translate('avatarCropModal.description')}
-
- {/* To avoid layout shift we should hide this component until the image container & image is initialized */}
- {!isImageInitialized || !isImageContainerInitialized ? (
-
- ) : (
- <>
- }
+
+ {props.translate('avatarCropModal.description')}
+
+ {/* To avoid layout shift we should hide this component until the image container & image is initialized */}
+ {!isImageInitialized || !isImageContainerInitialized ? (
+
-
-
+
- runOnUI(sliderOnPress)(e.nativeEvent.locationX)}
- accessibilityLabel="slider"
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.ADJUSTABLE}
- >
-
+
-
-
-
- runOnUI(sliderOnPress)(e.nativeEvent.locationX)}
+ accessibilityLabel="slider"
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.ADJUSTABLE}
+ >
+
-
-
-
- >
- )}
-
-
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
);
}
diff --git a/src/components/AvatarWithDisplayName.js b/src/components/AvatarWithDisplayName.js
index 0f1300ebf03d..2fe1b3b7c57f 100644
--- a/src/components/AvatarWithDisplayName.js
+++ b/src/components/AvatarWithDisplayName.js
@@ -18,6 +18,9 @@ import * as OptionsListUtils from '../libs/OptionsListUtils';
import Text from './Text';
import * as StyleUtils from '../styles/StyleUtils';
import ParentNavigationSubtitle from './ParentNavigationSubtitle';
+import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
+import Navigation from '../libs/Navigation/Navigation';
+import ROUTES from '../ROUTES';
const propTypes = {
/** The report currently being looked at */
@@ -50,6 +53,20 @@ const defaultProps = {
size: CONST.AVATAR_SIZE.DEFAULT,
};
+const showActorDetails = (report) => {
+ if (ReportUtils.isExpenseReport(report)) {
+ Navigation.navigate(ROUTES.getProfileRoute(report.ownerAccountID));
+ return;
+ }
+
+ if (!ReportUtils.isIOUReport(report) && report.participantAccountIDs.length === 1) {
+ Navigation.navigate(ROUTES.getProfileRoute(report.participantAccountIDs[0]));
+ return;
+ }
+
+ Navigation.navigate(ROUTES.getReportParticipantsRoute(report.reportID));
+};
+
function AvatarWithDisplayName(props) {
const title = ReportUtils.getReportName(props.report);
const subtitle = ReportUtils.getChatRoomSubtitle(props.report);
@@ -61,24 +78,31 @@ function AvatarWithDisplayName(props) {
const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(props.report);
const isExpenseRequest = ReportUtils.isExpenseRequest(props.report);
const defaultSubscriptSize = isExpenseRequest ? CONST.AVATAR_SIZE.SMALL_NORMAL : props.size;
+
return (
{Boolean(props.report && title) && (
- {shouldShowSubscriptAvatar ? (
-
- ) : (
-
- )}
+ showActorDetails(props.report)}
+ accessibilityLabel={title}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ >
+ {shouldShowSubscriptAvatar ? (
+
+ ) : (
+
+ )}
+
{props.text}
diff --git a/src/components/BaseMiniContextMenuItem.js b/src/components/BaseMiniContextMenuItem.js
index 0393b94620d2..acf5d165d7c7 100644
--- a/src/components/BaseMiniContextMenuItem.js
+++ b/src/components/BaseMiniContextMenuItem.js
@@ -53,6 +53,7 @@ function BaseMiniContextMenuItem(props) {
e.preventDefault()}
accessibilityLabel={props.tooltipText}
style={({hovered, pressed}) => [
styles.reportActionContextMenuMiniButton,
diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js
index d02fa55a6434..6ec8b5250f37 100644
--- a/src/components/BlockingViews/BlockingView.js
+++ b/src/components/BlockingViews/BlockingView.js
@@ -9,6 +9,7 @@ import themeColors from '../../styles/themes/default';
import TextLink from '../TextLink';
import Navigation from '../../libs/Navigation/Navigation';
import AutoEmailLink from '../AutoEmailLink';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
/** Expensicon for the page */
@@ -24,7 +25,7 @@ const propTypes = {
subtitle: PropTypes.string,
/** Link message below the subtitle */
- link: PropTypes.string,
+ linkKey: PropTypes.string,
/** Whether we should show a link to navigate elsewhere */
shouldShowLink: PropTypes.bool,
@@ -43,13 +44,14 @@ const defaultProps = {
iconColor: themeColors.offline,
subtitle: '',
shouldShowLink: false,
- link: 'notFound.goBackHome',
+ linkKey: 'notFound.goBackHome',
iconWidth: variables.iconSizeSuperLarge,
iconHeight: variables.iconSizeSuperLarge,
onLinkPress: () => Navigation.dismissModal(),
};
function BlockingView(props) {
+ const {translate} = useLocalize();
return (
- {props.link}
+ {translate(props.linkKey)}
) : null}
diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.js
index 0a90d4b2e72d..54bdc015de37 100644
--- a/src/components/BlockingViews/FullPageNotFoundView.js
+++ b/src/components/BlockingViews/FullPageNotFoundView.js
@@ -3,16 +3,13 @@ import PropTypes from 'prop-types';
import {View} from 'react-native';
import BlockingView from './BlockingView';
import * as Illustrations from '../Icon/Illustrations';
-import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import HeaderWithBackButton from '../HeaderWithBackButton';
import Navigation from '../../libs/Navigation/Navigation';
import variables from '../../styles/variables';
import styles from '../../styles/styles';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
- /** Props to fetch translation features */
- ...withLocalizePropTypes,
-
/** Child elements */
children: PropTypes.node,
@@ -48,41 +45,42 @@ const defaultProps = {
subtitleKey: 'notFound.pageNotFound',
linkKey: 'notFound.goBackHome',
onBackButtonPress: Navigation.goBack,
- shouldShowLink: false,
+ shouldShowLink: true,
shouldShowBackButton: true,
onLinkPress: () => Navigation.dismissModal(),
};
// eslint-disable-next-line rulesdir/no-negated-variables
-function FullPageNotFoundView(props) {
- if (props.shouldShow) {
+function FullPageNotFoundView({children, shouldShow, titleKey, subtitleKey, linkKey, onBackButtonPress, shouldShowLink, shouldShowBackButton, onLinkPress}) {
+ const {translate} = useLocalize();
+ if (shouldShow) {
return (
<>
>
);
}
- return props.children;
+ return children;
}
FullPageNotFoundView.propTypes = propTypes;
FullPageNotFoundView.defaultProps = defaultProps;
FullPageNotFoundView.displayName = 'FullPageNotFoundView';
-export default withLocalize(FullPageNotFoundView);
+export default FullPageNotFoundView;
diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js
index 5c1a95b8755f..641e65ce9d12 100644
--- a/src/components/ButtonWithDropdownMenu.js
+++ b/src/components/ButtonWithDropdownMenu.js
@@ -10,6 +10,7 @@ import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import themeColors from '../styles/themes/default';
import CONST from '../CONST';
+import * as StyleUtils from '../styles/StyleUtils';
const propTypes = {
/** Text to display for the menu header */
@@ -21,6 +22,9 @@ const propTypes = {
/** Whether we should show a loading state for the main button */
isLoading: PropTypes.bool,
+ /** The size of button size */
+ buttonSize: PropTypes.oneOf(_.values(CONST.DROPDOWN_BUTTON_SIZE)),
+
/** Should the confirmation button be disabled? */
isDisabled: PropTypes.bool,
@@ -45,6 +49,9 @@ const propTypes = {
horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
}),
+
+ /* ref for the button */
+ buttonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
};
const defaultProps = {
@@ -52,10 +59,12 @@ const defaultProps = {
isDisabled: false,
menuHeaderText: '',
style: [],
+ buttonSize: CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
},
+ buttonRef: () => {},
};
function ButtonWithDropdownMenu(props) {
@@ -65,6 +74,8 @@ function ButtonWithDropdownMenu(props) {
const {windowWidth, windowHeight} = useWindowDimensions();
const caretButton = useRef(null);
const selectedItem = props.options[selectedItemIndex];
+ const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(props.buttonSize);
+ const isButtonSizeLarge = props.buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
useEffect(() => {
if (!caretButton.current) {
@@ -90,6 +101,7 @@ function ButtonWithDropdownMenu(props) {
props.onPress(event, selectedItem.value)}
text={selectedItem.text}
isDisabled={props.isDisabled}
@@ -97,20 +109,31 @@ function ButtonWithDropdownMenu(props) {
shouldRemoveRightBorderRadius
style={[styles.flex1, styles.pr0]}
pressOnEnter
+ large={isButtonSizeLarge}
+ medium={!isButtonSizeLarge}
+ innerStyles={[innerStyleDropButton]}
/>
-
+
setIsMenuVisible(true)}
+ onPress={() => setIsMenuVisible(!isMenuVisible)}
shouldRemoveLeftBorderRadius
+ large={isButtonSizeLarge}
+ medium={!isButtonSizeLarge}
+ innerStyles={[styles.dropDownButtonCartIconContainerPadding, innerStyleDropButton]}
>
-
+
+
+
+
+
+
) : (
@@ -122,6 +145,9 @@ function ButtonWithDropdownMenu(props) {
text={selectedItem.text}
onPress={(event) => props.onPress(event, props.options[0].value)}
pressOnEnter
+ large={isButtonSizeLarge}
+ medium={!isButtonSizeLarge}
+ innerStyles={[innerStyleDropButton]}
/>
)}
{props.options.length > 1 && !_.isEmpty(popoverAnchorPosition) && (
diff --git a/src/components/CategoryPicker/categoryPickerPropTypes.js b/src/components/CategoryPicker/categoryPickerPropTypes.js
new file mode 100644
index 000000000000..ccc1643021ce
--- /dev/null
+++ b/src/components/CategoryPicker/categoryPickerPropTypes.js
@@ -0,0 +1,24 @@
+import PropTypes from 'prop-types';
+import categoryPropTypes from '../categoryPropTypes';
+
+const propTypes = {
+ /** The report ID of the IOU */
+ reportID: PropTypes.string.isRequired,
+
+ /** The policyID we are getting categories for */
+ policyID: PropTypes.string,
+
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string.isRequired,
+
+ /* Onyx Props */
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
+};
+
+const defaultProps = {
+ policyID: '',
+ policyCategories: null,
+};
+
+export {propTypes, defaultProps};
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
new file mode 100644
index 000000000000..163ab6673ca2
--- /dev/null
+++ b/src/components/CategoryPicker/index.js
@@ -0,0 +1,56 @@
+import React, {useMemo} from 'react';
+import _ from 'underscore';
+import {withOnyx} from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+import {propTypes, defaultProps} from './categoryPickerPropTypes';
+import OptionsList from '../OptionsList';
+import styles from '../../styles/styles';
+import ScreenWrapper from '../ScreenWrapper';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+
+function CategoryPicker({policyCategories, reportID, iouType}) {
+ const sections = useMemo(() => {
+ const categoryList = _.chain(policyCategories)
+ .values()
+ .map((category) => ({
+ text: category.name,
+ keyForList: category.name,
+ tooltipText: category.name,
+ }))
+ .value();
+
+ return [
+ {
+ data: categoryList,
+ },
+ ];
+ }, [policyCategories]);
+
+ const navigateBack = () => {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
+ };
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ )}
+
+ );
+}
+
+CategoryPicker.displayName = 'CategoryPicker';
+CategoryPicker.propTypes = propTypes;
+CategoryPicker.defaultProps = defaultProps;
+
+export default withOnyx({
+ policyCategories: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ },
+})(CategoryPicker);
diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js
index 4b4601247008..dc9b5ba4ac67 100755
--- a/src/components/Composer/index.js
+++ b/src/components/Composer/index.js
@@ -1,8 +1,9 @@
-import React from 'react';
+import React, {useState, useRef, useEffect, useCallback, useMemo} from 'react';
import {StyleSheet, View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
+import {flushSync} from 'react-dom';
import RNTextInput from '../RNTextInput';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import themeColors from '../../styles/themes/default';
@@ -17,6 +18,7 @@ import Text from '../Text';
import isEnterWhileComposition from '../../libs/KeyboardShortcut/isEnterWhileComposition';
import CONST from '../../CONST';
import withNavigation from '../withNavigation';
+import ReportActionComposeFocusManager from '../../libs/ReportActionComposeFocusManager';
const propTypes = {
/** Maximum number of lines in the text input */
@@ -72,15 +74,15 @@ const propTypes = {
/** Allow the full composer to be opened */
setIsFullComposerAvailable: PropTypes.func,
- /** Whether the composer is full size */
- isComposerFullSize: PropTypes.bool,
-
/** Should we calculate the caret position */
shouldCalculateCaretPosition: PropTypes.bool,
/** Function to check whether composer is covered up or not */
checkComposerVisibility: PropTypes.func,
+ /** Whether this is the report action compose */
+ isReportActionCompose: PropTypes.bool,
+
...withLocalizePropTypes,
...windowDimensionsPropTypes,
@@ -106,198 +108,160 @@ const defaultProps = {
},
isFullComposerAvailable: false,
setIsFullComposerAvailable: () => {},
- isComposerFullSize: false,
shouldCalculateCaretPosition: false,
checkComposerVisibility: () => false,
+ isReportActionCompose: false,
};
/**
- * Enable Markdown parsing.
- * On web we like to have the Text Input field always focused so the user can easily type a new chat
+ * Retrieves the characters from the specified cursor position up to the next space or new line.
+ *
+ * @param {string} str - The input string.
+ * @param {number} cursorPos - The position of the cursor within the input string.
+ * @returns {string} - The substring from the cursor position up to the next space or new line.
+ * If no space or new line is found, returns the substring from the cursor position to the end of the input string.
*/
-class Composer extends React.Component {
- constructor(props) {
- super(props);
-
- const initialValue = props.defaultValue ? `${props.defaultValue}` : `${props.value || ''}`;
-
- this.state = {
- numberOfLines: props.numberOfLines,
- selection: {
- start: initialValue.length,
- end: initialValue.length,
- },
- valueBeforeCaret: '',
- };
-
- this.paste = this.paste.bind(this);
- this.handleKeyPress = this.handleKeyPress.bind(this);
- this.handlePaste = this.handlePaste.bind(this);
- this.handlePastedHTML = this.handlePastedHTML.bind(this);
- this.handleWheel = this.handleWheel.bind(this);
- this.shouldCallUpdateNumberOfLines = this.shouldCallUpdateNumberOfLines.bind(this);
- this.addCursorPositionToSelectionChange = this.addCursorPositionToSelectionChange.bind(this);
- this.textRef = React.createRef(null);
- this.unsubscribeBlur = () => null;
- this.unsubscribeFocus = () => null;
- }
+const getNextChars = (str, cursorPos) => {
+ // Get the substring starting from the cursor position
+ const substr = str.substring(cursorPos);
- componentDidMount() {
- this.updateNumberOfLines();
+ // Find the index of the next space or new line character
+ const spaceIndex = substr.search(/[ \n]/);
- // 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.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
- this.props.forwardedRef(this.textInput);
- }
-
- // There is no onPaste or onDrag for TextInput in react-native so we will add event
- // listeners here and unbind when the component unmounts
- if (this.textInput) {
- this.textInput.addEventListener('wheel', this.handleWheel);
-
- // we need to handle listeners on navigation focus/blur as Composer is not unmounting
- // when navigating away to different report
- this.unsubscribeFocus = this.props.navigation.addListener('focus', () => document.addEventListener('paste', this.handlePaste));
- this.unsubscribeBlur = this.props.navigation.addListener('blur', () => document.removeEventListener('paste', this.handlePaste));
-
- // We need to add paste listener manually as well as navigation focus event is not triggered on component mount
- document.addEventListener('paste', this.handlePaste);
- }
+ if (spaceIndex === -1) {
+ return substr;
}
- componentDidUpdate(prevProps) {
- if (!prevProps.shouldClear && this.props.shouldClear) {
- this.textInput.clear();
- // eslint-disable-next-line react/no-did-update-set-state
- this.setState({numberOfLines: 1});
- this.props.onClear();
- }
-
- if (
- prevProps.value !== this.props.value ||
- prevProps.defaultValue !== this.props.defaultValue ||
- prevProps.isComposerFullSize !== this.props.isComposerFullSize ||
- prevProps.windowWidth !== this.props.windowWidth ||
- prevProps.numberOfLines !== this.props.numberOfLines
- ) {
- this.updateNumberOfLines();
- }
-
- if (prevProps.selection !== this.props.selection) {
- // eslint-disable-next-line react/no-did-update-set-state
- this.setState({selection: this.props.selection});
- }
- }
+ // If there is a space or new line, return the substring up to the space or new line
+ return substr.substring(0, spaceIndex);
+};
- componentWillUnmount() {
- if (!this.textInput) {
+// Enable Markdown parsing.
+// On web we like to have the Text Input field always focused so the user can easily type a new chat
+function Composer({
+ value,
+ defaultValue,
+ maxLines,
+ onKeyPress,
+ style,
+ shouldClear,
+ autoFocus,
+ translate,
+ isFullComposerAvailable,
+ shouldCalculateCaretPosition,
+ numberOfLines: numberOfLinesProp,
+ isDisabled,
+ forwardedRef,
+ navigation,
+ onClear,
+ onPasteFile,
+ onSelectionChange,
+ onNumberOfLinesChange,
+ setIsFullComposerAvailable,
+ checkComposerVisibility,
+ selection: selectionProp,
+ isReportActionCompose,
+ ...props
+}) {
+ const textRef = useRef(null);
+ const textInput = useRef(null);
+ const initialValue = defaultValue ? `${defaultValue}` : `${value || ''}`;
+ const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp);
+ const [selection, setSelection] = useState({
+ start: initialValue.length,
+ end: initialValue.length,
+ });
+ const [caretContent, setCaretContent] = useState('');
+ const [valueBeforeCaret, setValueBeforeCaret] = useState('');
+ const [textInputWidth, setTextInputWidth] = useState('');
+
+ useEffect(() => {
+ if (!shouldClear) {
return;
}
-
- document.removeEventListener('paste', this.handlePaste);
- this.unsubscribeFocus();
- this.unsubscribeBlur();
- this.textInput.removeEventListener('wheel', this.handleWheel);
- }
-
- // Get characters from the cursor to the next space or new line
- getNextChars(str, cursorPos) {
- // Get the substring starting from the cursor position
- const substr = str.substring(cursorPos);
-
- // Find the index of the next space or new line character
- const spaceIndex = substr.search(/[ \n]/);
-
- if (spaceIndex === -1) {
- return substr;
- }
-
- // If there is a space or new line, return the substring up to the space or new line
- return substr.substring(0, spaceIndex);
- }
+ textInput.current.clear();
+ setNumberOfLines(1);
+ onClear();
+ }, [shouldClear, onClear]);
+
+ useEffect(() => {
+ setSelection((prevSelection) => {
+ if (!!prevSelection && selectionProp.start === prevSelection.start && selectionProp.end === prevSelection.end) {
+ return;
+ }
+ return selectionProp;
+ });
+ }, [selectionProp]);
/**
* Adds the cursor position to the selection change event.
*
* @param {Event} event
*/
- addCursorPositionToSelectionChange(event) {
- if (this.props.shouldCalculateCaretPosition) {
- const newValueBeforeCaret = event.target.value.slice(0, event.nativeEvent.selection.start);
-
- this.setState(
- {
- valueBeforeCaret: newValueBeforeCaret,
- caretContent: this.getNextChars(this.props.value, event.nativeEvent.selection.start),
- },
-
- () => {
- const customEvent = {
- nativeEvent: {
- selection: {
- start: event.nativeEvent.selection.start,
- end: event.nativeEvent.selection.end,
- positionX: this.textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH,
- positionY: this.textRef.current.offsetTop,
- },
- },
- };
- this.props.onSelectionChange(customEvent);
+ const addCursorPositionToSelectionChange = (event) => {
+ if (shouldCalculateCaretPosition) {
+ // we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state
+ flushSync(() => {
+ setValueBeforeCaret(event.target.value.slice(0, event.nativeEvent.selection.start));
+ setCaretContent(getNextChars(value, event.nativeEvent.selection.start));
+ });
+ const selectionValue = {
+ start: event.nativeEvent.selection.start,
+ end: event.nativeEvent.selection.end,
+ positionX: textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH,
+ positionY: textRef.current.offsetTop,
+ };
+ onSelectionChange({
+ nativeEvent: {
+ selection: selectionValue,
},
- );
- return;
- }
-
- this.props.onSelectionChange(event);
- }
-
- // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed
- handleKeyPress(e) {
- if (!this.props.onKeyPress || isEnterWhileComposition(e)) {
- return;
+ });
+ setSelection(selectionValue);
+ } else {
+ onSelectionChange(event);
+ setSelection(event.nativeEvent.selection);
}
- this.props.onKeyPress(e);
- }
+ };
/**
* Set pasted text to clipboard
* @param {String} text
*/
- paste(text) {
+ const paste = useCallback((text) => {
try {
- this.textInput.focus();
document.execCommand('insertText', false, text);
- this.updateNumberOfLines();
-
// Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view.
- this.textInput.blur();
- this.textInput.focus();
+ textInput.current.blur();
+ textInput.current.focus();
// eslint-disable-next-line no-empty
} catch (e) {}
- }
+ }, []);
/**
* Manually place the pasted HTML into Composer
*
* @param {String} html - pasted HTML
*/
- handlePastedHTML(html) {
- const parser = new ExpensiMark();
- this.paste(parser.htmlToMarkdown(html));
- }
+ const handlePastedHTML = useCallback(
+ (html) => {
+ const parser = new ExpensiMark();
+ paste(parser.htmlToMarkdown(html));
+ },
+ [paste],
+ );
/**
* Paste the plaintext content into Composer.
*
* @param {ClipboardEvent} event
*/
- handlePastePlainText(event) {
- const plainText = event.clipboardData.getData('text/plain');
- this.paste(plainText);
- }
+ const handlePastePlainText = useCallback(
+ (event) => {
+ const plainText = event.clipboardData.getData('text/plain');
+ paste(plainText);
+ },
+ [paste],
+ );
/**
* Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file,
@@ -305,164 +269,210 @@ class Composer extends React.Component {
*
* @param {ClipboardEvent} event
*/
- handlePaste(event) {
- const isVisible = this.props.checkComposerVisibility();
- const isFocused = this.textInput.isFocused();
+ const handlePaste = useCallback(
+ (event) => {
+ const isVisible = checkComposerVisibility();
+ const isFocused = textInput.current.isFocused();
- if (!(isVisible || isFocused)) {
- return;
- }
+ if (!(isVisible || isFocused)) {
+ return;
+ }
- if (this.textInput !== event.target) {
- return;
- }
+ if (textInput.current !== event.target) {
+ return;
+ }
- event.preventDefault();
+ event.preventDefault();
- const {files, types} = event.clipboardData;
- const TEXT_HTML = 'text/html';
+ const {files, types} = event.clipboardData;
+ const TEXT_HTML = 'text/html';
- // If paste contains files, then trigger file management
- if (files.length > 0) {
- // Prevent the default so we do not post the file name into the text box
- this.props.onPasteFile(event.clipboardData.files[0]);
- return;
- }
+ // If paste contains files, then trigger file management
+ if (files.length > 0) {
+ // Prevent the default so we do not post the file name into the text box
+ onPasteFile(event.clipboardData.files[0]);
+ return;
+ }
- // If paste contains HTML
- if (types.includes(TEXT_HTML)) {
- const pastedHTML = event.clipboardData.getData(TEXT_HTML);
+ // If paste contains HTML
+ if (types.includes(TEXT_HTML)) {
+ const pastedHTML = event.clipboardData.getData(TEXT_HTML);
- const domparser = new DOMParser();
- const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
+ const domparser = new DOMParser();
+ const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images;
- // Exclude parsing img tags in the HTML, as fetching the image via fetch triggers a connect-src Content-Security-Policy error.
- if (embeddedImages.length > 0 && embeddedImages[0].src) {
- // If HTML has emoji, then treat this as plain text.
- if (embeddedImages[0].dataset && embeddedImages[0].dataset.stringifyType === 'emoji') {
- this.handlePastePlainText(event);
- return;
+ // Exclude parsing img tags in the HTML, as fetching the image via fetch triggers a connect-src Content-Security-Policy error.
+ if (embeddedImages.length > 0 && embeddedImages[0].src) {
+ // If HTML has emoji, then treat this as plain text.
+ if (embeddedImages[0].dataset && embeddedImages[0].dataset.stringifyType === 'emoji') {
+ handlePastePlainText(event);
+ return;
+ }
}
+ handlePastedHTML(pastedHTML);
+ return;
}
-
- this.handlePastedHTML(pastedHTML);
- return;
- }
-
- this.handlePastePlainText(event);
- }
+ handlePastePlainText(event);
+ },
+ [onPasteFile, handlePastedHTML, checkComposerVisibility, handlePastePlainText],
+ );
/**
* Manually scrolls the text input, then prevents the event from being passed up to the parent.
* @param {Object} event native Event
*/
- handleWheel(event) {
+ const handleWheel = useCallback((event) => {
if (event.target !== document.activeElement) {
return;
}
- this.textInput.scrollTop += event.deltaY;
+ textInput.current.scrollTop += event.deltaY;
event.preventDefault();
event.stopPropagation();
- }
+ }, []);
/**
- * We want to call updateNumberOfLines only when the parent doesn't provide value in props
- * as updateNumberOfLines is already being called when value changes in componentDidUpdate
+ * Check the current scrollHeight of the textarea (minus any padding) and
+ * divide by line height to get the total number of rows for the textarea.
*/
- shouldCallUpdateNumberOfLines() {
- if (!_.isEmpty(this.props.value)) {
+ const updateNumberOfLines = useCallback(() => {
+ if (textInput.current === null) {
return;
}
+ // we reset the height to 0 to get the correct scrollHeight
+ textInput.current.style.height = 0;
+ const computedStyle = window.getComputedStyle(textInput.current);
+ const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20;
+ const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + parseInt(computedStyle.paddingTop, 10);
+ setTextInputWidth(computedStyle.width);
+
+ const computedNumberOfLines = ComposerUtils.getNumberOfLines(maxLines, lineHeight, paddingTopAndBottom, textInput.current.scrollHeight);
+ const generalNumberOfLines = computedNumberOfLines === 0 ? numberOfLinesProp : computedNumberOfLines;
+
+ onNumberOfLinesChange(generalNumberOfLines);
+ updateIsFullComposerAvailable({isFullComposerAvailable, setIsFullComposerAvailable}, generalNumberOfLines);
+ setNumberOfLines(generalNumberOfLines);
+ textInput.current.style.height = 'auto';
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value, maxLines, numberOfLinesProp, onNumberOfLinesChange, isFullComposerAvailable, setIsFullComposerAvailable]);
+
+ useEffect(() => {
+ updateNumberOfLines();
+ }, [updateNumberOfLines]);
+
+ useEffect(() => {
+ // we need to handle listeners on navigation focus/blur as Composer is not unmounting
+ // when navigating away to different report
+ const unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste));
+ const unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste));
+
+ if (_.isFunction(forwardedRef)) {
+ forwardedRef(textInput.current);
+ }
- this.updateNumberOfLines();
- }
-
- /**
- * Check the current scrollHeight of the textarea (minus any padding) and
- * divide by line height to get the total number of rows for the textarea.
- */
- updateNumberOfLines() {
- // Hide the composer expand button so we can get an accurate reading of
- // the height of the text input
- this.props.setIsFullComposerAvailable(false);
-
- // We have to reset the rows back to the minimum before updating so that the scrollHeight is not
- // affected by the previous row setting. If we don't, rows will be added but not removed on backspace/delete.
- this.setState({numberOfLines: 1}, () => {
- const computedStyle = window.getComputedStyle(this.textInput);
- const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20;
- const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + parseInt(computedStyle.paddingTop, 10);
- const computedNumberOfLines = ComposerUtils.getNumberOfLines(this.props.maxLines, lineHeight, paddingTopAndBottom, this.textInput.scrollHeight);
- const numberOfLines = computedNumberOfLines === 0 ? this.props.numberOfLines : computedNumberOfLines;
- updateIsFullComposerAvailable(this.props, numberOfLines);
- this.setState({
- numberOfLines,
- width: computedStyle.width,
- });
- this.props.onNumberOfLinesChange(numberOfLines);
- });
- }
+ if (textInput.current) {
+ document.addEventListener('paste', handlePaste);
+ textInput.current.addEventListener('wheel', handleWheel);
+ }
- render() {
- const propStyles = StyleSheet.flatten(this.props.style);
- propStyles.outline = 'none';
- const propsWithoutStyles = _.omit(this.props, 'style');
-
- // This code creates a hidden text component that helps track the caret position in the visible input.
- const renderElementForCaretPosition = (
- {
+ if (!isReportActionCompose) {
+ ReportActionComposeFocusManager.clear();
+ }
+ unsubscribeFocus();
+ unsubscribeBlur();
+ document.removeEventListener('paste', handlePaste);
+ // eslint-disable-next-line es/no-optional-chaining
+ if (!textInput.current) {
+ return;
+ }
+ textInput.current.removeEventListener('wheel', handleWheel);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleKeyPress = useCallback(
+ (e) => {
+ // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed
+ if (!onKeyPress || isEnterWhileComposition(e)) {
+ return;
+ }
+ onKeyPress(e);
+ },
+ [onKeyPress],
+ );
+
+ const renderElementForCaretPosition = (
+
+
+ {`${valueBeforeCaret} `}
- {`${this.state.valueBeforeCaret} `}
-
- {`${this.state.caretContent}`}
-
+ {`${caretContent}`}
-
- );
-
- // We're disabling autoCorrect for iOS Safari until Safari fixes this issue. See https://github.com/Expensify/App/issues/8592
- return (
- <>
- (this.textInput = el)}
- selection={this.state.selection}
- onChange={this.shouldCallUpdateNumberOfLines}
- style={[
- propStyles,
-
- // We are hiding the scrollbar to prevent it from reducing the text input width,
- // so we can get the correct scroll height while calculating the number of lines.
- this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {},
- StyleUtils.getComposeTextAreaPadding(this.props.numberOfLines),
- ]}
- /* eslint-disable-next-line react/jsx-props-no-spreading */
- {...propsWithoutStyles}
- onSelectionChange={this.addCursorPositionToSelectionChange}
- numberOfLines={this.state.numberOfLines}
- disabled={this.props.isDisabled}
- onKeyPress={this.handleKeyPress}
- />
- {this.props.shouldCalculateCaretPosition && renderElementForCaretPosition}
- >
- );
- }
+
+
+ );
+
+ const inputStyleMemo = useMemo(
+ () => [
+ // We are hiding the scrollbar to prevent it from reducing the text input width,
+ // so we can get the correct scroll height while calculating the number of lines.
+ numberOfLines < maxLines ? styles.overflowHidden : {},
+
+ StyleSheet.flatten([style, {outline: 'none'}]),
+ StyleUtils.getComposeTextAreaPadding(numberOfLinesProp),
+ ],
+ [style, maxLines, numberOfLinesProp, numberOfLines],
+ );
+
+ return (
+ <>
+ (textInput.current = el)}
+ selection={selection}
+ style={inputStyleMemo}
+ value={value}
+ forwardedRef={forwardedRef}
+ defaultValue={defaultValue}
+ autoFocus={autoFocus}
+ /* eslint-disable-next-line react/jsx-props-no-spreading */
+ {...props}
+ onSelectionChange={addCursorPositionToSelectionChange}
+ numberOfLines={numberOfLines}
+ disabled={isDisabled}
+ onKeyPress={handleKeyPress}
+ onFocus={(e) => {
+ ReportActionComposeFocusManager.onComposerFocus(() => {
+ if (!textInput.current) {
+ return;
+ }
+
+ textInput.current.focus();
+ });
+ if (props.onFocus) {
+ props.onFocus(e);
+ }
+ }}
+ />
+ {shouldCalculateCaretPosition && renderElementForCaretPosition}
+ >
+ );
}
Composer.propTypes = propTypes;
diff --git a/src/components/ConfirmContent.js b/src/components/ConfirmContent.js
index 6981fd451309..9a72d4e7d584 100644
--- a/src/components/ConfirmContent.js
+++ b/src/components/ConfirmContent.js
@@ -8,6 +8,8 @@ import Button from './Button';
import useLocalize from '../hooks/useLocalize';
import useNetwork from '../hooks/useNetwork';
import Text from './Text';
+import variables from '../styles/variables';
+import Icon from './Icon';
const propTypes = {
/** Title of the modal */
@@ -40,9 +42,30 @@ const propTypes = {
/** Whether we should show the cancel button */
shouldShowCancelButton: PropTypes.bool,
+ /** Icon to display above the title */
+ iconSource: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+
+ /** Whether to center the icon / text content */
+ shouldCenterContent: PropTypes.bool,
+
+ /** Whether to stack the buttons */
+ shouldStackButtons: PropTypes.bool,
+
+ /** Styles for title */
+ // eslint-disable-next-line react/forbid-prop-types
+ titleStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for prompt */
+ // eslint-disable-next-line react/forbid-prop-types
+ promptStyles: PropTypes.arrayOf(PropTypes.object),
+
/** Styles for view */
// eslint-disable-next-line react/forbid-prop-types
contentStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for icon */
+ // eslint-disable-next-line react/forbid-prop-types
+ iconAdditionalStyles: PropTypes.arrayOf(PropTypes.object),
};
const defaultProps = {
@@ -55,36 +78,87 @@ const defaultProps = {
shouldDisableConfirmButtonWhenOffline: false,
shouldShowCancelButton: true,
contentStyles: [],
+ iconSource: null,
+ shouldCenterContent: false,
+ shouldStackButtons: true,
+ titleStyles: [],
+ promptStyles: [],
+ iconAdditionalStyles: [],
};
function ConfirmContent(props) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
+ const isCentered = props.shouldCenterContent;
+
return (
-
-
+
+ {!_.isEmpty(props.iconSource) ||
+ (_.isFunction(props.iconSource) && (
+
+
+
+ ))}
+
+
+
+
+
+ {_.isString(props.prompt) ? {props.prompt} : props.prompt}
- {_.isString(props.prompt) ? {props.prompt} : props.prompt}
-
-
- {props.shouldShowCancelButton && (
-
+ {props.shouldStackButtons ? (
+ <>
+
+ {props.shouldShowCancelButton && (
+
+ )}
+ >
+ ) : (
+
+ {props.shouldShowCancelButton && (
+
+ )}
+
+
)}
);
diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js
index 56bcfe933aaf..705a05ec2058 100755
--- a/src/components/ConfirmModal.js
+++ b/src/components/ConfirmModal.js
@@ -45,6 +45,27 @@ const propTypes = {
/** Should we announce the Modal visibility changes? */
shouldSetModalVisibility: PropTypes.bool,
+ /** Icon to display above the title */
+ iconSource: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+
+ /** Styles for title */
+ // eslint-disable-next-line react/forbid-prop-types
+ titleStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for prompt */
+ // eslint-disable-next-line react/forbid-prop-types
+ promptStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Styles for icon */
+ // eslint-disable-next-line react/forbid-prop-types
+ iconAdditionalStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Whether to center the icon / text content */
+ shouldCenterContent: PropTypes.bool,
+
+ /** Whether to stack the buttons */
+ shouldStackButtons: PropTypes.bool,
+
...windowDimensionsPropTypes,
};
@@ -59,7 +80,13 @@ const defaultProps = {
shouldShowCancelButton: true,
shouldSetModalVisibility: true,
title: '',
+ iconSource: null,
onModalHide: () => {},
+ titleStyles: [],
+ iconAdditionalStyles: [],
+ promptStyles: [],
+ shouldCenterContent: false,
+ shouldStackButtons: true,
};
function ConfirmModal(props) {
@@ -85,6 +112,12 @@ function ConfirmModal(props) {
danger={props.danger}
shouldDisableConfirmButtonWhenOffline={props.shouldDisableConfirmButtonWhenOffline}
shouldShowCancelButton={props.shouldShowCancelButton}
+ shouldCenterContent={props.shouldCenterContent}
+ iconSource={props.iconSource}
+ iconAdditionalStyles={props.iconAdditionalStyles}
+ titleStyles={props.titleStyles}
+ promptStyles={props.promptStyles}
+ shouldStackButtons={props.shouldStackButtons}
/>
);
diff --git a/src/components/ConnectBankAccountButton.js b/src/components/ConnectBankAccountButton.js
index b62d46170768..f5e0afe1d52e 100644
--- a/src/components/ConnectBankAccountButton.js
+++ b/src/components/ConnectBankAccountButton.js
@@ -10,6 +10,7 @@ import compose from '../libs/compose';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import networkPropTypes from './networkPropTypes';
import Text from './Text';
+import Navigation from '../libs/Navigation/Navigation';
const propTypes = {
...withLocalizePropTypes,
@@ -29,6 +30,7 @@ const defaultProps = {
};
function ConnectBankAccountButton(props) {
+ const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, '');
return props.network.isOffline ? (
{`${props.translate('common.youAppearToBeOffline')} ${props.translate('common.thisFeatureRequiresInternet')}`}
@@ -36,7 +38,7 @@ function ConnectBankAccountButton(props) {
) : (
ReimbursementAccount.navigateToBankAccountRoute(props.policyID)}
+ onPress={() => ReimbursementAccount.navigateToBankAccountRoute(props.policyID, activeRoute)}
icon={Expensicons.Bank}
style={props.style}
iconStyles={[styles.buttonCTAIcon]}
diff --git a/src/components/CountryPicker/CountrySelectorModal.js b/src/components/CountryPicker/CountrySelectorModal.js
index b917e771e815..146b023bbf0c 100644
--- a/src/components/CountryPicker/CountrySelectorModal.js
+++ b/src/components/CountryPicker/CountrySelectorModal.js
@@ -4,9 +4,12 @@ import PropTypes from 'prop-types';
import CONST from '../../CONST';
import useLocalize from '../../hooks/useLocalize';
import HeaderWithBackButton from '../HeaderWithBackButton';
-import SelectionListRadio from '../SelectionListRadio';
+import SelectionList from '../SelectionList';
import Modal from '../Modal';
+import ScreenWrapper from '../ScreenWrapper';
+import styles from '../../styles/styles';
import searchCountryOptions from '../../libs/searchCountryOptions';
+import StringUtils from '../../libs/StringUtils';
const propTypes = {
/** Whether the modal is visible */
@@ -44,7 +47,7 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele
keyForList: countryISO,
text: countryName,
isSelected: currentCountry === countryISO,
- searchValue: `${countryISO}${countryName}`.toLowerCase().replaceAll(CONST.REGEX.NON_ALPHABETIC_AND_NON_LATIN_CHARS, ''),
+ searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`),
})),
[translate, currentCountry],
);
@@ -61,23 +64,27 @@ function CountrySelectorModal({currentCountry, isVisible, onClose, onCountrySele
hideModalContentWhileAnimating
useNativeDriver
>
-
-
+
+
+
+
);
}
diff --git a/src/components/CurrencySymbolButton.js b/src/components/CurrencySymbolButton.js
index 0c19ce0f63b3..ab4488c65f49 100644
--- a/src/components/CurrencySymbolButton.js
+++ b/src/components/CurrencySymbolButton.js
@@ -3,9 +3,9 @@ import PropTypes from 'prop-types';
import Text from './Text';
import styles from '../styles/styles';
import Tooltip from './Tooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import CONST from '../CONST';
+import useLocalize from '../hooks/useLocalize';
const propTypes = {
/** Currency symbol of selected currency */
@@ -13,19 +13,18 @@ const propTypes = {
/** Function to call when currency button is pressed */
onCurrencyButtonPress: PropTypes.func.isRequired,
-
- ...withLocalizePropTypes,
};
-function CurrencySymbolButton(props) {
+function CurrencySymbolButton({onCurrencyButtonPress, currencySymbol}) {
+ const {translate} = useLocalize();
return (
-
+
- {props.currencySymbol}
+ {currencySymbol}
);
@@ -34,4 +33,4 @@ function CurrencySymbolButton(props) {
CurrencySymbolButton.propTypes = propTypes;
CurrencySymbolButton.displayName = 'CurrencySymbolButton';
-export default withLocalize(CurrencySymbolButton);
+export default CurrencySymbolButton;
diff --git a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
index 3392b6c3c9d8..e31429af61b6 100644
--- a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
+++ b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js
@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
import TextLink from '../TextLink';
import Text from '../Text';
import Icon from '../Icon';
@@ -9,14 +10,29 @@ import * as Expensicons from '../Icon/Expensicons';
import colors from '../../styles/colors';
import styles from '../../styles/styles';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import compose from '../../libs/compose';
+import ONYXKEYS from '../../ONYXKEYS';
const propTypes = {
openLinkInBrowser: PropTypes.func.isRequired,
+ session: PropTypes.shape({
+ /** Currently logged-in user email */
+ email: PropTypes.string,
+ }),
+
...withLocalizePropTypes,
};
-function DeeplinkRedirectLoadingIndicator(props) {
+const defaultProps = {
+ session: {
+ email: '',
+ },
+};
+
+function DeeplinkRedirectLoadingIndicator({translate, openLinkInBrowser, session}) {
return (
@@ -27,11 +43,12 @@ function DeeplinkRedirectLoadingIndicator(props) {
src={Illustrations.RocketBlue}
/>
- {props.translate('deeplinkWrapper.launching')}
+ {translate('deeplinkWrapper.launching')}
- {props.translate('deeplinkWrapper.redirectedToDesktopApp')}
-
- {props.translate('deeplinkWrapper.youCanAlso')} {props.translate('deeplinkWrapper.openLinkInBrowser')} .
+ {translate('deeplinkWrapper.loggedInAs', {email: session.email})}
+
+ {translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')}
+ {translate('deeplinkWrapper.or')} Navigation.navigate(ROUTES.HOME)}>{translate('deeplinkWrapper.continueInWeb')} .
@@ -48,6 +65,14 @@ function DeeplinkRedirectLoadingIndicator(props) {
}
DeeplinkRedirectLoadingIndicator.propTypes = propTypes;
+DeeplinkRedirectLoadingIndicator.defaultProps = defaultProps;
DeeplinkRedirectLoadingIndicator.displayName = 'DeeplinkRedirectLoadingIndicator';
-export default withLocalize(DeeplinkRedirectLoadingIndicator);
+export default compose(
+ withLocalize,
+ withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ }),
+)(DeeplinkRedirectLoadingIndicator);
diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.js
index 1d83f4af55f7..0cd7bfd93241 100644
--- a/src/components/DeeplinkWrapper/index.website.js
+++ b/src/components/DeeplinkWrapper/index.website.js
@@ -1,38 +1,98 @@
import PropTypes from 'prop-types';
-import {useEffect} from 'react';
+import {useRef, useState, useEffect} from 'react';
import Str from 'expensify-common/lib/str';
+import _ from 'underscore';
import * as Browser from '../../libs/Browser';
import ROUTES from '../../ROUTES';
import * as App from '../../libs/actions/App';
import CONST from '../../CONST';
import CONFIG from '../../CONFIG';
+import shouldPreventDeeplinkPrompt from '../../libs/Navigation/shouldPreventDeeplinkPrompt';
+import navigationRef from '../../libs/Navigation/navigationRef';
+import Navigation from '../../libs/Navigation/Navigation';
const propTypes = {
/** Children to render. */
children: PropTypes.node.isRequired,
+ /** User authentication status */
+ isAuthenticated: PropTypes.bool.isRequired,
};
function isMacOSWeb() {
return !Browser.isMobile() && typeof navigator === 'object' && typeof navigator.userAgent === 'string' && /Mac/i.test(navigator.userAgent) && !/Electron/i.test(navigator.userAgent);
}
-function DeeplinkWrapper({children}) {
+function promptToOpenInDesktopApp() {
+ // If the current url path is /transition..., meaning it was opened from oldDot, during this transition period:
+ // 1. The user session may not exist, because sign-in has not been completed yet.
+ // 2. There may be non-idempotent operations (e.g. create a new workspace), which obviously should not be executed again in the desktop app.
+ // So we need to wait until after sign-in and navigation are complete before starting the deeplink redirect.
+ if (Str.startsWith(window.location.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS))) {
+ App.beginDeepLinkRedirectAfterTransition();
+ } else {
+ // Match any magic link (/v//<6 digit code>)
+ const isMagicLink = CONST.REGEX.ROUTES.VALIDATE_LOGIN.test(window.location.pathname);
+
+ App.beginDeepLinkRedirect(!isMagicLink);
+ }
+}
+function DeeplinkWrapper({children, isAuthenticated}) {
+ const [currentScreen, setCurrentScreen] = useState();
+ const [hasShownPrompt, setHasShownPrompt] = useState(false);
+ const removeListener = useRef();
+
useEffect(() => {
- if (!isMacOSWeb() || CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV) {
- return;
+ // If we've shown the prompt and still have a listener registered,
+ // remove the listener and reset its ref to undefined
+ if (hasShownPrompt && removeListener.current !== undefined) {
+ removeListener.current();
+ removeListener.current = undefined;
}
- // If the current url path is /transition..., meaning it was opened from oldDot, during this transition period:
- // 1. The user session may not exist, because sign-in has not been completed yet.
- // 2. There may be non-idempotent operations (e.g. create a new workspace), which obviously should not be executed again in the desktop app.
- // So we need to wait until after sign-in and navigation are complete before starting the deeplink redirect.
- if (Str.startsWith(window.location.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS))) {
- App.beginDeepLinkRedirectAfterTransition();
+ if (isAuthenticated === false) {
+ setHasShownPrompt(false);
+ Navigation.isNavigationReady().then(() => {
+ // Get initial route
+ const initialRoute = navigationRef.current.getCurrentRoute();
+ setCurrentScreen(initialRoute.name);
+
+ removeListener.current = navigationRef.current.addListener('state', (event) => {
+ setCurrentScreen(Navigation.getRouteNameFromStateEvent(event));
+ });
+ });
+ }
+ }, [hasShownPrompt, isAuthenticated]);
+ useEffect(() => {
+ // According to the design, we don't support unlink in Desktop app https://github.com/Expensify/App/issues/19681#issuecomment-1610353099
+ const isUnsupportedDeeplinkRoute = _.some([CONST.REGEX.ROUTES.UNLINK_LOGIN], (unsupportRouteRegex) => {
+ const routeRegex = new RegExp(unsupportRouteRegex);
+ return routeRegex.test(window.location.pathname);
+ });
+ // Making a few checks to exit early before checking authentication status
+ if (!isMacOSWeb() || isUnsupportedDeeplinkRoute || CONFIG.ENVIRONMENT === CONST.ENVIRONMENT.DEV || hasShownPrompt) {
return;
}
+ // We want to show the prompt immediately if the user is already authenticated.
+ // Otherwise, we want to wait until the navigation state is set up
+ // and we know the user is on a screen that supports deeplinks.
+ if (isAuthenticated) {
+ promptToOpenInDesktopApp();
+ setHasShownPrompt(true);
+ } else {
+ // Navigation state is not set up yet, we're unsure if we should show the deep link prompt or not
+ if (currentScreen === undefined || isAuthenticated === false) {
+ return;
+ }
- App.beginDeepLinkRedirect();
- }, []);
+ const preventPrompt = shouldPreventDeeplinkPrompt(currentScreen);
+ if (preventPrompt === true) {
+ return;
+ }
+
+ promptToOpenInDesktopApp();
+ setHasShownPrompt(true);
+ }
+ }, [currentScreen, hasShownPrompt, isAuthenticated]);
return children;
}
diff --git a/src/components/DisplayNames/DisplayNamesTooltipItem.js b/src/components/DisplayNames/DisplayNamesTooltipItem.js
new file mode 100644
index 000000000000..16f0f3a6420d
--- /dev/null
+++ b/src/components/DisplayNames/DisplayNamesTooltipItem.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, {useCallback} from 'react';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import UserDetailsTooltip from '../UserDetailsTooltip';
+
+const propTypes = {
+ index: PropTypes.number,
+
+ /** The full title of the DisplayNames component (not split up) */
+ getTooltipShiftX: PropTypes.func,
+
+ /** The Account ID for the tooltip */
+ accountID: PropTypes.number,
+
+ /** The name to display in bold */
+ displayName: PropTypes.string,
+
+ /** The login for the tooltip fallback */
+ login: PropTypes.string,
+
+ /** The avatar for the tooltip fallback */
+ avatar: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+
+ /** Arbitrary styles of the displayName text */
+ // eslint-disable-next-line react/forbid-prop-types
+ textStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Refs to all the names which will be used to correct the horizontal position of the tooltip */
+ childRefs: PropTypes.shape({
+ // eslint-disable-next-line react/forbid-prop-types
+ current: PropTypes.arrayOf(PropTypes.object),
+ }),
+};
+
+const defaultProps = {
+ index: 0,
+ getTooltipShiftX: () => {},
+ accountID: 0,
+ displayName: '',
+ login: '',
+ avatar: '',
+ textStyles: [],
+ childRefs: {current: []},
+};
+
+function DisplayNamesTooltipItem({index, getTooltipShiftX, accountID, avatar, login, displayName, textStyles, childRefs}) {
+ const tooltipIndexBridge = useCallback(() => getTooltipShiftX(index), [getTooltipShiftX, index]);
+
+ return (
+
+ {/* We need to get the refs to all the names which will be used to correct the horizontal position of the tooltip */}
+ (childRefs.current[index] = el)}
+ style={[...textStyles, styles.pre]}
+ >
+ {displayName}
+
+
+ );
+}
+
+DisplayNamesTooltipItem.propTypes = propTypes;
+DisplayNamesTooltipItem.defaultProps = defaultProps;
+DisplayNamesTooltipItem.displayName = 'DisplayNamesTooltipItem';
+
+export default DisplayNamesTooltipItem;
diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.js b/src/components/DisplayNames/DisplayNamesWithTooltip.js
new file mode 100644
index 000000000000..77e3319af266
--- /dev/null
+++ b/src/components/DisplayNames/DisplayNamesWithTooltip.js
@@ -0,0 +1,92 @@
+import React, {Fragment, useCallback, useEffect, useRef, useState} from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import Tooltip from '../Tooltip';
+import DisplayNamesTooltipItem from './DisplayNamesTooltipItem';
+import {defaultProps, propTypes} from './displayNamesPropTypes';
+
+function DisplayNamesWithToolTip(props) {
+ const containerRef = useRef(null);
+ const childRefs = useRef([]);
+ const [isEllipsisActive, setIsEllipsisActive] = useState(false);
+
+ useEffect(() => {
+ setIsEllipsisActive(
+ containerRef.current && containerRef.current.offsetWidth && containerRef.current.scrollWidth && containerRef.current.offsetWidth < containerRef.current.scrollWidth,
+ );
+ }, []);
+
+ /**
+ * We may need to shift the Tooltip horizontally as some of the inline text wraps well with ellipsis,
+ * but their container node overflows the parent view which causes the tooltip to be misplaced.
+ *
+ * So we shift it by calculating it as follows:
+ * 1. We get the container layout and take the Child inline text node.
+ * 2. Now we get the tooltip original position.
+ * 3. If inline node's right edge is overflowing the container's right edge, we set the tooltip to the center
+ * of the distance between the left edge of the inline node and right edge of the container.
+ * @param {Number} index Used to get the Ref to the node at the current index
+ * @returns {Number} Distance to shift the tooltip horizontally
+ */
+ const getTooltipShiftX = useCallback((index) => {
+ // Only shift the tooltip in case the containerLayout or Refs to the text node are available
+ if (!containerRef || !childRefs.current[index]) {
+ return;
+ }
+ const {width: containerWidth, left: containerLeft} = containerRef.current.getBoundingClientRect();
+
+ // We have to return the value as Number so we can't use `measureWindow` which takes a callback
+ const {width: textNodeWidth, left: textNodeLeft} = childRefs.current[index].getBoundingClientRect();
+ const tooltipX = textNodeWidth / 2 + textNodeLeft;
+ const containerRight = containerWidth + containerLeft;
+ const textNodeRight = textNodeWidth + textNodeLeft;
+ const newToolX = textNodeLeft + (containerRight - textNodeLeft) / 2;
+
+ // When text right end is beyond the Container right end
+ return textNodeRight > containerRight ? -(tooltipX - newToolX) : 0;
+ }, []);
+
+ return (
+ // Tokenization of string only support prop numberOfLines on Web
+ (containerRef.current = el)}
+ >
+ {props.shouldUseFullTitle
+ ? props.fullTitle
+ : _.map(props.displayNamesWithTooltips, ({displayName, accountID, avatar, login}, index) => (
+
+
+ {index < props.displayNamesWithTooltips.length - 1 && , }
+
+ ))}
+ {Boolean(isEllipsisActive) && (
+
+
+ {/* There is some Gap for real ellipsis so we are adding 4 `.` to cover */}
+ ....
+
+
+ )}
+
+ );
+}
+
+DisplayNamesWithToolTip.propTypes = propTypes;
+DisplayNamesWithToolTip.defaultProps = defaultProps;
+DisplayNamesWithToolTip.displayName = 'DisplayNamesWithTooltip';
+
+export default DisplayNamesWithToolTip;
diff --git a/src/components/DisplayNames/DisplayNamesWithoutTooltip.js b/src/components/DisplayNames/DisplayNamesWithoutTooltip.js
new file mode 100644
index 000000000000..66604d09673c
--- /dev/null
+++ b/src/components/DisplayNames/DisplayNamesWithoutTooltip.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from '../../styles/styles';
+import Text from '../Text';
+
+const propTypes = {
+ /** The full title of the DisplayNames component (not split up) */
+ fullTitle: PropTypes.string,
+
+ /** Arbitrary styles of the displayName text */
+ // eslint-disable-next-line react/forbid-prop-types
+ textStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Number of lines before wrapping */
+ numberOfLines: PropTypes.number,
+};
+
+const defaultProps = {
+ fullTitle: '',
+ textStyles: [],
+ numberOfLines: 1,
+};
+
+function DisplayNamesWithoutTooltip({textStyles, numberOfLines, fullTitle}) {
+ return (
+
+ {fullTitle}
+
+ );
+}
+
+DisplayNamesWithoutTooltip.propTypes = propTypes;
+DisplayNamesWithoutTooltip.defaultProps = defaultProps;
+DisplayNamesWithoutTooltip.displayName = 'DisplayNamesWithoutTooltip';
+
+export default DisplayNamesWithoutTooltip;
diff --git a/src/components/DisplayNames/index.js b/src/components/DisplayNames/index.js
index 6a7123f723f8..197940d80ca6 100644
--- a/src/components/DisplayNames/index.js
+++ b/src/components/DisplayNames/index.js
@@ -1,118 +1,25 @@
-import _ from 'underscore';
-import React, {Fragment, PureComponent} from 'react';
-import {View} from 'react-native';
-import {propTypes, defaultProps} from './displayNamesPropTypes';
-import styles from '../../styles/styles';
-import Tooltip from '../Tooltip';
-import Text from '../Text';
-import UserDetailsTooltip from '../UserDetailsTooltip';
-
-class DisplayNames extends PureComponent {
- constructor(props) {
- super(props);
- this.containerRef = null;
- this.childRefs = [];
- this.state = {
- isEllipsisActive: false,
- };
- this.getTooltipShiftX = this.getTooltipShiftX.bind(this);
- }
-
- componentDidMount() {
- this.setState({
- isEllipsisActive: this.containerRef && this.containerRef.offsetWidth && this.containerRef.scrollWidth && this.containerRef.offsetWidth < this.containerRef.scrollWidth,
- });
- }
-
- /**
- * We may need to shift the Tooltip horizontally as some of the inline text wraps well with ellipsis,
- * but their container node overflows the parent view which causes the tooltip to be misplaced.
- *
- * So we shift it by calculating it as follows:
- * 1. We get the container layout and take the Child inline text node.
- * 2. Now we get the tooltip original position.
- * 3. If inline node's right edge is overflowing the container's right edge, we set the tooltip to the center
- * of the distance between the left edge of the inline node and right edge of the container.
- * @param {Number} index Used to get the Ref to the node at the current index
- * @returns {Number} Distance to shift the tooltip horizontally
- */
- getTooltipShiftX(index) {
- // Only shift the tooltip in case the container ref or Refs to the text node are available
- if (!this.containerRef || !this.childRefs[index]) {
- return;
- }
- const {width: containerWidth, left: containerLeft} = this.containerRef.getBoundingClientRect();
-
- // We have to return the value as Number so we can't use `measureWindow` which takes a callback
- const {width: textNodeWidth, left: textNodeLeft} = this.childRefs[index].getBoundingClientRect();
- const tooltipX = textNodeWidth / 2 + textNodeLeft;
- const containerRight = containerWidth + containerLeft;
- const textNodeRight = textNodeWidth + textNodeLeft;
- const newToolX = textNodeLeft + (containerRight - textNodeLeft) / 2;
-
- // When text right end is beyond the Container right end
- return textNodeRight > containerRight ? -(tooltipX - newToolX) : 0;
- }
-
- render() {
- if (!this.props.tooltipEnabled) {
- // No need for any complex text-splitting, just return a simple Text component
- return (
-
- {this.props.fullTitle}
-
- );
- }
+import React from 'react';
+import DisplayNamesWithToolTip from './DisplayNamesWithTooltip';
+import DisplayNamesWithoutTooltip from './DisplayNamesWithoutTooltip';
+import {defaultProps, propTypes} from './displayNamesPropTypes';
+function DisplayNames(props) {
+ if (!props.tooltipEnabled) {
return (
- // Tokenization of string only support prop numberOfLines on Web
- (this.containerRef = el)}
- >
- {this.props.shouldUseFullTitle
- ? this.props.fullTitle
- : _.map(this.props.displayNamesWithTooltips, ({displayName, accountID, avatar, login}, index) => (
-
- this.getTooltipShiftX(index)}
- >
- {/* // We need to get the refs to all the names which will be used to correct
- the horizontal position of the tooltip */}
- (this.childRefs[index] = el)}
- style={[...this.props.textStyles, styles.pre]}
- >
- {displayName}
-
-
- {index < this.props.displayNamesWithTooltips.length - 1 && , }
-
- ))}
- {Boolean(this.state.isEllipsisActive) && (
-
-
- {/* There is some Gap for real ellipsis so we are adding 4 `.` to cover */}
- ....
-
-
- )}
-
+
);
}
+
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ return ;
}
+
DisplayNames.propTypes = propTypes;
DisplayNames.defaultProps = defaultProps;
+DisplayNames.displayName = 'DisplayNames';
export default DisplayNames;
diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js
index 7bbbfe3f3a01..78b4e7b87c11 100644
--- a/src/components/DistanceRequest.js
+++ b/src/components/DistanceRequest.js
@@ -1,34 +1,237 @@
-import React, {useEffect} from 'react';
-import {View} from 'react-native';
+import React, {useEffect, useState} from 'react';
+import {ScrollView, View} from 'react-native';
+import lodashGet from 'lodash/get';
+import _ from 'underscore';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
-import Text from './Text';
-import * as IOU from '../libs/actions/IOU';
-import styles from '../styles/styles';
+import MapView from 'react-native-x-maps';
import ONYXKEYS from '../ONYXKEYS';
+import * as Transaction from '../libs/actions/Transaction';
+import * as TransactionUtils from '../libs/TransactionUtils';
+import MenuItemWithTopDescription from './MenuItemWithTopDescription';
+import * as Expensicons from './Icon/Expensicons';
+import theme from '../styles/themes/default';
+import Button from './Button';
+import styles from '../styles/styles';
+import variables from '../styles/variables';
+import LinearGradient from './LinearGradient';
+import * as MapboxToken from '../libs/actions/MapboxToken';
+import CONST from '../CONST';
+import BlockingView from './BlockingViews/BlockingView';
+import useNetwork from '../hooks/useNetwork';
+import useLocalize from '../hooks/useLocalize';
+import Navigation from '../libs/Navigation/Navigation';
+import ROUTES from '../ROUTES';
+import transactionPropTypes from './transactionPropTypes';
+import ScreenWrapper from './ScreenWrapper';
+import DotIndicatorMessage from './DotIndicatorMessage';
+import * as ErrorUtils from '../libs/ErrorUtils';
+import usePrevious from '../hooks/usePrevious';
+
+const MAX_WAYPOINTS = 25;
+const MAX_WAYPOINTS_TO_DISPLAY = 4;
+
+const MAP_PADDING = 50;
+const DEFAULT_ZOOM_LEVEL = 10;
const propTypes = {
/** The transactionID of this request */
transactionID: PropTypes.string,
+
+ /** The optimistic transaction for this request */
+ transaction: transactionPropTypes,
+
+ /** Data about Mapbox token for calling Mapbox API */
+ mapboxAccessToken: PropTypes.shape({
+ /** Temporary token for Mapbox API */
+ token: PropTypes.string,
+
+ /** Time when the token will expire in ISO 8601 */
+ expiration: PropTypes.string,
+ }),
};
const defaultProps = {
transactionID: '',
+ transaction: {},
+ mapboxAccessToken: {},
};
-function DistanceRequest(props) {
+function DistanceRequest({transactionID, transaction, mapboxAccessToken}) {
+ const [shouldShowGradient, setShouldShowGradient] = useState(false);
+ const [scrollContainerHeight, setScrollContainerHeight] = useState(0);
+ const [scrollContentHeight, setScrollContentHeight] = useState(0);
+ const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+
+ const waypoints = lodashGet(transaction, 'comment.waypoints', {});
+ const numberOfWaypoints = _.size(waypoints);
+
+ const lastWaypointIndex = numberOfWaypoints - 1;
+ const isLoadingRoute = lodashGet(transaction, 'comment.isLoading', false);
+ const hasRouteError = Boolean(lodashGet(transaction, 'errorFields.route'));
+ const previousWaypoints = usePrevious(waypoints);
+ const haveWaypointsChanged = !_.isEqual(previousWaypoints, waypoints);
+ const shouldFetchRoute = haveWaypointsChanged && !isOffline && !isLoadingRoute && TransactionUtils.validateWaypoints(waypoints);
+
+ const waypointMarkers = _.filter(
+ _.map(waypoints, (waypoint, key) => {
+ if (!waypoint || waypoint.lng === undefined || waypoint.lat === undefined) {
+ return;
+ }
+
+ const index = Number(key.replace('waypoint', ''));
+ let MarkerComponent;
+ if (index === 0) {
+ MarkerComponent = Expensicons.DotIndicatorUnfilled;
+ } else if (index === lastWaypointIndex) {
+ MarkerComponent = Expensicons.Location;
+ } else {
+ MarkerComponent = Expensicons.DotIndicator;
+ }
+
+ return {
+ coordinate: [waypoint.lng, waypoint.lat],
+ markerComponent: () => (
+
+ ),
+ };
+ }),
+ (waypoint) => waypoint,
+ );
+
+ // Show up to the max number of waypoints plus 1/2 of one to hint at scrolling
+ const halfMenuItemHeight = Math.floor(variables.baseMenuItemHeight / 2);
+ const scrollContainerMaxHeight = variables.baseMenuItemHeight * MAX_WAYPOINTS_TO_DISPLAY + halfMenuItemHeight;
+
+ useEffect(() => {
+ MapboxToken.init();
+ return MapboxToken.stop;
+ }, []);
+
+ useEffect(() => {
+ if (!transactionID || !_.isEmpty(waypoints)) {
+ return;
+ }
+ // Create the initial start and stop waypoints
+ Transaction.createInitialWaypoints(transactionID);
+ }, [transactionID, waypoints]);
+
+ const updateGradientVisibility = (event = {}) => {
+ // If a waypoint extends past the bottom of the visible area show the gradient, else hide it.
+ const visibleAreaEnd = lodashGet(event, 'nativeEvent.contentOffset.y', 0) + scrollContainerHeight;
+ setShouldShowGradient(visibleAreaEnd < scrollContentHeight);
+ };
+
+ // Handle fetching the route when there are at least 2 waypoints
useEffect(() => {
- if (props.transactionID) {
+ if (!shouldFetchRoute) {
return;
}
- IOU.createEmptyTransaction();
- }, [props.transactionID]);
+
+ Transaction.getRoute(transactionID, waypoints);
+ }, [shouldFetchRoute, transactionID, waypoints]);
+
+ useEffect(updateGradientVisibility, [scrollContainerHeight, scrollContentHeight]);
return (
-
- Distance Request
- transactionID: {props.transactionID}
-
+
+ setScrollContainerHeight(lodashGet(event, 'nativeEvent.layout.height', 0))}
+ >
+ setScrollContentHeight(height)}
+ onScroll={updateGradientVisibility}
+ scrollEventThrottle={16}
+ >
+ {_.map(waypoints, (waypoint, key) => {
+ // key is of the form waypoint0, waypoint1, ...
+ const index = Number(key.replace('waypoint', ''));
+ let descriptionKey = 'distance.waypointDescription.';
+ let waypointIcon;
+ if (index === 0) {
+ descriptionKey += 'start';
+ waypointIcon = Expensicons.DotIndicatorUnfilled;
+ } else if (index === lastWaypointIndex) {
+ descriptionKey += 'finish';
+ waypointIcon = Expensicons.Location;
+ } else {
+ descriptionKey += 'stop';
+ waypointIcon = Expensicons.DotIndicator;
+ }
+
+ return (
+ Navigation.navigate(ROUTES.getMoneyRequestWaypointRoute('request', index))}
+ key={key}
+ />
+ );
+ })}
+
+ {shouldShowGradient && (
+
+ )}
+ {hasRouteError && (
+
+ )}
+
+
+ Transaction.addStop(transactionID)}
+ text={translate('distance.addStop')}
+ isDisabled={numberOfWaypoints === MAX_WAYPOINTS}
+ innerStyles={[styles.ph10]}
+ />
+
+
+ {!isOffline && Boolean(mapboxAccessToken.token) ? (
+
+ ) : (
+
+
+
+ )}
+
+
);
}
@@ -36,5 +239,10 @@ DistanceRequest.displayName = 'DistanceRequest';
DistanceRequest.propTypes = propTypes;
DistanceRequest.defaultProps = defaultProps;
export default withOnyx({
- transactionID: {key: ONYXKEYS.IOU, selector: (iou) => (iou && iou.transactionID) || ''},
+ transaction: {
+ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
+ },
+ mapboxAccessToken: {
+ key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
+ },
})(DistanceRequest);
diff --git a/src/components/DownloadAppModal.js b/src/components/DownloadAppModal.js
new file mode 100644
index 000000000000..1eeab1c72fd3
--- /dev/null
+++ b/src/components/DownloadAppModal.js
@@ -0,0 +1,74 @@
+import React, {useState} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import ONYXKEYS from '../ONYXKEYS';
+import styles from '../styles/styles';
+import CONST from '../CONST';
+import AppIcon from '../../assets/images/expensify-app-icon.svg';
+import useLocalize from '../hooks/useLocalize';
+import * as Link from '../libs/actions/Link';
+import * as Browser from '../libs/Browser';
+import getOperatingSystem from '../libs/getOperatingSystem';
+import setShowDownloadAppModal from '../libs/actions/DownloadAppModal';
+import ConfirmModal from './ConfirmModal';
+
+const propTypes = {
+ /** ONYX PROP to hide banner for a user that has dismissed it */
+ // eslint-disable-next-line react/forbid-prop-types
+ showDownloadAppBanner: PropTypes.bool,
+};
+
+const defaultProps = {
+ showDownloadAppBanner: true,
+};
+
+function DownloadAppModal({showDownloadAppBanner}) {
+ const [shouldShowBanner, setshouldShowBanner] = useState(Browser.isMobile() && showDownloadAppBanner);
+
+ const {translate} = useLocalize();
+
+ const handleCloseBanner = () => {
+ setShowDownloadAppModal(false);
+ setshouldShowBanner(false);
+ };
+
+ let link = '';
+
+ if (getOperatingSystem() === CONST.OS.IOS) {
+ link = CONST.APP_DOWNLOAD_LINKS.IOS;
+ } else if (getOperatingSystem() === CONST.OS.ANDROID) {
+ link = CONST.APP_DOWNLOAD_LINKS.ANDROID;
+ }
+
+ const handleOpenAppStore = () => {
+ Link.openExternalLink(link, true);
+ };
+
+ return (
+
+ );
+}
+
+DownloadAppModal.displayName = 'DownloadAppModal';
+DownloadAppModal.propTypes = propTypes;
+DownloadAppModal.defaultProps = defaultProps;
+
+export default withOnyx({
+ showDownloadAppBanner: {
+ key: ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER,
+ },
+})(DownloadAppModal);
diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js
index 2a3a7ba296f2..40d91ff03267 100644
--- a/src/components/EmojiPicker/EmojiPicker.js
+++ b/src/components/EmojiPicker/EmojiPicker.js
@@ -1,5 +1,5 @@
import React, {useState, useEffect, useRef, forwardRef, useImperativeHandle} from 'react';
-import {Dimensions, Keyboard} from 'react-native';
+import {Dimensions} from 'react-native';
import _ from 'underscore';
import EmojiPickerMenu from './EmojiPickerMenu';
import CONST from '../../CONST';
@@ -27,8 +27,8 @@ const EmojiPicker = forwardRef((props, ref) => {
horizontal: 0,
vertical: 0,
});
- const [reportAction, setReportAction] = useState({});
const [emojiPopoverAnchorOrigin, setEmojiPopoverAnchorOrigin] = useState(DEFAULT_ANCHOR_ORIGIN);
+ const [activeID, setActiveID] = useState();
const emojiPopoverAnchor = useRef(null);
const onModalHide = useRef(() => {});
const onEmojiSelected = useRef(() => {});
@@ -42,9 +42,9 @@ const EmojiPicker = forwardRef((props, ref) => {
* @param {Element} emojiPopoverAnchorValue - Element to which Popover is anchored
* @param {Object} [anchorOrigin=DEFAULT_ANCHOR_ORIGIN] - Anchor origin for Popover
* @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show
- * @param {Object} reportActionValue - ReportAction for EmojiPicker
+ * @param {String} id - Unique id for EmojiPicker
*/
- const showEmojiPicker = (onModalHideValue, onEmojiSelectedValue, emojiPopoverAnchorValue, anchorOrigin, onWillShow = () => {}, reportActionValue) => {
+ const showEmojiPicker = (onModalHideValue, onEmojiSelectedValue, emojiPopoverAnchorValue, anchorOrigin, onWillShow = () => {}, id) => {
onModalHide.current = onModalHideValue;
onEmojiSelected.current = onEmojiSelectedValue;
emojiPopoverAnchor.current = emojiPopoverAnchorValue;
@@ -60,7 +60,7 @@ const EmojiPicker = forwardRef((props, ref) => {
setIsEmojiPickerVisible(true);
setEmojiPopoverAnchorPosition(value);
setEmojiPopoverAnchorOrigin(anchorOriginValue);
- setReportAction(reportActionValue);
+ setActiveID(id);
});
};
@@ -107,22 +107,18 @@ const EmojiPicker = forwardRef((props, ref) => {
};
/**
- * Whether Context Menu is active for the Report Action.
+ * Whether emoji picker is active for the given id.
*
- * @param {Number|String} actionID
+ * @param {String} id
* @return {Boolean}
*/
- const isActiveReportAction = (actionID) => Boolean(actionID) && reportAction.reportActionID === actionID;
+ const isActive = (id) => Boolean(id) && id === activeID;
const resetEmojiPopoverAnchor = () => (emojiPopoverAnchor.current = null);
- useImperativeHandle(ref, () => ({showEmojiPicker, isActiveReportAction, hideEmojiPicker, isEmojiPickerVisible, resetEmojiPopoverAnchor}));
+ useImperativeHandle(ref, () => ({showEmojiPicker, isActive, hideEmojiPicker, isEmojiPickerVisible, resetEmojiPopoverAnchor}));
useEffect(() => {
- if (isEmojiPickerVisible) {
- Keyboard.dismiss();
- }
-
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
diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js
index c78e9fdd285a..cbfc3517117c 100644
--- a/src/components/EmojiPicker/EmojiPickerButton.js
+++ b/src/components/EmojiPicker/EmojiPickerButton.js
@@ -17,12 +17,8 @@ const propTypes = {
/** Id to use for the emoji picker button */
nativeID: PropTypes.string,
- /**
- * ReportAction for EmojiPicker.
- */
- reportAction: PropTypes.shape({
- reportActionID: PropTypes.string,
- }),
+ /** Unique id for emoji picker */
+ emojiPickerID: PropTypes.string,
...withLocalizePropTypes,
};
@@ -30,7 +26,7 @@ const propTypes = {
const defaultProps = {
isDisabled: false,
nativeID: '',
- reportAction: {},
+ emojiPickerID: '',
};
function EmojiPickerButton(props) {
@@ -46,7 +42,7 @@ function EmojiPickerButton(props) {
disabled={props.isDisabled}
onPress={() => {
if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) {
- EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.reportAction);
+ EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID);
} else {
EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker();
}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index dfa06e8daab2..6e2856a7e058 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -524,6 +524,9 @@ class EmojiPickerMenu extends Component {
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 (
overflowLimit ? 'auto' : 'hidden'},
+ ]}
extraData={[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]}
stickyHeaderIndices={this.state.headerIndices}
onScroll={(e) => (this.currentScrollOffset = e.nativeEvent.contentOffset.y)}
diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js
index 76eb90f2295a..cfde38537840 100644
--- a/src/components/EmojiSuggestions.js
+++ b/src/components/EmojiSuggestions.js
@@ -8,6 +8,7 @@ import * as EmojiUtils from '../libs/EmojiUtils';
import Text from './Text';
import getStyledTextArray from '../libs/GetStyledTextArray';
import AutoCompleteSuggestions from './AutoCompleteSuggestions';
+import refPropType from './refPropTypes';
const propTypes = {
/** The index of the highlighted emoji */
@@ -45,9 +46,14 @@ const propTypes = {
/** Stores user's preferred skin tone */
preferredSkinToneIndex: PropTypes.number.isRequired,
+
+ /** Ref of the container enclosing the menu.
+ * This is needed to render the menu in correct position inside a portal
+ */
+ containerRef: refPropType,
};
-const defaultProps = {highlightedEmojiIndex: 0};
+const defaultProps = {highlightedEmojiIndex: 0, containerRef: {current: null}};
/**
* Create unique keys for each emoji item
@@ -98,6 +104,7 @@ function EmojiSuggestions(props) {
isSuggestionPickerLarge={props.isEmojiPickerLarge}
shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight}
accessibilityLabelExtractor={keyExtractor}
+ parentContainerRef={props.containerRef}
/>
);
}
diff --git a/src/components/Form.js b/src/components/Form.js
index d4db912d975a..eb6945f6ec78 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -62,6 +62,12 @@ const propTypes = {
/** Whether the form submit action is dangerous */
isSubmitActionDangerous: PropTypes.bool,
+ /** Whether the validate() method should run on input changes */
+ shouldValidateOnChange: PropTypes.bool,
+
+ /** Whether the validate() method should run on blur */
+ shouldValidateOnBlur: PropTypes.bool,
+
/** Whether ScrollWithContext should be used instead of regular ScrollView.
* Set to true when there's a nested Picker component in Form.
*/
@@ -83,12 +89,13 @@ const defaultProps = {
isSubmitButtonVisible: true,
formState: {
isLoading: false,
- errors: null,
},
draftValues: {},
enabledWhenOffline: false,
isSubmitActionDangerous: false,
scrollContextEnabled: false,
+ shouldValidateOnChange: true,
+ shouldValidateOnBlur: true,
footerContent: null,
style: [],
validate: () => ({}),
@@ -306,7 +313,9 @@ function Form(props) {
// web and mobile web platforms.
setTimeout(() => {
setTouchedInput(inputID);
- onValidate(inputValues);
+ if (props.shouldValidateOnBlur) {
+ onValidate(inputValues);
+ }
}, 200);
}
@@ -324,7 +333,10 @@ function Form(props) {
...prevState,
[inputKey]: value,
};
- onValidate(newState);
+
+ if (props.shouldValidateOnChange) {
+ onValidate(newState);
+ }
return newState;
});
@@ -341,7 +353,7 @@ function Form(props) {
return childrenElements;
},
- [errors, inputRefs, inputValues, onValidate, props.draftValues, props.formID, props.formState, setTouchedInput],
+ [errors, inputRefs, inputValues, onValidate, props.draftValues, props.formID, props.formState, setTouchedInput, props.shouldValidateOnBlur, props.shouldValidateOnChange],
);
const scrollViewContent = useCallback(
diff --git a/src/components/FormSubmit/index.js b/src/components/FormSubmit/index.js
index d0850766ce6f..35a9b64dc208 100644
--- a/src/components/FormSubmit/index.js
+++ b/src/components/FormSubmit/index.js
@@ -1,9 +1,10 @@
-import React from 'react';
+import React, {useEffect} from 'react';
import lodashGet from 'lodash/get';
import {View} from 'react-native';
import * as formSubmitPropTypes from './formSubmitPropTypes';
import CONST from '../../CONST';
import isEnterWhileComposition from '../../libs/KeyboardShortcut/isEnterWhileComposition';
+import * as ComponentUtils from '../../libs/ComponentUtils';
function FormSubmit({innerRef, children, onSubmit, style}) {
/**
@@ -36,11 +37,31 @@ function FormSubmit({innerRef, children, onSubmit, style}) {
}
};
+ const preventDefaultFormBehavior = (e) => e.preventDefault();
+
+ useEffect(() => {
+ const form = innerRef.current;
+
+ // Prevent the browser from applying its own validation, which affects the email input
+ form.setAttribute('novalidate', '');
+
+ form.addEventListener('submit', preventDefaultFormBehavior);
+
+ return () => {
+ if (!form) {
+ return;
+ }
+
+ form.removeEventListener('submit', preventDefaultFormBehavior);
+ };
+ }, [innerRef]);
+
return (
// React-native-web prevents event bubbling on TextInput for key presses
// https://github.com/necolas/react-native-web/blob/fa47f80d34ee6cde2536b2a2241e326f84b633c4/packages/react-native-web/src/exports/TextInput/index.js#L272
// Thus use capture phase.
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
index 103e653d7d88..643785ab09d1 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js
@@ -33,11 +33,11 @@ function ImageRenderer(props) {
// Concierge responder attachments are uploaded to S3 without any access
// control and thus require no authToken to verify access.
//
- const isAttachment = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]);
+ const isAttachmentOrReceipt = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]);
// Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified
const previewSource = tryResolveUrlFromApiRoot(htmlAttribs.src);
- const source = tryResolveUrlFromApiRoot(isAttachment ? htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] : htmlAttribs.src);
+ const source = tryResolveUrlFromApiRoot(isAttachmentOrReceipt ? htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] : htmlAttribs.src);
const imageWidth = htmlAttribs['data-expensify-width'] ? parseInt(htmlAttribs['data-expensify-width'], 10) : undefined;
const imageHeight = htmlAttribs['data-expensify-height'] ? parseInt(htmlAttribs['data-expensify-height'], 10) : undefined;
@@ -47,7 +47,7 @@ function ImageRenderer(props) {
@@ -67,7 +67,7 @@ function ImageRenderer(props) {
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js
index 57e3f5ba9bf1..fa38a6fcc23d 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js
@@ -34,7 +34,7 @@ const BasePreRenderer = forwardRef((props, ref) => {
diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js
index b0c1d5a9249c..4ad00c771296 100644
--- a/src/components/Icon/Expensicons.js
+++ b/src/components/Icon/Expensicons.js
@@ -3,6 +3,7 @@ import AdminRoomAvatar from '../../../assets/images/avatars/admin-room.svg';
import Android from '../../../assets/images/android.svg';
import AnnounceRoomAvatar from '../../../assets/images/avatars/announce-room.svg';
import Apple from '../../../assets/images/apple.svg';
+import AppleLogo from '../../../assets/images/signIn/apple-logo.svg';
import ArrowRight from '../../../assets/images/arrow-right.svg';
import ArrowRightLong from '../../../assets/images/arrow-right-long.svg';
import ArrowsUpDown from '../../../assets/images/arrows-updown.svg';
@@ -32,9 +33,12 @@ import Document from '../../../assets/images/document.svg';
import DeletedRoomAvatar from '../../../assets/images/avatars/deleted-room.svg';
import DomainRoomAvatar from '../../../assets/images/avatars/domain-room.svg';
import DotIndicator from '../../../assets/images/dot-indicator.svg';
+import DotIndicatorUnfilled from '../../../assets/images/dot-indicator-unfilled.svg';
import DownArrow from '../../../assets/images/down.svg';
import Download from '../../../assets/images/download.svg';
+import DragHandles from '../../../assets/images/drag-handles.svg';
import Emoji from '../../../assets/images/emoji.svg';
+import EmptyStateRoutePending from '../../../assets/images/emptystate__routepending.svg';
import Exclamation from '../../../assets/images/exclamation.svg';
import Exit from '../../../assets/images/exit.svg';
import ExpensifyCard from '../../../assets/images/expensifycard.svg';
@@ -49,6 +53,7 @@ import FlagLevelThree from '../../../assets/images/flag_level_03.svg';
import Gallery from '../../../assets/images/gallery.svg';
import Gear from '../../../assets/images/gear.svg';
import Globe from '../../../assets/images/globe.svg';
+import GoogleLogo from '../../../assets/images/signIn/google-logo.svg';
import Hashtag from '../../../assets/images/hashtag.svg';
import History from '../../../assets/images/history.svg';
import Hourglass from '../../../assets/images/hourglass.svg';
@@ -60,8 +65,9 @@ import Key from '../../../assets/images/key.svg';
import Keyboard from '../../../assets/images/keyboard.svg';
import Link from '../../../assets/images/link.svg';
import LinkCopy from '../../../assets/images/link-copy.svg';
+import Location from '../../../assets/images/location.svg';
import Lock from '../../../assets/images/lock.svg';
-import LoungeAccess from '../../../assets/images/lounge-access.svg';
+import LoungeAccess from './svgs/LoungeAccessIcon';
import Luggage from '../../../assets/images/luggage.svg';
import MagnifyingGlass from '../../../assets/images/magnifying-glass.svg';
import Mail from '../../../assets/images/mail.svg';
@@ -125,6 +131,7 @@ export {
Android,
AnnounceRoomAvatar,
Apple,
+ AppleLogo,
ArrowRight,
ArrowRightLong,
ArrowsUpDown,
@@ -154,10 +161,13 @@ export {
Document,
DomainRoomAvatar,
DotIndicator,
+ DotIndicatorUnfilled,
DownArrow,
Download,
DragAndDrop,
+ DragHandles,
Emoji,
+ EmptyStateRoutePending,
Exclamation,
Exit,
ExpensifyCard,
@@ -176,6 +186,7 @@ export {
Gallery,
Gear,
Globe,
+ GoogleLogo,
Hashtag,
History,
Hourglass,
@@ -187,6 +198,7 @@ export {
Keyboard,
Link,
LinkCopy,
+ Location,
Lock,
LoungeAccess,
Luggage,
diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js
index 8c6559451215..5cdd5c79704d 100644
--- a/src/components/Icon/index.js
+++ b/src/components/Icon/index.js
@@ -1,9 +1,9 @@
import React, {PureComponent} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
-import styles from '../../styles/styles';
import themeColors from '../../styles/themes/default';
import variables from '../../styles/variables';
+import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
import IconWrapperStyles from './IconWrapperStyles';
@@ -26,6 +26,12 @@ const propTypes = {
/** Is inline icon */
inline: PropTypes.bool,
+ /** Is icon hovered */
+ hovered: PropTypes.bool,
+
+ /** Is icon pressed */
+ pressed: PropTypes.bool,
+
// eslint-disable-next-line react/forbid-prop-types
additionalStyles: PropTypes.arrayOf(PropTypes.object),
};
@@ -37,6 +43,8 @@ const defaultProps = {
small: false,
inline: false,
additionalStyles: [],
+ hovered: false,
+ pressed: false,
};
// We must use a class component to create an animatable component with the Animated API
@@ -58,6 +66,8 @@ class Icon extends PureComponent {
width={width}
height={height}
fill={this.props.fill}
+ hovered={this.props.hovered.toString()}
+ pressed={this.props.pressed.toString()}
/>
@@ -73,6 +83,8 @@ class Icon extends PureComponent {
width={width}
height={height}
fill={this.props.fill}
+ hovered={this.props.hovered.toString()}
+ pressed={this.props.pressed.toString()}
/>
);
diff --git a/src/components/Icon/svgs/LoungeAccessIcon.js b/src/components/Icon/svgs/LoungeAccessIcon.js
new file mode 100644
index 000000000000..b00fbb312c85
--- /dev/null
+++ b/src/components/Icon/svgs/LoungeAccessIcon.js
@@ -0,0 +1,70 @@
+import * as React from 'react';
+import Svg, {G, Path, Polygon} from 'react-native-svg';
+import PropTypes from 'prop-types';
+import themeColors from '../../../styles/themes/default';
+
+const propTypes = {
+ /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */
+ fill: PropTypes.string,
+
+ /** Is icon hovered */
+ hovered: PropTypes.string,
+
+ /** Is icon pressed */
+ pressed: PropTypes.string,
+};
+
+const defaultProps = {
+ fill: themeColors.icon,
+ hovered: 'false',
+ pressed: 'false',
+};
+
+function LoungeAccessIcon(props) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+LoungeAccessIcon.displayName = 'LoungeAccessIcon';
+LoungeAccessIcon.propTypes = propTypes;
+LoungeAccessIcon.defaultProps = defaultProps;
+
+export default LoungeAccessIcon;
diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js
index 7fc340426d69..d1403bd4029e 100644
--- a/src/components/IllustratedHeaderPageLayout.js
+++ b/src/components/IllustratedHeaderPageLayout.js
@@ -11,6 +11,7 @@ import themeColors from '../styles/themes/default';
import * as StyleUtils from '../styles/StyleUtils';
import useWindowDimensions from '../hooks/useWindowDimensions';
import FixedFooter from './FixedFooter';
+import useNetwork from '../hooks/useNetwork';
const propTypes = {
...headerWithBackButtonPropTypes,
@@ -35,6 +36,7 @@ const defaultProps = {
function IllustratedHeaderPageLayout({backgroundColor, children, illustration, footer, ...propsToPassToHeader}) {
const {windowHeight} = useWindowDimensions();
+ const {isOffline} = useNetwork();
return (
-
+
{
+ if (!newContainerWidth || !newImageWidth || !newContainerHeight || !newImageHeight) {
return;
}
-
- document.removeEventListener('mousemove', this.trackMovement);
- document.removeEventListener('mouseup', this.trackPointerPosition);
- }
+ const newZoomScale = Math.min(newContainerWidth / newImageWidth, newContainerHeight / newImageHeight);
+ setZoomScale(newZoomScale);
+ };
/**
* @param {SyntheticEvent} e
*/
- onContainerLayoutChanged(e) {
+ const onContainerLayoutChanged = (e) => {
const {width, height} = e.nativeEvent.layout;
- this.setScale(width, height, this.state.imgWidth, this.state.imgHeight);
- this.setState({
- containerHeight: height,
- containerWidth: width,
- });
- }
+ setScale(width, height, imgWidth, imgHeight);
- /**
- * @param {SyntheticEvent} e
- */
- onContainerPressIn(e) {
- const {pageX, pageY} = e.nativeEvent;
- this.setState({
- isMouseDown: true,
- initialX: pageX,
- initialY: pageY,
- initialScrollLeft: this.scrollableRef.scrollLeft,
- initialScrollTop: this.scrollableRef.scrollTop,
- });
- }
-
- /**
- * @param {SyntheticEvent} e
- */
- onContainerPress(e) {
- let scrollX;
- let scrollY;
- if (!this.state.isZoomed && !this.state.isDragging) {
- const {offsetX, offsetY} = e.nativeEvent;
-
- // Dividing clicked positions by the zoom scale to get coordinates
- // so that once we zoom we will scroll to the clicked location.
- const delta = this.getScrollOffset(offsetX / this.state.zoomScale, offsetY / this.state.zoomScale);
- scrollX = delta.offsetX;
- scrollY = delta.offsetY;
- }
-
- if (this.state.isZoomed && this.state.isDragging && this.state.isMouseDown) {
- this.setState({isDragging: false, isMouseDown: false});
- } else {
- // We first zoom and once its done then we scroll to the location the user clicked.
- this.setState(
- (prevState) => ({
- isZoomed: !prevState.isZoomed,
- isMouseDown: false,
- }),
- () => {
- this.scrollableRef.scrollTop = scrollY;
- this.scrollableRef.scrollLeft = scrollX;
- },
- );
- }
- }
+ setContainerHeight(height);
+ setContainerWidth(width);
+ };
/**
* When open image, set image width, height.
* @param {Number} imageWidth
* @param {Number} imageHeight
*/
- setImageRegion(imageWidth, imageHeight) {
+ const setImageRegion = (imageWidth, imageHeight) => {
if (imageHeight <= 0) {
return;
}
-
- this.setScale(this.state.containerWidth, this.state.containerHeight, imageWidth, imageHeight);
- this.setState({
- imgWidth: imageWidth,
- imgHeight: imageHeight,
- });
- }
+ setScale(containerWidth, containerHeight, imageWidth, imageHeight);
+ setImgWidth(imageWidth);
+ setImgHeight(imageHeight);
+ };
+
+ const imageLoadingStart = () => {
+ if (!isLoading) return;
+ setIsLoading(true);
+ setZoomScale(0);
+ setIsZoomed(false);
+ };
+
+ const imageLoad = ({nativeEvent}) => {
+ setImageRegion(nativeEvent.width, nativeEvent.height);
+ setIsLoading(false);
+ };
/**
- * @param {Number} containerWidth
- * @param {Number} containerHeight
- * @param {Number} imageWidth
- * @param {Number} imageHeight
+ * @param {SyntheticEvent} e
*/
- setScale(containerWidth, containerHeight, imageWidth, imageHeight) {
- if (!containerWidth || !imageWidth || !containerHeight || !imageHeight) {
- return;
- }
- const newZoomScale = Math.min(containerWidth / imageWidth, containerHeight / imageHeight);
- this.setState({zoomScale: newZoomScale});
- }
+ const onContainerPressIn = (e) => {
+ const {pageX, pageY} = e.nativeEvent;
+ setIsMouseDown(true);
+ setInitialX(pageX);
+ setInitialY(pageY);
+ setInitialScrollLeft(scrollableRef.current.scrollLeft);
+ setInitialScrollTop(scrollableRef.current.scrollTop);
+ };
/**
* Convert touch point to zoomed point
@@ -183,131 +116,161 @@ class ImageView extends PureComponent {
* @param {Boolean} y y point when click zoom
* @returns {Object} converted touch point
*/
- getScrollOffset(x, y) {
+ const getScrollOffset = (x, y) => {
let offsetX;
let offsetY;
// Container size bigger than clicked position offset
- if (x <= this.state.containerWidth / 2) {
+ if (x <= containerWidth / 2) {
offsetX = 0;
- } else if (x > this.state.containerWidth / 2) {
+ } else if (x > containerWidth / 2) {
// Minus half of container size because we want to be center clicked position
- offsetX = x - this.state.containerWidth / 2;
+ offsetX = x - containerWidth / 2;
}
- if (y <= this.state.containerHeight / 2) {
+ if (y <= containerHeight / 2) {
offsetY = 0;
- } else if (y > this.state.containerHeight / 2) {
+ } else if (y > containerHeight / 2) {
// Minus half of container size because we want to be center clicked position
- offsetY = y - this.state.containerHeight / 2;
+ offsetY = y - containerHeight / 2;
}
return {offsetX, offsetY};
- }
+ };
/**
* @param {SyntheticEvent} e
*/
- trackPointerPosition(e) {
- // Whether the pointer is released inside the ImageView
- const isInsideImageView = this.scrollableRef.contains(e.nativeEvent.target);
-
- if (!isInsideImageView && this.state.isZoomed && this.state.isDragging && this.state.isMouseDown) {
- this.setState({isDragging: false, isMouseDown: false});
+ const onContainerPress = (e) => {
+ if (!isZoomed && !isDragging) {
+ const {offsetX, offsetY} = e.nativeEvent;
+ // Dividing clicked positions by the zoom scale to get coordinates
+ // so that once we zoom we will scroll to the clicked location.
+ const delta = getScrollOffset(offsetX / zoomScale, offsetY / zoomScale);
+ setZoomDelta(delta);
}
- }
- trackMovement(e) {
- if (!this.state.isZoomed) {
- return;
+ if (isZoomed && isDragging && isMouseDown) {
+ setIsDragging(false);
+ setIsMouseDown(false);
+ } else {
+ // We first zoom and once its done then we scroll to the location the user clicked.
+ setIsZoomed(!isZoomed);
+ setIsMouseDown(false);
}
+ };
- if (this.state.isDragging && this.state.isMouseDown) {
- const x = e.nativeEvent.x;
- const y = e.nativeEvent.y;
- const moveX = this.state.initialX - x;
- const moveY = this.state.initialY - y;
- this.scrollableRef.scrollLeft = this.state.initialScrollLeft + moveX;
- this.scrollableRef.scrollTop = this.state.initialScrollTop + moveY;
+ /**
+ * @param {SyntheticEvent} e
+ */
+ const trackPointerPosition = useCallback(
+ (e) => {
+ // Whether the pointer is released inside the ImageView
+ const isInsideImageView = scrollableRef.current.contains(e.nativeEvent.target);
+
+ if (!isInsideImageView && isZoomed && isDragging && isMouseDown) {
+ setIsDragging(false);
+ setIsMouseDown(false);
+ }
+ },
+ [isDragging, isMouseDown, isZoomed],
+ );
+
+ const trackMovement = useCallback(
+ (e) => {
+ if (!isZoomed) {
+ return;
+ }
+
+ if (isDragging && isMouseDown) {
+ const x = e.nativeEvent.x;
+ const y = e.nativeEvent.y;
+ const moveX = initialX - x;
+ const moveY = initialY - y;
+ scrollableRef.current.scrollLeft = initialScrollLeft + moveX;
+ scrollableRef.current.scrollTop = initialScrollTop + moveY;
+ }
+
+ setIsDragging(isMouseDown);
+ },
+ [initialScrollLeft, initialScrollTop, initialX, initialY, isDragging, isMouseDown, isZoomed],
+ );
+
+ useEffect(() => {
+ if (!isZoomed || !zoomDelta || !scrollableRef.current) {
+ return;
}
+ scrollableRef.current.scrollLeft = zoomDelta.offsetX;
+ scrollableRef.current.scrollTop = zoomDelta.offsetY;
+ }, [zoomDelta, isZoomed]);
- this.setState((prevState) => ({isDragging: prevState.isMouseDown}));
- }
-
- imageLoad({nativeEvent}) {
- this.setImageRegion(nativeEvent.width, nativeEvent.height);
- this.setState({isLoading: false});
- }
-
- imageLoadingStart() {
- if (this.state.isLoading) {
+ useEffect(() => {
+ if (canUseTouchScreen) {
return;
}
- this.setState({isLoading: true, zoomScale: 0, isZoomed: false});
- }
+ document.addEventListener('mousemove', trackMovement);
+ document.addEventListener('mouseup', trackPointerPosition);
- render() {
- if (this.canUseTouchScreen) {
- return (
-
- 1 ? Image.resizeMode.center : Image.resizeMode.contain}
- onLoadStart={this.imageLoadingStart}
- onLoad={this.imageLoad}
- />
- {this.state.isLoading && }
-
- );
- }
+ return () => {
+ document.removeEventListener('mousemove', trackMovement);
+ document.removeEventListener('mouseup', trackPointerPosition);
+ };
+ }, [canUseTouchScreen, trackMovement, trackPointerPosition]);
+
+ if (canUseTouchScreen) {
return (
(this.scrollableRef = el)}
- onLayout={this.onContainerLayoutChanged}
- style={[styles.imageViewContainer, styles.overflowAuto, styles.pRelative]}
+ style={[styles.imageViewContainer, styles.overflowHidden]}
+ onLayout={onContainerLayoutChanged}
>
- = 1 ? styles.pRelative : styles.pAbsolute),
- ...styles.flex1,
- }}
- onPressIn={this.onContainerPressIn}
- onPress={this.onContainerPress}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGE}
- accessibilityLabel={this.props.fileName}
- >
-
-
-
- {this.state.isLoading && }
+ 1 ? Image.resizeMode.center : Image.resizeMode.contain}
+ onLoadStart={imageLoadingStart}
+ onLoad={imageLoad}
+ />
+ {isLoading && }
);
}
+ return (
+
+ = 1 ? styles.pRelative : styles.pAbsolute),
+ ...styles.flex1,
+ }}
+ onPressIn={onContainerPressIn}
+ onPress={onContainerPress}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGE}
+ accessibilityLabel={fileName}
+ >
+
+
+
+ {isLoading && }
+
+ );
}
ImageView.propTypes = propTypes;
ImageView.defaultProps = defaultProps;
-export default withWindowDimensions(ImageView);
+ImageView.displayName = 'ImageView';
+
+export default ImageView;
diff --git a/src/components/Indicator.js b/src/components/Indicator.js
index 765d79e156af..24eb20ad5eee 100644
--- a/src/components/Indicator.js
+++ b/src/components/Indicator.js
@@ -29,10 +29,7 @@ const propTypes = {
/** List of bank accounts */
bankAccountList: PropTypes.objectOf(bankAccountPropTypes),
- /** List of cards */
- cardList: PropTypes.objectOf(cardPropTypes),
-
- /** List of cards */
+ /** List of user cards */
fundList: PropTypes.objectOf(cardPropTypes),
/** The user's wallet (coming from Onyx) */
@@ -59,7 +56,6 @@ const defaultProps = {
allPolicyMembers: {},
policies: {},
bankAccountList: {},
- cardList: null,
fundList: null,
userWallet: {},
walletTerms: {},
@@ -72,7 +68,7 @@ function Indicator(props) {
const cleanPolicies = _.pick(props.policies, (policy) => policy);
const cleanAllPolicyMembers = _.pick(props.allPolicyMembers, (policyMembers) => policyMembers);
- const paymentCardList = props.fundList || props.cardList || {};
+ const paymentCardList = props.fundList || {};
// All of the error & info-checking methods are put into an array. This is so that using _.some() will return
// early as soon as the first error / info condition is returned. This makes the checks very efficient since
@@ -116,9 +112,6 @@ export default withOnyx({
reimbursementAccount: {
key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
},
- cardList: {
- key: ONYXKEYS.CARD_LIST,
- },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js
index 534f00b7f746..4684901e47b1 100644
--- a/src/components/KYCWall/BaseKYCWall.js
+++ b/src/components/KYCWall/BaseKYCWall.js
@@ -23,6 +23,7 @@ class KYCWall extends React.Component {
this.continue = this.continue.bind(this);
this.setMenuPosition = this.setMenuPosition.bind(this);
+ this.anchorRef = React.createRef(null);
this.state = {
shouldShowAddPaymentMenu: false,
@@ -95,9 +96,13 @@ class KYCWall extends React.Component {
* @param {String} iouPaymentType
*/
continue(event, iouPaymentType) {
+ if (this.state.shouldShowAddPaymentMenu) {
+ this.setState({shouldShowAddPaymentMenu: false});
+ return;
+ }
this.setState({transferBalanceButton: event.nativeEvent.target});
const isExpenseReport = ReportUtils.isExpenseReport(this.props.iouReport);
- const paymentCardList = this.props.fundList || this.props.cardList || {};
+ const paymentCardList = this.props.fundList || {};
// Check to see if user has a valid payment method on file and display the add payment popover if they don't
if (
@@ -113,7 +118,6 @@ class KYCWall extends React.Component {
});
return;
}
-
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;
@@ -123,7 +127,6 @@ class KYCWall extends React.Component {
return;
}
}
-
Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them');
this.props.onSuccessfulKYC(iouPaymentType);
}
@@ -134,6 +137,7 @@ class KYCWall extends React.Component {
this.setState({shouldShowAddPaymentMenu: false})}
+ anchorRef={this.anchorRef}
anchorPosition={{
vertical: this.state.anchorPositionVertical,
horizontal: this.state.anchorPositionHorizontal,
@@ -148,7 +152,7 @@ class KYCWall extends React.Component {
}
}}
/>
- {this.props.children(this.continue)}
+ {this.props.children(this.continue, this.anchorRef)}
>
);
}
@@ -161,9 +165,6 @@ export default withOnyx({
userWallet: {
key: ONYXKEYS.USER_WALLET,
},
- cardList: {
- key: ONYXKEYS.CARD_LIST,
- },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
diff --git a/src/components/KYCWall/kycWallPropTypes.js b/src/components/KYCWall/kycWallPropTypes.js
index 06afedb23e52..482d61f7232a 100644
--- a/src/components/KYCWall/kycWallPropTypes.js
+++ b/src/components/KYCWall/kycWallPropTypes.js
@@ -30,10 +30,7 @@ const propTypes = {
/** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */
chatReportID: PropTypes.string,
- /** List of cards */
- cardList: PropTypes.objectOf(cardPropTypes),
-
- /** List of cards */
+ /** List of user's cards */
fundList: PropTypes.objectOf(cardPropTypes),
/** List of bank accounts */
@@ -56,7 +53,6 @@ const defaultProps = {
isDisabled: false,
chatReportID: '',
bankAccountList: {},
- cardList: null,
fundList: null,
chatReport: null,
reimbursementAccount: {},
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index a3fbb5e41378..eb7a5bc0e39a 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -2,9 +2,11 @@ import _ from 'underscore';
import React, {useState, useRef} from 'react';
import PropTypes from 'prop-types';
import {View, StyleSheet} from 'react-native';
+import lodashGet from 'lodash/get';
import * as optionRowStyles from '../../styles/optionRowStyles';
import styles from '../../styles/styles';
import * as StyleUtils from '../../styles/StyleUtils';
+import DateUtils from '../../libs/DateUtils';
import Icon from '../Icon';
import * as Expensicons from '../Icon/Expensicons';
import MultipleAvatars from '../MultipleAvatars';
@@ -22,12 +24,17 @@ import * as ContextMenuActions from '../../pages/home/report/ContextMenu/Context
import * as OptionsListUtils from '../../libs/OptionsListUtils';
import * as ReportUtils from '../../libs/ReportUtils';
import useLocalize from '../../hooks/useLocalize';
+import Permissions from '../../libs/Permissions';
+import Tooltip from '../Tooltip';
const propTypes = {
/** Style for hovered state */
// eslint-disable-next-line react/forbid-prop-types
hoverStyle: PropTypes.object,
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
+
/** The ID of the report that the option is for */
reportID: PropTypes.string.isRequired,
@@ -54,6 +61,7 @@ const defaultProps = {
style: null,
optionItem: null,
isFocused: false,
+ betas: [],
};
function OptionRowLHN(props) {
@@ -124,6 +132,13 @@ function OptionRowLHN(props) {
);
};
+ const emojiCode = lodashGet(optionItem, 'status.emojiCode', '');
+ const statusText = lodashGet(optionItem, 'status.text', '');
+ const statusClearAfterDate = lodashGet(optionItem, 'status.clearAfter', '');
+ const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate);
+ const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText;
+ const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem);
+
return (
+ {isStatusVisible && (
+
+ {emojiCode}
+
+ )}
{optionItem.alternateText ? (
login: personalData.login,
displayName: personalData.displayName,
firstName: personalData.firstName,
+ status: personalData.status,
avatar: UserUtils.getAvatar(personalData.avatar, personalData.accountID),
};
return finalPersonalDetails;
diff --git a/src/components/LinearGradient/index.js b/src/components/LinearGradient/index.js
new file mode 100644
index 000000000000..8270681641d0
--- /dev/null
+++ b/src/components/LinearGradient/index.js
@@ -0,0 +1,3 @@
+import LinearGradient from 'react-native-web-linear-gradient';
+
+export default LinearGradient;
diff --git a/src/components/LinearGradient/index.native.js b/src/components/LinearGradient/index.native.js
new file mode 100644
index 000000000000..c8d5af2646b2
--- /dev/null
+++ b/src/components/LinearGradient/index.native.js
@@ -0,0 +1,3 @@
+import LinearGradient from 'react-native-linear-gradient';
+
+export default LinearGradient;
diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js
index 1480d98d7899..799fccb74a5e 100644
--- a/src/components/MentionSuggestions.js
+++ b/src/components/MentionSuggestions.js
@@ -10,6 +10,7 @@ import Avatar from './Avatar';
import AutoCompleteSuggestions from './AutoCompleteSuggestions';
import getStyledTextArray from '../libs/GetStyledTextArray';
import avatarPropTypes from './avatarPropTypes';
+import refPropType from './refPropTypes';
const propTypes = {
/** The index of the highlighted mention */
@@ -42,10 +43,18 @@ const propTypes = {
/** Show that we should include ReportRecipientLocalTime view height */
shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired,
+
+ /** Ref of the container enclosing the menu.
+ * This is needed to render the menu in correct position inside a portal
+ */
+ containerRef: refPropType,
};
const defaultProps = {
highlightedMentionIndex: 0,
+ containerRef: {
+ current: null,
+ },
};
/**
@@ -122,6 +131,7 @@ function MentionSuggestions(props) {
isSuggestionPickerLarge={props.isMentionPickerLarge}
shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight}
accessibilityLabelExtractor={keyExtractor}
+ parentContainerRef={props.containerRef}
/>
);
}
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 40745e7833c1..e284244f694c 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -42,12 +42,14 @@ const defaultProps = {
descriptionTextStyle: styles.breakWord,
success: false,
icon: undefined,
+ secondaryIcon: undefined,
iconWidth: undefined,
iconHeight: undefined,
description: undefined,
iconRight: Expensicons.ArrowRight,
iconStyles: [],
iconFill: undefined,
+ secondaryIconFill: undefined,
focused: false,
disabled: false,
isSelected: false,
@@ -131,7 +133,7 @@ const MenuItem = React.forwardRef((props, ref) => {
disabled={props.disabled}
ref={ref}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.MENUITEM}
- accessibilityLabel={props.title}
+ accessibilityLabel={props.title ? props.title.toString() : ''}
>
{({pressed}) => (
<>
@@ -161,6 +163,8 @@ const MenuItem = React.forwardRef((props, ref) => {
{props.iconType === CONST.ICON_TYPE_ICON && (
{
)}
)}
+ {Boolean(props.secondaryIcon) && (
+
+
+
+ )}
{Boolean(props.description) && props.shouldShowDescriptionOnTop && (
{
+ ComposerFocusManager.resetReadyToFocus();
+ }}
onModalShow={() => {
if (this.props.shouldSetModalVisibility) {
Modal.setModalVisibility(true);
@@ -117,6 +124,7 @@ class BaseModal extends PureComponent {
}}
propagateSwipe={this.props.propagateSwipe}
onModalHide={this.hideModal}
+ onDismiss={() => ComposerFocusManager.setReadyToFocus()}
onSwipeComplete={this.props.onClose}
swipeDirection={swipeDirection}
isVisible={this.props.isVisible}
diff --git a/src/components/Modal/index.android.js b/src/components/Modal/index.android.js
index 09df74329b20..b5f11a02650a 100644
--- a/src/components/Modal/index.android.js
+++ b/src/components/Modal/index.android.js
@@ -1,7 +1,17 @@
import React from 'react';
+import {AppState} from 'react-native';
import withWindowDimensions from '../withWindowDimensions';
import BaseModal from './BaseModal';
import {propTypes, defaultProps} from './modalPropTypes';
+import ComposerFocusManager from '../../libs/ComposerFocusManager';
+
+AppState.addEventListener('focus', () => {
+ ComposerFocusManager.setReadyToFocus();
+});
+
+AppState.addEventListener('blur', () => {
+ ComposerFocusManager.resetReadyToFocus();
+});
// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS
// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 4079d1240e61..1adee0d7a0e3 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -1,9 +1,9 @@
import React, {useCallback, useMemo, useReducer, useState} from 'react';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
+import {format} from 'date-fns';
import _ from 'underscore';
import {View} from 'react-native';
-import Str from 'expensify-common/lib/str';
import styles from '../styles/styles';
import * as ReportUtils from '../libs/ReportUtils';
import * as OptionsListUtils from '../libs/OptionsListUtils';
@@ -25,12 +25,9 @@ import Button from './Button';
import * as Expensicons from './Icon/Expensicons';
import themeColors from '../styles/themes/default';
import Image from './Image';
-import ReceiptHTML from '../../assets/images/receipt-html.png';
-import ReceiptDoc from '../../assets/images/receipt-doc.png';
-import ReceiptGeneric from '../../assets/images/receipt-generic.png';
-import ReceiptSVG from '../../assets/images/receipt-svg.png';
-import * as FileUtils from '../libs/fileDownload/FileUtils';
import useLocalize from '../hooks/useLocalize';
+import * as ReceiptUtils from '../libs/ReceiptUtils';
+import categoryPropTypes from './categoryPropTypes';
const propTypes = {
/** Callback to inform parent modal of success */
@@ -58,11 +55,14 @@ const propTypes = {
iouType: PropTypes.string,
/** IOU date */
- iouDate: PropTypes.string,
+ iouCreated: PropTypes.string,
/** IOU merchant */
iouMerchant: PropTypes.string,
+ /** IOU Category */
+ iouCategory: PropTypes.string,
+
/** Selected participants from MoneyRequestModal with login / accountID */
selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired,
@@ -96,6 +96,10 @@ const propTypes = {
/** File source of the receipt */
receiptSource: PropTypes.string,
+
+ /* Onyx Props */
+ /** Collection of categories attached to a policy */
+ policyCategories: PropTypes.objectOf(categoryPropTypes),
};
const defaultProps = {
@@ -103,6 +107,7 @@ const defaultProps = {
onSendMoney: () => {},
onSelectParticipant: () => {},
iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST,
+ iouCategory: '',
payeePersonalDetails: null,
canModifyParticipants: false,
isReadOnly: false,
@@ -115,6 +120,7 @@ const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
receiptPath: '',
receiptSource: '',
+ policyCategories: {},
};
function MoneyRequestConfirmationList(props) {
@@ -125,6 +131,7 @@ function MoneyRequestConfirmationList(props) {
// A flag and a toggler for showing the rest of the form fields
const [showAllFields, toggleShowAllFields] = useReducer((state) => !state, false);
+ const isTypeRequest = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST;
/**
* Returns the participants with amount
@@ -133,7 +140,7 @@ function MoneyRequestConfirmationList(props) {
*/
const getParticipantsWithAmount = useCallback(
(participantsList) => {
- const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount);
+ const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode);
return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(participantsList, CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode));
},
[props.iouAmount, props.iouCurrencyCode],
@@ -176,7 +183,7 @@ function MoneyRequestConfirmationList(props) {
}));
}
- const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, true);
+ const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true);
const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
payeePersonalDetails,
CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode),
@@ -250,7 +257,9 @@ function MoneyRequestConfirmationList(props) {
*/
const navigateToReportOrUserDetail = (option) => {
if (option.accountID) {
- Navigation.navigate(ROUTES.getProfileRoute(option.accountID));
+ const activeRoute = Navigation.getActiveRoute().replace(/\?.*/, '');
+
+ Navigation.navigate(ROUTES.getProfileRoute(option.accountID, activeRoute));
} else if (option.reportID) {
Navigation.navigate(ROUTES.getReportDetailsRoute(option.reportID));
}
@@ -303,6 +312,7 @@ function MoneyRequestConfirmationList(props) {
currency={props.iouCurrencyCode}
policyID={props.policyID}
shouldShowPaymentOptions
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
anchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
@@ -313,40 +323,11 @@ function MoneyRequestConfirmationList(props) {
isDisabled={shouldDisableButton}
onPress={(_event, value) => confirm(value)}
options={splitOrRequestOptions}
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
/>
);
}, [confirm, props.selectedParticipants, props.bankAccountRoute, props.iouCurrencyCode, props.iouType, props.isReadOnly, props.policyID, selectedParticipants, splitOrRequestOptions]);
- /**
- * Grab the appropriate image URI based on file type
- *
- * @param {String} receiptPath
- * @param {String} receiptSource
- * @returns {*}
- */
- const getImageURI = (receiptPath, receiptSource) => {
- const {fileExtension} = FileUtils.splitExtensionFromFileName(receiptSource);
- const isReceiptImage = Str.isImage(props.receiptSource);
-
- if (isReceiptImage) {
- return receiptPath;
- }
-
- if (fileExtension === CONST.IOU.FILE_TYPES.HTML) {
- return ReceiptHTML;
- }
-
- if (fileExtension === CONST.IOU.FILE_TYPES.DOC || fileExtension === CONST.IOU.FILE_TYPES.DOCX) {
- return ReceiptDoc;
- }
-
- if (fileExtension === CONST.IOU.FILE_TYPES.SVG) {
- return ReceiptSVG;
- }
-
- return ReceiptGeneric;
- };
-
return (
) : (
Navigation.navigate(ROUTES.getMoneyRequestDescriptionRoute(props.iouType, props.reportID))}
style={[styles.moneyRequestMenuItem, styles.mb2]}
+ titleStyle={styles.flex1}
disabled={didConfirm || props.isReadOnly}
/>
{!showAllFields && (
@@ -406,19 +388,33 @@ function MoneyRequestConfirmationList(props) {
{showAllFields && (
<>
Navigation.navigate(ROUTES.getMoneyRequestCreatedRoute(props.iouType, props.reportID))}
+ disabled={didConfirm || props.isReadOnly || !isTypeRequest}
/>
Navigation.navigate(ROUTES.getMoneyRequestMerchantRoute(props.iouType, props.reportID))}
+ disabled={didConfirm || props.isReadOnly || !isTypeRequest}
/>
+ {!_.isEmpty(props.policyCategories) && (
+ Navigation.navigate(ROUTES.getMoneyRequestCategoryRoute(props.iouType, props.reportID))}
+ style={[styles.moneyRequestMenuItem, styles.mb2]}
+ disabled={didConfirm || props.isReadOnly}
+ />
+ )}
>
)}
@@ -434,5 +430,8 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
+ policyCategories: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ },
}),
)(MoneyRequestConfirmationList);
diff --git a/src/components/MoneyRequestDetails.js b/src/components/MoneyRequestDetails.js
deleted file mode 100644
index a690c31c000c..000000000000
--- a/src/components/MoneyRequestDetails.js
+++ /dev/null
@@ -1,231 +0,0 @@
-import React from 'react';
-import {withOnyx} from 'react-native-onyx';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import lodashGet from 'lodash/get';
-import iouReportPropTypes from '../pages/iouReportPropTypes';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import * as ReportUtils from '../libs/ReportUtils';
-import * as Expensicons from './Icon/Expensicons';
-import Text from './Text';
-import participantPropTypes from './participantPropTypes';
-import Avatar from './Avatar';
-import styles from '../styles/styles';
-import themeColors from '../styles/themes/default';
-import CONST from '../CONST';
-import withWindowDimensions from './withWindowDimensions';
-import compose from '../libs/compose';
-import ROUTES from '../ROUTES';
-import Icon from './Icon';
-import SettlementButton from './SettlementButton';
-import * as Policy from '../libs/actions/Policy';
-import ONYXKEYS from '../ONYXKEYS';
-import * as IOU from '../libs/actions/IOU';
-import * as CurrencyUtils from '../libs/CurrencyUtils';
-import MenuItemWithTopDescription from './MenuItemWithTopDescription';
-import DateUtils from '../libs/DateUtils';
-import reportPropTypes from '../pages/reportPropTypes';
-import * as UserUtils from '../libs/UserUtils';
-import OfflineWithFeedback from './OfflineWithFeedback';
-
-const propTypes = {
- /** The report currently being looked at */
- report: iouReportPropTypes.isRequired,
-
- /** The expense report or iou report (only will have a value if this is a transaction thread) */
- parentReport: iouReportPropTypes,
-
- /** The policy object for the current route */
- policy: PropTypes.shape({
- /** The name of the policy */
- name: PropTypes.string,
-
- /** The URL for the policy avatar */
- avatar: PropTypes.string,
- }),
-
- /** The chat report this report is linked to */
- chatReport: reportPropTypes,
-
- /** Personal details so we can get the ones for the report participants */
- personalDetails: PropTypes.objectOf(participantPropTypes).isRequired,
-
- /** Whether we're viewing a report with a single transaction in it */
- isSingleTransactionView: PropTypes.bool,
-
- /** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user email */
- email: PropTypes.string,
- }),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- isSingleTransactionView: false,
- chatReport: {},
- session: {
- email: null,
- },
- parentReport: {},
- policy: null,
-};
-
-function MoneyRequestDetails(props) {
- // These are only used for the single transaction view and not for expense and iou reports
- const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription} = ReportUtils.getMoneyRequestAction(props.parentReportAction);
- const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency);
- const transactionDate = lodashGet(props.parentReportAction, ['created']);
- const formattedTransactionDate = DateUtils.getDateStringFromISOTimestamp(transactionDate);
-
- const reportTotal = ReportUtils.getMoneyRequestTotal(props.report);
- const formattedAmount = CurrencyUtils.convertToDisplayString(reportTotal, props.report.currency);
- const moneyRequestReport = props.isSingleTransactionView ? props.parentReport : props.report;
- const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
- const isExpenseReport = ReportUtils.isExpenseReport(moneyRequestReport);
- const payeeName = isExpenseReport ? ReportUtils.getPolicyName(moneyRequestReport) : ReportUtils.getDisplayNameForParticipant(moneyRequestReport.managerID);
- const payeeAvatar = isExpenseReport
- ? ReportUtils.getWorkspaceAvatar(moneyRequestReport)
- : UserUtils.getAvatar(lodashGet(props.personalDetails, [moneyRequestReport.managerID, 'avatar']), moneyRequestReport.managerID);
- const isPayer =
- Policy.isAdminOfFreePolicy([props.policy]) || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(props.session, 'accountID', null) === moneyRequestReport.managerID);
- const shouldShowSettlementButton =
- moneyRequestReport.reportID && !isSettled && !props.isSingleTransactionView && isPayer && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0;
- const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
- const shouldShowPaypal = Boolean(lodashGet(props.personalDetails, [moneyRequestReport.ownerAccountID, 'payPalMeAddress']));
- let description = `${props.translate('iou.amount')} • ${props.translate('iou.cash')}`;
- if (isSettled) {
- description += ` • ${props.translate('iou.settledExpensify')}`;
- } else if (props.report.isWaitingOnBankAccount) {
- description += ` • ${props.translate('iou.pending')}`;
- }
-
- const {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(props.report);
- return (
-
-
-
- {props.translate('common.to')}
-
-
-
-
-
- {payeeName}
-
- {isExpenseReport && (
-
- {props.translate('workspace.common.workspace')}
-
- )}
-
-
-
- {!props.isSingleTransactionView && {formattedAmount} }
- {!props.isSingleTransactionView && isSettled && (
-
-
-
- )}
- {shouldShowSettlementButton && !props.isSmallScreenWidth && (
-
- IOU.payMoneyRequest(paymentType, props.chatReport, props.report)}
- enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW}
- addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
- />
-
- )}
-
-
- {shouldShowSettlementButton && props.isSmallScreenWidth && (
- IOU.payMoneyRequest(paymentType, props.chatReport, props.report)}
- enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW}
- addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
- />
- )}
-
- {props.isSingleTransactionView && (
- <>
- Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
- />
- Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))}
- />
- Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
- />
- >
- )}
-
-
- );
-}
-
-MoneyRequestDetails.displayName = 'MoneyRequestDetails';
-MoneyRequestDetails.propTypes = propTypes;
-MoneyRequestDetails.defaultProps = defaultProps;
-
-export default compose(
- withWindowDimensions,
- withLocalize,
- withOnyx({
- chatReport: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- parentReport: {
- key: (props) => `${ONYXKEYS.COLLECTION.REPORT}${props.report.parentReportID}`,
- },
- }),
-)(MoneyRequestDetails);
diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js
index 79a144a2dc1a..de1a08c8f83c 100644
--- a/src/components/MoneyRequestHeader.js
+++ b/src/components/MoneyRequestHeader.js
@@ -13,12 +13,13 @@ import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimen
import compose from '../libs/compose';
import Navigation from '../libs/Navigation/Navigation';
import ROUTES from '../ROUTES';
-import * as Policy from '../libs/actions/Policy';
import ONYXKEYS from '../ONYXKEYS';
import * as IOU from '../libs/actions/IOU';
import * as ReportActionsUtils from '../libs/ReportActionsUtils';
import ConfirmModal from './ConfirmModal';
import useLocalize from '../hooks/useLocalize';
+import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar';
+import * as TransactionUtils from '../libs/TransactionUtils';
const propTypes = {
/** The report currently being looked at */
@@ -57,26 +58,29 @@ function MoneyRequestHeader(props) {
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const moneyRequestReport = props.parentReport;
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
- const policy = props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`];
- const isPayer =
- Policy.isAdminOfFreePolicy([policy]) || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(props.session, 'accountID', null) === moneyRequestReport.managerID);
+ const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
+
+ // Only the requestor can take delete the request, admins can only edit it.
+ const isActionOwner = parentReportAction.actorAccountID === lodashGet(props.session, 'accountID', null);
const report = props.report;
report.ownerAccountID = lodashGet(props, ['parentReport', 'ownerAccountID'], null);
report.ownerEmail = lodashGet(props, ['parentReport', 'ownerEmail'], '');
- const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
const deleteTransaction = useCallback(() => {
IOU.deleteMoneyRequest(parentReportAction.originalMessage.IOUTransactionID, parentReportAction, true);
setIsDeleteModalVisible(false);
}, [parentReportAction, setIsDeleteModalVisible]);
+ const transaction = TransactionUtils.getLinkedTransaction(parentReportAction);
+ const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
+
return (
<>
Navigation.goBack(ROUTES.HOME, false, true)}
- shouldShowBorderBottom
/>
+ {isScanning && }
+
+ {translate('iou.receiptStatusTitle')}
+
+
+ {translate('iou.receiptStatusText')}
+
+
+ );
+}
+
+MoneyRequestHeaderStatusBar.displayName = 'MoneyRequestHeaderStatusBar';
+
+export default MoneyRequestHeaderStatusBar;
diff --git a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
index 24e22fb1f746..00ab73966b4b 100644
--- a/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
+++ b/src/components/NewDatePicker/CalendarPicker/YearPickerModal.js
@@ -3,17 +3,19 @@ import PropTypes from 'prop-types';
import _ from 'underscore';
import HeaderWithBackButton from '../../HeaderWithBackButton';
import CONST from '../../../CONST';
-import SelectionListRadio from '../../SelectionListRadio';
+import SelectionList from '../../SelectionList';
import Modal from '../../Modal';
-import {radioListItemPropTypes} from '../../SelectionListRadio/selectionListRadioPropTypes';
+import {radioListItemPropTypes} from '../../SelectionList/selectionListPropTypes';
import useLocalize from '../../../hooks/useLocalize';
+import ScreenWrapper from '../../ScreenWrapper';
+import styles from '../../../styles/styles';
const propTypes = {
/** Whether the modal is visible */
isVisible: PropTypes.bool.isRequired,
/** The list of years to render */
- years: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes)).isRequired,
+ years: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes.item)).isRequired,
/** Currently selected year */
currentYear: PropTypes.number,
@@ -58,22 +60,28 @@ function YearPickerModal(props) {
hideModalContentWhileAnimating
useNativeDriver
>
-
- setSearchText(text.replace(CONST.REGEX.NON_NUMERIC, '').trim())}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
- headerMessage={headerMessage}
- sections={sections}
- onSelectRow={(option) => props.onYearChange(option.value)}
- initiallyFocusedOptionKey={props.currentYear.toString()}
- />
+
+
+ setSearchText(text.replace(CONST.REGEX.NON_NUMERIC, '').trim())}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ headerMessage={headerMessage}
+ sections={sections}
+ onSelectRow={(option) => props.onYearChange(option.value)}
+ initiallyFocusedOptionKey={props.currentYear.toString()}
+ />
+
);
}
diff --git a/src/components/OfflineWithFeedback.js b/src/components/OfflineWithFeedback.js
index 820cce252205..fb0411d24f4c 100644
--- a/src/components/OfflineWithFeedback.js
+++ b/src/components/OfflineWithFeedback.js
@@ -37,6 +37,9 @@ const propTypes = {
/** Whether we should show the error messages */
shouldShowErrorMessages: PropTypes.bool,
+ /** Whether we should disable opacity */
+ shouldDisableOpacity: PropTypes.bool,
+
/** A function to run when the X button next to the error is clicked */
onClose: PropTypes.func,
@@ -63,6 +66,7 @@ const defaultProps = {
shouldHideOnDelete: true,
errors: null,
shouldShowErrorMessages: true,
+ shouldDisableOpacity: false,
onClose: () => {},
style: [],
contentContainerStyle: [],
@@ -94,10 +98,10 @@ function OfflineWithFeedback(props) {
const errorMessages = _.omit(props.errors, (e) => e === null);
const hasErrorMessages = !_.isEmpty(errorMessages);
const isOfflinePendingAction = props.network.isOffline && props.pendingAction;
- const isUpdateOrDeleteError = hasErrors && (props.pendingAction === 'delete' || props.pendingAction === 'update');
- const isAddError = hasErrors && props.pendingAction === 'add';
- const needsOpacity = (isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError;
- const needsStrikeThrough = props.network.isOffline && props.pendingAction === 'delete';
+ const isUpdateOrDeleteError = hasErrors && (props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ const isAddError = hasErrors && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD;
+ const needsOpacity = !props.shouldDisableOpacity && ((isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError);
+ const needsStrikeThrough = props.network.isOffline && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
const hideChildren = props.shouldHideOnDelete && !props.network.isOffline && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !hasErrors;
let children = props.children;
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index b894e8c3bfc1..7273616ea57e 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -96,18 +96,6 @@ class BaseOptionsSelector extends Component {
);
this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false);
-
- if (!this.props.autoFocus) {
- return;
- }
-
- if (this.props.shouldShowTextInput) {
- if (this.props.shouldDelayFocus) {
- this.focusTimeout = setTimeout(() => this.textInput.focus(), CONST.ANIMATED_TRANSITION);
- } else {
- this.textInput.focus();
- }
- }
}
componentDidUpdate(prevProps) {
@@ -339,6 +327,8 @@ class BaseOptionsSelector extends Component {
selectTextOnFocus
blurOnSubmit={Boolean(this.state.allOptions.length)}
spellCheck={false}
+ autoFocus={this.props.autoFocus}
+ shouldDelayFocus={this.props.shouldDelayFocus}
/>
);
const optionsList = (
diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js
index adbe3e801776..3a7814684ebb 100644
--- a/src/components/PDFView/PDFPasswordForm.js
+++ b/src/components/PDFView/PDFPasswordForm.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import React, {Component} from 'react';
+import React, {useState, useRef, useEffect, useMemo} from 'react';
import PropTypes from 'prop-types';
import {View, ScrollView} from 'react-native';
import Button from '../Button';
@@ -7,12 +7,11 @@ import Text from '../Text';
import TextInput from '../TextInput';
import styles from '../../styles/styles';
import PDFInfoMessage from './PDFInfoMessage';
-import compose from '../../libs/compose';
-import withLocalize, {withLocalizePropTypes} from '../withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions';
import shouldDelayFocus from '../../libs/shouldDelayFocus';
import * as Browser from '../../libs/Browser';
import CONST from '../../CONST';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
/** If the submitted password is invalid (show an error message) */
@@ -32,9 +31,6 @@ const propTypes = {
/** Should focus to the password input */
isFocused: PropTypes.bool.isRequired,
-
- ...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
};
const defaultProps = {
@@ -45,133 +41,113 @@ const defaultProps = {
onPasswordFieldFocused: () => {},
};
-class PDFPasswordForm extends Component {
- constructor(props) {
- super(props);
- this.state = {
- password: '',
- validationErrorText: '',
- shouldShowForm: false,
- };
- this.submitPassword = this.submitPassword.bind(this);
- this.updatePassword = this.updatePassword.bind(this);
- this.showForm = this.showForm.bind(this);
- this.validateAndNotifyPasswordBlur = this.validateAndNotifyPasswordBlur.bind(this);
- this.getErrorText = this.getErrorText.bind(this);
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.isFocused || !this.props.isFocused || !this.textInputRef) {
- return;
- }
- this.textInputRef.focus();
- }
+function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicator, onSubmit, onPasswordUpdated, onPasswordFieldFocused}) {
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const {translate} = useLocalize();
- getErrorText() {
- if (this.props.isPasswordInvalid) {
- return this.props.translate('attachmentView.passwordIncorrect');
+ const [password, setPassword] = useState('');
+ const [validationErrorText, setValidationErrorText] = useState('');
+ const [shouldShowForm, setShouldShowForm] = useState(false);
+ const textInputRef = useRef(null);
+
+ const errorText = useMemo(() => {
+ if (isPasswordInvalid) {
+ return translate('attachmentView.passwordIncorrect');
}
- if (!_.isEmpty(this.state.validationErrorText)) {
- return this.props.translate(this.state.validationErrorText);
+ if (!_.isEmpty(validationErrorText)) {
+ return translate(validationErrorText);
}
-
return '';
- }
+ }, [isPasswordInvalid, translate, validationErrorText]);
- submitPassword() {
- if (!this.validate()) {
+ useEffect(() => {
+ if (!isFocused) {
+ return;
+ }
+ if (!textInputRef.current) {
return;
}
- this.props.onSubmit(this.state.password);
- }
+ textInputRef.current.focus();
+ }, [isFocused]);
- updatePassword(password) {
- this.props.onPasswordUpdated(password);
- if (!_.isEmpty(password) && this.state.validationErrorText) {
- this.setState({validationErrorText: ''});
+ const updatePassword = (newPassword) => {
+ onPasswordUpdated(newPassword);
+ if (!_.isEmpty(newPassword) && validationErrorText) {
+ setValidationErrorText('');
}
- this.setState({password});
- }
+ setPassword(newPassword);
+ };
- validate() {
- if (!this.props.isPasswordInvalid && !_.isEmpty(this.state.password)) {
+ const validate = () => {
+ if (!isPasswordInvalid && !_.isEmpty(password)) {
return true;
}
-
- if (_.isEmpty(this.state.password)) {
- this.setState({
- validationErrorText: 'attachmentView.passwordRequired',
- });
+ if (_.isEmpty(password)) {
+ setValidationErrorText('attachmentView.passwordRequired');
}
-
return false;
- }
-
- validateAndNotifyPasswordBlur() {
- this.validate();
- this.props.onPasswordFieldFocused(false);
- }
-
- showForm() {
- this.setState({shouldShowForm: true});
- }
-
- render() {
- const errorText = this.getErrorText();
- const containerStyle = this.props.isSmallScreenWidth ? [styles.flex1, styles.w100] : styles.pdfPasswordForm.wideScreenWidth;
-
- return (
- <>
- {this.state.shouldShowForm ? (
-
-
- {this.props.translate('attachmentView.pdfPasswordForm.formLabel')}
-
- (this.textInputRef = el)}
- label={this.props.translate('common.password')}
- accessibilityLabel={this.props.translate('common.password')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
- /**
- * This is a workaround to bypass Safari's autofill odd behaviour.
- * This tricks the browser not to fill the username somewhere else and still fill the password correctly.
- */
- autoComplete={Browser.getBrowser() === CONST.BROWSER.SAFARI ? 'username' : 'off'}
- autoCorrect={false}
- textContentType="password"
- onChangeText={this.updatePassword}
- returnKeyType="done"
- onSubmitEditing={this.submitPassword}
- errorText={errorText}
- onFocus={() => this.props.onPasswordFieldFocused(true)}
- onBlur={this.validateAndNotifyPasswordBlur}
- autoFocus
- shouldDelayFocus={shouldDelayFocus}
- secureTextEntry
- />
-
-
- ) : (
-
-
-
- )}
- >
- );
- }
+ };
+
+ const submitPassword = () => {
+ if (!validate()) {
+ return;
+ }
+ onSubmit(password);
+ };
+
+ const validateAndNotifyPasswordBlur = () => {
+ validate();
+ onPasswordFieldFocused(false);
+ };
+
+ return shouldShowForm ? (
+
+
+ {translate('attachmentView.pdfPasswordForm.formLabel')}
+
+ onPasswordFieldFocused(true)}
+ onBlur={validateAndNotifyPasswordBlur}
+ autoFocus
+ shouldDelayFocus={shouldDelayFocus}
+ secureTextEntry
+ />
+
+
+ ) : (
+
+ setShouldShowForm(true)} />
+
+ );
}
PDFPasswordForm.propTypes = propTypes;
PDFPasswordForm.defaultProps = defaultProps;
+PDFPasswordForm.displayName = 'PDFPasswordForm';
-export default compose(withWindowDimensions, withLocalize)(PDFPasswordForm);
+export default PDFPasswordForm;
diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.js
index e12e7a96e549..efa230d920d5 100644
--- a/src/components/PopoverProvider/index.js
+++ b/src/components/PopoverProvider/index.js
@@ -48,7 +48,10 @@ function PopoverContextProvider(props) {
}, [closePopover]);
React.useEffect(() => {
- const listener = () => {
+ const listener = (e) => {
+ if (!activePopoverRef.current || !activePopoverRef.current.ref || !activePopoverRef.current.ref.current || activePopoverRef.current.ref.current.contains(e.target)) {
+ return;
+ }
closePopover();
};
document.addEventListener('contextmenu', listener);
diff --git a/src/components/PopoverWithoutOverlay/index.js b/src/components/PopoverWithoutOverlay/index.js
index d42f735b19a8..778f65349969 100644
--- a/src/components/PopoverWithoutOverlay/index.js
+++ b/src/components/PopoverWithoutOverlay/index.js
@@ -35,6 +35,7 @@ function Popover(props) {
} else {
props.onModalHide();
close(props.anchorRef);
+ Modal.onModalDidClose();
}
Modal.willAlertModalBecomeVisible(props.isVisible);
Modal.setCloseModal(props.isVisible ? () => props.onClose(props.anchorRef) : null);
diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js
index de34ebf95242..922be96084d8 100644
--- a/src/components/Reactions/AddReactionBubble.js
+++ b/src/components/Reactions/AddReactionBubble.js
@@ -67,7 +67,7 @@ function AddReactionBubble(props) {
refParam || ref.current,
anchorOrigin,
props.onWillShowPicker,
- props.reportAction,
+ props.reportAction.reportActionID,
);
};
diff --git a/src/components/Reactions/EmojiReactionBubble.js b/src/components/Reactions/EmojiReactionBubble.js
index 30520cb633ef..bb37735d6920 100644
--- a/src/components/Reactions/EmojiReactionBubble.js
+++ b/src/components/Reactions/EmojiReactionBubble.js
@@ -38,6 +38,9 @@ const propTypes = {
*/
hasUserReacted: PropTypes.bool,
+ /** We disable reacting with emojis on report actions that have errors */
+ shouldBlockReactions: PropTypes.bool,
+
...windowDimensionsPropTypes,
};
@@ -45,6 +48,7 @@ const defaultProps = {
count: 0,
onReactionListOpen: () => {},
isContextMenu: false,
+ shouldBlockReactions: false,
...withCurrentUserPersonalDetailsDefaultProps,
};
@@ -52,8 +56,18 @@ const defaultProps = {
function EmojiReactionBubble(props) {
return (
[styles.emojiReactionBubble, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, props.hasUserReacted, props.isContextMenu)]}
- onPress={props.onPress}
+ style={({hovered, pressed}) => [
+ styles.emojiReactionBubble,
+ StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, props.hasUserReacted, props.isContextMenu),
+ props.shouldBlockReactions && styles.cursorDisabled,
+ ]}
+ onPress={() => {
+ if (props.shouldBlockReactions) {
+ return;
+ }
+
+ props.onPress();
+ }}
onLongPress={props.onReactionListOpen}
onSecondaryInteraction={props.onReactionListOpen}
ref={props.forwardedRef}
diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js
index 1ebb8a971827..82f83cb1e961 100644
--- a/src/components/Reactions/MiniQuickEmojiReactions.js
+++ b/src/components/Reactions/MiniQuickEmojiReactions.js
@@ -67,7 +67,7 @@ function MiniQuickEmojiReactions(props) {
ref.current,
undefined,
() => {},
- props.reportAction,
+ props.reportAction.reportActionID,
);
};
diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.js
index 806e87b4301d..ec2755f1a5dd 100644
--- a/src/components/Reactions/ReportActionItemEmojiReactions.js
+++ b/src/components/Reactions/ReportActionItemEmojiReactions.js
@@ -28,12 +28,16 @@ const propTypes = {
*/
toggleReaction: PropTypes.func.isRequired,
+ /** We disable reacting with emojis on report actions that have errors */
+ shouldBlockReactions: PropTypes.bool,
+
...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
emojiReactions: {},
+ shouldBlockReactions: false,
};
function ReportActionItemEmojiReactions(props) {
@@ -138,15 +142,18 @@ function ReportActionItemEmojiReactions(props) {
reactionUsers={reaction.reactionUsers}
hasUserReacted={reaction.hasUserReacted}
onReactionListOpen={reaction.onReactionListOpen}
+ shouldBlockReactions={props.shouldBlockReactions}
/>
);
})}
-
+ {!props.shouldBlockReactions && (
+
+ )}
)
);
diff --git a/src/components/ReportActionItem/ChronosOOOListActions.js b/src/components/ReportActionItem/ChronosOOOListActions.js
index 3c9c65d8f254..61c504d122ff 100644
--- a/src/components/ReportActionItem/ChronosOOOListActions.js
+++ b/src/components/ReportActionItem/ChronosOOOListActions.js
@@ -37,8 +37,8 @@ function ChronosOOOListActions(props) {
{_.map(events, (event) => {
- const start = DateUtils.getLocalMomentFromDatetime(props.preferredLocale, lodashGet(event, 'start.date', ''));
- const end = DateUtils.getLocalMomentFromDatetime(props.preferredLocale, lodashGet(event, 'end.date', ''));
+ const start = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'start.date', ''));
+ const end = DateUtils.getLocalDateFromDatetime(props.preferredLocale, lodashGet(event, 'end.date', ''));
return (
{
+ const onMoneyRequestPreviewPressed = () => {
if (isSplitBillAction) {
const reportActionID = lodashGet(props.action, 'reportActionID', '0');
Navigation.navigate(ROUTES.getSplitBillDetailsRoute(props.chatReportID, reportActionID));
@@ -100,12 +100,9 @@ function MoneyRequestAction(props) {
const participantAccountIDs = _.uniq([props.session.accountID, Number(props.action.actorAccountID)]);
const thread = ReportUtils.buildOptimisticChatReport(
participantAccountIDs,
- props.translate(ReportActionsUtils.isSentMoneyReportAction(props.action) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', {
- formattedAmount: ReportActionsUtils.getFormattedAmount(props.action),
- comment: props.action.originalMessage.comment,
- }),
+ ReportUtils.getTransactionReportName(props.action),
'',
- CONST.POLICY.OWNER_EMAIL_FAKE,
+ lodashGet(props.iouReport, 'policyID', CONST.POLICY.OWNER_EMAIL_FAKE),
CONST.POLICY.OWNER_ACCOUNT_ID_FAKE,
false,
'',
@@ -135,13 +132,13 @@ function MoneyRequestAction(props) {
props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD &&
props.network.isOffline
) {
- shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(props.reportActions, props.iouReport);
+ shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(props.iouReport);
}
return isDeletedParentAction ? (
${props.translate('parentReportAction.deletedRequest')}`} />
) : (
-
);
diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
similarity index 55%
rename from src/components/ReportActionItem/IOUPreview.js
rename to src/components/ReportActionItem/MoneyRequestPreview.js
index 85a0b22ac327..0112b2cca7f3 100644
--- a/src/components/ReportActionItem/IOUPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -26,8 +26,12 @@ import * as OptionsListUtils from '../../libs/OptionsListUtils';
import * as CurrencyUtils from '../../libs/CurrencyUtils';
import * as IOUUtils from '../../libs/IOUUtils';
import * as ReportUtils from '../../libs/ReportUtils';
+import * as TransactionUtils from '../../libs/TransactionUtils';
import refPropTypes from '../refPropTypes';
import PressableWithFeedback from '../Pressable/PressableWithoutFeedback';
+import * as ReceiptUtils from '../../libs/ReceiptUtils';
+import ReportActionItemImages from './ReportActionItemImages';
+import transactionPropTypes from '../transactionPropTypes';
const propTypes = {
/** The active IOUReport, used for Onyx subscription */
@@ -87,6 +91,9 @@ const propTypes = {
}),
),
+ /** The transaction attached to the action.message.iouTransactionID */
+ transaction: transactionPropTypes,
+
/** Session info for the currently logged in user. */
session: PropTypes.shape({
/** Currently logged in user email */
@@ -96,9 +103,6 @@ const propTypes = {
/** Information about the user accepting the terms for payments */
walletTerms: walletTermsPropTypes,
- /** Pending action, if any */
- pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
-
/** Whether or not an IOU report contains money requests in a different currency
* that are either created or cancelled offline, and thus haven't been converted to the report's currency yet
*/
@@ -115,33 +119,43 @@ const defaultProps = {
checkIfContextMenuActive: () => {},
containerStyles: [],
walletTerms: {},
- pendingAction: null,
isHovered: false,
personalDetails: {},
session: {
email: null,
},
+ transaction: {},
shouldShowPendingConversionMessage: false,
};
-function IOUPreview(props) {
+function MoneyRequestPreview(props) {
if (_.isEmpty(props.iouReport) && !props.isBillSplit) {
return null;
}
const sessionAccountID = lodashGet(props.session, 'accountID', null);
const managerID = props.iouReport.managerID || '';
const ownerAccountID = props.iouReport.ownerAccountID || '';
+ const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.chatReport);
+
const participantAccountIDs = props.isBillSplit ? lodashGet(props.action, 'originalMessage.participantAccountIDs', []) : [managerID, ownerAccountID];
const participantAvatars = OptionsListUtils.getAvatarsForAccountIDs(participantAccountIDs, props.personalDetails);
+ if (isPolicyExpenseChat && props.isBillSplit) {
+ participantAvatars.push(ReportUtils.getWorkspaceIcon(props.chatReport));
+ }
// Pay button should only be visible to the manager of the report.
const isCurrentUserManager = managerID === sessionAccountID;
- const moneyRequestAction = ReportUtils.getMoneyRequestAction(props.action);
+ const {amount: requestAmount, currency: requestCurrency, comment: requestComment, merchant: requestMerchant} = ReportUtils.getTransactionDetails(props.transaction);
+ let description = requestComment;
+ const hasReceipt = TransactionUtils.hasReceipt(props.transaction);
+ const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(props.transaction);
+ const isDistanceRequest = TransactionUtils.isDistanceRequest(props.transaction);
- const requestAmount = moneyRequestAction.amount;
- const requestCurrency = moneyRequestAction.currency;
- const requestComment = moneyRequestAction.comment.trim();
+ // On a distance request the merchant of the transaction will be used for the description since that's where it's stored in the database
+ if (isDistanceRequest) {
+ description = props.transaction.merchant;
+ }
const getSettledMessage = () => {
switch (lodashGet(props.action, 'originalMessage.paymentType', '')) {
@@ -161,6 +175,14 @@ function IOUPreview(props) {
};
const getPreviewHeaderText = () => {
+ if (isDistanceRequest) {
+ return props.translate('tabSelector.distance');
+ }
+
+ if (isScanning) {
+ return props.translate('common.receipt');
+ }
+
if (props.isBillSplit) {
return props.translate('iou.split');
}
@@ -174,10 +196,21 @@ function IOUPreview(props) {
return message;
};
+ const getDisplayAmountText = () => {
+ if (isDistanceRequest) {
+ return CurrencyUtils.convertToDisplayString(TransactionUtils.getAmount(props.transaction), props.transaction.currency);
+ }
+
+ if (isScanning) {
+ return props.translate('iou.receiptScanning');
+ }
+
+ return CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency);
+ };
+
const childContainer = (
{
PaymentMethods.clearWalletTermsError();
@@ -186,61 +219,78 @@ function IOUPreview(props) {
errorRowStyles={[styles.mbn1]}
needsOffscreenAlphaCompositing
>
-
-
-
- {getPreviewHeaderText()}
- {Boolean(getSettledMessage()) && (
- <>
-
- {getSettledMessage()}
- >
- )}
+
+ {hasReceipt && (
+
+ )}
+
+
+
+ {getPreviewHeaderText()}
+ {Boolean(getSettledMessage()) && (
+ <>
+
+ {getSettledMessage()}
+ >
+ )}
+
+
-
-
-
- {CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency)}
- {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && (
-
-
+
+ {getDisplayAmountText()}
+ {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && (
+
+
+
+ )}
+
+ {props.isBillSplit && (
+
+
)}
- {props.isBillSplit && (
-
-
+ {!props.isBillSplit && !_.isEmpty(requestMerchant) && (
+
+ {requestMerchant}
)}
-
-
-
- {!isCurrentUserManager && props.shouldShowPendingConversionMessage && (
- {props.translate('iou.pendingConversionMessage')}
+
+
+ {!isCurrentUserManager && props.shouldShowPendingConversionMessage && (
+ {props.translate('iou.pendingConversionMessage')}
+ )}
+ {!_.isEmpty(description) && {description} }
+
+ {props.isBillSplit && !_.isEmpty(participantAccountIDs) && (
+
+ {props.translate('iou.amountEach', {
+ amount: CurrencyUtils.convertToDisplayString(
+ IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency),
+ requestCurrency,
+ ),
+ })}
+
)}
- {!_.isEmpty(requestComment) && {requestComment} }
- {props.isBillSplit && !_.isEmpty(participantAccountIDs) && (
-
- {props.translate('iou.amountEach', {
- amount: CurrencyUtils.convertToDisplayString(IOUUtils.calculateAmount(participantAccountIDs.length - 1, requestAmount), requestCurrency),
- })}
-
- )}
@@ -265,9 +315,9 @@ function IOUPreview(props) {
);
}
-IOUPreview.propTypes = propTypes;
-IOUPreview.defaultProps = defaultProps;
-IOUPreview.displayName = 'IOUPreview';
+MoneyRequestPreview.propTypes = propTypes;
+MoneyRequestPreview.defaultProps = defaultProps;
+MoneyRequestPreview.displayName = 'MoneyRequestPreview';
export default compose(
withLocalize,
@@ -275,14 +325,20 @@ export default compose(
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
+ chatReport: {
+ key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
+ },
iouReport: {
key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
},
session: {
key: ONYXKEYS.SESSION,
},
+ transaction: {
+ key: ({action}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${(action && action.originalMessage && action.originalMessage.IOUTransactionID) || 0}`,
+ },
walletTerms: {
key: ONYXKEYS.WALLET_TERMS,
},
}),
-)(IOUPreview);
+)(MoneyRequestPreview);
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index dc8916bdaecb..a22eaf65412e 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -1,11 +1,13 @@
import React from 'react';
-import {View, Image} from 'react-native';
+import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import reportPropTypes from '../../pages/reportPropTypes';
import ONYXKEYS from '../../ONYXKEYS';
-import withWindowDimensions from '../withWindowDimensions';
+import ROUTES from '../../ROUTES';
+import * as Policy from '../../libs/actions/Policy';
+import Navigation from '../../libs/Navigation/Navigation';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails';
import compose from '../../libs/compose';
import MenuItemWithTopDescription from '../MenuItemWithTopDescription';
@@ -16,10 +18,16 @@ import * as StyleUtils from '../../styles/StyleUtils';
import CONST from '../../CONST';
import * as Expensicons from '../Icon/Expensicons';
import iouReportPropTypes from '../../pages/iouReportPropTypes';
-import DateUtils from '../../libs/DateUtils';
import * as CurrencyUtils from '../../libs/CurrencyUtils';
import EmptyStateBackgroundImage from '../../../assets/images/empty-state_background-fade.png';
import useLocalize from '../../hooks/useLocalize';
+import * as ReceiptUtils from '../../libs/ReceiptUtils';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import transactionPropTypes from '../transactionPropTypes';
+import Image from '../Image';
+import ReportActionItemImage from './ReportActionItemImage';
+import * as TransactionUtils from '../../libs/TransactionUtils';
+import OfflineWithFeedback from '../OfflineWithFeedback';
const propTypes = {
/** The report currently being looked at */
@@ -28,6 +36,24 @@ const propTypes = {
/** The expense report or iou report (only will have a value if this is a transaction thread) */
parentReport: iouReportPropTypes,
+ /** The policy object for the current route */
+ policy: PropTypes.shape({
+ /** The name of the policy */
+ name: PropTypes.string,
+
+ /** The URL for the policy avatar */
+ avatar: PropTypes.string,
+ }),
+
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user email */
+ email: PropTypes.string,
+ }),
+
+ /** The transaction associated with the transactionThread */
+ transaction: transactionPropTypes,
+
/** Whether we should display the horizontal rule below the component */
shouldShowHorizontalRule: PropTypes.bool.isRequired,
@@ -36,54 +62,119 @@ const propTypes = {
const defaultProps = {
parentReport: {},
+ policy: null,
+ session: {
+ email: null,
+ },
+ transaction: {
+ amount: 0,
+ currency: CONST.CURRENCY.USD,
+ comment: {comment: ''},
+ },
};
-function MoneyRequestView(props) {
- const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
- const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription} = ReportUtils.getMoneyRequestAction(parentReportAction);
+function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, policy, session, transaction}) {
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const {translate} = useLocalize();
+
+ const parentReportAction = ReportActionsUtils.getParentReportAction(report);
+ const moneyRequestReport = parentReport;
+ const {
+ created: transactionDate,
+ amount: transactionAmount,
+ currency: transactionCurrency,
+ comment: transactionDescription,
+ merchant: transactionMerchant,
+ } = ReportUtils.getTransactionDetails(transaction);
const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency);
- const transactionDate = lodashGet(parentReportAction, ['created']);
- const formattedTransactionDate = DateUtils.getDateStringFromISOTimestamp(transactionDate);
- const moneyRequestReport = props.parentReport;
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
- const {translate} = useLocalize();
+ const isAdmin = Policy.isAdminOfFreePolicy([policy]) && ReportUtils.isExpenseReport(moneyRequestReport);
+ const isRequestor = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === parentReportAction.actorAccountID;
+ const canEdit = !isSettled && (isAdmin || isRequestor);
+
+ let description = `${translate('iou.amount')} • ${translate('iou.cash')}`;
+ if (isSettled) {
+ description += ` • ${translate('iou.settledExpensify')}`;
+ } else if (report.isWaitingOnBankAccount) {
+ description += ` • ${translate('iou.pending')}`;
+ }
+
+ // A temporary solution to hide the transaction detail
+ // This will be removed after we properly add the transaction as a prop
+ if (ReportActionsUtils.isDeletedAction(parentReportAction)) {
+ return null;
+ }
+
+ const hasReceipt = TransactionUtils.hasReceipt(transaction);
+ let receiptURIs;
+ if (hasReceipt) {
+ receiptURIs = ReceiptUtils.getThumbnailAndImageURIs(transaction.receipt.source, transaction.filename);
+ }
+
+ const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction);
return (
-
+
- Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
- />
- Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))}
- />
- Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
- />
- {props.shouldShowHorizontalRule && }
+ {hasReceipt && (
+
+
+
+ )}
+
+ Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
+ />
+
+
+ Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))}
+ />
+
+
+ Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))}
+ />
+
+
+ Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
+ />
+
+ {shouldShowHorizontalRule && }
);
}
@@ -93,11 +184,23 @@ MoneyRequestView.defaultProps = defaultProps;
MoneyRequestView.displayName = 'MoneyRequestView';
export default compose(
- withWindowDimensions,
withCurrentUserPersonalDetails,
withOnyx({
parentReport: {
- key: (props) => `${ONYXKEYS.COLLECTION.REPORT}${props.report.parentReportID}`,
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`,
+ },
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ transaction: {
+ key: ({report}) => {
+ const parentReportAction = ReportActionsUtils.getParentReportAction(report);
+ const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], 0);
+ return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
+ },
},
}),
)(MoneyRequestView);
diff --git a/src/components/ReportActionItem/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js
new file mode 100644
index 000000000000..089df6cb4a6f
--- /dev/null
+++ b/src/components/ReportActionItem/ReportActionItemImage.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from '../../styles/styles';
+import Image from '../Image';
+import ThumbnailImage from '../ThumbnailImage';
+import tryResolveUrlFromApiRoot from '../../libs/tryResolveUrlFromApiRoot';
+import ROUTES from '../../ROUTES';
+import CONST from '../../CONST';
+import {ShowContextMenuContext} from '../ShowContextMenuContext';
+import Navigation from '../../libs/Navigation/Navigation';
+import PressableWithoutFocus from '../Pressable/PressableWithoutFocus';
+import useLocalize from '../../hooks/useLocalize';
+
+const propTypes = {
+ /** thumbnail URI for the image */
+ thumbnail: PropTypes.string,
+
+ /** URI for the image */
+ image: PropTypes.string.isRequired,
+
+ /** whether or not to enable the image preview modal */
+ enablePreviewModal: PropTypes.bool,
+};
+
+const defaultProps = {
+ thumbnail: null,
+ enablePreviewModal: false,
+};
+
+/**
+ * An image with an optional thumbnail that fills its parent container. If the thumbnail is passed,
+ * we try to resolve both the image and thumbnail from the API. Similar to ImageRenderer, we show
+ * and optional preview modal as well.
+ */
+
+function ReportActionItemImage({thumbnail, image, enablePreviewModal}) {
+ const {translate} = useLocalize();
+
+ if (thumbnail) {
+ const imageSource = tryResolveUrlFromApiRoot(image);
+ const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail);
+ const thumbnailComponent = (
+
+ );
+
+ if (enablePreviewModal) {
+ return (
+
+ {({report}) => (
+ {
+ const route = ROUTES.getReportAttachmentRoute(report.reportID, imageSource);
+ Navigation.navigate(route);
+ }}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ accessibilityLabel={translate('accessibilityHints.viewAttachment')}
+ >
+ {thumbnailComponent}
+
+ )}
+
+ );
+ }
+ return thumbnailComponent;
+ }
+
+ return (
+
+ );
+}
+
+ReportActionItemImage.propTypes = propTypes;
+ReportActionItemImage.defaultProps = defaultProps;
+ReportActionItemImage.displayName = 'ReportActionItemImage';
+
+export default ReportActionItemImage;
diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js
new file mode 100644
index 000000000000..138412fed37b
--- /dev/null
+++ b/src/components/ReportActionItem/ReportActionItemImages.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import ReportActionItemImage from './ReportActionItemImage';
+
+const propTypes = {
+ /** array of image and thumbnail URIs */
+ images: PropTypes.arrayOf(
+ PropTypes.shape({
+ thumbnail: PropTypes.string,
+ image: PropTypes.string,
+ }),
+ ).isRequired,
+
+ // We're not providing default values for size and total and disabling the ESLint rule
+ // because we want them to default to the length of images, but we can't set default props
+ // to be computed from another prop
+
+ /** max number of images to show in the row if different than images length */
+ // eslint-disable-next-line react/require-default-props
+ size: PropTypes.number,
+
+ /** total number of images if different than images length */
+ // eslint-disable-next-line react/require-default-props
+ total: PropTypes.number,
+
+ /** if the corresponding report action item is hovered */
+ isHovered: PropTypes.bool,
+};
+
+const defaultProps = {
+ isHovered: false,
+};
+
+/**
+ * This component displays a row of images in a report action item like a card, such
+ * as report previews or money request previews which contain receipt images. The maximum of images
+ * shown in this row is dictated by the size prop, which, if not passed, is just the number of images.
+ * Otherwise, if size is passed and the number of images is over size, we show a small overlay on the
+ * last image of how many additional images there are. If passed, total prop can be used to change how this
+ * additional number when subtracted from size.
+ */
+
+function ReportActionItemImages({images, size, total, isHovered}) {
+ const numberOfShownImages = size || images.length;
+ const shownImages = images.slice(0, size);
+ const remaining = (total || images.length) - size;
+
+ const hoverStyle = isHovered ? styles.reportPreviewBoxHoverBorder : undefined;
+ return (
+
+ {_.map(shownImages, ({thumbnail, image}, index) => {
+ const isLastImage = index === numberOfShownImages - 1;
+ return (
+
+
+ {isLastImage && remaining > 0 && (
+
+ +{remaining}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+ReportActionItemImages.propTypes = propTypes;
+ReportActionItemImages.defaultProps = defaultProps;
+ReportActionItemImages.displayName = 'ReportActionItemImages';
+
+export default ReportActionItemImages;
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 0ba0a1c4099f..18ca7e7cf2c2 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -25,6 +25,10 @@ import refPropTypes from '../refPropTypes';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
import themeColors from '../../styles/themes/default';
import reportPropTypes from '../../pages/reportPropTypes';
+import * as ReceiptUtils from '../../libs/ReceiptUtils';
+import * as ReportActionUtils from '../../libs/ReportActionsUtils';
+import * as TransactionUtils from '../../libs/TransactionUtils';
+import ReportActionItemImages from './ReportActionItemImages';
const propTypes = {
/** All the data of the action */
@@ -95,16 +99,36 @@ const defaultProps = {
function ReportPreview(props) {
const managerID = props.iouReport.managerID || props.action.actorAccountID || 0;
const isCurrentUserManager = managerID === lodashGet(props.session, 'accountID');
- const moneyRequestCount = lodashGet(props.action, 'childMoneyRequestCount', 0);
- const moneyRequestComment = lodashGet(props.action, 'childLastMoneyRequestComment', '');
- const showComment = moneyRequestComment || moneyRequestCount > 1;
const reportTotal = ReportUtils.getMoneyRequestTotal(props.iouReport);
- let displayAmount;
- if (reportTotal) {
- displayAmount = CurrencyUtils.convertToDisplayString(reportTotal, props.iouReport.currency);
- } else {
+
+ const iouSettled = ReportUtils.isSettled(props.iouReportID);
+ const numberOfRequests = ReportActionUtils.getNumberOfMoneyRequests(props.action);
+ const moneyRequestComment = lodashGet(props.action, 'childLastMoneyRequestComment', '');
+
+ const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(props.iouReportID);
+ const numberOfScanningReceipts = _.filter(transactionsWithReceipts, (transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length;
+ const hasReceipts = transactionsWithReceipts.length > 0;
+ const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action);
+ const lastThreeTransactionsWithReceipts = ReportUtils.getReportPreviewDisplayTransactions(props.action);
+
+ const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts;
+ const previewSubtitle = hasOnlyOneReceiptRequest
+ ? transactionsWithReceipts[0].merchant
+ : props.translate('iou.requestCount', {
+ count: numberOfRequests,
+ scanningReceipts: numberOfScanningReceipts,
+ });
+
+ const getDisplayAmount = () => {
+ if (reportTotal) {
+ return CurrencyUtils.convertToDisplayString(reportTotal, props.iouReport.currency);
+ }
+ if (isScanning) {
+ return props.translate('iou.receiptScanning');
+ }
+
// 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")
- displayAmount = '';
+ let displayAmount = '';
const actionMessage = lodashGet(props.action, ['message', 0, 'text'], '');
const splits = actionMessage.split(' ');
for (let i = 0; i < splits.length; i++) {
@@ -112,13 +136,20 @@ function ReportPreview(props) {
displayAmount = splits[i];
}
}
- }
+ return displayAmount;
+ };
+
+ const getPreviewMessage = () => {
+ const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true);
+ if (isScanning) {
+ return props.translate('common.receipt');
+ }
+ return props.translate(iouSettled || props.iouReport.isWaitingOnBankAccount ? 'iou.payerPaid' : 'iou.payerOwes', {payer: managerName});
+ };
- const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerID, true);
const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
- const previewMessage = props.translate(ReportUtils.isSettled(props.iouReportID) || props.iouReport.isWaitingOnBankAccount ? 'iou.payerPaid' : 'iou.payerOwes', {payer: managerName});
- const shouldShowSettlementButton =
- !_.isEmpty(props.iouReport) && isCurrentUserManager && !ReportUtils.isSettled(props.iouReportID) && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
+ const shouldShowSettlementButton = !_.isEmpty(props.iouReport) && isCurrentUserManager && !iouSettled && !props.iouReport.isWaitingOnBankAccount && reportTotal !== 0;
+
return (
-
-
-
- {previewMessage}
-
-
-
-
- {displayAmount}
- {ReportUtils.isSettled(props.iouReportID) && (
-
-
-
- )}
+
+ {hasReceipts && (
+ ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename))}
+ size={3}
+ total={transactionsWithReceipts.length}
+ isHovered={props.isHovered || isScanning}
+ />
+ )}
+
+
+
+ {getPreviewMessage()}
+
+
-
- {showComment && (
-
-
-
- {moneyRequestCount > 1 ? props.translate('iou.requestCount', {count: moneyRequestCount}) : moneyRequestComment}
-
+
+
+ {getDisplayAmount()}
+ {ReportUtils.isSettled(props.iouReportID) && (
+
+
+
+ )}
- )}
- {shouldShowSettlementButton && (
- IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)}
- enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW}
- addBankAccountRoute={bankAccountRoute}
- style={[styles.requestPreviewBox]}
- />
- )}
+ {hasReceipts && !isScanning && (
+
+
+ {previewSubtitle || moneyRequestComment}
+
+
+ )}
+ {shouldShowSettlementButton && (
+ IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)}
+ enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW}
+ addBankAccountRoute={bankAccountRoute}
+ style={[styles.requestPreviewBox]}
+ />
+ )}
+
diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js
index d05ebd4bfd09..ca4103624440 100644
--- a/src/components/ReportActionItem/TaskPreview.js
+++ b/src/components/ReportActionItem/TaskPreview.js
@@ -22,10 +22,11 @@ import * as ReportUtils from '../../libs/ReportUtils';
import RenderHTML from '../RenderHTML';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
import personalDetailsPropType from '../../pages/personalDetailsPropType';
+import * as Session from '../../libs/actions/Session';
const propTypes = {
/** All personal details asssociated with user */
- personalDetailsList: personalDetailsPropType,
+ personalDetailsList: PropTypes.objectOf(personalDetailsPropType),
/** The ID of the associated taskReport */
taskReportID: PropTypes.string.isRequired,
@@ -86,13 +87,13 @@ function TaskPreview(props) {
containerStyle={[styles.taskCheckbox]}
isChecked={isTaskCompleted}
disabled={ReportUtils.isCanceledTaskReport(props.taskReport)}
- onPress={() => {
+ onPress={Session.checkIfActionIsAllowed(() => {
if (isTaskCompleted) {
Task.reopenTask(props.taskReport, taskTitle);
} else {
Task.completeTask(props.taskReport, taskTitle);
}
- }}
+ })}
accessibilityLabel={props.translate('task.task')}
/>
diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js
index 83c15acee79f..965c3120d51b 100644
--- a/src/components/ReportActionItem/TaskView.js
+++ b/src/components/ReportActionItem/TaskView.js
@@ -79,7 +79,13 @@ function TaskView(props) {
{props.translate('task.title')}
(isCompleted ? Task.reopenTask(props.report, taskTitle) : Task.completeTask(props.report, taskTitle))}
+ onPress={Session.checkIfActionIsAllowed(() => {
+ if (isCompleted) {
+ Task.reopenTask(props.report, taskTitle);
+ } else {
+ Task.completeTask(props.report, taskTitle);
+ }
+ })}
isChecked={isCompleted}
style={styles.taskMenuItemCheckbox}
containerSize={24}
diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js
index fb559ba29999..80c26b5d6b3f 100644
--- a/src/components/ReportWelcomeText.js
+++ b/src/components/ReportWelcomeText.js
@@ -10,6 +10,7 @@ import Text from './Text';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import compose from '../libs/compose';
import * as ReportUtils from '../libs/ReportUtils';
+import * as PolicyUtils from '../libs/PolicyUtils';
import * as OptionsListUtils from '../libs/OptionsListUtils';
import ONYXKEYS from '../ONYXKEYS';
import Navigation from '../libs/Navigation/Navigation';
@@ -33,6 +34,15 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes,
+ /** The policy object for the current route */
+ policy: PropTypes.shape({
+ /** The name of the policy */
+ name: PropTypes.string,
+
+ /** The URL for the policy avatar */
+ avatar: PropTypes.string,
+ }),
+
/* Onyx Props */
/** All of the personal details for everyone */
@@ -46,6 +56,7 @@ const propTypes = {
const defaultProps = {
report: {},
+ policy: {},
personalDetails: {},
betas: [],
};
@@ -60,12 +71,16 @@ function ReportWelcomeText(props) {
OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, props.personalDetails),
isMultipleParticipant,
);
- const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(props.report);
+ const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(props.policy);
+ const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(props.report, isUserPolicyAdmin);
const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(props.report, participantAccountIDs, props.betas);
+
return (
<>
- {props.translate('reportActionsView.sayHello')}
+
+ {isChatRoom ? props.translate('reportActionsView.welcomeToRoom', {roomName: ReportUtils.getReportName(props.report)}) : props.translate('reportActionsView.sayHello')}
+
{isPolicyExpenseChat && (
@@ -84,13 +99,16 @@ function ReportWelcomeText(props) {
{isChatRoom && (
<>
{roomWelcomeMessage.phrase1}
- Navigation.navigate(ROUTES.getReportDetailsRoute(props.report.reportID))}
- >
- {ReportUtils.getReportName(props.report)}
-
- {roomWelcomeMessage.phrase2}
+ {roomWelcomeMessage.showReportName && (
+ Navigation.navigate(ROUTES.getReportDetailsRoute(props.report.reportID))}
+ suppressHighlighting
+ >
+ {ReportUtils.getReportName(props.report)}
+
+ )}
+ {roomWelcomeMessage.phrase2 !== undefined && {roomWelcomeMessage.phrase2} }
>
)}
{isDefault && (
@@ -105,6 +123,7 @@ function ReportWelcomeText(props) {
Navigation.navigate(ROUTES.getProfileRoute(accountID))}
+ suppressHighlighting
>
{displayName}
diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.js
index 6f78e6ace66d..af594dc2415a 100644
--- a/src/components/RoomHeaderAvatars.js
+++ b/src/components/RoomHeaderAvatars.js
@@ -9,6 +9,9 @@ import Avatar from './Avatar';
import themeColors from '../styles/themes/default';
import * as StyleUtils from '../styles/StyleUtils';
import avatarPropTypes from './avatarPropTypes';
+import PressableWithoutFocus from './Pressable/PressableWithoutFocus';
+import * as UserUtils from '../libs/UserUtils';
+import AttachmentModal from './AttachmentModal';
const propTypes = {
icons: PropTypes.arrayOf(avatarPropTypes),
@@ -25,14 +28,31 @@ function RoomHeaderAvatars(props) {
if (props.icons.length === 1) {
return (
-
+
+ {({show}) => (
+
+
+
+ )}
+
);
}
@@ -45,27 +65,45 @@ function RoomHeaderAvatars(props) {
StyleUtils.getAvatarStyle(CONST.AVATAR_SIZE.LARGE_BORDERED),
];
return (
-
+
{_.map(iconsToDisplay, (icon, index) => (
-
+
+ {({show}) => (
+
+
+
+ )}
+
{index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && (
<>
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
new file mode 100644
index 000000000000..f14c3fef5d83
--- /dev/null
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -0,0 +1,405 @@
+import React, {useEffect, useMemo, useRef, useState} from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import SectionList from '../SectionList';
+import Text from '../Text';
+import styles from '../../styles/styles';
+import TextInput from '../TextInput';
+import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
+import CONST from '../../CONST';
+import variables from '../../styles/variables';
+import {propTypes as selectionListPropTypes} from './selectionListPropTypes';
+import RadioListItem from './RadioListItem';
+import CheckboxListItem from './CheckboxListItem';
+import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
+import SafeAreaConsumer from '../SafeAreaConsumer';
+import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
+import Checkbox from '../Checkbox';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import FixedFooter from '../FixedFooter';
+import Button from '../Button';
+import useLocalize from '../../hooks/useLocalize';
+import Log from '../../libs/Log';
+import OptionsListSkeletonView from '../OptionsListSkeletonView';
+
+const propTypes = {
+ ...keyboardStatePropTypes,
+ ...selectionListPropTypes,
+};
+
+function BaseSelectionList({
+ sections,
+ canSelectMultiple = false,
+ onSelectRow,
+ onSelectAll,
+ onDismissError,
+ textInputLabel = '',
+ textInputPlaceholder = '',
+ textInputValue = '',
+ textInputMaxLength,
+ keyboardType = CONST.KEYBOARD_TYPE.DEFAULT,
+ onChangeText,
+ initiallyFocusedOptionKey = '',
+ shouldDelayFocus = false,
+ onScroll,
+ onScrollBeginDrag,
+ headerMessage = '',
+ confirmButtonText = '',
+ onConfirm,
+ showScrollIndicator = false,
+ showLoadingPlaceholder = false,
+ isKeyboardShown = false,
+}) {
+ const {translate} = useLocalize();
+ const firstLayoutRef = useRef(true);
+ const listRef = useRef(null);
+ const textInputRef = useRef(null);
+ const focusTimeoutRef = useRef(null);
+ const shouldShowTextInput = Boolean(textInputLabel);
+ const shouldShowSelectAll = Boolean(onSelectAll);
+ const shouldShowConfirmButton = Boolean(onConfirm);
+
+ /**
+ * Iterates through the sections and items inside each section, and builds 3 arrays along the way:
+ * - `allOptions`: Contains all the items in the list, flattened, regardless of section
+ * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager
+ * - `itemLayouts`: Contains the layout information for each item, header and footer in the list,
+ * so we can calculate the position of any given item when scrolling programmatically
+ *
+ * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}}
+ */
+ const flattenedSections = useMemo(() => {
+ const allOptions = [];
+
+ const disabledOptionsIndexes = [];
+ let disabledIndex = 0;
+
+ let offset = 0;
+ const itemLayouts = [{length: 0, offset}];
+
+ const selectedOptions = [];
+
+ _.each(sections, (section, sectionIndex) => {
+ const sectionHeaderHeight = variables.optionsListSectionHeaderHeight;
+ itemLayouts.push({length: sectionHeaderHeight, offset});
+ offset += sectionHeaderHeight;
+
+ _.each(section.data, (item, optionIndex) => {
+ // Add item to the general flattened array
+ allOptions.push({
+ ...item,
+ sectionIndex,
+ index: optionIndex,
+ });
+
+ // If disabled, add to the disabled indexes array
+ if (section.isDisabled || item.isDisabled) {
+ disabledOptionsIndexes.push(disabledIndex);
+ }
+ disabledIndex += 1;
+
+ // Account for the height of the item in getItemLayout
+ const fullItemHeight = variables.optionRowHeight;
+ itemLayouts.push({length: fullItemHeight, offset});
+ offset += fullItemHeight;
+
+ if (item.isSelected) {
+ selectedOptions.push(item);
+ }
+ });
+
+ // We're not rendering any section footer, but we need to push to the array
+ // because React Native accounts for it in getItemLayout
+ itemLayouts.push({length: 0, offset});
+ });
+
+ // We're not rendering the list footer, but we need to push to the array
+ // because React Native accounts for it in getItemLayout
+ itemLayouts.push({length: 0, offset});
+
+ if (selectedOptions.length > 1 && !canSelectMultiple) {
+ Log.alert(
+ 'Dev error: SelectionList - multiple items are selected but prop `canSelectMultiple` is false. Please enable `canSelectMultiple` or make your list have only 1 item with `isSelected: true`.',
+ );
+ }
+
+ return {
+ allOptions,
+ selectedOptions,
+ disabledOptionsIndexes,
+ itemLayouts,
+ allSelected: selectedOptions.length > 0 && selectedOptions.length === allOptions.length - disabledOptionsIndexes.length,
+ };
+ }, [canSelectMultiple, sections]);
+
+ const [focusedIndex, setFocusedIndex] = useState(() => {
+ const defaultIndex = 0;
+
+ const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey);
+
+ if (indexOfInitiallyFocusedOption >= 0) {
+ return indexOfInitiallyFocusedOption;
+ }
+
+ return defaultIndex;
+ });
+
+ /**
+ * Scrolls to the desired item index in the section list
+ *
+ * @param {Number} index - the index of the item to scroll to
+ * @param {Boolean} animated - whether to animate the scroll
+ */
+ const scrollToIndex = (index, animated) => {
+ const item = flattenedSections.allOptions[index];
+
+ if (!listRef.current || !item) {
+ return;
+ }
+
+ const itemIndex = item.index;
+ const sectionIndex = item.sectionIndex;
+
+ // Note: react-native's SectionList automatically strips out any empty sections.
+ // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to.
+ // Otherwise, it will cause an index-out-of-bounds error and crash the app.
+ let adjustedSectionIndex = sectionIndex;
+ for (let i = 0; i < sectionIndex; i++) {
+ if (_.isEmpty(lodashGet(sections, `[${i}].data`))) {
+ adjustedSectionIndex--;
+ }
+ }
+
+ listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated});
+ };
+
+ const selectRow = (item, index) => {
+ // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item
+ if (canSelectMultiple) {
+ if (sections.length === 1) {
+ // If the list has only 1 section (e.g. Workspace Members list), we always focus the next available item
+ const nextAvailableIndex = _.findIndex(flattenedSections.allOptions, (option, i) => i > index && !option.isDisabled);
+ setFocusedIndex(nextAvailableIndex);
+ } else {
+ // If the list has multiple sections (e.g. Workspace Invite list), we focus the first one after all the selected (selected items are always at the top)
+ const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1;
+ setFocusedIndex(selectedOptionsCount);
+ }
+ }
+
+ onSelectRow(item);
+ };
+
+ const selectFocusedOption = () => {
+ const focusedOption = flattenedSections.allOptions[focusedIndex];
+
+ if (!focusedOption || focusedOption.isDisabled) {
+ return;
+ }
+
+ selectRow(focusedOption, focusedIndex);
+ };
+
+ /**
+ * This function is used to compute the layout of any given item in our list.
+ * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList.
+ *
+ * @param {Array} data - This is the same as the data we pass into the component
+ * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks:
+ *
+ * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those.
+ * 2. Each section includes a header, even if we don't provide/render one.
+ *
+ * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this:
+ *
+ * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}]
+ *
+ * @returns {Object}
+ */
+ const getItemLayout = (data, flatDataArrayIndex) => {
+ const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
+
+ return {
+ length: targetItem.length,
+ offset: targetItem.offset,
+ index: flatDataArrayIndex,
+ };
+ };
+
+ const renderSectionHeader = ({section}) => {
+ if (!section.title || _.isEmpty(section.data)) {
+ return null;
+ }
+
+ return (
+ // Note: The `optionsListSectionHeader` style provides an explicit height to section headers.
+ // We do this so that we can reference the height in `getItemLayout` –
+ // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item.
+ // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well.
+
+ {section.title}
+
+ );
+ };
+
+ const renderItem = ({item, index, section}) => {
+ const isDisabled = section.isDisabled;
+ const isFocused = !isDisabled && focusedIndex === index + lodashGet(section, 'indexOffset', 0);
+
+ if (canSelectMultiple) {
+ return (
+ selectRow(item, index)}
+ onDismissError={onDismissError}
+ />
+ );
+ }
+
+ return (
+ selectRow(item, index)}
+ />
+ );
+ };
+
+ /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */
+ useEffect(() => {
+ if (shouldShowTextInput) {
+ if (shouldDelayFocus) {
+ focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION);
+ } else {
+ textInputRef.current.focus();
+ }
+ }
+
+ return () => {
+ if (!focusTimeoutRef.current) {
+ return;
+ }
+ clearTimeout(focusTimeoutRef.current);
+ };
+ }, [shouldDelayFocus, shouldShowTextInput]);
+
+ /** Selects row when pressing enter */
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
+ captureOnInputs: true,
+ shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
+ });
+
+ return (
+ {
+ setFocusedIndex(newFocusedIndex);
+ scrollToIndex(newFocusedIndex, true);
+ }}
+ >
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+ {shouldShowTextInput && (
+
+
+
+ )}
+ {Boolean(headerMessage) && (
+
+ {headerMessage}
+
+ )}
+ {flattenedSections.allOptions.length === 0 && showLoadingPlaceholder ? (
+
+ ) : (
+ <>
+ {!headerMessage && canSelectMultiple && shouldShowSelectAll && (
+
+
+
+ {translate('workspace.people.selectAll')}
+
+
+ )}
+ item.keyForList}
+ extraData={focusedIndex}
+ indicatorStyle="white"
+ keyboardShouldPersistTaps="always"
+ showsVerticalScrollIndicator={showScrollIndicator}
+ initialNumToRender={12}
+ maxToRenderPerBatch={5}
+ windowSize={5}
+ viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
+ testID="selection-list"
+ onLayout={() => {
+ if (!firstLayoutRef.current) {
+ return;
+ }
+ scrollToIndex(focusedIndex, false);
+ firstLayoutRef.current = false;
+ }}
+ />
+ >
+ )}
+ {shouldShowConfirmButton && (
+
+
+
+ )}
+
+ )}
+
+
+ );
+}
+
+BaseSelectionList.displayName = 'BaseSelectionList';
+BaseSelectionList.propTypes = propTypes;
+
+export default withKeyboardState(BaseSelectionList);
diff --git a/src/components/SelectionList/CheckboxListItem.js b/src/components/SelectionList/CheckboxListItem.js
new file mode 100644
index 000000000000..539b436ba65a
--- /dev/null
+++ b/src/components/SelectionList/CheckboxListItem.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import {checkboxListItemPropTypes} from './selectionListPropTypes';
+import Checkbox from '../Checkbox';
+import Avatar from '../Avatar';
+import OfflineWithFeedback from '../OfflineWithFeedback';
+import CONST from '../../CONST';
+
+function CheckboxListItem({item, isFocused = false, onSelectRow, onDismissError = () => {}}) {
+ const hasError = !_.isEmpty(item.errors);
+
+ return (
+ onDismissError(item)}
+ pendingAction={item.pendingAction}
+ errors={item.errors}
+ errorRowStyles={styles.ph5}
+ >
+ onSelectRow(item)}
+ disabled={item.isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole="checkbox"
+ accessibilityState={{checked: item.isSelected}}
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ focusStyle={styles.hoveredComponentBG}
+ >
+ onSelectRow(item)}
+ style={item.isDisabled ? styles.buttonOpacityDisabled : {}}
+ />
+ {Boolean(item.avatar) && (
+
+ )}
+
+
+ {item.text}
+
+ {Boolean(item.alternateText) && (
+
+ {item.alternateText}
+
+ )}
+
+ {Boolean(item.rightElement) && item.rightElement}
+
+
+ );
+}
+
+CheckboxListItem.displayName = 'CheckboxListItem';
+CheckboxListItem.propTypes = checkboxListItemPropTypes;
+
+export default CheckboxListItem;
diff --git a/src/components/SelectionList/RadioListItem.js b/src/components/SelectionList/RadioListItem.js
new file mode 100644
index 000000000000..92e3e84b66c8
--- /dev/null
+++ b/src/components/SelectionList/RadioListItem.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import {View} from 'react-native';
+import PressableWithFeedback from '../Pressable/PressableWithFeedback';
+import styles from '../../styles/styles';
+import Text from '../Text';
+import Icon from '../Icon';
+import * as Expensicons from '../Icon/Expensicons';
+import themeColors from '../../styles/themes/default';
+import {radioListItemPropTypes} from './selectionListPropTypes';
+
+function RadioListItem({item, isFocused = false, isDisabled = false, onSelectRow}) {
+ return (
+ onSelectRow(item)}
+ disabled={isDisabled}
+ accessibilityLabel={item.text}
+ accessibilityRole="button"
+ hoverDimmingValue={1}
+ hoverStyle={styles.hoveredComponentBG}
+ focusStyle={styles.hoveredComponentBG}
+ >
+
+
+
+ {item.text}
+
+
+ {Boolean(item.alternateText) && (
+ {item.alternateText}
+ )}
+
+
+ {item.isSelected && (
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+RadioListItem.displayName = 'RadioListItem';
+RadioListItem.propTypes = radioListItemPropTypes;
+
+export default RadioListItem;
diff --git a/src/components/SelectionListRadio/index.android.js b/src/components/SelectionList/index.android.js
similarity index 53%
rename from src/components/SelectionListRadio/index.android.js
rename to src/components/SelectionList/index.android.js
index 53fc12b23d31..53d5b6bbce06 100644
--- a/src/components/SelectionListRadio/index.android.js
+++ b/src/components/SelectionList/index.android.js
@@ -1,9 +1,9 @@
import React, {forwardRef} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
-const SelectionListRadio = forwardRef((props, ref) => (
- (
+ (
/>
));
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionListRadio/index.ios.js b/src/components/SelectionList/index.ios.js
similarity index 51%
rename from src/components/SelectionListRadio/index.ios.js
rename to src/components/SelectionList/index.ios.js
index b8faad18420b..7f2a282aeb89 100644
--- a/src/components/SelectionListRadio/index.ios.js
+++ b/src/components/SelectionList/index.ios.js
@@ -1,9 +1,9 @@
import React, {forwardRef} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
-const SelectionListRadio = forwardRef((props, ref) => (
- (
+ (
/>
));
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionListRadio/index.js b/src/components/SelectionList/index.js
similarity index 85%
rename from src/components/SelectionListRadio/index.js
rename to src/components/SelectionList/index.js
index a4d019476168..d2ad9ab3cf13 100644
--- a/src/components/SelectionListRadio/index.js
+++ b/src/components/SelectionList/index.js
@@ -1,9 +1,9 @@
import React, {forwardRef, useEffect, useState} from 'react';
import {Keyboard} from 'react-native';
-import BaseSelectionListRadio from './BaseSelectionListRadio';
+import BaseSelectionList from './BaseSelectionList';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
-const SelectionListRadio = forwardRef((props, ref) => {
+const SelectionList = forwardRef((props, ref) => {
const [isScreenTouched, setIsScreenTouched] = useState(false);
const touchStart = () => setIsScreenTouched(true);
@@ -26,7 +26,7 @@ const SelectionListRadio = forwardRef((props, ref) => {
}, []);
return (
- {
);
});
-SelectionListRadio.displayName = 'SelectionListRadio';
+SelectionList.displayName = 'SelectionList';
-export default SelectionListRadio;
+export default SelectionList;
diff --git a/src/components/SelectionList/selectionListPropTypes.js b/src/components/SelectionList/selectionListPropTypes.js
new file mode 100644
index 000000000000..414a838d269f
--- /dev/null
+++ b/src/components/SelectionList/selectionListPropTypes.js
@@ -0,0 +1,158 @@
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../../CONST';
+
+const checkboxListItemPropTypes = {
+ /** The section list item */
+ item: PropTypes.shape({
+ /** Text to display */
+ text: PropTypes.string.isRequired,
+
+ /** Alternate text to display */
+ alternateText: PropTypes.string,
+
+ /** Key used internally by React */
+ keyForList: PropTypes.string.isRequired,
+
+ /** Whether this option is selected */
+ isSelected: PropTypes.bool,
+
+ /** Whether this option is disabled for selection */
+ isDisabled: PropTypes.bool,
+
+ /** User accountID */
+ accountID: PropTypes.number,
+
+ /** User login */
+ login: PropTypes.string,
+
+ /** Element to show on the right side of the item */
+ rightElement: PropTypes.element,
+
+ /** Avatar for the user */
+ avatar: PropTypes.shape({
+ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ }),
+
+ /** Errors that this user may contain */
+ errors: PropTypes.objectOf(PropTypes.string),
+
+ /** The type of action that's pending */
+ pendingAction: PropTypes.oneOf(_.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)),
+ }).isRequired,
+
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+};
+
+const radioListItemPropTypes = {
+ /** The section list item */
+ item: PropTypes.shape({
+ /** Text to display */
+ text: PropTypes.string.isRequired,
+
+ /** Alternate text to display */
+ alternateText: PropTypes.string,
+
+ /** Key used internally by React */
+ keyForList: PropTypes.string.isRequired,
+
+ /** Whether this option is selected */
+ isSelected: PropTypes.bool,
+ }).isRequired,
+
+ /** Whether this item is focused (for arrow key controls) */
+ isFocused: PropTypes.bool,
+
+ /** Whether this item is disabled */
+ isDisabled: PropTypes.bool,
+
+ /** Callback to fire when the item is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+};
+
+const propTypes = {
+ /** Sections for the section list */
+ sections: PropTypes.arrayOf(
+ PropTypes.shape({
+ /** Title of the section */
+ title: PropTypes.string,
+
+ /** The initial index of this section given the total number of options in each section's data array */
+ indexOffset: PropTypes.number,
+
+ /** Array of options */
+ data: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.shape(checkboxListItemPropTypes.item), PropTypes.shape(radioListItemPropTypes.item)])),
+
+ /** Whether this section items disabled for selection */
+ isDisabled: PropTypes.bool,
+ }),
+ ).isRequired,
+
+ /** Whether this is a multi-select list */
+ canSelectMultiple: PropTypes.bool,
+
+ /** Callback to fire when a row is pressed */
+ onSelectRow: PropTypes.func.isRequired,
+
+ /** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
+ onSelectAll: PropTypes.func,
+
+ /** Callback to fire when an error is dismissed */
+ onDismissError: PropTypes.func,
+
+ /** Label for the text input */
+ textInputLabel: PropTypes.string,
+
+ /** Placeholder for the text input */
+ textInputPlaceholder: PropTypes.string,
+
+ /** Value for the text input */
+ textInputValue: PropTypes.string,
+
+ /** Max length for the text input */
+ textInputMaxLength: PropTypes.number,
+
+ /** Callback to fire when the text input changes */
+ onChangeText: PropTypes.func,
+
+ /** Keyboard type for the text input */
+ keyboardType: PropTypes.string,
+
+ /** Item `keyForList` to focus initially */
+ initiallyFocusedOptionKey: PropTypes.string,
+
+ /** Whether to delay focus on the text input when mounting. Used for a smoother animation on Android */
+ shouldDelayFocus: PropTypes.bool,
+
+ /** Callback to fire when the list is scrolled */
+ onScroll: PropTypes.func,
+
+ /** Callback to fire when the list is scrolled and the user begins dragging */
+ onScrollBeginDrag: PropTypes.func,
+
+ /** Message to display at the top of the list */
+ headerMessage: PropTypes.string,
+
+ /** Text to display on the confirm button */
+ confirmButtonText: PropTypes.string,
+
+ /** Callback to fire when the confirm button is pressed */
+ onConfirm: PropTypes.func,
+
+ /** Whether to show the vertical scroll indicator */
+ showScrollIndicator: PropTypes.bool,
+
+ /** Whether to show the loading placeholder */
+ showLoadingPlaceholder: PropTypes.bool,
+};
+
+export {propTypes, radioListItemPropTypes, checkboxListItemPropTypes};
diff --git a/src/components/SelectionListRadio/BaseSelectionListRadio.js b/src/components/SelectionListRadio/BaseSelectionListRadio.js
deleted file mode 100644
index 4daacb184ea1..000000000000
--- a/src/components/SelectionListRadio/BaseSelectionListRadio.js
+++ /dev/null
@@ -1,277 +0,0 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
-import SectionList from '../SectionList';
-import Text from '../Text';
-import styles from '../../styles/styles';
-import TextInput from '../TextInput';
-import ArrowKeyFocusManager from '../ArrowKeyFocusManager';
-import CONST from '../../CONST';
-import variables from '../../styles/variables';
-import {propTypes as selectionListRadioPropTypes, defaultProps as selectionListRadioDefaultProps} from './selectionListRadioPropTypes';
-import RadioListItem from './RadioListItem';
-import useKeyboardShortcut from '../../hooks/useKeyboardShortcut';
-import SafeAreaConsumer from '../SafeAreaConsumer';
-import withKeyboardState, {keyboardStatePropTypes} from '../withKeyboardState';
-
-const propTypes = {
- ...keyboardStatePropTypes,
- ...selectionListRadioPropTypes,
-};
-
-function BaseSelectionListRadio(props) {
- const firstLayoutRef = useRef(true);
- const listRef = useRef(null);
- const textInputRef = useRef(null);
- const focusTimeoutRef = useRef(null);
- const shouldShowTextInput = Boolean(props.textInputLabel);
-
- /**
- * Iterates through the sections and items inside each section, and builds 3 arrays along the way:
- * - `allOptions`: Contains all the items in the list, flattened, regardless of section
- * - `disabledOptionsIndexes`: Contains the indexes of all the disabled items in the list, to be used by the ArrowKeyFocusManager
- * - `itemLayouts`: Contains the layout information for each item, header and footer in the list,
- * so we can calculate the position of any given item when scrolling programmatically
- *
- * @return {{itemLayouts: [{offset: number, length: number}], disabledOptionsIndexes: *[], allOptions: *[]}}
- */
- const getFlattenedSections = () => {
- const allOptions = [];
-
- const disabledOptionsIndexes = [];
- let disabledIndex = 0;
-
- let offset = 0;
- const itemLayouts = [{length: 0, offset}];
-
- _.each(props.sections, (section, sectionIndex) => {
- // We're not rendering any section header, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- const sectionHeaderHeight = 0;
- itemLayouts.push({length: sectionHeaderHeight, offset});
- offset += sectionHeaderHeight;
-
- _.each(section.data, (option, optionIndex) => {
- // Add item to the general flattened array
- allOptions.push({
- ...option,
- sectionIndex,
- index: optionIndex,
- });
-
- // If disabled, add to the disabled indexes array
- if (section.isDisabled || option.isDisabled) {
- disabledOptionsIndexes.push(disabledIndex);
- }
- disabledIndex += 1;
-
- // Account for the height of the item in getItemLayout
- const fullItemHeight = variables.optionRowHeight;
- itemLayouts.push({length: fullItemHeight, offset});
- offset += fullItemHeight;
- });
-
- // We're not rendering any section footer, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- itemLayouts.push({length: 0, offset});
- });
-
- // We're not rendering the list footer, but we need to push to the array
- // because React Native accounts for it in getItemLayout
- itemLayouts.push({length: 0, offset});
-
- return {
- allOptions,
- disabledOptionsIndexes,
- itemLayouts,
- };
- };
-
- const flattenedSections = getFlattenedSections();
-
- const [focusedIndex, setFocusedIndex] = useState(() => {
- const defaultIndex = 0;
-
- const indexOfInitiallyFocusedOption = _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey);
-
- if (indexOfInitiallyFocusedOption >= 0) {
- return indexOfInitiallyFocusedOption;
- }
-
- return defaultIndex;
- });
-
- /**
- * Scrolls to the desired item index in the section list
- *
- * @param {Number} index - the index of the item to scroll to
- * @param {Boolean} animated - whether to animate the scroll
- */
- const scrollToIndex = (index, animated) => {
- const item = flattenedSections.allOptions[index];
-
- if (!listRef.current || !item) {
- return;
- }
-
- const itemIndex = item.index;
- const sectionIndex = item.sectionIndex;
-
- // Note: react-native's SectionList automatically strips out any empty sections.
- // So we need to reduce the sectionIndex to remove any empty sections in front of the one we're trying to scroll to.
- // Otherwise, it will cause an index-out-of-bounds error and crash the app.
- let adjustedSectionIndex = sectionIndex;
- for (let i = 0; i < sectionIndex; i++) {
- if (_.isEmpty(lodashGet(props.sections, `[${i}].data`))) {
- adjustedSectionIndex--;
- }
- }
-
- listRef.current.scrollToLocation({sectionIndex: adjustedSectionIndex, itemIndex, animated});
- };
-
- /**
- * This function is used to compute the layout of any given item in our list.
- * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList.
- *
- * @param {Array} data - This is the same as the data we pass into the component
- * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks:
- *
- * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those.
- * 2. Each section includes a header, even if we don't provide/render one.
- *
- * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this:
- *
- * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}]
- *
- * @returns {Object}
- */
- const getItemLayout = (data, flatDataArrayIndex) => {
- const targetItem = flattenedSections.itemLayouts[flatDataArrayIndex];
-
- return {
- length: targetItem.length,
- offset: targetItem.offset,
- index: flatDataArrayIndex,
- };
- };
-
- const renderItem = ({item, index, section}) => {
- const isFocused = focusedIndex === index + lodashGet(section, 'indexOffset', 0);
-
- return (
-
- );
- };
-
- /** Focuses the text input when the component mounts. If `props.shouldDelayFocus` is true, we wait for the animation to finish */
- useEffect(() => {
- if (shouldShowTextInput) {
- if (props.shouldDelayFocus) {
- focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION);
- } else {
- textInputRef.current.focus();
- }
- }
-
- return () => {
- if (!focusTimeoutRef.current) {
- return;
- }
- clearTimeout(focusTimeoutRef.current);
- };
- }, [props.shouldDelayFocus, shouldShowTextInput]);
-
- const selectFocusedOption = () => {
- const focusedOption = flattenedSections.allOptions[focusedIndex];
-
- if (!focusedOption) {
- return;
- }
-
- props.onSelectRow(focusedOption);
- };
-
- useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
- captureOnInputs: true,
- shouldBubble: () => !flattenedSections.allOptions[focusedIndex],
- });
-
- return (
- {
- setFocusedIndex(newFocusedIndex);
- scrollToIndex(newFocusedIndex, true);
- }}
- >
-
- {({safeAreaPaddingBottomStyle}) => (
-
- {shouldShowTextInput && (
-
-
-
- )}
- {Boolean(props.headerMessage) && (
-
- {props.headerMessage}
-
- )}
- item.keyForList}
- extraData={focusedIndex}
- indicatorStyle="white"
- keyboardShouldPersistTaps="always"
- showsVerticalScrollIndicator={false}
- OP
- initialNumToRender={12}
- maxToRenderPerBatch={5}
- windowSize={5}
- viewabilityConfig={{viewAreaCoveragePercentThreshold: 95}}
- onLayout={() => {
- if (!firstLayoutRef.current) {
- return;
- }
- scrollToIndex(focusedIndex, false);
- firstLayoutRef.current = false;
- }}
- />
-
- )}
-
-
- );
-}
-
-BaseSelectionListRadio.displayName = 'BaseSelectionListRadio';
-BaseSelectionListRadio.propTypes = propTypes;
-BaseSelectionListRadio.defaultProps = selectionListRadioDefaultProps;
-
-export default withKeyboardState(BaseSelectionListRadio);
diff --git a/src/components/SelectionListRadio/RadioListItem.js b/src/components/SelectionListRadio/RadioListItem.js
deleted file mode 100644
index c5c4b3aeaf2c..000000000000
--- a/src/components/SelectionListRadio/RadioListItem.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import PropTypes from 'prop-types';
-import PressableWithFeedback from '../Pressable/PressableWithFeedback';
-import styles from '../../styles/styles';
-import Text from '../Text';
-import Icon from '../Icon';
-import * as Expensicons from '../Icon/Expensicons';
-import themeColors from '../../styles/themes/default';
-import {radioListItemPropTypes} from './selectionListRadioPropTypes';
-
-const propTypes = {
- /** The section list item */
- item: PropTypes.shape(radioListItemPropTypes),
-
- /** Whether this item is focused (for arrow key controls) */
- isFocused: PropTypes.bool,
-
- /** Callback to fire when the item is pressed */
- onSelectRow: PropTypes.func,
-};
-
-const defaultProps = {
- item: {},
- isFocused: false,
- onSelectRow: () => {},
-};
-
-function RadioListItem(props) {
- return (
- props.onSelectRow(props.item)}
- accessibilityLabel={props.item.text}
- accessibilityRole="button"
- hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
- focusStyle={styles.hoveredComponentBG}
- >
-
-
-
- {props.item.text}
-
-
- {Boolean(props.item.alternateText) && (
-
- {props.item.alternateText}
-
- )}
-
-
- {props.item.isSelected && (
-
-
-
-
-
- )}
-
-
- );
-}
-
-RadioListItem.displayName = 'RadioListItem';
-RadioListItem.propTypes = propTypes;
-RadioListItem.defaultProps = defaultProps;
-
-export default RadioListItem;
diff --git a/src/components/SelectionListRadio/selectionListRadioPropTypes.js b/src/components/SelectionListRadio/selectionListRadioPropTypes.js
deleted file mode 100644
index 14e41b195d7b..000000000000
--- a/src/components/SelectionListRadio/selectionListRadioPropTypes.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import PropTypes from 'prop-types';
-import CONST from '../../CONST';
-
-const radioListItemPropTypes = {
- /** Text to display */
- text: PropTypes.string,
-
- /** Alternate text to display */
- alternateText: PropTypes.string,
-
- /** Key used internally by React */
- keyForList: PropTypes.string,
-
- /** Whether this option is selected */
- isSelected: PropTypes.bool,
-};
-
-const propTypes = {
- /** Sections for the section list */
- sections: PropTypes.arrayOf(
- PropTypes.shape({
- /** Title of the section */
- title: PropTypes.string,
-
- /** The initial index of this section given the total number of options in each section's data array */
- indexOffset: PropTypes.number,
-
- /** Array of options */
- data: PropTypes.arrayOf(PropTypes.shape(radioListItemPropTypes)),
-
- /** Whether this section items disabled for selection */
- isDisabled: PropTypes.bool,
- }),
- ).isRequired,
-
- /** Callback to fire when a row is tapped */
- onSelectRow: PropTypes.func,
-
- /** Label for the text input */
- textInputLabel: PropTypes.string,
-
- /** Placeholder for the text input */
- textInputPlaceholder: PropTypes.string,
-
- /** Value for the text input */
- textInputValue: PropTypes.string,
-
- /** Max length for the text input */
- textInputMaxLength: PropTypes.number,
-
- /** Callback to fire when the text input changes */
- onChangeText: PropTypes.func,
-
- /** Keyboard type for the text input */
- keyboardType: PropTypes.string,
-
- /** Item `keyForList` to focus initially */
- initiallyFocusedOptionKey: PropTypes.string,
-
- /** Whether to delay focus on the text input when mounting. Used for a smoother animation on Android */
- shouldDelayFocus: PropTypes.bool,
-
- /** Callback to fire when the list is scrolled */
- onScroll: PropTypes.func,
-
- /** Callback to fire when the list is scrolled and the user begins dragging */
- onScrollBeginDrag: PropTypes.func,
-
- /** Message to display at the top of the list */
- headerMessage: PropTypes.string,
-};
-
-const defaultProps = {
- onSelectRow: () => {},
- textInputLabel: '',
- textInputPlaceholder: '',
- textInputValue: '',
- textInputMaxLength: undefined,
- keyboardType: CONST.KEYBOARD_TYPE.DEFAULT,
- onChangeText: () => {},
- initiallyFocusedOptionKey: '',
- shouldDelayFocus: false,
- onScroll: () => {},
- onScrollBeginDrag: () => {},
- headerMessage: '',
-};
-
-export {propTypes, radioListItemPropTypes, defaultProps};
diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js
index 78ebbce42798..21af004a0683 100644
--- a/src/components/SettlementButton.js
+++ b/src/components/SettlementButton.js
@@ -57,7 +57,11 @@ const propTypes = {
/** Total money amount in form */
formattedAmount: PropTypes.string,
+ /** The size of button size */
+ buttonSize: PropTypes.oneOf(_.values(CONST.DROPDOWN_BUTTON_SIZE)),
+
/** The anchor alignment of the popover menu */
+
anchorAlignment: PropTypes.shape({
horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
@@ -77,6 +81,7 @@ const defaultProps = {
iouReport: {},
policyID: '',
formattedAmount: '',
+ buttonSize: CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
@@ -85,7 +90,7 @@ const defaultProps = {
class SettlementButton extends React.Component {
componentDidMount() {
- PaymentMethods.openPaymentsPage();
+ PaymentMethods.openWalletPage();
}
getButtonOptionsFromProps() {
@@ -174,8 +179,9 @@ class SettlementButton extends React.Component {
chatReportID={this.props.chatReportID}
iouReport={this.props.iouReport}
>
- {(triggerKYCFlow) => (
+ {(triggerKYCFlow, buttonRef) => (
{
@@ -188,6 +194,7 @@ class SettlementButton extends React.Component {
}}
options={this.getButtonOptionsFromProps()}
style={this.props.style}
+ buttonSize={this.props.buttonSize}
anchorAlignment={this.props.anchorAlignment}
/>
)}
diff --git a/src/components/SignInButtons/AppleAuthWrapper/index.ios.js b/src/components/SignInButtons/AppleAuthWrapper/index.ios.js
new file mode 100644
index 000000000000..9f97182b2e7b
--- /dev/null
+++ b/src/components/SignInButtons/AppleAuthWrapper/index.ios.js
@@ -0,0 +1,27 @@
+import {useEffect} from 'react';
+import appleAuth from '@invertase/react-native-apple-authentication';
+import * as Session from '../../../libs/actions/Session';
+
+/**
+ * Apple Sign In wrapper for iOS
+ * revokes the session if the credential is revoked.
+ *
+ * @returns {null}
+ */
+function AppleAuthWrapper() {
+ useEffect(() => {
+ if (!appleAuth.isSupported) {
+ return;
+ }
+ const listener = appleAuth.onCredentialRevoked(() => {
+ Session.signOut();
+ });
+ return () => {
+ listener.remove();
+ };
+ }, []);
+
+ return null;
+}
+
+export default AppleAuthWrapper;
diff --git a/src/components/SignInButtons/AppleAuthWrapper/index.js b/src/components/SignInButtons/AppleAuthWrapper/index.js
new file mode 100644
index 000000000000..7586d01f0213
--- /dev/null
+++ b/src/components/SignInButtons/AppleAuthWrapper/index.js
@@ -0,0 +1,5 @@
+function AppleAuthWrapper() {
+ return null;
+}
+
+export default AppleAuthWrapper;
diff --git a/src/components/SignInButtons/AppleSignIn/index.android.js b/src/components/SignInButtons/AppleSignIn/index.android.js
new file mode 100644
index 000000000000..83a99683b178
--- /dev/null
+++ b/src/components/SignInButtons/AppleSignIn/index.android.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import {appleAuthAndroid} from '@invertase/react-native-apple-authentication';
+import Log from '../../../libs/Log';
+import IconButton from '../IconButton';
+import * as Session from '../../../libs/actions/Session';
+import CONFIG from '../../../CONFIG';
+import CONST from '../../../CONST';
+
+/**
+ * Apple Sign In Configuration for Android.
+ */
+const config = {
+ clientId: CONFIG.APPLE_SIGN_IN.SERVICE_ID,
+ redirectUri: CONFIG.APPLE_SIGN_IN.REDIRECT_URI,
+ responseType: appleAuthAndroid.ResponseType.ALL,
+ scope: appleAuthAndroid.Scope.ALL,
+};
+
+/**
+ * Apple Sign In method for Android that returns authToken.
+ * @returns {Promise}
+ */
+function appleSignInRequest() {
+ appleAuthAndroid.configure(config);
+ return appleAuthAndroid
+ .signIn()
+ .then((response) => response.id_token)
+ .catch((e) => {
+ throw e;
+ });
+}
+
+/**
+ * Apple Sign In button for Android.
+ * @returns {React.Component}
+ */
+function AppleSignIn() {
+ const handleSignIn = () => {
+ appleSignInRequest()
+ .then((token) => Session.beginAppleSignIn(token))
+ .catch((e) => {
+ if (e.message === appleAuthAndroid.Error.SIGNIN_CANCELLED) return null;
+ Log.alert('[Apple Sign In] Apple authentication failed', e);
+ });
+ };
+ return (
+
+ );
+}
+
+AppleSignIn.displayName = 'AppleSignIn';
+
+export default AppleSignIn;
diff --git a/src/components/SignInButtons/AppleSignIn/index.desktop.js b/src/components/SignInButtons/AppleSignIn/index.desktop.js
new file mode 100644
index 000000000000..425e88ddf930
--- /dev/null
+++ b/src/components/SignInButtons/AppleSignIn/index.desktop.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import {View} from 'react-native';
+import IconButton from '../IconButton';
+import CONFIG from '../../../CONFIG';
+import ROUTES from '../../../ROUTES';
+import styles from '../../../styles/styles';
+import CONST from '../../../CONST';
+
+const appleSignInWebRouteForDesktopFlow = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.APPLE_SIGN_IN}`;
+
+/**
+ * Apple Sign In button for desktop flow
+ * @returns {React.Component}
+ */
+function AppleSignIn() {
+ return (
+
+ {
+ window.open(appleSignInWebRouteForDesktopFlow);
+ }}
+ provider={CONST.SIGN_IN_METHOD.APPLE}
+ />
+
+ );
+}
+
+AppleSignIn.displayName = 'AppleSignIn';
+
+export default AppleSignIn;
diff --git a/src/components/SignInButtons/AppleSignIn/index.ios.js b/src/components/SignInButtons/AppleSignIn/index.ios.js
new file mode 100644
index 000000000000..681eebb298c5
--- /dev/null
+++ b/src/components/SignInButtons/AppleSignIn/index.ios.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import appleAuth from '@invertase/react-native-apple-authentication';
+import Log from '../../../libs/Log';
+import IconButton from '../IconButton';
+import * as Session from '../../../libs/actions/Session';
+import CONST from '../../../CONST';
+
+/**
+ * Apple Sign In method for iOS that returns identityToken.
+ * @returns {Promise}
+ */
+function appleSignInRequest() {
+ return appleAuth
+ .performRequest({
+ requestedOperation: appleAuth.Operation.LOGIN,
+
+ // FULL_NAME must come first, see https://github.com/invertase/react-native-apple-authentication/issues/293.
+ requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
+ })
+ .then((response) =>
+ appleAuth.getCredentialStateForUser(response.user).then((credentialState) => {
+ if (credentialState !== appleAuth.State.AUTHORIZED) {
+ Log.alert('[Apple Sign In] Authentication failed. Original response: ', response);
+ throw new Error('Authentication failed');
+ }
+ return response.identityToken;
+ }),
+ );
+}
+
+/**
+ * Apple Sign In button for iOS.
+ * @returns {React.Component}
+ */
+function AppleSignIn() {
+ const handleSignIn = () => {
+ appleSignInRequest()
+ .then((token) => Session.beginAppleSignIn(token))
+ .catch((e) => {
+ if (e.code === appleAuth.Error.CANCELED) return null;
+ Log.alert('[Apple Sign In] Apple authentication failed', e);
+ });
+ };
+ return (
+
+ );
+}
+
+AppleSignIn.displayName = 'AppleSignIn';
+
+export default AppleSignIn;
diff --git a/src/components/SignInButtons/AppleSignIn/index.website.js b/src/components/SignInButtons/AppleSignIn/index.website.js
new file mode 100644
index 000000000000..41c8f2afd4d5
--- /dev/null
+++ b/src/components/SignInButtons/AppleSignIn/index.website.js
@@ -0,0 +1,150 @@
+import React, {useEffect, useState} from 'react';
+import PropTypes from 'prop-types';
+import Config from 'react-native-config';
+import get from 'lodash/get';
+import getUserLanguage from '../GetUserLanguage';
+import * as Session from '../../../libs/actions/Session';
+import Log from '../../../libs/Log';
+import CONFIG from '../../../CONFIG';
+import CONST from '../../../CONST';
+import withNavigationFocus from '../../withNavigationFocus';
+
+// react-native-config doesn't trim whitespace on iOS for some reason so we
+// add a trim() call to lodashGet here to prevent headaches.
+const lodashGet = (config, key, defaultValue) => get(config, key, defaultValue).trim();
+
+const requiredPropTypes = {
+ isDesktopFlow: PropTypes.bool.isRequired,
+};
+
+const singletonPropTypes = {
+ ...requiredPropTypes,
+
+ // From withNavigationFocus
+ isFocused: PropTypes.bool.isRequired,
+};
+
+const propTypes = {
+ // Prop to indicate if this is the desktop flow or not.
+ isDesktopFlow: PropTypes.bool,
+};
+const defaultProps = {
+ isDesktopFlow: false,
+};
+
+/**
+ * Apple Sign In Configuration for Web.
+ */
+const config = {
+ clientId: lodashGet(Config, 'ASI_CLIENTID_OVERRIDE', CONFIG.APPLE_SIGN_IN.SERVICE_ID),
+ scope: 'name email',
+ // never used, but required for configuration
+ redirectURI: lodashGet(Config, 'ASI_REDIRECTURI_OVERRIDE', CONFIG.APPLE_SIGN_IN.REDIRECT_URI),
+ state: '',
+ nonce: '',
+ usePopup: true,
+};
+
+/**
+ * Apple Sign In success and failure listeners.
+ */
+
+const successListener = (event) => {
+ const token = event.detail.authorization.id_token;
+ Session.beginAppleSignIn(token);
+};
+
+const failureListener = (event) => {
+ if (!event.detail || event.detail.error === 'popup_closed_by_user') return null;
+ Log.warn(`Apple sign-in failed: ${event.detail}`);
+};
+
+/**
+ * Apple Sign In button for Web.
+ * @returns {React.Component}
+ */
+function AppleSignInDiv({isDesktopFlow}) {
+ useEffect(() => {
+ // `init` renders the button, so it must be called after the div is
+ // first mounted.
+ window.AppleID.auth.init(config);
+ }, []);
+ // Result listeners need to live within the focused item to avoid duplicate
+ // side effects on success and failure.
+ React.useEffect(() => {
+ document.addEventListener('AppleIDSignInOnSuccess', successListener);
+ document.addEventListener('AppleIDSignInOnFailure', failureListener);
+ return () => {
+ document.removeEventListener('AppleIDSignInOnSuccess', successListener);
+ document.removeEventListener('AppleIDSignInOnFailure', failureListener);
+ };
+ }, []);
+
+ return isDesktopFlow ? (
+
+ ) : (
+
+ );
+}
+
+AppleSignInDiv.propTypes = requiredPropTypes;
+
+// The Sign in with Apple script may fail to render button if there are multiple
+// of these divs present in the app, as it matches based on div id. So we'll
+// only mount the div when it should be visible.
+function SingletonAppleSignInButton({isFocused, isDesktopFlow}) {
+ if (!isFocused) {
+ return null;
+ }
+ return ;
+}
+
+SingletonAppleSignInButton.propTypes = singletonPropTypes;
+
+// withNavigationFocus is used to only render the button when it is visible.
+const SingletonAppleSignInButtonWithFocus = withNavigationFocus(SingletonAppleSignInButton);
+
+function AppleSignIn({isDesktopFlow}) {
+ const [scriptLoaded, setScriptLoaded] = useState(false);
+ useEffect(() => {
+ if (window.appleAuthScriptLoaded) return;
+
+ const localeCode = getUserLanguage();
+ const script = document.createElement('script');
+ script.src = `https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1//${localeCode}/appleid.auth.js`;
+ script.async = true;
+ script.onload = () => setScriptLoaded(true);
+
+ document.body.appendChild(script);
+ }, []);
+
+ if (scriptLoaded === false) {
+ return null;
+ }
+
+ return ;
+}
+
+AppleSignIn.propTypes = propTypes;
+AppleSignIn.defaultProps = defaultProps;
+
+export default withNavigationFocus(AppleSignIn);
diff --git a/src/components/SignInButtons/GetUserLanguage.js b/src/components/SignInButtons/GetUserLanguage.js
new file mode 100644
index 000000000000..7f45f1fa1e89
--- /dev/null
+++ b/src/components/SignInButtons/GetUserLanguage.js
@@ -0,0 +1,14 @@
+const localeCodes = {
+ en: 'en_US',
+ es: 'es_ES',
+};
+
+const GetUserLanguage = () => {
+ const userLanguage = navigator.language || navigator.userLanguage;
+ const languageCode = userLanguage.split('-')[0];
+ return localeCodes[languageCode] || 'en_US';
+};
+
+GetUserLanguage.displayName = 'GetUserLanguage';
+
+export default GetUserLanguage;
diff --git a/src/components/SignInButtons/GoogleSignIn/index.desktop.js b/src/components/SignInButtons/GoogleSignIn/index.desktop.js
new file mode 100644
index 000000000000..bdba2052d664
--- /dev/null
+++ b/src/components/SignInButtons/GoogleSignIn/index.desktop.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import {View} from 'react-native';
+import withLocalize, {withLocalizePropTypes} from '../../withLocalize';
+import IconButton from '../IconButton';
+import CONFIG from '../../../CONFIG';
+import ROUTES from '../../../ROUTES';
+import styles from '../../../styles/styles';
+import CONST from '../../../CONST';
+
+const propTypes = {...withLocalizePropTypes};
+
+const googleSignInWebRouteForDesktopFlow = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.GOOGLE_SIGN_IN}`;
+
+/**
+ * Google Sign In button for desktop flow.
+ * @returns {React.Component}
+ */
+function GoogleSignIn() {
+ return (
+
+ {
+ window.open(googleSignInWebRouteForDesktopFlow);
+ }}
+ provider={CONST.SIGN_IN_METHOD.GOOGLE}
+ />
+
+ );
+}
+
+GoogleSignIn.displayName = 'GoogleSignIn';
+GoogleSignIn.propTypes = propTypes;
+
+export default withLocalize(GoogleSignIn);
diff --git a/src/components/SignInButtons/GoogleSignIn/index.native.js b/src/components/SignInButtons/GoogleSignIn/index.native.js
new file mode 100644
index 000000000000..9e638f0723cf
--- /dev/null
+++ b/src/components/SignInButtons/GoogleSignIn/index.native.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import {GoogleSignin, statusCodes} from '@react-native-google-signin/google-signin';
+import Log from '../../../libs/Log';
+import IconButton from '../IconButton';
+import * as Session from '../../../libs/actions/Session';
+import CONST from '../../../CONST';
+import CONFIG from '../../../CONFIG';
+
+/**
+ * Google Sign In method for iOS and android that returns identityToken.
+ */
+function googleSignInRequest() {
+ GoogleSignin.configure({
+ webClientId: CONFIG.GOOGLE_SIGN_IN.WEB_CLIENT_ID,
+ iosClientId: CONFIG.GOOGLE_SIGN_IN.IOS_CLIENT_ID,
+ offlineAccess: false,
+ });
+
+ // The package on android can sign in without prompting
+ // the user which is not what we want. So we sign out
+ // before signing in to ensure the user is prompted.
+ GoogleSignin.signOut();
+
+ GoogleSignin.signIn()
+ .then((response) => response.idToken)
+ .then((token) => Session.beginGoogleSignIn(token))
+ .catch((error) => {
+ if (error.code === statusCodes.SIGN_IN_CANCELLED) {
+ Log.alert('[Google Sign In] Google sign in cancelled', true, {error});
+ } else if (error.code === statusCodes.IN_PROGRESS) {
+ Log.alert('[Google Sign In] Google sign in already in progress', true, {error});
+ } else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
+ Log.alert('[Google Sign In] Google play services not available or outdated', true, {error});
+ } else {
+ Log.alert('[Google Sign In] Unknown Google sign in error', true, {error});
+ }
+ });
+}
+
+/**
+ * Google Sign In button for iOS.
+ * @returns {React.Component}
+ */
+function GoogleSignIn() {
+ return (
+
+ );
+}
+
+GoogleSignIn.displayName = 'GoogleSignIn';
+
+export default GoogleSignIn;
diff --git a/src/components/SignInButtons/GoogleSignIn/index.website.js b/src/components/SignInButtons/GoogleSignIn/index.website.js
new file mode 100644
index 000000000000..e1d71f5aaa7d
--- /dev/null
+++ b/src/components/SignInButtons/GoogleSignIn/index.website.js
@@ -0,0 +1,94 @@
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import withLocalize, {withLocalizePropTypes} from '../../withLocalize';
+import * as Session from '../../../libs/actions/Session';
+import CONFIG from '../../../CONFIG';
+import styles from '../../../styles/styles';
+
+const propTypes = {
+ /** Whether we're rendering in the Desktop Flow, if so show a different button. */
+ isDesktopFlow: PropTypes.bool,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ isDesktopFlow: false,
+};
+
+/** Div IDs for styling the two different Google Sign-In buttons. */
+const mainId = 'google-sign-in-main';
+const desktopId = 'google-sign-in-desktop';
+
+const signIn = (response) => {
+ Session.beginGoogleSignIn(response.credential);
+};
+
+/**
+ * Google Sign In button for Web.
+ * We have to load the gis script and then determine if the page is focused before rendering the button.
+ * @returns {React.Component}
+ */
+function GoogleSignIn({translate, isDesktopFlow}) {
+ const loadScript = useCallback(() => {
+ const google = window.google;
+ if (google) {
+ google.accounts.id.initialize({
+ client_id: CONFIG.GOOGLE_SIGN_IN.WEB_CLIENT_ID,
+ callback: signIn,
+ });
+ // Apply styles for each button
+ google.accounts.id.renderButton(document.getElementById(mainId), {
+ theme: 'outline',
+ size: 'large',
+ type: 'icon',
+ shape: 'circle',
+ });
+ google.accounts.id.renderButton(document.getElementById(desktopId), {
+ theme: 'outline',
+ size: 'large',
+ type: 'standard',
+ shape: 'pill',
+ width: '300px',
+ });
+ }
+ }, []);
+
+ React.useEffect(() => {
+ const script = document.createElement('script');
+ script.src = 'https://accounts.google.com/gsi/client';
+ script.addEventListener('load', loadScript);
+ script.async = true;
+ document.body.appendChild(script);
+
+ return () => {
+ script.removeEventListener('load', loadScript);
+ document.body.removeChild(script);
+ };
+ }, [loadScript]);
+
+ return isDesktopFlow ? (
+
+
+
+ ) : (
+
+
+
+ );
+}
+
+GoogleSignIn.displayName = 'GoogleSignIn';
+GoogleSignIn.propTypes = propTypes;
+GoogleSignIn.defaultProps = defaultProps;
+
+export default withLocalize(GoogleSignIn);
diff --git a/src/components/SignInButtons/IconButton.js b/src/components/SignInButtons/IconButton.js
new file mode 100644
index 000000000000..4f1692ddb677
--- /dev/null
+++ b/src/components/SignInButtons/IconButton.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from '../../styles/styles';
+import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
+import withLocalize, {withLocalizePropTypes} from '../withLocalize';
+import CONST from '../../CONST';
+import * as Expensicons from '../Icon/Expensicons';
+import Icon from '../Icon';
+
+const propTypes = {
+ /** The on press method */
+ onPress: PropTypes.func,
+
+ /** Which provider you are using to sign in */
+ provider: PropTypes.string.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ onPress: () => {},
+};
+
+const providerData = {
+ [CONST.SIGN_IN_METHOD.APPLE]: {
+ icon: Expensicons.AppleLogo,
+ accessibilityLabel: 'common.signInWithApple',
+ },
+ [CONST.SIGN_IN_METHOD.GOOGLE]: {
+ icon: Expensicons.GoogleLogo,
+ accessibilityLabel: 'common.signInWithGoogle',
+ },
+};
+
+function IconButton({onPress, translate, provider}) {
+ return (
+
+
+
+ );
+}
+
+IconButton.displayName = 'IconButton';
+IconButton.propTypes = propTypes;
+IconButton.defaultProps = defaultProps;
+
+export default withLocalize(IconButton);
diff --git a/src/components/SignInPageForm/index.js b/src/components/SignInPageForm/index.js
index 21df710d2aa5..9db9ffea0e6a 100644
--- a/src/components/SignInPageForm/index.js
+++ b/src/components/SignInPageForm/index.js
@@ -13,6 +13,9 @@ class Form extends React.Component {
return;
}
+ // Prevent the browser from applying its own validation, which affects the email input
+ this.form.setAttribute('novalidate', '');
+
// Password Managers need these attributes to be able to identify the form elements properly.
this.form.setAttribute('method', 'post');
this.form.setAttribute('action', '/');
diff --git a/src/components/SplashScreenHider/index.native.js b/src/components/SplashScreenHider/index.native.js
index ad3b97ae7c12..f4c234bb877d 100644
--- a/src/components/SplashScreenHider/index.native.js
+++ b/src/components/SplashScreenHider/index.native.js
@@ -70,7 +70,7 @@ function SplashScreenHider(props) {
>
diff --git a/src/components/StatePicker/StateSelectorModal.js b/src/components/StatePicker/StateSelectorModal.js
index 869c818a7009..91ee1b225a1f 100644
--- a/src/components/StatePicker/StateSelectorModal.js
+++ b/src/components/StatePicker/StateSelectorModal.js
@@ -4,9 +4,12 @@ import PropTypes from 'prop-types';
import CONST from '../../CONST';
import Modal from '../Modal';
import HeaderWithBackButton from '../HeaderWithBackButton';
-import SelectionListRadio from '../SelectionListRadio';
+import SelectionList from '../SelectionList';
import useLocalize from '../../hooks/useLocalize';
+import ScreenWrapper from '../ScreenWrapper';
+import styles from '../../styles/styles';
import searchCountryOptions from '../../libs/searchCountryOptions';
+import StringUtils from '../../libs/StringUtils';
const propTypes = {
/** Whether the modal is visible */
@@ -48,7 +51,7 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected,
keyForList: state.stateISO,
text: state.stateName,
isSelected: currentState === state.stateISO,
- searchValue: `${state.stateISO}${state.stateName}`.toLowerCase().replaceAll(CONST.REGEX.NON_ALPHABETIC_AND_NON_LATIN_CHARS, ''),
+ searchValue: StringUtils.sanitizeString(`${state.stateISO}${state.stateName}`),
})),
[translate, currentState],
);
@@ -65,24 +68,28 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected,
hideModalContentWhileAnimating
useNativeDriver
>
-
-
+
+
+
+
);
}
diff --git a/src/components/SwipeableView/index.native.js b/src/components/SwipeableView/index.native.js
index b4751789fcfe..2f1148721af1 100644
--- a/src/components/SwipeableView/index.native.js
+++ b/src/components/SwipeableView/index.native.js
@@ -1,4 +1,4 @@
-import React, {PureComponent} from 'react';
+import React, {useRef} from 'react';
import {PanResponder, View} from 'react-native';
import PropTypes from 'prop-types';
import CONST from '../../CONST';
@@ -10,35 +10,32 @@ const propTypes = {
onSwipeDown: PropTypes.func.isRequired,
};
-class SwipeableView extends PureComponent {
- constructor(props) {
- super(props);
-
- const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT;
- this.oldY = 0;
- this.panResponder = PanResponder.create({
+function SwipeableView(props) {
+ 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
- // & swip direction is downwards
+ // & swipe direction is downwards
onMoveShouldSetPanResponderCapture: (_event, gestureState) => {
- if (gestureState.dy - this.oldY > 0 && gestureState.dy > minimumPixelDistance) {
+ if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) {
return true;
}
- this.oldY = gestureState.dy;
+ oldYRef.current = gestureState.dy;
},
// Calls the callback when the swipe down is released; after the completion of the gesture
- onPanResponderRelease: this.props.onSwipeDown,
- });
- }
-
- render() {
- return (
- // eslint-disable-next-line react/jsx-props-no-spreading
- {this.props.children}
- );
- }
+ onPanResponderRelease: props.onSwipeDown,
+ }),
+ ).current;
+
+ return (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {props.children}
+ );
}
SwipeableView.propTypes = propTypes;
+SwipeableView.displayName = 'SwipeableView';
export default SwipeableView;
diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js
index 148cf81ce672..aa7694095f5b 100644
--- a/src/components/TaskHeaderActionButton.js
+++ b/src/components/TaskHeaderActionButton.js
@@ -5,13 +5,9 @@ import {withOnyx} from 'react-native-onyx';
import reportPropTypes from '../pages/reportPropTypes';
import withLocalize, {withLocalizePropTypes} from './withLocalize';
import styles from '../styles/styles';
-import Navigation from '../libs/Navigation/Navigation';
-import ROUTES from '../ROUTES';
import Button from './Button';
import * as Task from '../libs/actions/Task';
-import PressableWithFeedback from './Pressable/PressableWithFeedback';
import * as ReportUtils from '../libs/ReportUtils';
-import CONST from '../CONST';
import compose from '../libs/compose';
import ONYXKEYS from '../ONYXKEYS';
@@ -35,27 +31,18 @@ const defaultProps = {
function TaskHeaderActionButton(props) {
return (
- Navigation.navigate(ROUTES.getTaskReportAssigneeRoute(props.report.reportID))}
- disabled={!ReportUtils.isOpenTaskReport(props.report)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={props.translate('task.assignee')}
- hoverDimmingValue={1}
- pressDimmingValue={0.2}
- >
-
-
- ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report, props.report.reportName) : Task.completeTask(props.report, props.report.reportName)
- }
- style={[styles.flex1]}
- />
-
-
+
+
+ ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report, props.report.reportName) : Task.completeTask(props.report, props.report.reportName)
+ }
+ style={[styles.flex1]}
+ />
+
);
}
diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js
index 68c09e3a7f82..c3f1cec4ef1a 100644
--- a/src/components/TextInput/BaseTextInput.js
+++ b/src/components/TextInput/BaseTextInput.js
@@ -190,9 +190,9 @@ function BaseTextInput(props) {
// We can't use inputValue here directly, as it might contain
// the defaultValue, which doesn't get updated when the text changes.
// We can't use props.value either, as it might be undefined.
- if (hasValueRef.current || isFocused) {
+ if (hasValueRef.current || isFocused || isInputAutoFilled(input.current)) {
activateLabel();
- } else if (!hasValueRef.current && !isFocused) {
+ } else {
deactivateLabel();
}
}, [activateLabel, deactivateLabel, inputValue, isFocused]);
diff --git a/src/components/TextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol.js
index e7538d4dac6a..f3931a541cfa 100644
--- a/src/components/TextInputWithCurrencySymbol.js
+++ b/src/components/TextInputWithCurrencySymbol.js
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import AmountTextInput from './AmountTextInput';
import CurrencySymbolButton from './CurrencySymbolButton';
import * as CurrencyUtils from '../libs/CurrencyUtils';
+import useLocalize from '../hooks/useLocalize';
+import * as MoneyRequestUtils from '../libs/MoneyRequestUtils';
const propTypes = {
/** A ref to forward to amount text input */
@@ -43,6 +45,7 @@ const defaultProps = {
};
function TextInputWithCurrencySymbol(props) {
+ const {fromLocaleDigit} = useLocalize();
const currencySymbol = CurrencyUtils.getLocalizedCurrencySymbol(props.selectedCurrencyCode);
const isCurrencySymbolLTR = CurrencyUtils.isCurrencySymbolLTR(props.selectedCurrencyCode);
@@ -59,10 +62,20 @@ function TextInputWithCurrencySymbol(props) {
/>
);
+ /**
+ * Set a new amount value properly formatted
+ *
+ * @param {String} text - Changed text from user input
+ */
+ const setFormattedAmount = (text) => {
+ const newAmount = MoneyRequestUtils.addLeadingZero(MoneyRequestUtils.replaceAllDigits(text, fromLocaleDigit));
+ props.onChangeAmount(newAmount);
+ };
+
const amountTextInput = (
diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js
index c60060144099..b5637a4f3879 100644
--- a/src/components/ThreeDotsMenu/index.js
+++ b/src/components/ThreeDotsMenu/index.js
@@ -1,11 +1,11 @@
-import React, {Component} from 'react';
+import React, {useState, useRef} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import Icon from '../Icon';
import PopoverMenu from '../PopoverMenu';
import styles from '../../styles/styles';
-import withLocalize, {withLocalizePropTypes} from '../withLocalize';
+import useLocalize from '../../hooks/useLocalize';
import Tooltip from '../Tooltip';
import * as Expensicons from '../Icon/Expensicons';
import ThreeDotsMenuItemPropTypes from './ThreeDotsMenuItemPropTypes';
@@ -13,8 +13,6 @@ import CONST from '../../CONST';
import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback';
const propTypes = {
- ...withLocalizePropTypes,
-
/** Tooltip for the popup icon */
iconTooltip: PropTypes.string,
@@ -61,68 +59,64 @@ const defaultProps = {
},
};
-class ThreeDotsMenu extends Component {
- constructor(props) {
- super(props);
-
- this.hidePopoverMenu = this.hidePopoverMenu.bind(this);
- this.showPopoverMenu = this.showPopoverMenu.bind(this);
- this.state = {
- isPopupMenuVisible: false,
- };
- this.buttonRef = React.createRef(null);
- }
-
- showPopoverMenu() {
- this.setState({isPopupMenuVisible: true});
- }
-
- hidePopoverMenu() {
- this.setState({isPopupMenuVisible: false});
- }
-
- render() {
- return (
- <>
-
-
- {
- this.showPopoverMenu();
- if (this.props.onIconPress) {
- this.props.onIconPress();
- }
- }}
- ref={this.buttonRef}
- style={[styles.touchableButtonImage, ...this.props.iconStyles]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={this.props.translate(this.props.iconTooltip)}
- >
-
-
-
-
-
- >
- );
- }
+function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment}) {
+ const [isPopupMenuVisible, setPopupMenuVisible] = useState(false);
+ const buttonRef = useRef(null);
+ const {translate} = useLocalize();
+
+ const showPopoverMenu = () => {
+ setPopupMenuVisible(true);
+ };
+
+ const hidePopoverMenu = () => {
+ setPopupMenuVisible(false);
+ };
+
+ return (
+ <>
+
+
+ {
+ if (isPopupMenuVisible) {
+ hidePopoverMenu();
+ return;
+ }
+ showPopoverMenu();
+ if (onIconPress) {
+ onIconPress();
+ }
+ }}
+ ref={buttonRef}
+ style={[styles.touchableButtonImage, ...iconStyles]}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ accessibilityLabel={translate(iconTooltip)}
+ >
+
+
+
+
+
+ >
+ );
}
ThreeDotsMenu.propTypes = propTypes;
ThreeDotsMenu.defaultProps = defaultProps;
+ThreeDotsMenu.displayName = 'ThreeDotsMenu';
-export default withLocalize(ThreeDotsMenu);
+export default ThreeDotsMenu;
export {ThreeDotsMenuItemPropTypes};
diff --git a/src/components/ThumbnailImage.js b/src/components/ThumbnailImage.js
index 47516164864f..983f806bb2e2 100644
--- a/src/components/ThumbnailImage.js
+++ b/src/components/ThumbnailImage.js
@@ -1,10 +1,11 @@
import lodashClamp from 'lodash/clamp';
import React, {useCallback, useState} from 'react';
-import {View} from 'react-native';
+import {View, Dimensions} from 'react-native';
import PropTypes from 'prop-types';
import ImageWithSizeCalculation from './ImageWithSizeCalculation';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
+import * as DeviceCapabilities from '../libs/DeviceCapabilities';
import useWindowDimensions from '../hooks/useWindowDimensions';
const propTypes = {
@@ -23,12 +24,16 @@ const propTypes = {
/** Height of the thumbnail image */
imageHeight: PropTypes.number,
+
+ /** Should the image be resized on load or just fit container */
+ shouldDynamicallyResize: PropTypes.bool,
};
const defaultProps = {
style: {},
imageWidth: 200,
imageHeight: 200,
+ shouldDynamicallyResize: true,
};
/**
@@ -41,12 +46,17 @@ const defaultProps = {
*/
function calculateThumbnailImageSize(width, height, windowHeight) {
+ if (!width || !height) {
+ return {};
+ }
// Width of the thumbnail works better as a constant than it does
// a percentage of the screen width since it is relative to each screen
// Note: Clamp minimum width 40px to support touch device
let thumbnailScreenWidth = lodashClamp(width, 40, 250);
const imageHeight = height / (width / thumbnailScreenWidth);
- let thumbnailScreenHeight = lodashClamp(imageHeight, 40, windowHeight * 0.4);
+ // On mWeb, when soft keyboard opens, window height changes, making thumbnail height inconsistent. We use screen height instead.
+ const screenHeight = DeviceCapabilities.canUseTouchScreen() ? Dimensions.get('screen').height : windowHeight;
+ let thumbnailScreenHeight = lodashClamp(imageHeight, 40, screenHeight * 0.4);
const aspectRatio = height / width;
// If thumbnail height is greater than its width, then the image is portrait otherwise landscape.
@@ -79,9 +89,12 @@ function ThumbnailImage(props) {
},
[windowHeight],
);
+
+ const sizeStyles = props.shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(imageWidth, imageHeight)] : [styles.w100, styles.h100];
+
return (
-
+
{
setIsVideoChatMenuActive(false);
- Linking.openURL(CONST.NEW_ZOOM_MEETING_URL);
+ Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL);
},
},
{
@@ -48,7 +49,7 @@ function BaseVideoChatButtonAndMenu(props) {
text: props.translate('videoChatButtonAndMenu.googleMeet'),
onPress: () => {
setIsVideoChatMenuActive(false);
- Linking.openURL(props.googleMeetURL);
+ Link.openExternalLink(props.googleMeetURL);
},
},
];
@@ -93,7 +94,7 @@ function BaseVideoChatButtonAndMenu(props) {
// If this is the Concierge chat, we'll open the modal for requesting a setup call instead
if (props.isConcierge && props.guideCalendarLink) {
- Linking.openURL(props.guideCalendarLink);
+ Link.openExternalLink(props.guideCalendarLink);
return;
}
setIsVideoChatMenuActive((previousVal) => !previousVal);
diff --git a/src/components/categoryPropTypes.js b/src/components/categoryPropTypes.js
new file mode 100644
index 000000000000..90c3ac368d1c
--- /dev/null
+++ b/src/components/categoryPropTypes.js
@@ -0,0 +1,9 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ /** Name of a category */
+ name: PropTypes.string.isRequired,
+
+ /** Flag that determines if a category is active and able to be selected */
+ enabled: PropTypes.bool.isRequired,
+});
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index 2a9398c96554..a6424518478c 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -24,6 +24,9 @@ const propTypes = {
/** Icon to display on the left side of component */
icon: PropTypes.oneOfType([PropTypes.elementType, PropTypes.string, PropTypes.arrayOf(avatarPropTypes)]),
+ /** Secondary icon to display on the left side of component, right of the icon */
+ secondaryIcon: PropTypes.elementType,
+
/** Icon Width */
iconWidth: PropTypes.number,
@@ -72,6 +75,9 @@ const propTypes = {
/** The fill color to pass into the icon. */
iconFill: PropTypes.string,
+ /** The fill color to pass into the secondary icon. */
+ secondaryIconFill: PropTypes.string,
+
/** Whether item is focused or active */
focused: PropTypes.bool,
diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js
new file mode 100644
index 000000000000..66ed18a1f0b7
--- /dev/null
+++ b/src/components/transactionPropTypes.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import CONST from '../CONST';
+
+export default PropTypes.shape({
+ /** The transaction id */
+ transactionID: PropTypes.string,
+
+ /** The iouReportID associated with the transaction */
+ reportID: PropTypes.string,
+
+ /** The original transaction amount */
+ amount: PropTypes.number,
+
+ /** The edited transaction amount */
+ modifiedAmount: PropTypes.number,
+
+ /** The original created data */
+ created: PropTypes.string,
+
+ /** The edited transaction date */
+ modifiedCreated: PropTypes.string,
+
+ /** The filename of the associated receipt */
+ filename: PropTypes.string,
+
+ /** The original merchant name */
+ merchant: PropTypes.string,
+
+ /** The edited merchant name */
+ modifiedMerchant: PropTypes.string,
+
+ /** The comment object on the transaction */
+ comment: PropTypes.shape({
+ /** The text of the comment */
+ comment: PropTypes.string,
+
+ /** The waypoints defining the distance request */
+ waypoints: PropTypes.shape({
+ /** The latitude of the waypoint */
+ lat: PropTypes.number,
+
+ /** The longitude of the waypoint */
+ lng: PropTypes.number,
+
+ /** The address of the waypoint */
+ address: PropTypes.string,
+ }),
+ }),
+
+ /** The type of transaction */
+ type: PropTypes.oneOf(_.values(CONST.TRANSACTION.TYPE)),
+
+ /** Custom units attached to the transaction */
+ customUnits: PropTypes.arrayOf(
+ PropTypes.shape({
+ /** The name of the custom unit */
+ name: PropTypes.string,
+ }),
+ ),
+
+ /** The original currency of the transaction */
+ currency: PropTypes.string,
+
+ /** The edited currency of the transaction */
+ modifiedCurrency: PropTypes.string,
+
+ /** The receipt object associated with the transaction */
+ receipt: PropTypes.shape({
+ receiptID: PropTypes.number,
+ source: PropTypes.string,
+ state: PropTypes.string,
+ }),
+
+ /** Server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+});
diff --git a/src/global.d.ts b/src/global.d.ts
index 0e32eb6f7457..0dc745d5e0ea 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -1,3 +1,5 @@
+import {OnyxKey, OnyxCollectionKey, OnyxValues} from './ONYXKEYS';
+
declare module '*.png' {
const value: import('react-native').ImageSourcePropType;
export default value;
@@ -15,3 +17,12 @@ declare module '*.svg' {
}
declare module 'react-native-device-info/jest/react-native-device-info-mock';
+
+declare module 'react-native-onyx' {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface CustomTypeOptions {
+ keys: OnyxKey;
+ collectionKeys: OnyxCollectionKey;
+ values: OnyxValues;
+ }
+}
diff --git a/src/hooks/useDefaultDragAndDrop/index.js b/src/hooks/useDefaultDragAndDrop/index.js
new file mode 100644
index 000000000000..34005c9de2b3
--- /dev/null
+++ b/src/hooks/useDefaultDragAndDrop/index.js
@@ -0,0 +1,21 @@
+import {useEffect} from 'react';
+
+export default function useDefaultDragAndDrop() {
+ useEffect(() => {
+ const dropDragListener = (event) => {
+ event.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ event.dataTransfer.dropEffect = 'none';
+ };
+ document.addEventListener('dragover', dropDragListener);
+ document.addEventListener('dragenter', dropDragListener);
+ document.addEventListener('dragleave', dropDragListener);
+ document.addEventListener('drop', dropDragListener);
+ return () => {
+ document.removeEventListener('dragover', dropDragListener);
+ document.removeEventListener('dragenter', dropDragListener);
+ document.removeEventListener('dragleave', dropDragListener);
+ document.removeEventListener('drop', dropDragListener);
+ };
+ }, []);
+}
diff --git a/src/hooks/useDefaultDragAndDrop/index.native.js b/src/hooks/useDefaultDragAndDrop/index.native.js
new file mode 100644
index 000000000000..2d1ec238274a
--- /dev/null
+++ b/src/hooks/useDefaultDragAndDrop/index.native.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/src/hooks/useDragAndDrop.js b/src/hooks/useDragAndDrop.js
index 0ec4bb042173..fb1d158e4063 100644
--- a/src/hooks/useDragAndDrop.js
+++ b/src/hooks/useDragAndDrop.js
@@ -53,7 +53,7 @@ export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllow
*/
const dropZoneDragHandler = useCallback(
(event) => {
- if (!isFocused || isDisabled) {
+ if (!isFocused || isDisabled || !shouldAcceptDrop(event)) {
return;
}
@@ -89,7 +89,7 @@ export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllow
break;
}
},
- [isFocused, isDisabled, setDropEffect, isDraggingOver, onDrop],
+ [isFocused, isDisabled, shouldAcceptDrop, setDropEffect, isDraggingOver, onDrop],
);
useEffect(() => {
diff --git a/src/languages/en.js b/src/languages/en.js
index a3d5a85277af..84b0561b3c38 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -30,10 +30,14 @@ export default {
workspaces: 'Workspaces',
profile: 'Profile',
payments: 'Payments',
+ wallet: 'Wallet',
preferences: 'Preferences',
view: 'View',
not: 'Not',
signIn: 'Sign in',
+ signInWithGoogle: 'Sign in with Google',
+ signInWithApple: 'Sign in with Apple',
+ signInWith: 'Sign in with',
continue: 'Continue',
firstName: 'First name',
lastName: 'Last name',
@@ -151,6 +155,9 @@ export default {
edit: 'Edit',
showMore: 'Show more',
merchant: 'Merchant',
+ category: 'Category',
+ receipt: 'Receipt',
+ replace: 'Replace',
},
anonymousReportFooter: {
logoTagline: 'Join in on the discussion.',
@@ -191,6 +198,11 @@ export default {
redirectedToDesktopApp: "We've redirected you to the desktop app.",
youCanAlso: 'You can also',
openLinkInBrowser: 'open this link in your browser',
+ loggedInAs: ({email}) => `You're logged in as ${email}. Click "Open link" in the prompt to log into the desktop app with this account.`,
+ doNotSeePrompt: "Can't see the prompt?",
+ tryAgain: 'Try again',
+ or: ', or',
+ continueInWeb: 'continue to the web app',
},
validateCodeModal: {
successfulSignInTitle: 'Abracadabra,\nyou are signed in!',
@@ -235,12 +247,26 @@ export default {
newFaceEnterMagicCode: ({login}) => `It's always great to see a new face around here! Please enter the magic code sent to ${login}. It should arrive within a minute or two.`,
welcomeEnterMagicCode: ({login}) => `Please enter the magic code sent to ${login}. It should arrive within a minute or two.`,
},
+ DownloadAppModal: {
+ downloadTheApp: 'Download the app',
+ keepTheConversationGoing: 'Keep the conversation going in New Expensify, download the app for an enhanced experience.',
+ noThanks: 'No thanks',
+ },
login: {
hero: {
header: 'Split bills, request payments, and chat with friends.',
body: 'Welcome to the future of Expensify, your new go-to place for financial collaboration with friends and teammates alike.',
},
},
+ thirdPartySignIn: {
+ alreadySignedIn: ({email}) => `You are already signed in as ${email}.`,
+ goBackMessage: ({provider}) => `Don't want to sign in with ${provider}?`,
+ continueWithMyCurrentSession: 'Continue with my current session',
+ redirectToDesktopMessage: "We'll redirect you to the desktop app once you finish signing in.",
+ signInAgreementMessage: 'By logging in, you agree to the',
+ termsOfService: 'Terms of Service',
+ privacy: 'Privacy',
+ },
reportActionCompose: {
addAction: 'Actions',
dropToUpload: 'Drop to upload',
@@ -291,8 +317,7 @@ export default {
beginningOfChatHistoryDomainRoomPartTwo: ' to chat with colleagues, share tips, and ask questions.',
beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}) => `Collaboration among ${workspaceName} admins starts here! 🎉\nUse `,
beginningOfChatHistoryAdminRoomPartTwo: ' to chat about topics such as workspace configurations and more.',
- beginningOfChatHistoryAdminOnlyPostingRoomPartOne: 'Use ',
- beginningOfChatHistoryAdminOnlyPostingRoomPartTwo: ({workspaceName}) => ` to hear about important announcements related to ${workspaceName}`,
+ beginningOfChatHistoryAdminOnlyPostingRoom: 'Only admins can send messages in this room.',
beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}) => `Collaboration between all ${workspaceName} members starts here! 🎉\nUse `,
beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}) => ` to chat about anything ${workspaceName} related.`,
beginningOfChatHistoryUserRoomPartOne: 'Collaboration starts here! 🎉\nUse this space to chat about anything ',
@@ -303,6 +328,7 @@ export default {
beginningOfChatHistoryPolicyExpenseChatPartThree: ' starts here! 🎉 This is the place to chat, request money and settle up.',
chatWithAccountManager: 'Chat with your account manager here',
sayHello: 'Say hello!',
+ welcomeToRoom: ({roomName}) => `Welcome to ${roomName}!`,
usePlusButton: '\n\nYou can also use the + button below to request money or assign a task!',
},
reportAction: {
@@ -380,6 +406,11 @@ export default {
pay: 'Pay',
viewDetails: 'View details',
pending: 'Pending',
+ deleteReceipt: 'Delete receipt',
+ receiptScanning: 'Receipt scan in progress…',
+ receiptStatusTitle: 'Scanning…',
+ receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.",
+ requestCount: ({count, scanningReceipts = 0}) => `${count} requests${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}`,
deleteRequest: 'Delete request',
deleteConfirmation: 'Are you sure that you want to delete this request?',
settledExpensify: 'Paid',
@@ -405,12 +436,12 @@ export default {
pendingConversionMessage: "Total will update when you're back online",
threadRequestReportName: ({formattedAmount, comment}) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`,
- requestCount: ({count}) => `${count} requests`,
error: {
invalidSplit: 'Split amounts do not equal total amount',
other: 'Unexpected error, please try again later',
genericCreateFailureMessage: 'Unexpected error requesting money, please try again later',
genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later',
+ genericEditFailureMessage: 'Unexpected error editing the money request, please try again later',
},
},
notificationPreferencesPage: {
@@ -645,7 +676,7 @@ export default {
password: 'Please enter your Expensify password',
},
},
- paymentsPage: {
+ walletPage: {
paymentMethodsTitle: 'Payment methods',
setDefaultConfirmation: 'Make default payment method',
setDefaultSuccess: 'Default payment method set!',
@@ -672,8 +703,8 @@ export default {
transferDetailBankAccount: 'Your money should arrive in the next 1-3 business days.',
transferDetailDebitCard: 'Your money should arrive immediately.',
failedTransfer: 'Your balance isn’t fully settled. Please transfer to a bank account.',
- notHereSubTitle: 'Please transfer your balance from the payments page',
- goToPayment: 'Go to Payments',
+ notHereSubTitle: 'Please transfer your balance from the wallet page',
+ goToWallet: 'Go to Wallet',
},
chooseTransferAccountPage: {
chooseAccount: 'Choose account',
@@ -874,6 +905,8 @@ export default {
clearStatus: 'Clear status',
save: 'Save',
message: 'Message',
+ untilTomorrow: 'Until tomorrow',
+ untilTime: ({time}) => `Until ${time}`,
},
stepCounter: ({step, total, text}) => {
let result = `Step ${step}`;
@@ -1577,10 +1610,36 @@ export default {
levelTwoResult: 'Message hidden from channel, plus anonymous warning and message is reported for review.',
levelThreeResult: 'Message removed from channel plus anonymous warning and message is reported for review.',
},
+ distance: {
+ addStop: 'Add stop',
+ address: 'Address',
+ waypointEditor: 'Waypoint Editor',
+ waypointDescription: {
+ start: 'Start',
+ finish: 'Finish',
+ stop: 'Stop',
+ },
+ mapPending: {
+ title: 'Map pending',
+ subtitle: 'The map will be generated when you go back online',
+ onlineSubtitle: 'One moment while we set up the map',
+ },
+ errors: {
+ selectSuggestedAddress: 'Please select a suggested address',
+ },
+ },
countrySelectorModal: {
placeholderText: 'Search to see options',
},
stateSelectorModal: {
placeholderText: 'Search to see options',
},
+ demos: {
+ saastr: {
+ signInWelcome: 'Welcome to SaaStr! Hop in to start networking now.',
+ },
+ sbe: {
+ signInWelcome: 'Welcome to Small Business Expo! Get paid back for your ride.',
+ },
+ },
};
diff --git a/src/languages/es.js b/src/languages/es.js
index afc7aa7a7846..eb2a667bdb38 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -29,10 +29,14 @@ export default {
workspaces: 'Espacios de trabajo',
profile: 'Perfil',
payments: 'Pagos',
+ wallet: 'Billetera',
preferences: 'Preferencias',
view: 'Ver',
not: 'No',
signIn: 'Conectarse',
+ signInWithGoogle: 'Iniciar sesión con Google',
+ signInWithApple: 'Iniciar sesión con Apple',
+ signInWith: 'Iniciar sesión con',
continue: 'Continuar',
firstName: 'Nombre',
lastName: 'Apellidos',
@@ -150,6 +154,9 @@ export default {
edit: 'Editar',
showMore: 'Mostrar más',
merchant: 'Comerciante',
+ category: 'Categoría',
+ receipt: 'Recibo',
+ replace: 'Sustituir',
},
anonymousReportFooter: {
logoTagline: 'Únete a la discussion.',
@@ -190,6 +197,11 @@ export default {
redirectedToDesktopApp: 'Te hemos redirigido a la aplicación de escritorio.',
youCanAlso: 'También puedes',
openLinkInBrowser: 'abrir este enlace en tu navegador',
+ loggedInAs: ({email}) => `Has iniciado sesión como ${email}. Haga clic en "Abrir enlace" en el aviso para iniciar sesión en la aplicación de escritorio con esta cuenta.`,
+ doNotSeePrompt: '¿No ves el aviso?',
+ tryAgain: 'Inténtalo de nuevo',
+ or: ', o',
+ continueInWeb: 'continuar en la web',
},
validateCodeModal: {
successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!',
@@ -234,12 +246,26 @@ export default {
newFaceEnterMagicCode: ({login}) => `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}. Debería llegar en un par de minutos.`,
welcomeEnterMagicCode: ({login}) => `Por favor, introduce el código mágico enviado a ${login}. Debería llegar en un par de minutos.`,
},
+ DownloadAppModal: {
+ downloadTheApp: 'Descarga la aplicación',
+ keepTheConversationGoing: 'Mantén la conversación en New Expensify, descarga la aplicación para una experiencia mejorada.',
+ noThanks: 'No, gracias',
+ },
login: {
hero: {
header: 'Divida las facturas, solicite pagos y chatee con sus amigos.',
body: 'Bienvenido al futuro de Expensify, tu nuevo lugar de referencia para la colaboración financiera con amigos y compañeros de equipo por igual.',
},
},
+ thirdPartySignIn: {
+ alreadySignedIn: ({email}) => `Ya has iniciado sesión con ${email}.`,
+ goBackMessage: ({provider}) => `No quieres iniciar sesión con ${provider}?`,
+ continueWithMyCurrentSession: 'Continuar con mi sesión actual',
+ redirectToDesktopMessage: 'Lo redirigiremos a la aplicación de escritorio una vez que termine de iniciar sesión.',
+ signInAgreementMessage: 'Al iniciar sesión, aceptas las',
+ termsOfService: 'Términos de servicio',
+ privacy: 'Privacidad',
+ },
reportActionCompose: {
addAction: 'Acción',
dropToUpload: 'Suelta el archivo aquí para compartirlo',
@@ -290,8 +316,7 @@ export default {
beginningOfChatHistoryDomainRoomPartTwo: ' para chatear con compañeros, compartir consejos o hacer una pregunta.',
beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}) => `Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `,
beginningOfChatHistoryAdminRoomPartTwo: ' para chatear sobre temas como la configuración del espacio de trabajo y mas.',
- beginningOfChatHistoryAdminOnlyPostingRoomPartOne: 'Utiliza ',
- beginningOfChatHistoryAdminOnlyPostingRoomPartTwo: ({workspaceName}) => ` para enterarte de anuncios importantes relacionados con ${workspaceName}`,
+ beginningOfChatHistoryAdminOnlyPostingRoom: 'Solo los administradores pueden enviar mensajes en esta sala.',
beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}) => `Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `,
beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`,
beginningOfChatHistoryUserRoomPartOne: 'Este es el lugar para colaborar! 🎉\nUsa este espacio para chatear sobre cualquier cosa relacionada con ',
@@ -302,6 +327,7 @@ export default {
beginningOfChatHistoryPolicyExpenseChatPartThree: ' empieza aquí! 🎉 Este es el lugar donde chatear, pedir dinero y pagar.',
chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí',
sayHello: '¡Saluda!',
+ welcomeToRoom: ({roomName}) => `¡Bienvenido a ${roomName}!`,
usePlusButton: '\n\n¡También puedes usar el botón + de abajo para pedir dinero o asignar una tarea!',
},
reportAction: {
@@ -379,6 +405,11 @@ export default {
pay: 'Pagar',
viewDetails: 'Ver detalles',
pending: 'Pendiente',
+ deleteReceipt: 'Eliminar recibo',
+ receiptScanning: 'Escaneo de recibo en curso…',
+ receiptStatusTitle: 'Escaneando…',
+ receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.',
+ requestCount: ({count, scanningReceipts = 0}) => `${count} solicitudes${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`,
deleteRequest: 'Eliminar pedido',
deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?',
settledExpensify: 'Pagado',
@@ -404,12 +435,12 @@ export default {
pendingConversionMessage: 'El total se actualizará cuando estés online',
threadRequestReportName: ({formattedAmount, comment}) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`,
threadSentMoneyReportName: ({formattedAmount, comment}) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`,
- requestCount: ({count}) => `${count} solicitudes`,
error: {
invalidSplit: 'La suma de las partes no equivale al monto total',
other: 'Error inesperado, por favor inténtalo más tarde',
genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde',
genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde',
+ genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde',
},
},
notificationPreferencesPage: {
@@ -646,7 +677,7 @@ export default {
password: 'Por favor, introduce tu contraseña de Expensify',
},
},
- paymentsPage: {
+ walletPage: {
paymentMethodsTitle: 'Métodos de pago',
setDefaultConfirmation: 'Marcar como método de pago predeterminado',
setDefaultSuccess: 'Método de pago configurado',
@@ -673,8 +704,8 @@ export default {
transferDetailBankAccount: 'Tu dinero debería llegar en 1-3 días laborables.',
transferDetailDebitCard: 'Tu dinero debería llegar de inmediato.',
failedTransfer: 'Tu saldo no se ha acreditado completamente. Por favor, transfiere los fondos a una cuenta bancaria.',
- notHereSubTitle: 'Por favor, transfiere el saldo desde la página de pagos',
- goToPayment: 'Ir a pagos',
+ notHereSubTitle: 'Por favor, transfiere el saldo desde la página de billetera',
+ goToWallet: 'Ir a billetera',
},
chooseTransferAccountPage: {
chooseAccount: 'Elegir cuenta',
@@ -878,6 +909,23 @@ export default {
clearStatus: 'Borrar estado',
save: 'Guardar',
message: 'Mensaje',
+ untilTomorrow: 'Hasta mañana',
+ untilTime: ({time}) => {
+ // Check for HH:MM AM/PM format and starts with '01:'
+ if (CONST.REGEX.TIME_STARTS_01.test(time)) {
+ return `Hasta la ${time}`;
+ }
+ // Check for any HH:MM AM/PM format not starting with '01:'
+ if (CONST.REGEX.TIME_FORMAT.test(time)) {
+ return `Hasta las ${time}`;
+ }
+ // Check for date-time format like "06-29 11:30 AM"
+ if (CONST.REGEX.DATE_TIME_FORMAT.test(time)) {
+ return `Hasta el día ${time}`;
+ }
+ // Default case
+ return `Hasta ${time}`;
+ },
},
stepCounter: ({step, total, text}) => {
let result = `Paso ${step}`;
@@ -2049,10 +2097,36 @@ export default {
levelTwoResult: 'Mensaje ocultado del canal, más advertencia anónima y mensaje reportado para revisión.',
levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.',
},
+ distance: {
+ addStop: 'Agregar parada',
+ address: 'Dirección',
+ waypointEditor: 'Editor de puntos de ruta',
+ waypointDescription: {
+ start: 'Comienzo',
+ finish: 'Final',
+ stop: 'Parada',
+ },
+ mapPending: {
+ title: 'Mapa pendiente',
+ subtitle: 'El mapa se generará cuando vuelvas a estar en línea',
+ onlineSubtitle: 'Un momento mientras configuramos el mapa',
+ },
+ errors: {
+ selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida',
+ },
+ },
countrySelectorModal: {
placeholderText: 'Buscar para ver opciones',
},
stateSelectorModal: {
placeholderText: 'Buscar para ver opciones',
},
+ demos: {
+ saastr: {
+ signInWelcome: '¡Bienvenido a SaaStr! Entra y empieza a establecer contactos.',
+ },
+ sbe: {
+ signInWelcome: '¡Bienvenido a Small Business Expo! Recupera el dinero de tu viaje.',
+ },
+ },
};
diff --git a/src/libs/Browser/index.web.js b/src/libs/Browser/index.web.js
index 9e48bb25a105..32f6392aef76 100644
--- a/src/libs/Browser/index.web.js
+++ b/src/libs/Browser/index.web.js
@@ -1,5 +1,6 @@
import CONST from '../../CONST';
import CONFIG from '../../CONFIG';
+import ROUTES from '../../ROUTES';
/**
* Fetch browser name from UA string
@@ -72,7 +73,10 @@ function isSafari() {
*/
function openRouteInDesktopApp(shortLivedAuthToken = '', email = '') {
const params = new URLSearchParams();
- params.set('exitTo', `${window.location.pathname}${window.location.search}${window.location.hash}`);
+ // If the user is opening the desktop app through a third party signin flow, we need to manually add the exitTo param
+ // so that the desktop app redirects to the correct home route after signin is complete.
+ const openingFromDesktopRedirect = window.location.pathname === `/${ROUTES.DESKTOP_SIGN_IN_REDIRECT}`;
+ params.set('exitTo', `${openingFromDesktopRedirect ? '/r' : window.location.pathname}${window.location.search}${window.location.hash}`);
if (email && shortLivedAuthToken) {
params.set('email', email);
params.set('shortLivedAuthToken', shortLivedAuthToken);
diff --git a/src/libs/ComposerFocusManager.js b/src/libs/ComposerFocusManager.js
new file mode 100644
index 000000000000..569e165da962
--- /dev/null
+++ b/src/libs/ComposerFocusManager.js
@@ -0,0 +1,23 @@
+let isReadyToFocusPromise = Promise.resolve();
+let resolveIsReadyToFocus;
+
+function resetReadyToFocus() {
+ isReadyToFocusPromise = new Promise((resolve) => {
+ resolveIsReadyToFocus = resolve;
+ });
+}
+function setReadyToFocus() {
+ if (!resolveIsReadyToFocus) {
+ return;
+ }
+ resolveIsReadyToFocus();
+}
+function isReadyToFocus() {
+ return isReadyToFocusPromise;
+}
+
+export default {
+ resetReadyToFocus,
+ setReadyToFocus,
+ isReadyToFocus,
+};
diff --git a/src/libs/CurrencyUtils.js b/src/libs/CurrencyUtils.js
index f8d56f7e60e9..271086ede36e 100644
--- a/src/libs/CurrencyUtils.js
+++ b/src/libs/CurrencyUtils.js
@@ -28,7 +28,7 @@ Onyx.connect({
*/
function getCurrencyDecimals(currency = CONST.CURRENCY.USD) {
const decimals = lodashGet(currencyList, [currency, 'decimals']);
- return _.isUndefined(decimals) ? 2 : Math.min(decimals, 2);
+ return _.isUndefined(decimals) ? 2 : decimals;
}
/**
@@ -73,54 +73,49 @@ function isCurrencySymbolLTR(currencyCode) {
}
/**
- * Takes an amount as a floating point number and converts it to an integer amount.
- * For example, given [25, USD], return 2500.
- * Given [25.50, USD] return 2550.
- * Given [2500, JPY], return 2500.
+ * Takes an amount as a floating point number and converts it to an integer equivalent to the amount in "cents".
+ * This is because the backend always stores amounts in "cents". The backend works in integer cents to avoid precision errors
+ * when doing math operations.
*
- * @note we do not currently support any currencies with more than two decimal places. Sorry Tunisia :(
+ * @note we do not currently support any currencies with more than two decimal places. Decimal past the second place will be rounded. Sorry Tunisia :(
*
- * @param {String} currency
* @param {Number} amountAsFloat
* @returns {Number}
*/
-function convertToSmallestUnit(currency, amountAsFloat) {
- const currencyUnit = getCurrencyUnit(currency);
- // We round off the number to resolve floating-point precision issues.
- return Math.round(amountAsFloat * currencyUnit);
+function convertToBackendAmount(amountAsFloat) {
+ return Math.round(amountAsFloat * 100);
}
/**
- * Takes an amount as an integer and converts it to a floating point amount.
- * For example, give [25, USD], return 0.25
- * Given [2550, USD], return 25.50
- * Given [2500, JPY], return 2500
+ * Takes an amount in "cents" as an integer and converts it to a floating point amount used in the frontend.
*
* @note we do not support any currencies with more than two decimal places.
*
- * @param {String} currency
* @param {Number} amountAsInt
* @returns {Number}
*/
-function convertToWholeUnit(currency, amountAsInt) {
- const currencyUnit = getCurrencyUnit(currency);
- return Math.trunc(amountAsInt) / currencyUnit;
+function convertToFrontendAmount(amountAsInt) {
+ return Math.trunc(amountAsInt) / 100.0;
}
/**
- * Given an amount in the smallest units of a currency, convert it to a string for display in the UI.
+ * Given an amount in the "cents", convert it to a string for display in the UI.
+ * The backend always handle things in "cents" (subunit equal to 1/100)
*
- * @param {Number} amountInSmallestUnit – should be an integer. Anything after a decimal place will be dropped.
+ * @param {Number} amountInCents – should be an integer. Anything after a decimal place will be dropped.
* @param {String} currency
* @returns {String}
*/
-function convertToDisplayString(amountInSmallestUnit, currency = CONST.CURRENCY.USD) {
- const currencyUnit = getCurrencyUnit(currency);
- const convertedAmount = Math.trunc(amountInSmallestUnit) / currencyUnit;
+function convertToDisplayString(amountInCents, currency = CONST.CURRENCY.USD) {
+ const convertedAmount = convertToFrontendAmount(amountInCents);
return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, {
style: 'currency',
currency,
+
+ // We are forcing the number of decimals because we override the default number of decimals in the backend for RSD
+ // See: https://github.com/Expensify/PHP-Libs/pull/834
+ minimumFractionDigits: currency === 'RSD' ? getCurrencyDecimals(currency) : undefined,
});
}
-export {getCurrencyDecimals, getCurrencyUnit, getLocalizedCurrencySymbol, isCurrencySymbolLTR, convertToSmallestUnit, convertToWholeUnit, convertToDisplayString};
+export {getCurrencyDecimals, getCurrencyUnit, getLocalizedCurrencySymbol, isCurrencySymbolLTR, convertToBackendAmount, convertToFrontendAmount, convertToDisplayString};
diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js
index 6be627dc643d..b33a1b1b2a73 100644
--- a/src/libs/DateUtils.js
+++ b/src/libs/DateUtils.js
@@ -1,8 +1,23 @@
-import moment from 'moment-timezone';
import lodashGet from 'lodash/get';
-
-// IMPORTANT: load any locales (other than english) that might be passed to moment.locale()
-import 'moment/locale/es';
+import {zonedTimeToUtc, utcToZonedTime, formatInTimeZone} from 'date-fns-tz';
+import {es, enGB} from 'date-fns/locale';
+import {
+ formatDistanceToNow,
+ subMinutes,
+ isBefore,
+ subMilliseconds,
+ isToday,
+ isTomorrow,
+ isYesterday,
+ startOfWeek,
+ endOfWeek,
+ format,
+ setDefaultOptions,
+ endOfDay,
+ isSameDay,
+ isAfter,
+ isSameYear,
+} from 'date-fns';
import _ from 'underscore';
import Onyx from 'react-native-onyx';
@@ -32,25 +47,42 @@ Onyx.connect({
},
});
+/**
+ * Gets the locale string and setting default locale for date-fns
+ *
+ * @param {String} localeString
+ */
+function setLocale(localeString) {
+ switch (localeString) {
+ case CONST.LOCALES.EN:
+ setDefaultOptions({locale: enGB});
+ break;
+ case CONST.LOCALES.ES:
+ setDefaultOptions({locale: es});
+ break;
+ default:
+ break;
+ }
+}
+
/**
* Gets the user's stored time zone NVP and returns a localized
* Moment object for the given ISO-formatted datetime string
*
+ * @private
* @param {String} locale
* @param {String} datetime
* @param {String} [currentSelectedTimezone]
- *
* @returns {Moment}
*
- * @private
*/
-function getLocalMomentFromDatetime(locale, datetime, currentSelectedTimezone = timezone.selected) {
- moment.locale(locale);
+function getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone = timezone.selected) {
+ setLocale(locale);
if (!datetime) {
- return moment.tz(currentSelectedTimezone);
+ return utcToZonedTime(new Date(), currentSelectedTimezone);
}
-
- return moment.utc(datetime).tz(currentSelectedTimezone);
+ const parsedDatetime = new Date(`${datetime}Z`);
+ return utcToZonedTime(parsedDatetime, currentSelectedTimezone);
}
/**
@@ -66,32 +98,38 @@ function getLocalMomentFromDatetime(locale, datetime, currentSelectedTimezone =
* @param {Boolean} includeTimeZone
* @param {String} [currentSelectedTimezone]
* @param {Boolean} isLowercase
- *
* @returns {String}
*/
function datetimeToCalendarTime(locale, datetime, includeTimeZone = false, currentSelectedTimezone, isLowercase = false) {
- const date = getLocalMomentFromDatetime(locale, datetime, currentSelectedTimezone);
+ const date = getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone);
const tz = includeTimeZone ? ' [UTC]Z' : '';
-
let todayAt = Localize.translate(locale, 'common.todayAt');
let tomorrowAt = Localize.translate(locale, 'common.tomorrowAt');
let yesterdayAt = Localize.translate(locale, 'common.yesterdayAt');
const at = Localize.translate(locale, 'common.conjunctionAt');
+ const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+ const endOfCurrentWeek = endOfWeek(new Date(), {weekStartsOn: 1}); // Assuming Monday is the start of the week
+
if (isLowercase) {
todayAt = todayAt.toLowerCase();
tomorrowAt = tomorrowAt.toLowerCase();
yesterdayAt = yesterdayAt.toLowerCase();
}
- return moment(date).calendar({
- sameDay: `[${todayAt}] LT${tz}`,
- nextDay: `[${tomorrowAt}] LT${tz}`,
- lastDay: `[${yesterdayAt}] LT${tz}`,
- nextWeek: `MMM D [${at}] LT${tz}`,
- lastWeek: `MMM D [${at}] LT${tz}`,
- sameElse: `MMM D, YYYY [${at}] LT${tz}`,
- });
+ if (isToday(date)) {
+ return `${todayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`;
+ }
+ if (isTomorrow(date)) {
+ return `${tomorrowAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`;
+ }
+ if (isYesterday(date)) {
+ return `${yesterdayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`;
+ }
+ if (date >= startOfCurrentWeek && date <= endOfCurrentWeek) {
+ return `${format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT)} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`;
+ }
+ return `${format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT)} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`;
}
/**
@@ -109,20 +147,65 @@ function datetimeToCalendarTime(locale, datetime, includeTimeZone = false, curre
*
* @param {String} locale
* @param {String} datetime
- *
* @returns {String}
*/
function datetimeToRelative(locale, datetime) {
- const date = getLocalMomentFromDatetime(locale, datetime);
+ const date = getLocalDateFromDatetime(locale, datetime);
+ return formatDistanceToNow(date, {addSuffix: true});
+}
+
+/**
+ * Gets the zone abbreviation from the date
+ *
+ * e.g.
+ *
+ * PST
+ * EST
+ * GMT +07 - For GMT timezone
+ *
+ * @param {String} datetime
+ * @param {String} selectedTimezone
+ * @returns {String}
+ */
+function getZoneAbbreviation(datetime, selectedTimezone) {
+ return formatInTimeZone(datetime, selectedTimezone, 'zzz');
+}
- return moment(date).fromNow();
+/**
+ * Format date to a long date format with weekday
+ *
+ * @param {String} datetime
+ * @returns {String} Sunday, July 9, 2023
+ */
+function formatToLongDateWithWeekday(datetime) {
+ return format(new Date(datetime), CONST.DATE.LONG_DATE_FORMAT_WITH_WEEKDAY);
+}
+
+/**
+ * Format date to a weekday format
+ *
+ * @param {String} datetime
+ * @returns {String} Sunday
+ */
+function formatToDayOfWeek(datetime) {
+ return format(new Date(datetime), CONST.DATE.WEEKDAY_TIME_FORMAT);
+}
+
+/**
+ * Format date to a local time
+ *
+ * @param {String} datetime
+ * @returns {String} 2:30 PM
+ */
+function formatToLocalTime(datetime) {
+ return format(new Date(datetime), CONST.DATE.LOCAL_TIME_FORMAT);
}
/**
* A throttled version of a function that updates the current date in Onyx store
*/
const updateCurrentDate = _.throttle(() => {
- const currentDate = moment().format('YYYY-MM-DD');
+ const currentDate = format(new Date(), CONST.DATE.FNS_FORMAT_STRING);
CurrentDate.setCurrentDate(currentDate);
}, 1000 * 60 * 60 * 3); // 3 hours
@@ -140,7 +223,7 @@ function startCurrentDateUpdater() {
* @returns {Object}
*/
function getCurrentTimezone() {
- const currentTimezone = moment.tz.guess(true);
+ const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (timezone.automatic && timezone.selected !== currentTimezone) {
return {...timezone, selected: currentTimezone};
}
@@ -148,17 +231,20 @@ function getCurrentTimezone() {
}
// Used to throttle updates to the timezone when necessary
-let lastUpdatedTimezoneTime = moment();
+let lastUpdatedTimezoneTime = new Date();
/**
* @returns {Boolean}
*/
function canUpdateTimezone() {
- return lastUpdatedTimezoneTime.isBefore(moment().subtract(5, 'minutes'));
+ const currentTime = new Date();
+ const fiveMinutesAgo = subMinutes(currentTime, 5);
+ // Compare the last updated time with five minutes ago
+ return isBefore(lastUpdatedTimezoneTime, fiveMinutesAgo);
}
function setTimezoneUpdated() {
- lastUpdatedTimezoneTime = moment();
+ lastUpdatedTimezoneTime = new Date();
}
/**
@@ -174,7 +260,6 @@ function getMicroseconds() {
* Returns the current time in milliseconds in the format expected by the database
*
* @param {String|Number} [timestamp]
- *
* @returns {String}
*/
function getDBTime(timestamp = '') {
@@ -188,7 +273,8 @@ function getDBTime(timestamp = '') {
* @returns {String}
*/
function subtractMillisecondsFromDateTime(dateTime, milliseconds) {
- const newTimestamp = moment.utc(dateTime).subtract(milliseconds, 'milliseconds').valueOf();
+ const date = zonedTimeToUtc(dateTime, 'UTC');
+ const newTimestamp = subMilliseconds(date, milliseconds).valueOf();
return getDBTime(newTimestamp);
}
@@ -205,14 +291,51 @@ function getDateStringFromISOTimestamp(isoTimestamp) {
return dateString;
}
+/**
+ * receive date like 2020-05-16 05:34:14 and format it to show in string like "Until 05:34 PM"
+ *
+ * @param {String} inputDate
+ * @returns {String}
+ */
+function getStatusUntilDate(inputDate) {
+ if (!inputDate) return '';
+ const {translateLocal} = Localize;
+
+ const input = new Date(inputDate);
+ const now = new Date();
+ const endOfToday = endOfDay(now);
+
+ // If the date is equal to the end of today
+ if (isSameDay(input, endOfToday)) {
+ return translateLocal('statusPage.untilTomorrow');
+ }
+
+ // If it's a time on the same date
+ if (isSameDay(input, now)) {
+ return translateLocal('statusPage.untilTime', {time: format(input, CONST.DATE.LOCAL_TIME_FORMAT)});
+ }
+
+ // If it's further in the future than tomorrow but within the same year
+ if (isAfter(input, now) && isSameYear(input, now)) {
+ return translateLocal('statusPage.untilTime', {time: format(input, `${CONST.DATE.SHORT_DATE_FORMAT} ${CONST.DATE.LOCAL_TIME_FORMAT}`)});
+ }
+
+ // If it's in another year
+ return translateLocal('statusPage.untilTime', {time: format(input, `${CONST.DATE.FNS_FORMAT_STRING} ${CONST.DATE.LOCAL_TIME_FORMAT}`)});
+}
+
/**
* @namespace DateUtils
*/
const DateUtils = {
+ formatToDayOfWeek,
+ formatToLongDateWithWeekday,
+ formatToLocalTime,
+ getZoneAbbreviation,
datetimeToRelative,
datetimeToCalendarTime,
startCurrentDateUpdater,
- getLocalMomentFromDatetime,
+ getLocalDateFromDatetime,
getCurrentTimezone,
canUpdateTimezone,
setTimezoneUpdated,
@@ -220,6 +343,7 @@ const DateUtils = {
getDBTime,
subtractMillisecondsFromDateTime,
getDateStringFromISOTimestamp,
+ getStatusUntilDate,
};
export default DateUtils;
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index bbc4bee6ed0c..df00418b7524 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -13,7 +13,7 @@ Onyx.connect({
key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
callback: (val) => {
frequentlyUsedEmojis = _.map(val, (item) => {
- const emoji = Emojis.emojiCodeTable[item.code];
+ const emoji = Emojis.emojiCodeTableWithSkinTones[item.code];
if (emoji) {
return {...emoji, count: item.count, lastUpdatedAt: item.lastUpdatedAt};
}
@@ -33,7 +33,7 @@ const findEmojiByName = (name) => Emojis.emojiNameTable[name];
* @param {String} code
* @returns {Object}
*/
-const findEmojiByCode = (code) => Emojis.emojiCodeTable[code];
+const findEmojiByCode = (code) => Emojis.emojiCodeTableWithSkinTones[code];
/**
*
@@ -229,7 +229,7 @@ function getFrequentlyUsedEmojis(newEmoji) {
frequentEmojiList.splice(emojiIndex, 1);
}
- const updatedEmoji = {...Emojis.emojiCodeTable[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp};
+ const updatedEmoji = {...Emojis.emojiCodeTableWithSkinTones[emoji.code], count: currentEmojiCount, lastUpdatedAt: currentTimestamp};
// We want to make sure the current emoji is added to the list
// Hence, we take one less than the current frequent used emojis
@@ -259,6 +259,43 @@ const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => {
return code;
};
+/**
+ * Extracts emojis from a given text.
+ *
+ * @param {String} text - The text to extract emojis from.
+ * @returns {Object[]} An array of emoji codes.
+ */
+function extractEmojis(text) {
+ if (!text) {
+ return [];
+ }
+
+ // Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩']
+ const parsedEmojis = text.match(CONST.REGEX.EMOJIS);
+
+ if (!parsedEmojis) {
+ return [];
+ }
+
+ const emojis = [];
+
+ // Text can contain similar emojis as well as their skin tone variants. Create a Set to remove duplicate emojis from the search.
+ const foundEmojiCodes = new Set();
+
+ for (let i = 0; i < parsedEmojis.length; i++) {
+ const character = parsedEmojis[i];
+ const emoji = Emojis.emojiCodeTableWithSkinTones[character];
+
+ // Add the parsed emoji to the final emojis if not already present.
+ if (emoji && !foundEmojiCodes.has(emoji.code)) {
+ foundEmojiCodes.add(emoji.code);
+ emojis.push(emoji);
+ }
+ }
+
+ return emojis;
+}
+
/**
* Replace any emoji name in a text with the emoji icon.
* If we're on mobile, we also add a space after the emoji granted there's no text after it.
@@ -304,6 +341,22 @@ function replaceEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
return {text: newText, emojis};
}
+/**
+ * Find all emojis in a text and replace them with their code.
+ * @param {String} text
+ * @param {Number} preferredSkinTone
+ * @param {String} lang
+ * @returns {Object}
+ */
+function replaceAndExtractEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT) {
+ const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang);
+
+ return {
+ text: convertedText,
+ emojis: emojis.concat(extractEmojis(text)),
+ };
+}
+
/**
* Suggest emojis when typing emojis prefix after colon
* @param {String} text
@@ -421,4 +474,5 @@ export {
getPreferredSkinToneIndex,
getPreferredEmojiCode,
getUniqueEmojiCodes,
+ replaceAndExtractEmojis,
};
diff --git a/src/libs/IOUUtils.js b/src/libs/IOUUtils.js
index 241baf26c998..2042c6beda05 100644
--- a/src/libs/IOUUtils.js
+++ b/src/libs/IOUUtils.js
@@ -1,25 +1,31 @@
import _ from 'underscore';
import CONST from '../CONST';
-import * as ReportActionsUtils from './ReportActionsUtils';
+import * as TransactionUtils from './TransactionUtils';
+import * as CurrencyUtils from './CurrencyUtils';
/**
* Calculates the amount per user given a list of participants
*
* @param {Number} numberOfParticipants - Number of participants in the chat. It should not include the current user.
- * @param {Number} total - IOU total amount in the smallest units of the currency
+ * @param {Number} total - IOU total amount in backend format (cents, no matter the currency)
+ * @param {String} currency - This is used to know how many decimal places are valid to use when splitting the total
* @param {Boolean} isDefaultUser - Whether we are calculating the amount for the current user
* @returns {Number}
*/
-function calculateAmount(numberOfParticipants, total, isDefaultUser = false) {
+function calculateAmount(numberOfParticipants, total, currency, isDefaultUser = false) {
+ // Since the backend can maximum store 2 decimal places, any currency with more than 2 decimals
+ // has to be capped to 2 decimal places
+ const currencyUnit = Math.min(100, CurrencyUtils.getCurrencyUnit(currency));
+ const totalInCurrencySubunit = Math.round((total / 100) * currencyUnit);
const totalParticipants = numberOfParticipants + 1;
- const amountPerPerson = Math.round(total / totalParticipants);
+ const amountPerPerson = Math.round(totalInCurrencySubunit / totalParticipants);
let finalAmount = amountPerPerson;
if (isDefaultUser) {
const sumAmount = amountPerPerson * totalParticipants;
- const difference = total - sumAmount;
- finalAmount = total !== sumAmount ? amountPerPerson + difference : amountPerPerson;
+ const difference = totalInCurrencySubunit - sumAmount;
+ finalAmount = totalInCurrencySubunit !== sumAmount ? amountPerPerson + difference : amountPerPerson;
}
- return finalAmount;
+ return Math.round((finalAmount * 100) / currencyUnit);
}
/**
@@ -61,62 +67,17 @@ function updateIOUOwnerAndTotal(iouReport, actorAccountID, amount, currency, isD
return iouReportUpdate;
}
-/**
- * Returns the list of IOU actions depending on the type and whether or not they are pending.
- * Used below so that we can decide if an IOU report is pending currency conversion.
- *
- * @param {Array} reportActions
- * @param {Object} iouReport
- * @param {String} type - iouReportAction type. Can be oneOf(create, delete, pay, split)
- * @param {String} pendingAction
- * @param {Boolean} filterRequestsInDifferentCurrency
- *
- * @returns {Array}
- */
-function getIOUReportActions(reportActions, iouReport, type = '', pendingAction = '', filterRequestsInDifferentCurrency = false) {
- return _.chain(reportActions)
- .filter((action) => action.originalMessage && ReportActionsUtils.isMoneyRequestAction(action) && (!_.isEmpty(type) ? action.originalMessage.type === type : true))
- .filter((action) => action.originalMessage.IOUReportID.toString() === iouReport.reportID.toString())
- .filter((action) => (!_.isEmpty(pendingAction) ? action.pendingAction === pendingAction : true))
- .filter((action) => (filterRequestsInDifferentCurrency ? action.originalMessage.currency !== iouReport.currency : true))
- .value();
-}
-
/**
* Returns whether or not an IOU report contains money requests in a different currency
* that are either created or cancelled offline, and thus haven't been converted to the report's currency yet
*
- * @param {Array} reportActions
* @param {Object} iouReport
- *
* @returns {Boolean}
*/
-function isIOUReportPendingCurrencyConversion(reportActions, iouReport) {
- // Pending money requests that are in a different currency
- const pendingRequestsInDifferentCurrency = _.chain(getIOUReportActions(reportActions, iouReport, CONST.IOU.REPORT_ACTION_TYPE.CREATE, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, true))
- .map((action) => action.originalMessage.IOUTransactionID)
- .sort()
- .value();
-
- // Pending deleted money requests that are in a different currency
- const pendingDeletedRequestsInDifferentCurrency = _.chain(
- getIOUReportActions(reportActions, iouReport, CONST.IOU.REPORT_ACTION_TYPE.DELETE, CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, true),
- )
- .map((action) => action.originalMessage.IOUTransactionID)
- .sort()
- .value();
-
- const hasPendingRequests = Boolean(pendingRequestsInDifferentCurrency.length || pendingDeletedRequestsInDifferentCurrency.length);
-
- // If we have pending money requests made offline, check if all of them have been cancelled offline
- // In order to do that, we can grab transactionIDs of all the created and cancelled money requests and check if they're identical
- if (hasPendingRequests && _.isEqual(pendingRequestsInDifferentCurrency, pendingDeletedRequestsInDifferentCurrency)) {
- return false;
- }
-
- // Not all requests made offline had been cancelled,
- // simply return if we have any pending created or cancelled requests
- return hasPendingRequests;
+function isIOUReportPendingCurrencyConversion(iouReport) {
+ const reportTransactions = TransactionUtils.getAllReportTransactions(iouReport.reportID);
+ const pendingRequestsInDifferentCurrency = _.filter(reportTransactions, (transaction) => transaction.pendingAction && TransactionUtils.getCurrency(transaction) !== iouReport.currency);
+ return pendingRequestsInDifferentCurrency.length > 0;
}
/**
@@ -128,4 +89,4 @@ function isValidMoneyRequestType(iouType) {
return [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, CONST.IOU.MONEY_REQUEST_TYPE.SPLIT].includes(iouType);
}
-export {calculateAmount, updateIOUOwnerAndTotal, getIOUReportActions, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType};
+export {calculateAmount, updateIOUOwnerAndTotal, isIOUReportPendingCurrencyConversion, isValidMoneyRequestType};
diff --git a/src/libs/Middleware/SaveResponseInOnyx.js b/src/libs/Middleware/SaveResponseInOnyx.js
index faecda08df7a..28b8a93fb585 100644
--- a/src/libs/Middleware/SaveResponseInOnyx.js
+++ b/src/libs/Middleware/SaveResponseInOnyx.js
@@ -1,6 +1,10 @@
import Onyx from 'react-native-onyx';
+import _ from 'underscore';
import CONST from '../../CONST';
+import ONYXKEYS from '../../ONYXKEYS';
import * as QueuedOnyxUpdates from '../actions/QueuedOnyxUpdates';
+import * as MemoryOnlyKeys from '../actions/MemoryOnlyKeys/MemoryOnlyKeys';
+import * as OnyxUpdates from '../actions/OnyxUpdates';
/**
* @param {Promise} response
@@ -14,6 +18,30 @@ function SaveResponseInOnyx(response, request) {
return;
}
+ // The data for this response comes in two different formats:
+ // 1. Original format - this is what was sent before the RELIABLE_UPDATES project and will go away once RELIABLE_UPDATES is fully complete
+ // - The data is an array of objects, where each object is an onyx update
+ // Example: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]
+ // 1. Reliable updates format - this is what was sent with the RELIABLE_UPDATES project and will be the format from now on
+ // - The data is an object, containing updateIDs from the server and an array of onyx updates (this array is the same format as the original format above)
+ // Example: {lastUpdateID: 1, previousUpdateID: 0, onyxData: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]}
+ // NOTE: This is slightly different than the format of the pusher event data, where pusher has "updates" and HTTPS responses have "onyxData" (long story)
+
+ // Supports both the old format and the new format
+ const onyxUpdates = _.isArray(responseData) ? responseData : responseData.onyxData;
+ // If there is an OnyxUpdate for using memory only keys, enable them
+ _.find(onyxUpdates, ({key, value}) => {
+ if (key !== ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS || !value) {
+ return false;
+ }
+
+ MemoryOnlyKeys.enable();
+ return true;
+ });
+
+ // Save the update IDs to Onyx so they can be used to fetch incremental updates if the client gets out of sync from the server
+ OnyxUpdates.saveUpdateIDs(Number(responseData.lastUpdateID || 0), Number(responseData.previousUpdateID || 0));
+
// For most requests we can immediately update Onyx. For write requests we queue the updates and apply them after the sequential queue has flushed to prevent a replay effect in
// the UI. See https://github.com/Expensify/App/issues/12775 for more info.
const updateHandler = request.data.apiRequestType === CONST.API_REQUEST_TYPE.WRITE ? QueuedOnyxUpdates.queueOnyxUpdates : Onyx.update;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index 71bf782959cd..7317306cdbe6 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -31,6 +31,7 @@ import RightModalNavigator from './Navigators/RightModalNavigator';
import CentralPaneNavigator from './Navigators/CentralPaneNavigator';
import NAVIGATORS from '../../../NAVIGATORS';
import FullScreenNavigator from './Navigators/FullScreenNavigator';
+import DesktopSignInRedirectPage from '../../../pages/signin/DesktopSignInRedirectPage';
import styles from '../../../styles/styles';
import * as SessionUtils from '../../SessionUtils';
import getNavigationModalCardStyle from '../../../styles/getNavigationModalCardStyles';
@@ -47,6 +48,11 @@ Onyx.connect({
}
currentAccountID = val.accountID;
+ if (Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) {
+ // This means sign in in RHP was successful, so we can dismiss the modal and subscribe to user events
+ Navigation.dismissModal();
+ User.subscribeToUserEvents();
+ }
},
});
@@ -96,8 +102,8 @@ const propTypes = {
/** Opt-in experimental mode that prevents certain Onyx keys from persisting to disk */
isUsingMemoryOnlyKeys: PropTypes.bool,
- /** The last Onyx update ID that is stored in Onyx (used for getting incremental updates when reconnecting) */
- onyxUpdatesLastUpdateID: PropTypes.number,
+ /** The last Onyx update ID was applied to the client */
+ lastUpdateIDAppliedToClient: PropTypes.number,
...windowDimensionsPropTypes,
};
@@ -108,7 +114,7 @@ const defaultProps = {
email: null,
},
lastOpenedPublicRoomID: null,
- onyxUpdatesLastUpdateID: 0,
+ lastUpdateIDAppliedToClient: null,
};
class AuthScreens extends React.Component {
@@ -120,7 +126,7 @@ class AuthScreens extends React.Component {
componentDidMount() {
NetworkConnection.listenForReconnect();
- NetworkConnection.onReconnect(() => App.reconnectApp(this.props.onyxUpdatesLastUpdateID));
+ NetworkConnection.onReconnect(() => App.reconnectApp(this.props.lastUpdateIDAppliedToClient));
PusherConnectionManager.init();
Pusher.init({
appKey: CONFIG.PUSHER.APP_KEY,
@@ -131,8 +137,7 @@ class AuthScreens extends React.Component {
});
// If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app
- // or returning from background. If so, we'll assume they have some app data already and we can call
- // reconnectApp(onyxUpdatesLastUpdateID) instead of openApp().
+ // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp().
// Note: If a Guide has enabled the memory only key mode then we do want to run OpenApp as their app will not be rehydrated with
// the correct state on refresh. They are explicitly opting out of storing data they would need (i.e. reports_) to take advantage of
// the optimizations performed during ReconnectApp.
@@ -140,10 +145,11 @@ class AuthScreens extends React.Component {
if (shouldGetAllData) {
App.openApp();
} else {
- App.reconnectApp(this.props.onyxUpdatesLastUpdateID);
+ App.reconnectApp(this.props.lastUpdateIDAppliedToClient);
}
App.setUpPoliciesAndNavigate(this.props.session, !this.props.isSmallScreenWidth);
+ App.redirectThirdPartyDesktopSignIn();
if (this.props.lastOpenedPublicRoomID) {
// Re-open the last opened public room if the user logged in from a public room link
@@ -309,6 +315,11 @@ class AuthScreens extends React.Component {
component={RightModalNavigator}
listeners={modalScreenListeners}
/>
+
);
}
@@ -328,8 +339,8 @@ export default compose(
isUsingMemoryOnlyKeys: {
key: ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS,
},
- onyxUpdatesLastUpdateID: {
- key: ONYXKEYS.ONYX_UPDATES.LAST_UPDATE_ID,
+ lastUpdateIDAppliedToClient: {
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
},
}),
)(AuthScreens);
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 8a489afb035e..d172911d68ed 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -69,6 +69,13 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator([
},
name: 'Money_Request_Currency',
},
+ {
+ getComponent: () => {
+ const MoneyRequestDatePage = require('../../../pages/iou/MoneyRequestDatePage').default;
+ return MoneyRequestDatePage;
+ },
+ name: 'Money_Request_Date',
+ },
{
getComponent: () => {
const MoneyRequestDescriptionPage = require('../../../pages/iou/MoneyRequestDescriptionPage').default;
@@ -76,6 +83,20 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator([
},
name: 'Money_Request_Description',
},
+ {
+ getComponent: () => {
+ const MoneyRequestCategoryPage = require('../../../pages/iou/MoneyRequestCategoryPage').default;
+ return MoneyRequestCategoryPage;
+ },
+ name: 'Money_Request_Category',
+ },
+ {
+ getComponent: () => {
+ const MoneyRequestMerchantPage = require('../../../pages/iou/MoneyRequestMerchantPage').default;
+ return MoneyRequestMerchantPage;
+ },
+ name: 'Money_Request_Merchant',
+ },
{
getComponent: () => {
const AddPersonalBankAccountPage = require('../../../pages/AddPersonalBankAccountPage').default;
@@ -85,7 +106,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator([
},
{
getComponent: () => {
- const AddDebitCardPage = require('../../../pages/settings/Payments/AddDebitCardPage').default;
+ const AddDebitCardPage = require('../../../pages/settings/Wallet/AddDebitCardPage').default;
return AddDebitCardPage;
},
name: 'IOU_Send_Add_Debit_Card',
@@ -97,6 +118,13 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator([
},
name: 'IOU_Send_Enable_Payments',
},
+ {
+ getComponent: () => {
+ const WaypointEditorPage = require('../../../pages/iou/WaypointEditorPage').default;
+ return WaypointEditorPage;
+ },
+ name: 'Money_Request_Waypoint',
+ },
]);
const SplitDetailsModalStackNavigator = createModalStackNavigator([
@@ -468,28 +496,28 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
{
getComponent: () => {
- const SettingsPaymentsPage = require('../../../pages/settings/Payments/PaymentsPage').default;
- return SettingsPaymentsPage;
+ const SettingsWalletPage = require('../../../pages/settings/Wallet/WalletPage').default;
+ return SettingsWalletPage;
},
- name: 'Settings_Payments',
+ name: 'Settings_Wallet',
},
{
getComponent: () => {
- const TransferBalancePage = require('../../../pages/settings/Payments/TransferBalancePage').default;
+ const TransferBalancePage = require('../../../pages/settings/Wallet/TransferBalancePage').default;
return TransferBalancePage;
},
- name: 'Settings_Payments_Transfer_Balance',
+ name: 'Settings_Wallet_Transfer_Balance',
},
{
getComponent: () => {
- const ChooseTransferAccountPage = require('../../../pages/settings/Payments/ChooseTransferAccountPage').default;
+ const ChooseTransferAccountPage = require('../../../pages/settings/Wallet/ChooseTransferAccountPage').default;
return ChooseTransferAccountPage;
},
- name: 'Settings_Payments_Choose_Transfer_Account',
+ name: 'Settings_Wallet_Choose_Transfer_Account',
},
{
getComponent: () => {
- const SettingsAddPayPalMePage = require('../../../pages/settings/Payments/AddPayPalMePage').default;
+ const SettingsAddPayPalMePage = require('../../../pages/settings/Wallet/AddPayPalMePage').default;
return SettingsAddPayPalMePage;
},
name: 'Settings_Add_Paypal_Me',
@@ -499,11 +527,11 @@ const SettingsModalStackNavigator = createModalStackNavigator([
const EnablePaymentsPage = require('../../../pages/EnablePayments/EnablePaymentsPage').default;
return EnablePaymentsPage;
},
- name: 'Settings_Payments_EnablePayments',
+ name: 'Settings_Wallet_EnablePayments',
},
{
getComponent: () => {
- const AddDebitCardPage = require('../../../pages/settings/Payments/AddDebitCardPage').default;
+ const AddDebitCardPage = require('../../../pages/settings/Wallet/AddDebitCardPage').default;
return AddDebitCardPage;
},
name: 'Settings_Add_Debit_Card',
@@ -630,38 +658,10 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
{
getComponent: () => {
- const SettingsTwoFactorAuthIsEnabled = require('../../../pages/settings/Security/TwoFactorAuth/IsEnabledPage').default;
- return SettingsTwoFactorAuthIsEnabled;
- },
- name: 'Settings_TwoFactorAuthIsEnabled',
- },
- {
- getComponent: () => {
- const SettingsTwoFactorAuthDisable = require('../../../pages/settings/Security/TwoFactorAuth/DisablePage').default;
- return SettingsTwoFactorAuthDisable;
- },
- name: 'Settings_TwoFactorAuthDisable',
- },
- {
- getComponent: () => {
- const SettingsTwoFactorAuthCodes = require('../../../pages/settings/Security/TwoFactorAuth/CodesPage').default;
- return SettingsTwoFactorAuthCodes;
- },
- name: 'Settings_TwoFactorAuthCodes',
- },
- {
- getComponent: () => {
- const SettingsTwoFactorAuthVerify = require('../../../pages/settings/Security/TwoFactorAuth/VerifyPage').default;
- return SettingsTwoFactorAuthVerify;
+ const SettingsTwoFactorAuth = require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default;
+ return SettingsTwoFactorAuth;
},
- name: 'Settings_TwoFactorAuthVerify',
- },
- {
- getComponent: () => {
- const SettingsTwoFactorAuthSuccess = require('../../../pages/settings/Security/TwoFactorAuth/SuccessPage').default;
- return SettingsTwoFactorAuthSuccess;
- },
- name: 'Settings_TwoFactorAuthSuccess',
+ name: 'Settings_TwoFactorAuth',
},
]);
@@ -723,6 +723,23 @@ const EditRequestStackNavigator = createModalStackNavigator([
},
name: 'EditRequest_Root',
},
+ {
+ getComponent: () => {
+ const IOUCurrencySelection = require('../../../pages/iou/IOUCurrencySelection').default;
+ return IOUCurrencySelection;
+ },
+ name: 'EditRequest_Currency',
+ },
+]);
+
+const SignInModalStackNavigator = createModalStackNavigator([
+ {
+ getComponent: () => {
+ const SignInModal = require('../../../pages/signin/SignInModal').default;
+ return SignInModal;
+ },
+ name: 'SignIn_Root',
+ },
]);
export {
@@ -746,4 +763,5 @@ export {
WalletStatementStackNavigator,
FlagCommentStackNavigator,
EditRequestStackNavigator,
+ SignInModalStackNavigator,
};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
index 64eadcbe06c3..f685497e477b 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator.js
@@ -2,9 +2,11 @@ import React from 'react';
import {createStackNavigator} from '@react-navigation/stack';
import SCREENS from '../../../../SCREENS';
import ReportScreenWrapper from '../ReportScreenWrapper';
+import DemoSetupPage from '../../../../pages/DemoSetupPage';
import getCurrentUrl from '../../currentUrl';
import styles from '../../../../styles/styles';
import FreezeWrapper from '../../FreezeWrapper';
+import CONST from '../../../../CONST';
const Stack = createStackNavigator();
@@ -28,6 +30,22 @@ function CentralPaneNavigator() {
}}
component={ReportScreenWrapper}
/>
+
+
);
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
index 53e2120f4c21..8e3f769ed13f 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
@@ -93,6 +93,10 @@ function RightModalNavigator() {
name="EditRequest"
component={ModalStackNavigators.EditRequestStackNavigator}
/>
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js
index 40325918451a..7a87530a2d9e 100644
--- a/src/libs/Navigation/AppNavigator/PublicScreens.js
+++ b/src/libs/Navigation/AppNavigator/PublicScreens.js
@@ -6,6 +6,8 @@ import LogInWithShortLivedAuthTokenPage from '../../../pages/LogInWithShortLived
import SCREENS from '../../../SCREENS';
import defaultScreenOptions from './defaultScreenOptions';
import UnlinkLoginPage from '../../../pages/UnlinkLoginPage';
+import AppleSignInDesktopPage from '../../../pages/signin/AppleSignInDesktopPage';
+import GoogleSignInDesktopPage from '../../../pages/signin/GoogleSignInDesktopPage';
const RootStack = createStackNavigator();
@@ -32,6 +34,16 @@ function PublicScreens() {
options={defaultScreenOptions}
component={UnlinkLoginPage}
/>
+
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js
index 2f9a899191bf..75a5a1f514f7 100644
--- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js
+++ b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/ThreePaneView.js
@@ -11,7 +11,6 @@ import styles from '../../../../styles/styles';
import CONST from '../../../../CONST';
import PressableWithoutFeedback from '../../../../components/Pressable/PressableWithoutFeedback';
import useLocalize from '../../../../hooks/useLocalize';
-import NoDropZone from '../../../../components/DragAndDrop/NoDropZone';
const propTypes = {
/* State from useNavigationBuilder */
@@ -53,28 +52,26 @@ function ThreePaneView(props) {
);
}
if (route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
- const Wrapper = props.state.index === i ? NoDropZone : React.Fragment;
return (
-
-
- props.navigation.goBack()}
- accessibilityLabel={translate('common.close')}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
- />
- {props.descriptors[route.key].render()}
-
-
+
+ props.navigation.goBack()}
+ accessibilityLabel={translate('common.close')}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ />
+ {props.descriptors[route.key].render()}
+
);
}
return (
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index 41f66967cc00..677981fcbeba 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -12,6 +12,7 @@ import NAVIGATORS from '../../NAVIGATORS';
import originalGetTopmostReportId from './getTopmostReportId';
import getStateFromPath from './getStateFromPath';
import SCREENS from '../../SCREENS';
+import CONST from '../../CONST';
let resolveNavigationIsReadyPromise;
const navigationIsReadyPromise = new Promise((resolve) => {
@@ -127,7 +128,7 @@ function goBack(fallbackRoute = ROUTES.HOME, shouldEnforceFallback = false, shou
}
if (shouldEnforceFallback || (isFirstRouteInNavigator && fallbackRoute)) {
- navigate(fallbackRoute, 'UP');
+ navigate(fallbackRoute, CONST.NAVIGATION.TYPE.UP);
return;
}
@@ -199,6 +200,22 @@ function getActiveRoute() {
return '';
}
+/** Returns the active route name from a state event from the navigationRef
+ * @param {Object} event
+ * @returns {String | undefined}
+ * */
+function getRouteNameFromStateEvent(event) {
+ if (!event.data.state) {
+ return;
+ }
+ const currentRouteName = event.data.state.routes.slice(-1).name;
+
+ // Check to make sure we have a route name
+ if (currentRouteName) {
+ return currentRouteName;
+ }
+}
+
/**
* Check whether the passed route is currently Active or not.
*
@@ -250,6 +267,7 @@ export default {
isNavigationReady,
setIsNavigationReady,
getTopmostReportId,
+ getRouteNameFromStateEvent,
};
export {navigationRef};
diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js
index c610ae710992..884a8aa02190 100644
--- a/src/libs/Navigation/linkTo.js
+++ b/src/libs/Navigation/linkTo.js
@@ -4,6 +4,7 @@ import NAVIGATORS from '../../NAVIGATORS';
import linkingConfig from './linkingConfig';
import getTopmostReportId from './getTopmostReportId';
import getStateFromPath from './getStateFromPath';
+import CONST from '../../CONST';
/**
* Motivation for this function is described in NAVIGATION.md
@@ -59,19 +60,23 @@ export default function linkTo(navigation, path, type) {
const action = getActionFromState(state, linkingConfig.config);
// If action type is different than NAVIGATE we can't change it to the PUSH safely
- if (action.type === 'NAVIGATE') {
- // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH
- if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(root.getState()) !== getTopmostReportId(state)) {
- action.type = 'PUSH';
+ if (action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) {
+ // In case if type is 'FORCED_UP' we replace current screen with the provided. This means the current screen no longer exists in the stack
+ if (type === CONST.NAVIGATION.TYPE.FORCED_UP) {
+ action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
+
+ // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack
+ } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(root.getState()) !== getTopmostReportId(state)) {
+ action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
// If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow
// and at the same time we want the back button to go to the page we were before the deeplink
- } else if (type === 'UP') {
- action.type = 'REPLACE';
+ } else if (type === CONST.NAVIGATION.TYPE.UP) {
+ action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
// If this action is navigating to the RightModalNavigator and the last route on the root navigator is not RightModalNavigator then push
} else if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && _.last(root.getState().routes).name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) {
- action.type = 'PUSH';
+ action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
}
}
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index dcc4f77fde73..a94daefbac6d 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -13,6 +13,9 @@ export default {
UnlinkLogin: ROUTES.UNLINK_LOGIN,
[SCREENS.TRANSITION_BETWEEN_APPS]: ROUTES.TRANSITION_BETWEEN_APPS,
Concierge: ROUTES.CONCIERGE,
+ AppleSignInDesktop: ROUTES.APPLE_SIGN_IN,
+ GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN,
+ DesktopSignInRedirect: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
[SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS,
// Sidebar
@@ -23,6 +26,8 @@ export default {
[NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: {
screens: {
[SCREENS.REPORT]: ROUTES.REPORT_WITH_ID,
+ [CONST.DEMO_PAGES.SAASTR]: ROUTES.SAASTR,
+ [CONST.DEMO_PAGES.SBE]: ROUTES.SBE,
},
},
[NAVIGATORS.FULL_SCREEN_NAVIGATOR]: {
@@ -66,20 +71,20 @@ export default {
path: ROUTES.SETTINGS_SECURITY,
exact: true,
},
- Settings_Payments: {
- path: ROUTES.SETTINGS_PAYMENTS,
+ Settings_Wallet: {
+ path: ROUTES.SETTINGS_WALLET,
exact: true,
},
- Settings_Payments_EnablePayments: {
+ Settings_Wallet_EnablePayments: {
path: ROUTES.SETTINGS_ENABLE_PAYMENTS,
exact: true,
},
- Settings_Payments_Transfer_Balance: {
- path: ROUTES.SETTINGS_PAYMENTS_TRANSFER_BALANCE,
+ Settings_Wallet_Transfer_Balance: {
+ path: ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE,
exact: true,
},
- Settings_Payments_Choose_Transfer_Account: {
- path: ROUTES.SETTINGS_PAYMENTS_CHOOSE_TRANSFER_ACCOUNT,
+ Settings_Wallet_Choose_Transfer_Account: {
+ path: ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT,
exact: true,
},
Settings_Add_Paypal_Me: {
@@ -152,24 +157,8 @@ export default {
path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS,
exact: true,
},
- Settings_TwoFactorAuthIsEnabled: {
- path: ROUTES.SETTINGS_2FA_IS_ENABLED,
- exact: true,
- },
- Settings_TwoFactorAuthDisable: {
- path: ROUTES.SETTINGS_2FA_DISABLE,
- exact: true,
- },
- Settings_TwoFactorAuthCodes: {
- path: ROUTES.SETTINGS_2FA_CODES,
- exact: true,
- },
- Settings_TwoFactorAuthVerify: {
- path: ROUTES.SETTINGS_2FA_VERIFY,
- exact: true,
- },
- Settings_TwoFactorAuthSuccess: {
- path: ROUTES.SETTINGS_2FA_SUCCESS,
+ Settings_TwoFactorAuth: {
+ path: ROUTES.SETTINGS_2FA,
exact: true,
},
Settings_Share_Code: {
@@ -319,8 +308,12 @@ export default {
Money_Request_Amount: ROUTES.MONEY_REQUEST_AMOUNT,
Money_Request_Participants: ROUTES.MONEY_REQUEST_PARTICIPANTS,
Money_Request_Confirmation: ROUTES.MONEY_REQUEST_CONFIRMATION,
+ Money_Request_Date: ROUTES.MONEY_REQUEST_DATE,
Money_Request_Currency: ROUTES.MONEY_REQUEST_CURRENCY,
Money_Request_Description: ROUTES.MONEY_REQUEST_DESCRIPTION,
+ Money_Request_Category: ROUTES.MONEY_REQUEST_CATEGORY,
+ Money_Request_Merchant: ROUTES.MONEY_REQUEST_MERCHANT,
+ Money_Request_Waypoint: ROUTES.MONEY_REQUEST_WAYPOINT,
IOU_Send_Enable_Payments: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
IOU_Send_Add_Bank_Account: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT,
IOU_Send_Add_Debit_Card: ROUTES.IOU_SEND_ADD_DEBIT_CARD,
@@ -361,6 +354,12 @@ export default {
EditRequest: {
screens: {
EditRequest_Root: ROUTES.EDIT_REQUEST,
+ EditRequest_Currency: ROUTES.EDIT_CURRENCY_REQUEST,
+ },
+ },
+ SignIn: {
+ screens: {
+ SignIn_Root: ROUTES.SIGN_IN_MODAL,
},
},
},
diff --git a/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js b/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js
new file mode 100644
index 000000000000..861e40eaa24d
--- /dev/null
+++ b/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js
@@ -0,0 +1,13 @@
+import CONST from '../../../CONST';
+
+/**
+ * Determines if the deeplink prompt should be shown on the current screen
+ * @param {String} screenName
+ * @param {Boolean} isAuthenticated
+ * @returns {Boolean}
+ */
+export default function shouldPreventDeeplinkPrompt(screenName) {
+ // We don't want to show the deeplink prompt on screens where a user is in the
+ // authentication process, so we are blocking the prompt on the following screens (Denylist)
+ return CONST.DEEPLINK_PROMPT_DENYLIST.includes(screenName);
+}
diff --git a/src/libs/Network/SequentialQueue.js b/src/libs/Network/SequentialQueue.js
index 93e3ba6bc207..f8ea396663a5 100644
--- a/src/libs/Network/SequentialQueue.js
+++ b/src/libs/Network/SequentialQueue.js
@@ -19,6 +19,7 @@ resolveIsReadyPromise();
let isSequentialQueueRunning = false;
let currentRequest = null;
+let isQueuePaused = false;
/**
* Process any persisted requests, when online, one at a time until the queue is empty.
@@ -30,6 +31,11 @@ let currentRequest = null;
* @returns {Promise}
*/
function process() {
+ // When the queue is paused, return early. This prevents any new requests from happening. The queue will be flushed again when the queue is unpaused.
+ if (isQueuePaused) {
+ return Promise.resolve();
+ }
+
const persistedRequests = PersistedRequests.getAll();
if (_.isEmpty(persistedRequests) || NetworkStore.isOffline()) {
return Promise.resolve();
@@ -57,6 +63,11 @@ function process() {
}
function flush() {
+ // When the queue is paused, return early. This will keep an requests in the queue and they will get flushed again when the queue is unpaused
+ if (isQueuePaused) {
+ return;
+ }
+
if (isSequentialQueueRunning || _.isEmpty(PersistedRequests.getAll())) {
return;
}
@@ -138,4 +149,30 @@ function waitForIdle() {
return isReadyPromise;
}
-export {flush, getCurrentRequest, isRunning, push, waitForIdle};
+/**
+ * Puts the queue into a paused state so that no requests will be processed
+ */
+function pause() {
+ if (isQueuePaused) {
+ return;
+ }
+
+ console.debug('[SequentialQueue] Pausing the queue');
+ isQueuePaused = true;
+}
+
+/**
+ * Unpauses the queue and flushes all the requests that were in it or were added to it while paused
+ */
+function unpause() {
+ if (!isQueuePaused) {
+ return;
+ }
+
+ const numberOfPersistedRequests = PersistedRequests.getAll().length || 0;
+ console.debug(`[SequentialQueue] Unpausing the queue and flushing ${numberOfPersistedRequests} requests`);
+ isQueuePaused = false;
+ flush();
+}
+
+export {flush, getCurrentRequest, isRunning, push, waitForIdle, pause, unpause};
diff --git a/src/libs/Notification/PushNotification/subscribePushNotification/index.js b/src/libs/Notification/PushNotification/subscribePushNotification/index.js
new file mode 100644
index 000000000000..45dc8d8a7ae9
--- /dev/null
+++ b/src/libs/Notification/PushNotification/subscribePushNotification/index.js
@@ -0,0 +1,26 @@
+import Onyx from 'react-native-onyx';
+import PushNotification from '..';
+import subscribeToReportCommentPushNotifications from '../subscribeToReportCommentPushNotifications';
+import ONYXKEYS from '../../../../ONYXKEYS';
+
+/**
+ * Manage push notification subscriptions on sign-in/sign-out.
+ *
+ * On Android, AuthScreens unmounts when the app is closed with the back button so we manage the
+ * push subscription when the session changes here.
+ */
+Onyx.connect({
+ key: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID,
+ callback: (notificationID) => {
+ if (notificationID) {
+ PushNotification.register(notificationID);
+
+ // Prevent issue where report linking fails after users switch accounts without closing the app
+ PushNotification.init();
+ subscribeToReportCommentPushNotifications();
+ } else {
+ PushNotification.deregister();
+ PushNotification.clearNotifications();
+ }
+ },
+});
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index b574bfd3d00e..72adae70e874 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -149,6 +149,7 @@ function getAvatarsForAccountIDs(accountIDs, personalDetails, defaultValues = {}
return _.map(accountIDs, (accountID) => {
const login = lodashGet(reversedDefaultValues, accountID, '');
const userPersonalDetail = lodashGet(personalDetails, accountID, {login, accountID, avatar: ''});
+
return {
id: accountID,
source: UserUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.accountID),
@@ -385,6 +386,8 @@ function getLastMessageTextForReport(report) {
} else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) {
const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction));
lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastReportAction);
+ } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
+ lastMessageTextFromReport = ReportUtils.getModifiedExpenseMessage(lastReportAction);
} else {
lastMessageTextFromReport = report ? report.lastMessageText || '' : '';
@@ -614,6 +617,8 @@ function getOptions(
includeTasks = false,
includeMoneyRequests = false,
excludeUnknownUsers = false,
+ includeP2P = true,
+ canInviteUser = true,
},
) {
if (!isPersonalDetailsReady(personalDetails)) {
@@ -625,12 +630,6 @@ function getOptions(
};
}
- // We're only picking personal details that have logins set
- // This is a temporary fix for all the logic that's been breaking because of the new privacy changes
- // See https://github.com/Expensify/Expensify/issues/293465 for more context
- // eslint-disable-next-line no-param-reassign
- personalDetails = _.pick(personalDetails, (detail) => Boolean(detail.login));
-
let recentReportOptions = [];
let personalDetailsOptions = [];
const reportMapForAccountIDs = {};
@@ -638,7 +637,7 @@ function getOptions(
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase();
// Filter out all the reports that shouldn't be displayed
- const filteredReports = _.filter(reports, (report) => ReportUtils.shouldReportBeInOptionList(report, Navigation.getTopmostReportId(), false, null, betas, policies));
+ const filteredReports = _.filter(reports, (report) => ReportUtils.shouldReportBeInOptionList(report, Navigation.getTopmostReportId(), false, betas, policies));
// Sorting the reports works like this:
// - Order everything by the last message timestamp (descending)
@@ -669,6 +668,11 @@ function getOptions(
return;
}
+ // When passing includeP2P false we are trying to hide features from users that are not ready for P2P and limited to workspace chats only.
+ if (!includeP2P && !isPolicyExpenseChat) {
+ return;
+ }
+
if (isThread && !includeThreads) {
return;
}
@@ -706,7 +710,12 @@ function getOptions(
);
});
- let allPersonalDetailsOptions = _.map(personalDetails, (personalDetail) =>
+ // We're only picking personal details that have logins set
+ // This is a temporary fix for all the logic that's been breaking because of the new privacy changes
+ // See https://github.com/Expensify/Expensify/issues/293465 for more context
+ // Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText
+ const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => Boolean(detail.login));
+ let allPersonalDetailsOptions = _.map(havingLoginPersonalDetails, (personalDetail) =>
createOption([personalDetail.accountID], personalDetails, reportMapForAccountIDs[personalDetail.accountID], reportActions, {
showChatPreviewLine,
forcePolicyNamePreview,
@@ -750,8 +759,17 @@ function getOptions(
// Finally check to see if this option is a match for the provided search string if we have one
const {searchText, participantsList, isChatRoom} = reportOption;
const participantNames = getParticipantNames(participantsList);
- if (searchValue && !isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom)) {
- continue;
+
+ if (searchValue) {
+ // Determine if the search is happening within a chat room and starts with the report ID
+ const isReportIdSearch = isChatRoom && Str.startsWith(reportOption.reportID, searchValue);
+
+ // Check if the search string matches the search text or participant names considering the type of the room
+ const isSearchMatch = isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom);
+
+ if (!isReportIdSearch && !isSearchMatch) {
+ continue;
+ }
}
recentReportOptions.push(reportOption);
@@ -856,7 +874,7 @@ function getOptions(
return {
personalDetails: personalDetailsOptions,
recentReports: recentReportOptions,
- userToInvite,
+ userToInvite: canInviteUser ? userToInvite : null,
currentUserOption,
};
}
@@ -896,9 +914,10 @@ function getSearchOptions(reports, personalDetails, searchValue = '', betas) {
* @returns {Object}
*/
function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail, amountText) {
+ const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login);
return {
- text: personalDetail.displayName ? personalDetail.displayName : personalDetail.login,
- alternateText: personalDetail.login || personalDetail.displayName,
+ text: personalDetail.displayName || formattedLogin,
+ alternateText: formattedLogin || personalDetail.displayName,
icons: [
{
source: UserUtils.getAvatar(personalDetail.avatar, personalDetail.accountID),
@@ -937,9 +956,21 @@ function getIOUConfirmationOptionsFromParticipants(participants, amountText) {
* @param {Array} [selectedOptions]
* @param {Array} [excludeLogins]
* @param {Boolean} [includeOwnedWorkspaceChats]
+ * @param {boolean} [includeP2P]
+ * @param {boolean} [canInviteUser]
* @returns {Object}
*/
-function getNewChatOptions(reports, personalDetails, betas = [], searchValue = '', selectedOptions = [], excludeLogins = [], includeOwnedWorkspaceChats = false) {
+function getNewChatOptions(
+ reports,
+ personalDetails,
+ betas = [],
+ searchValue = '',
+ selectedOptions = [],
+ excludeLogins = [],
+ includeOwnedWorkspaceChats = false,
+ includeP2P = true,
+ canInviteUser = true,
+) {
return getOptions(reports, personalDetails, {
betas,
searchInputValue: searchValue.trim(),
@@ -949,6 +980,8 @@ function getNewChatOptions(reports, personalDetails, betas = [], searchValue = '
maxRecentReportsToShow: 5,
excludeLogins,
includeOwnedWorkspaceChats,
+ includeP2P,
+ canInviteUser,
});
}
@@ -995,6 +1028,39 @@ function getShareDestinationOptions(
});
}
+/**
+ * Format personalDetails or userToInvite to be shown in the list
+ *
+ * @param {Object} member - personalDetails or userToInvite
+ * @param {Boolean} isSelected - whether the item is selected
+ * @returns {Object}
+ */
+function formatMemberForList(member, isSelected) {
+ if (!member) {
+ return undefined;
+ }
+
+ const avatarSource = lodashGet(member, 'participantsList[0].avatar', '') || lodashGet(member, 'avatar', '');
+ const accountID = lodashGet(member, 'accountID', '');
+
+ return {
+ text: lodashGet(member, 'text', '') || lodashGet(member, 'displayName', ''),
+ alternateText: lodashGet(member, 'alternateText', '') || lodashGet(member, 'login', ''),
+ keyForList: lodashGet(member, 'keyForList', '') || String(accountID),
+ isSelected,
+ isDisabled: false,
+ accountID,
+ login: lodashGet(member, 'login', ''),
+ rightElement: null,
+ avatar: {
+ source: UserUtils.getAvatar(avatarSource, accountID),
+ name: lodashGet(member, 'participantsList[0].login', '') || lodashGet(member, 'displayName', ''),
+ type: 'avatar',
+ },
+ pendingAction: lodashGet(member, 'pendingAction'),
+ };
+}
+
/**
* Build the options for the Workspace Member Invite view
*
@@ -1033,7 +1099,7 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma
const isValidEmail = Str.isValidEmail(searchValue);
- if (searchValue && CONST.REGEX.DIGITS_AND_PLUS.test(searchValue) && !isValidPhone) {
+ if (searchValue && CONST.REGEX.DIGITS_AND_PLUS.test(searchValue) && !isValidPhone && !hasSelectableOptions) {
return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone');
}
@@ -1084,4 +1150,5 @@ export {
isSearchStringMatch,
shouldOptionShowTooltip,
getLastMessageTextForReport,
+ formatMemberForList,
};
diff --git a/src/libs/PaymentUtils.js b/src/libs/PaymentUtils.js
index 3be0c11efd9f..c211f9538960 100644
--- a/src/libs/PaymentUtils.js
+++ b/src/libs/PaymentUtils.js
@@ -9,18 +9,18 @@ import * as Localize from './Localize';
/**
* Check to see if user has either a debit card or personal bank account added
*
- * @param {Array} [cardList]
+ * @param {Array} [fundList]
* @param {Array} [bankAccountList]
* @returns {Boolean}
*/
-function hasExpensifyPaymentMethod(cardList = [], bankAccountList = []) {
+function hasExpensifyPaymentMethod(fundList = [], bankAccountList = []) {
const validBankAccount = _.some(bankAccountList, (bankAccountJSON) => {
const bankAccount = new BankAccount(bankAccountJSON);
return bankAccount.isDefaultCredit();
});
// Hide any billing cards that are not P2P debit cards for now because you cannot make them your default method, or delete them
- const validDebitCard = _.some(cardList, (card) => lodashGet(card, 'accountData.additionalData.isP2PDebitCard', false));
+ const validDebitCard = _.some(fundList, (card) => lodashGet(card, 'accountData.additionalData.isP2PDebitCard', false));
return validBankAccount || validDebitCard;
}
@@ -46,11 +46,11 @@ function getPaymentMethodDescription(accountType, account) {
/**
* Get the PaymentMethods list
* @param {Array} bankAccountList
- * @param {Array} cardList
+ * @param {Array} fundList
* @param {Object} [payPalMeData = null]
* @returns {Array}
*/
-function formatPaymentMethods(bankAccountList, cardList, payPalMeData = null) {
+function formatPaymentMethods(bankAccountList, fundList, payPalMeData = null) {
const combinedPaymentMethods = [];
_.each(bankAccountList, (bankAccount) => {
@@ -70,7 +70,7 @@ function formatPaymentMethods(bankAccountList, cardList, payPalMeData = null) {
});
});
- _.each(cardList, (card) => {
+ _.each(fundList, (card) => {
const {icon, iconSize} = getBankIcon(lodashGet(card, 'accountData.bank', ''), true);
combinedPaymentMethods.push({
...card,
diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js
index fc97436eddcb..7f52c41ad0fd 100644
--- a/src/libs/Permissions.js
+++ b/src/libs/Permissions.js
@@ -70,14 +70,6 @@ function canUsePolicyRooms(betas) {
return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas);
}
-/**
- * @param {Array} betas
- * @returns {Boolean}
- */
-function canUsePolicyExpenseChat(betas) {
- return _.contains(betas, CONST.BETAS.POLICY_EXPENSE_CHAT) || canUseAllBetas(betas);
-}
-
/**
* @param {Array} betas
* @returns {Boolean}
@@ -110,6 +102,14 @@ function canUseDistanceRequests(betas) {
return _.contains(betas, CONST.BETAS.DISTANCE_REQUESTS) || canUseAllBetas(betas);
}
+/**
+ * Link previews are temporarily disabled.
+ * @returns {Boolean}
+ */
+function canUseLinkPreviews() {
+ return false;
+}
+
export default {
canUseChronos,
canUsePayWithExpensify,
@@ -118,9 +118,9 @@ export default {
canUseWallet,
canUseCommentLinking,
canUsePolicyRooms,
- canUsePolicyExpenseChat,
canUseTasks,
canUseScanReceipts,
canUseCustomStatus,
canUseDistanceRequests,
+ canUseLinkPreviews,
};
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 582271d6610e..164f284a4ef5 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -10,7 +10,7 @@ import ONYXKEYS from '../ONYXKEYS';
* @returns {Array}
*/
function getActivePolicies(policies) {
- return _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ return _.filter(policies, (policy) => policy && policy.isPolicyExpenseChatEnabled && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
}
/**
@@ -86,7 +86,7 @@ function getPolicyBrickRoadIndicatorStatus(policy, policyMembersCollection) {
function shouldShowPolicy(policy, isOffline) {
return (
policy &&
- policy.type === CONST.POLICY.TYPE.FREE &&
+ policy.isPolicyExpenseChatEnabled &&
policy.role === CONST.POLICY.ROLE.ADMIN &&
(isOffline || policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !_.isEmpty(policy.errors))
);
diff --git a/src/libs/Pusher/pusher.js b/src/libs/Pusher/pusher.js
index 60587a68e173..43fde187d00b 100644
--- a/src/libs/Pusher/pusher.js
+++ b/src/libs/Pusher/pusher.js
@@ -136,7 +136,7 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
try {
data = _.isObject(eventData) ? eventData : JSON.parse(eventData);
} catch (err) {
- Log.alert('[Pusher] Unable to parse JSON response from Pusher', {error: err, eventData});
+ Log.alert('[Pusher] Unable to parse single JSON event data from Pusher', {error: err, eventData});
return;
}
if (data.id === undefined || data.chunk === undefined || data.final === undefined) {
@@ -172,6 +172,9 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) {
error: err,
eventData: chunkedEvent.chunks.join(''),
});
+
+ // Using console.error is helpful here because it will print a usable stack trace to the console to debug where the error comes from
+ console.error(err);
}
delete chunkedDataEvents[data.id];
diff --git a/src/libs/PusherConnectionManager.js b/src/libs/PusherConnectionManager.js
index 4ffe7153a51f..a391a4973fd4 100644
--- a/src/libs/PusherConnectionManager.js
+++ b/src/libs/PusherConnectionManager.js
@@ -42,13 +42,16 @@ function init() {
break;
}
case 'connected':
- Log.info('[PusherConnectionManager] connected event');
+ Log.hmmm('[PusherConnectionManager] connected event');
break;
case 'disconnected':
- Log.info('[PusherConnectionManager] disconnected event');
+ Log.hmmm('[PusherConnectionManager] disconnected event');
+ break;
+ case 'state_change':
+ Log.hmmm('[PusherConnectionManager] state change', {states: error});
break;
default:
- Log.info('[PusherConnectionManager] unhandled event', false, {eventName});
+ Log.hmmm('[PusherConnectionManager] unhandled event', {eventName});
break;
}
});
diff --git a/src/libs/ReceiptUtils.js b/src/libs/ReceiptUtils.js
index b8ec54c0e899..c1c028073690 100644
--- a/src/libs/ReceiptUtils.js
+++ b/src/libs/ReceiptUtils.js
@@ -1,11 +1,16 @@
import lodashGet from 'lodash/get';
import _ from 'underscore';
+import Str from 'expensify-common/lib/str';
import * as FileUtils from './fileDownload/FileUtils';
import CONST from '../CONST';
import Receipt from './actions/Receipt';
import * as Localize from './Localize';
+import ReceiptHTML from '../../assets/images/receipt-html.png';
+import ReceiptDoc from '../../assets/images/receipt-doc.png';
+import ReceiptGeneric from '../../assets/images/receipt-generic.png';
+import ReceiptSVG from '../../assets/images/receipt-svg.png';
-const validateReceipt = (file) => {
+function validateReceipt(file) {
const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', ''));
if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) {
Receipt.setUploadReceiptError(true, Localize.translateLocal('attachmentPicker.wrongFileType'), Localize.translateLocal('attachmentPicker.notAllowedExtension'));
@@ -23,6 +28,41 @@ const validateReceipt = (file) => {
}
return true;
-};
+}
-export default {validateReceipt};
+/**
+ * Grab the appropriate receipt image and thumbnail URIs based on file type
+ *
+ * @param {String} path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
+ * @param {String} filename of uploaded image or last part of remote URI
+ * @returns {Object}
+ */
+function getThumbnailAndImageURIs(path, filename) {
+ // For local files, we won't have a thumbnail yet
+ if (path.startsWith('blob:') || path.startsWith('file:')) {
+ return {thumbnail: null, image: path};
+ }
+
+ const {fileExtension} = FileUtils.splitExtensionFromFileName(filename);
+ const isReceiptImage = Str.isImage(filename);
+
+ if (isReceiptImage) {
+ return {thumbnail: `${path}.1024.jpg`, image: path};
+ }
+
+ let image = ReceiptGeneric;
+ if (fileExtension === CONST.IOU.FILE_TYPES.HTML) {
+ image = ReceiptHTML;
+ }
+
+ if (fileExtension === CONST.IOU.FILE_TYPES.DOC || fileExtension === CONST.IOU.FILE_TYPES.DOCX) {
+ image = ReceiptDoc;
+ }
+
+ if (fileExtension === CONST.IOU.FILE_TYPES.SVG) {
+ image = ReceiptSVG;
+ }
+ return {thumbnail: null, image};
+}
+
+export {validateReceipt, getThumbnailAndImageURIs};
diff --git a/src/libs/ReportActionComposeFocusManager.js b/src/libs/ReportActionComposeFocusManager.js
index 2acbfadf98a8..7f31b17aaa57 100644
--- a/src/libs/ReportActionComposeFocusManager.js
+++ b/src/libs/ReportActionComposeFocusManager.js
@@ -2,16 +2,24 @@ import _ from 'underscore';
import React from 'react';
const composerRef = React.createRef();
+// There are two types of composer: general composer (edit composer) and main composer.
+// The general composer callback will take priority if it exists.
let focusCallback = null;
+let mainComposerFocusCallback = null;
/**
* Register a callback to be called when focus is requested.
* Typical uses of this would be call the focus on the ReportActionComposer.
*
* @param {Function} callback callback to register
+ * @param {Boolean} isMainComposer
*/
-function onComposerFocus(callback) {
- focusCallback = callback;
+function onComposerFocus(callback, isMainComposer = false) {
+ if (isMainComposer) {
+ mainComposerFocusCallback = callback;
+ } else {
+ focusCallback = callback;
+ }
}
/**
@@ -20,6 +28,11 @@ function onComposerFocus(callback) {
*/
function focus() {
if (!_.isFunction(focusCallback)) {
+ if (!_.isFunction(mainComposerFocusCallback)) {
+ return;
+ }
+
+ mainComposerFocusCallback();
return;
}
@@ -29,9 +42,14 @@ function focus() {
/**
* Clear the registered focus callback
*
+ * @param {Boolean} isMainComposer
*/
-function clear() {
- focusCallback = null;
+function clear(isMainComposer = false) {
+ if (isMainComposer) {
+ mainComposerFocusCallback = null;
+ } else {
+ focusCallback = null;
+ }
}
/**
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
index 6d777533360d..c90efb54785d 100644
--- a/src/libs/ReportActionsUtils.js
+++ b/src/libs/ReportActionsUtils.js
@@ -8,7 +8,6 @@ import * as CollectionUtils from './CollectionUtils';
import CONST from '../CONST';
import ONYXKEYS from '../ONYXKEYS';
import Log from './Log';
-import * as CurrencyUtils from './CurrencyUtils';
import isReportMessageAttachment from './isReportMessageAttachment';
const allReports = {};
@@ -93,6 +92,14 @@ function isReportPreviewAction(reportAction) {
return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
}
+/**
+ * @param {Object} reportAction
+ * @returns {Boolean}
+ */
+function isModifiedExpenseAction(reportAction) {
+ return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE;
+}
+
function isWhisperAction(action) {
return (action.whisperedToAccountIDs || []).length > 0;
}
@@ -145,19 +152,6 @@ function isSentMoneyReportAction(reportAction) {
);
}
-/**
- * Returns the formatted amount of a money request. The request and money sent (from send money flow) have
- * currency and amount in IOUDetails object.
- *
- * @param {Object} reportAction
- * @returns {Number}
- */
-function getFormattedAmount(reportAction) {
- return lodashGet(reportAction, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.PAY && lodashGet(reportAction, 'originalMessage.IOUDetails', false)
- ? CurrencyUtils.convertToDisplayString(lodashGet(reportAction, 'originalMessage.IOUDetails.amount', 0), lodashGet(reportAction, 'originalMessage.IOUDetails.currency', ''))
- : CurrencyUtils.convertToDisplayString(lodashGet(reportAction, 'originalMessage.amount', 0), lodashGet(reportAction, 'originalMessage.currency', ''));
-}
-
/**
* Returns whether the thread is a transaction thread, which is any thread with IOU parent
* report action from requesting money (type - create) or from sending money (type - pay with IOUDetails field)
@@ -572,6 +566,46 @@ function isMessageDeleted(reportAction) {
return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false);
}
+/**
+ * Returns the number of money requests associated with a report preview
+ *
+ * @param {Object|null} reportPreviewAction
+ * @returns {Number}
+ */
+function getNumberOfMoneyRequests(reportPreviewAction) {
+ return lodashGet(reportPreviewAction, 'childMoneyRequestCount', 0);
+}
+
+/**
+ * @param {*} reportAction
+ * @returns {Boolean}
+ */
+function isSplitBillAction(reportAction) {
+ return lodashGet(reportAction, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
+}
+
+/**
+ *
+ * @param {*} reportAction
+ * @returns {Boolean}
+ */
+function isTaskAction(reportAction) {
+ const reportActionName = lodashGet(reportAction, 'actionName', '');
+ return (
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ||
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED
+ );
+}
+
+/**
+ * @param {*} reportID
+ * @returns {[Object]}
+ */
+function getAllReportActions(reportID) {
+ return lodashGet(allReportActions, reportID, []);
+}
+
export {
getSortedReportActions,
getLastVisibleAction,
@@ -596,13 +630,17 @@ export {
getParentReportAction,
getParentReportActionInReport,
isTransactionThread,
- getFormattedAmount,
isSentMoneyReportAction,
isDeletedParentAction,
isReportPreviewAction,
+ isModifiedExpenseAction,
getIOUReportIDFromReportActionPreview,
isMessageDeleted,
isWhisperAction,
isPendingRemove,
getReportAction,
+ getNumberOfMoneyRequests,
+ isSplitBillAction,
+ isTaskAction,
+ getAllReportActions,
};
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index e8ef18a3ca27..681495350069 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import {format, parseISO} from 'date-fns';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import lodashIntersection from 'lodash/intersection';
@@ -11,8 +12,8 @@ import * as Expensicons from '../components/Icon/Expensicons';
import Navigation from './Navigation/Navigation';
import ROUTES from '../ROUTES';
import * as NumberUtils from './NumberUtils';
-import * as NumberFormatUtils from './NumberFormatUtils';
import * as ReportActionsUtils from './ReportActionsUtils';
+import * as TransactionUtils from './TransactionUtils';
import Permissions from './Permissions';
import DateUtils from './DateUtils';
import linkingConfig from './Navigation/linkingConfig';
@@ -39,17 +40,6 @@ Onyx.connect({
},
});
-let preferredLocale = CONST.LOCALES.DEFAULT;
-Onyx.connect({
- key: ONYXKEYS.NVP_PREFERRED_LOCALE,
- callback: (val) => {
- if (!val) {
- return;
- }
- preferredLocale = val;
- },
-});
-
let allPersonalDetails;
let currentUserPersonalDetails;
Onyx.connect({
@@ -239,40 +229,6 @@ function isCurrentUserSubmitter(reportID) {
return report && report.ownerEmail === currentUserEmail;
}
-/**
- * Can only delete if the author is this user and the action is an ADDCOMMENT action or an IOU action in an unsettled report, or if the user is a
- * policy admin
- *
- * @param {Object} reportAction
- * @param {String} reportID
- * @returns {Boolean}
- */
-function canDeleteReportAction(reportAction, reportID) {
- // For now, users cannot delete split actions
- if (ReportActionsUtils.isMoneyRequestAction(reportAction) && lodashGet(reportAction, 'originalMessage.type') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT) {
- return false;
- }
- const isActionOwner = reportAction.actorAccountID === currentUserAccountID;
- if (isActionOwner && ReportActionsUtils.isMoneyRequestAction(reportAction) && !isSettled(reportAction.originalMessage.IOUReportID)) {
- return true;
- }
- if (
- reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT ||
- reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
- ReportActionsUtils.isCreatedTaskReportAction(reportAction) ||
- (ReportActionsUtils.isMoneyRequestAction(reportAction) && isSettled(reportAction.originalMessage.IOUReportID)) ||
- reportAction.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE
- ) {
- return false;
- }
- if (isActionOwner) {
- return true;
- }
- const report = lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {});
- const policy = lodashGet(allPolicies, `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`) || {};
- return policy.role === CONST.POLICY.ROLE.ADMIN;
-}
-
/**
* Whether the provided report is an Admin room
* @param {Object} report
@@ -333,6 +289,17 @@ function isUserCreatedPolicyRoom(report) {
return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_ROOM;
}
+/**
+ * Get the policy type from a given report
+ * @param {Object} report
+ * @param {String} report.policyID
+ * @param {Object} policies must have Onyxkey prefix (i.e 'policy_') for keys
+ * @returns {String}
+ */
+function getPolicyType(report, policies) {
+ return lodashGet(policies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'type'], '');
+}
+
/**
* Whether the provided report is a Policy Expense chat.
* @param {Object} report
@@ -343,6 +310,22 @@ function isPolicyExpenseChat(report) {
return getChatType(report) === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT;
}
+/** Wether the provided report belongs to a Control policy and is an epxense chat
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function isControlPolicyExpenseChat(report) {
+ return isPolicyExpenseChat(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE;
+}
+
+/** Wether the provided report belongs to a Control policy and is an epxense report
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function isControlPolicyExpenseReport(report) {
+ return isExpenseReport(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE;
+}
+
/**
* Whether the provided report is a chat room
* @param {Object} report
@@ -375,17 +358,6 @@ function isPublicAnnounceRoom(report) {
return visibility === CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE;
}
-/**
- * Get the policy type from a given report
- * @param {Object} report
- * @param {String} report.policyID
- * @param {Object} policies must have Onyxkey prefix (i.e 'policy_') for keys
- * @returns {String}
- */
-function getPolicyType(report, policies) {
- return lodashGet(policies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'type'], '');
-}
-
/**
* If the report is a policy expense, the route should be for adding bank account for that policy
* else since the report is a personal IOU, the route should be for personal bank account.
@@ -459,7 +431,7 @@ function isConciergeChatReport(report) {
function shouldDisableDetailPage(report) {
const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []);
- if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report)) {
+ if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report)) {
return false;
}
if (participantAccountIDs.length === 1) {
@@ -516,11 +488,20 @@ function findLastAccessedReport(reports, ignoreDomainRooms, policies, isFirstTim
// since the Concierge report would be incorrectly selected over the deep-linked report in the logic below.
let sortedReports = sortReportsByLastRead(reports);
+ let adminReport;
+ if (openOnAdminRoom) {
+ adminReport = _.find(sortedReports, (report) => {
+ const chatType = getChatType(report);
+ return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS;
+ });
+ }
+
if (isFirstTimeNewExpensifyUser) {
if (sortedReports.length === 1) {
return sortedReports[0];
}
- return _.find(sortedReports, (report) => !isConciergeChatReport(report));
+
+ return adminReport || _.find(sortedReports, (report) => !isConciergeChatReport(report));
}
if (ignoreDomainRooms) {
@@ -533,14 +514,6 @@ function findLastAccessedReport(reports, ignoreDomainRooms, policies, isFirstTim
);
}
- let adminReport;
- if (openOnAdminRoom) {
- adminReport = _.find(sortedReports, (report) => {
- const chatType = getChatType(report);
- return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS;
- });
- }
-
return adminReport || _.last(sortedReports);
}
@@ -751,14 +724,49 @@ function isMoneyRequestReport(reportOrID) {
return isIOUReport(report) || isExpenseReport(report);
}
+/**
+ * Can only delete if the author is this user and the action is an ADDCOMMENT action or an IOU action in an unsettled report, or if the user is a
+ * policy admin
+ *
+ * @param {Object} reportAction
+ * @param {String} reportID
+ * @returns {Boolean}
+ */
+function canDeleteReportAction(reportAction, reportID) {
+ // For now, users cannot delete split actions
+ if (ReportActionsUtils.isMoneyRequestAction(reportAction) && lodashGet(reportAction, 'originalMessage.type') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT) {
+ return false;
+ }
+ const isActionOwner = reportAction.actorAccountID === currentUserAccountID;
+ if (isActionOwner && ReportActionsUtils.isMoneyRequestAction(reportAction) && !isSettled(reportAction.originalMessage.IOUReportID)) {
+ return true;
+ }
+ if (
+ reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT ||
+ reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
+ ReportActionsUtils.isCreatedTaskReportAction(reportAction) ||
+ (ReportActionsUtils.isMoneyRequestAction(reportAction) && isSettled(reportAction.originalMessage.IOUReportID)) ||
+ reportAction.actorAccountID === CONST.ACCOUNT_ID.CONCIERGE
+ ) {
+ return false;
+ }
+ if (isActionOwner) {
+ return true;
+ }
+ const report = lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {});
+ const policy = lodashGet(allPolicies, `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`) || {};
+ return policy.role === CONST.POLICY.ROLE.ADMIN && !isDM(report);
+}
+
/**
* Get welcome message based on room type
* @param {Object} report
+ * @param {Boolean} isUserPolicyAdmin
* @returns {Object}
*/
-function getRoomWelcomeMessage(report) {
- const welcomeMessage = {};
+function getRoomWelcomeMessage(report, isUserPolicyAdmin) {
+ const welcomeMessage = {showReportName: true};
const workspaceName = getPolicyName(report);
if (isArchivedRoom(report)) {
@@ -770,9 +778,9 @@ function getRoomWelcomeMessage(report) {
} else if (isAdminRoom(report)) {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartOne', {workspaceName});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartTwo');
- } else if (isAdminsOnlyPostingRoom(report)) {
- welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminOnlyPostingRoomPartOne');
- welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminOnlyPostingRoomPartTwo', {workspaceName});
+ } else if (isAdminsOnlyPostingRoom(report) && !isUserPolicyAdmin) {
+ welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminOnlyPostingRoom');
+ welcomeMessage.showReportName = false;
} else if (isAnnounceRoom(report)) {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartOne', {workspaceName});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartTwo', {workspaceName});
@@ -795,7 +803,7 @@ function chatIncludesConcierge(report) {
}
/**
- * Returns true if there is any automated expensify account in accountIDs
+ * Returns true if there is any automated expensify account `in accountIDs
* @param {Array} accountIDs
* @returns {Boolean}
*/
@@ -1104,59 +1112,24 @@ function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantR
});
}
-/**
- * We get the amount, currency and comment money request value from the action.originalMessage.
- * But for the send money action, the above value is put in the IOUDetails object.
- *
- * @param {Object} reportAction
- * @param {Number} reportAction.amount
- * @param {String} reportAction.currency
- * @param {String} reportAction.comment
- * @param {Object} [reportAction.IOUDetails]
- * @returns {Object}
- */
-function getMoneyRequestAction(reportAction = {}) {
- const originalMessage = lodashGet(reportAction, 'originalMessage', {});
- let amount = originalMessage.amount || 0;
- let currency = originalMessage.currency || CONST.CURRENCY.USD;
- let comment = originalMessage.comment || '';
-
- if (_.has(originalMessage, 'IOUDetails')) {
- amount = lodashGet(originalMessage, 'IOUDetails.amount', 0);
- currency = lodashGet(originalMessage, 'IOUDetails.currency', CONST.CURRENCY.USD);
- comment = lodashGet(originalMessage, 'IOUDetails.comment', '');
- }
-
- return {amount, currency, comment};
-}
-
/**
* Determines if a report has an IOU that is waiting for an action from the current user (either Pay or Add a credit bank account)
*
* @param {Object} report (chatReport or iouReport)
- * @param {Object} allReportsDict
* @returns {boolean}
*/
-function isWaitingForIOUActionFromCurrentUser(report, allReportsDict = null) {
- const allAvailableReports = allReportsDict || allReports;
- if (!report || !allAvailableReports) {
+function isWaitingForIOUActionFromCurrentUser(report) {
+ if (!report) {
return false;
}
// Money request waiting for current user to add their credit bank account
- if (report.ownerAccountID === currentUserAccountID && report.isWaitingOnBankAccount) {
+ if (report.hasOutstandingIOU && report.ownerAccountID === currentUserAccountID && report.isWaitingOnBankAccount) {
return true;
}
- let reportToLook = report;
- if (report.iouReportID) {
- const iouReport = allAvailableReports[`${ONYXKEYS.COLLECTION.REPORT}${report.iouReportID}`];
- if (iouReport) {
- reportToLook = iouReport;
- }
- }
- // Money request waiting for current user to Pay (from chat or from iou report)
- if (reportToLook.ownerAccountID && (reportToLook.ownerAccountID !== currentUserAccountID || currentUserAccountID === reportToLook.managerID) && reportToLook.hasOutstandingIOU) {
+ // Money request waiting for current user to Pay (from expense or iou report)
+ if (report.hasOutstandingIOU && report.ownerAccountID && (report.ownerAccountID !== currentUserAccountID || currentUserAccountID === report.managerID)) {
return true;
}
@@ -1235,7 +1208,10 @@ function getPolicyExpenseChatName(report, policy = undefined) {
function getMoneyRequestReportName(report, policy = undefined) {
const formattedAmount = CurrencyUtils.convertToDisplayString(getMoneyRequestTotal(report), report.currency);
const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID);
- const payerPaidAmountMesssage = Localize.translateLocal('iou.payerPaidAmount', {payer: payerName, amount: formattedAmount});
+ const payerPaidAmountMesssage = Localize.translateLocal('iou.payerPaidAmount', {
+ payer: payerName,
+ amount: formattedAmount,
+ });
if (report.isWaitingOnBankAccount) {
return `${payerPaidAmountMesssage} • ${Localize.translateLocal('iou.pending')}`;
@@ -1248,6 +1224,76 @@ function getMoneyRequestReportName(report, policy = undefined) {
return payerPaidAmountMesssage;
}
+/**
+ * Get the report given a reportID
+ *
+ * @param {String} reportID
+ * @returns {Object}
+ */
+function getReport(reportID) {
+ return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {});
+}
+
+/**
+ * Gets transaction created, amount, currency and comment
+ *
+ * @param {Object} transaction
+ * @returns {Object}
+ */
+function getTransactionDetails(transaction) {
+ const report = getReport(transaction.reportID);
+ return {
+ created: TransactionUtils.getCreated(transaction),
+ amount: TransactionUtils.getAmount(transaction, isExpenseReport(report)),
+ currency: TransactionUtils.getCurrency(transaction),
+ comment: TransactionUtils.getDescription(transaction),
+ merchant: TransactionUtils.getMerchant(transaction),
+ };
+}
+
+/**
+ * Gets all transactions on an IOU report with a receipt
+ *
+ * @param {Object|null} iouReportID
+ * @returns {[Object]}
+ */
+function getTransactionsWithReceipts(iouReportID) {
+ const reportActions = ReportActionsUtils.getAllReportActions(iouReportID);
+ return _.reduce(
+ reportActions,
+ (transactions, action) => {
+ if (ReportActionsUtils.isMoneyRequestAction(action)) {
+ const transaction = TransactionUtils.getLinkedTransaction(action);
+ if (TransactionUtils.hasReceipt(transaction)) {
+ transactions.push(transaction);
+ }
+ }
+ return transactions;
+ },
+ [],
+ );
+}
+
+/**
+ * For report previews, we display a "Receipt scan in progress" indicator
+ * instead of the report total only when we have no report total ready to show. This is the case when
+ * all requests are receipts that are being SmartScanned. As soon as we have a non-receipt request,
+ * or as soon as one receipt request is done scanning, we have at least one
+ * "ready" money request, and we remove this indicator to show the partial report total.
+ *
+ * @param {Object|null} iouReportID
+ * @param {Object|null} reportPreviewAction the preview action associated with the IOU report
+ * @returns {Boolean}
+ */
+function areAllRequestsBeingSmartScanned(iouReportID, reportPreviewAction) {
+ const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID);
+ // If we have more requests than requests with receipts, we have some manual requests
+ if (ReportActionsUtils.getNumberOfMoneyRequests(reportPreviewAction) > transactionsWithReceipts.length) {
+ return false;
+ }
+ return _.all(transactionsWithReceipts, (transaction) => TransactionUtils.isReceiptBeingScanned(transaction));
+}
+
/**
* Given a parent IOU report action get report name for the LHN.
*
@@ -1259,9 +1305,16 @@ function getTransactionReportName(reportAction) {
return Localize.translateLocal('parentReportAction.deletedRequest');
}
+ const transaction = TransactionUtils.getLinkedTransaction(reportAction);
+ if (TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction)) {
+ return Localize.translateLocal('iou.receiptScanning');
+ }
+
+ const {amount, currency, comment} = getTransactionDetails(transaction);
+
return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', {
- formattedAmount: ReportActionsUtils.getFormattedAmount(reportAction),
- comment: lodashGet(reportAction, 'originalMessage.comment'),
+ formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency),
+ comment,
});
}
@@ -1304,6 +1357,129 @@ function getReportPreviewMessage(report, reportAction = {}) {
return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount});
}
+/**
+ * Get the proper message schema for modified expense message.
+ *
+ * @param {String} newValue
+ * @param {String} oldValue
+ * @param {String} valueName
+ * @param {Boolean} valueInQuotes
+ * @returns {String}
+ */
+
+function getProperSchemaForModifiedExpenseMessage(newValue, oldValue, valueName, valueInQuotes) {
+ const newValueToDisplay = valueInQuotes ? `"${newValue}"` : newValue;
+ const oldValueToDisplay = valueInQuotes ? `"${oldValue}"` : oldValue;
+
+ if (!oldValue) {
+ return `set the ${valueName} to ${newValueToDisplay}`;
+ }
+ if (!newValue) {
+ return `removed the ${valueName} (previously ${oldValueToDisplay})`;
+ }
+ return `changed the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`;
+}
+
+/**
+ * Get the report action message when expense has been modified.
+ *
+ * @param {Object} reportAction
+ * @returns {String}
+ */
+function getModifiedExpenseMessage(reportAction) {
+ const reportActionOriginalMessage = lodashGet(reportAction, 'originalMessage', {});
+ if (_.isEmpty(reportActionOriginalMessage)) {
+ return `changed the request`;
+ }
+
+ const hasModifiedAmount =
+ _.has(reportActionOriginalMessage, 'oldAmount') &&
+ _.has(reportActionOriginalMessage, 'oldCurrency') &&
+ _.has(reportActionOriginalMessage, 'amount') &&
+ _.has(reportActionOriginalMessage, 'currency');
+ if (hasModifiedAmount) {
+ const oldCurrency = reportActionOriginalMessage.oldCurrency;
+ const oldAmount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.oldAmount, oldCurrency);
+
+ const currency = reportActionOriginalMessage.currency;
+ const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.amount, currency);
+
+ return getProperSchemaForModifiedExpenseMessage(amount, oldAmount, 'amount', false);
+ }
+
+ const hasModifiedComment = _.has(reportActionOriginalMessage, 'oldComment') && _.has(reportActionOriginalMessage, 'newComment');
+ if (hasModifiedComment) {
+ return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.newComment, reportActionOriginalMessage.oldComment, 'description', true);
+ }
+
+ const hasModifiedCreated = _.has(reportActionOriginalMessage, 'oldCreated') && _.has(reportActionOriginalMessage, 'created');
+ if (hasModifiedCreated) {
+ // Take only the YYYY-MM-DD value as the original date includes timestamp
+ let formattedOldCreated = parseISO(reportActionOriginalMessage.oldCreated);
+ formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING);
+ return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.created, formattedOldCreated, 'date', false);
+ }
+
+ const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant');
+ if (hasModifiedMerchant) {
+ return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, 'merchant', true);
+ }
+}
+
+/**
+ * Given the updates user made to the request, compose the originalMessage
+ * object of the modified expense action.
+ *
+ * At the moment, we only allow changing one transaction field at a time.
+ *
+ * @param {Object} oldTransaction
+ * @param {Object} transactionChanges
+ * @param {Boolen} isFromExpenseReport
+ * @returns {Object}
+ */
+function getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport) {
+ const originalMessage = {};
+
+ // Remark: Comment field is the only one which has new/old prefixes for the keys (newComment/ oldComment),
+ // all others have old/- pattern such as oldCreated/created
+ if (_.has(transactionChanges, 'comment')) {
+ originalMessage.oldComment = TransactionUtils.getDescription(oldTransaction);
+ originalMessage.newComment = transactionChanges.comment;
+ }
+ if (_.has(transactionChanges, 'created')) {
+ originalMessage.oldCreated = TransactionUtils.getCreated(oldTransaction);
+ originalMessage.created = transactionChanges.created;
+ }
+ if (_.has(transactionChanges, 'merchant')) {
+ originalMessage.oldMerchant = TransactionUtils.getMerchant(oldTransaction);
+ originalMessage.merchant = transactionChanges.merchant;
+ }
+
+ // The amount is always a combination of the currency and the number value so when one changes we need to store both
+ // to match how we handle the modified expense action in oldDot
+ if (_.has(transactionChanges, 'amount') || _.has(transactionChanges, 'currency')) {
+ originalMessage.oldAmount = TransactionUtils.getAmount(oldTransaction, isFromExpenseReport);
+ originalMessage.amount = lodashGet(transactionChanges, 'amount', originalMessage.oldAmount);
+ originalMessage.oldCurrency = TransactionUtils.getCurrency(oldTransaction);
+ originalMessage.currency = lodashGet(transactionChanges, 'currency', originalMessage.oldCurrency);
+ }
+
+ return originalMessage;
+}
+
+/**
+ * Returns the parentReport if the given report is a thread.
+ *
+ * @param {Object} report
+ * @returns {Object}
+ */
+function getParentReport(report) {
+ if (!report || !report.parentReportID) {
+ return {};
+ }
+ return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, {});
+}
+
/**
* Get the title for a report.
*
@@ -1374,12 +1550,12 @@ function getRootReportAndWorkspaceName(report) {
if (isIOURequest(report)) {
return {
- rootReportName: lodashGet(report, 'displayName', ''),
+ rootReportName: getReportName(report),
};
}
if (isExpenseRequest(report)) {
return {
- rootReportName: lodashGet(report, 'displayName', ''),
+ rootReportName: getReportName(report),
workspaceName: isIOUReport(report) ? CONST.POLICY.OWNER_EMAIL_FAKE : getPolicyName(report, true),
};
}
@@ -1433,16 +1609,6 @@ function getParentNavigationSubtitle(report) {
return {};
}
-/**
- * Get the report for a reportID
- *
- * @param {String} reportID
- * @returns {Object}
- */
-function getReport(reportID) {
- return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {});
-}
-
/**
* Navigate to the details page of a given report
*
@@ -1451,7 +1617,7 @@ function getReport(reportID) {
function navigateToDetailsPage(report) {
const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []);
- if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report)) {
+ if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report) || isTaskReport(report)) {
Navigation.navigate(ROUTES.getReportDetailsRoute(report.reportID));
return;
}
@@ -1717,6 +1883,7 @@ function buildOptimisticExpenseReport(chatReportID, policyID, payeeAccountID, to
}
/**
+ * @param {String} iouReportID - the report ID of the IOU report the action belongs to
* @param {String} type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay, split)
* @param {Number} total - IOU total in cents
* @param {String} comment - IOU comment
@@ -1725,9 +1892,8 @@ function buildOptimisticExpenseReport(chatReportID, policyID, payeeAccountID, to
* @param {Boolean} isSettlingUp - Whether we are settling up an IOU
* @returns {Array}
*/
-function getIOUReportActionMessage(type, total, comment, currency, paymentType = '', isSettlingUp = false) {
- const currencyUnit = CurrencyUtils.getCurrencyUnit(currency);
- const amount = NumberFormatUtils.format(preferredLocale, Math.abs(total) / currencyUnit, {style: 'currency', currency});
+function getIOUReportActionMessage(iouReportID, type, total, comment, currency, paymentType = '', isSettlingUp = false) {
+ let amount = CurrencyUtils.convertToDisplayString(total, currency);
let paymentMethodMessage;
switch (paymentType) {
case CONST.IOU.PAYMENT_TYPE.ELSEWHERE:
@@ -1753,6 +1919,7 @@ function getIOUReportActionMessage(type, total, comment, currency, paymentType =
iouMessage = `deleted the ${amount} request${comment && ` for ${comment}`}`;
break;
case CONST.IOU.REPORT_ACTION_TYPE.PAY:
+ amount = CurrencyUtils.convertToDisplayString(getMoneyRequestTotal(getReport(iouReportID)), currency);
iouMessage = isSettlingUp ? `paid ${amount}${paymentMethodMessage}` : `sent ${amount}${comment && ` for ${comment}`}${paymentMethodMessage}`;
break;
default:
@@ -1777,12 +1944,13 @@ function getIOUReportActionMessage(type, total, comment, currency, paymentType =
* @param {String} currency
* @param {String} comment - User comment for the IOU.
* @param {Array} participants - An array with participants details.
- * @param {String} transactionID
+ * @param {String} [transactionID] - Not required if the IOUReportAction type is 'pay'
* @param {String} [paymentType] - Only required if the IOUReportAction type is 'pay'. Can be oneOf(elsewhere, payPal, Expensify).
* @param {String} [iouReportID] - Only required if the IOUReportActions type is oneOf(decline, cancel, pay). Generates a randomID as default.
* @param {Boolean} [isSettlingUp] - Whether we are settling up an IOU.
* @param {Boolean} [isSendMoneyFlow] - Whether this is send money flow
* @param {Object} [receipt]
+ * @param {Boolean} [isOwnPolicyExpenseChat] - Whether this is an expense report create from the current user's policy expense chat
* @returns {Object}
*/
function buildOptimisticIOUReportAction(
@@ -1791,12 +1959,13 @@ function buildOptimisticIOUReportAction(
currency,
comment,
participants,
- transactionID,
+ transactionID = '',
paymentType = '',
iouReportID = '',
isSettlingUp = false,
isSendMoneyFlow = false,
receipt = {},
+ isOwnPolicyExpenseChat = false,
) {
const IOUReportID = iouReportID || generateReportID();
@@ -1809,19 +1978,32 @@ function buildOptimisticIOUReportAction(
type,
};
- // We store amount, comment, currency in IOUDetails when type = pay
- if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY && isSendMoneyFlow) {
- _.each(['amount', 'comment', 'currency'], (key) => {
- delete originalMessage[key];
- });
- originalMessage.IOUDetails = {amount, comment, currency};
- originalMessage.paymentType = paymentType;
+ if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY) {
+ // In send money flow, we store amount, comment, currency in IOUDetails when type = pay
+ if (isSendMoneyFlow) {
+ _.each(['amount', 'comment', 'currency'], (key) => {
+ delete originalMessage[key];
+ });
+ originalMessage.IOUDetails = {amount, comment, currency};
+ originalMessage.paymentType = paymentType;
+ } else {
+ // In case of pay money request action, we dont store the comment
+ // and there is no single transctionID to link the action to.
+ delete originalMessage.IOUTransactionID;
+ delete originalMessage.comment;
+ originalMessage.paymentType = paymentType;
+ }
}
// IOUs of type split only exist in group DMs and those don't have an iouReport so we need to delete the IOUReportID key
if (type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT) {
delete originalMessage.IOUReportID;
- originalMessage.participantAccountIDs = [currentUserAccountID, ..._.pluck(participants, 'accountID')];
+ // Split bill made from a policy expense chat only have the payee's accountID as the participant because the payer could be any policy admin
+ if (isOwnPolicyExpenseChat) {
+ originalMessage.participantAccountIDs = [currentUserAccountID];
+ } else {
+ originalMessage.participantAccountIDs = [currentUserAccountID, ..._.pluck(participants, 'accountID')];
+ }
}
return {
@@ -1831,7 +2013,7 @@ function buildOptimisticIOUReportAction(
avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatar(currentUserAccountID)),
isAttachment: false,
originalMessage,
- message: getIOUReportActionMessage(type, amount, comment, currency, paymentType, isSettlingUp),
+ message: getIOUReportActionMessage(iouReportID, type, amount, comment, currency, paymentType, isSettlingUp),
person: [
{
style: 'strong',
@@ -1844,6 +2026,7 @@ function buildOptimisticIOUReportAction(
created: DateUtils.getDBTime(),
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
receipt,
+ whisperedToAccountIDs: !_.isEmpty(receipt) ? [currentUserAccountID] : [],
};
}
@@ -1853,10 +2036,12 @@ function buildOptimisticIOUReportAction(
* @param {Object} chatReport
* @param {Object} iouReport
* @param {String} [comment] - User comment for the IOU.
+ * @param {Object} [transaction] - optimistic first transaction of preview
*
* @returns {Object}
*/
-function buildOptimisticReportPreview(chatReport, iouReport, comment = '') {
+function buildOptimisticReportPreview(chatReport, iouReport, comment = '', transaction = undefined) {
+ const hasReceipt = TransactionUtils.hasReceipt(transaction);
const message = getReportPreviewMessage(iouReport);
return {
reportActionID: NumberUtils.rand64(),
@@ -1876,9 +2061,53 @@ function buildOptimisticReportPreview(chatReport, iouReport, comment = '') {
],
created: DateUtils.getDBTime(),
accountID: iouReport.managerID || 0,
- actorAccountID: iouReport.managerID || 0,
+ // The preview is initially whispered if created with a receipt, so the actor is the current user as well
+ actorAccountID: hasReceipt ? currentUserAccountID : iouReport.managerID || 0,
childMoneyRequestCount: 1,
childLastMoneyRequestComment: comment,
+ childLastReceiptTransactionIDs: hasReceipt ? transaction.transactionID : '',
+ whisperedToAccountIDs: hasReceipt ? [currentUserAccountID] : [],
+ };
+}
+
+/**
+ * Builds an optimistic modified expense action with a randomly generated reportActionID.
+ *
+ * @param {Object} transactionThread
+ * @param {Object} oldTransaction
+ * @param {Object} transactionChanges
+ * @param {Object} isFromExpenseReport
+ * @returns {Object}
+ */
+function buildOptimisticModifiedExpenseReportAction(transactionThread, oldTransaction, transactionChanges, isFromExpenseReport) {
+ const originalMessage = getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport);
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE,
+ actorAccountID: currentUserAccountID,
+ automatic: false,
+ avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatar(currentUserAccountID)),
+ created: DateUtils.getDBTime(),
+ isAttachment: false,
+ message: [
+ {
+ // Currently we are composing the message from the originalMessage and message is only used in OldDot and not in the App
+ text: 'You',
+ style: 'strong',
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ },
+ ],
+ originalMessage,
+ person: [
+ {
+ style: 'strong',
+ text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserAccountID),
+ type: 'TEXT',
+ },
+ ],
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ reportActionID: NumberUtils.rand64(),
+ reportID: transactionThread.reportID,
+ shouldShow: true,
};
}
@@ -1888,10 +2117,15 @@ function buildOptimisticReportPreview(chatReport, iouReport, comment = '') {
* @param {Object} iouReport
* @param {Object} reportPreviewAction
* @param {String} [comment] - User comment for the IOU.
+ * @param {Object} [transaction] - optimistic newest transaction of a report preview
*
* @returns {Object}
*/
-function updateReportPreview(iouReport, reportPreviewAction, comment = '') {
+function updateReportPreview(iouReport, reportPreviewAction, comment = '', transaction = undefined) {
+ const hasReceipt = TransactionUtils.hasReceipt(transaction);
+ const lastReceiptTransactionIDs = lodashGet(reportPreviewAction, 'childLastReceiptTransactionIDs', '');
+ const previousTransactionIDs = lastReceiptTransactionIDs.split(',').slice(0, 2);
+
const message = getReportPreviewMessage(iouReport, reportPreviewAction);
return {
...reportPreviewAction,
@@ -1906,6 +2140,10 @@ function updateReportPreview(iouReport, reportPreviewAction, comment = '') {
],
childLastMoneyRequestComment: comment || reportPreviewAction.childLastMoneyRequestComment,
childMoneyRequestCount: reportPreviewAction.childMoneyRequestCount + 1,
+ childLastReceiptTransactionIDs: hasReceipt ? [transaction.transactionID, ...previousTransactionIDs].join(',') : lastReceiptTransactionIDs,
+ // As soon as we add a transaction without a receipt to the report, it will have ready money requests,
+ // so we remove the whisper
+ whisperedToAccountIDs: hasReceipt ? reportPreviewAction.whisperedToAccountIDs : [],
};
}
@@ -2208,6 +2446,7 @@ function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parent
reportName: title,
description,
ownerAccountID,
+ participantAccountIDs: assigneeAccountID && assigneeAccountID !== ownerAccountID ? [assigneeAccountID] : [],
managerID: assigneeAccountID,
type: CONST.REPORT.TYPE.TASK,
parentReportID,
@@ -2268,6 +2507,29 @@ function isIOUOwnedByCurrentUser(report, allReportsDict = null) {
return reportToLook.ownerAccountID === currentUserAccountID;
}
+/**
+ * Should return true only for personal 1:1 report
+ *
+ * @param {Object} report (chatReport or iouReport)
+ * @returns {boolean}
+ */
+function isOneOnOneChat(report) {
+ const isChatRoomValue = lodashGet(report, 'isChatRoom', false);
+ const participantsListValue = lodashGet(report, 'participantsList', []);
+ return (
+ !isThread(report) &&
+ !isChatRoom(report) &&
+ !isChatRoomValue &&
+ !isExpenseRequest(report) &&
+ !isMoneyRequestReport(report) &&
+ !isPolicyExpenseChat(report) &&
+ !isTaskReport(report) &&
+ isDM(report) &&
+ !isIOUReport(report) &&
+ participantsListValue.length === 1
+ );
+}
+
/**
* Assuming the passed in report is a default room, lets us know whether we can see it or not, based on permissions and
* the various subsets of users we've allowed to use default rooms.
@@ -2337,13 +2599,13 @@ function canAccessReport(report, policies, betas, allReportActions) {
* @param {Object} report
* @param {String} currentReportId
* @param {Boolean} isInGSDMode
- * @param {Object} iouReports
* @param {String[]} betas
* @param {Object} policies
* @param {Object} allReportActions
+ * @param {Boolean} excludeEmptyChats
* @returns {boolean}
*/
-function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, iouReports, betas, policies, allReportActions) {
+function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, policies, allReportActions, excludeEmptyChats = false) {
const isInDefaultMode = !isInGSDMode;
// Exclude reports that have no data because there wouldn't be anything to show in the option item.
@@ -2352,6 +2614,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, iouRep
if (
!report ||
!report.reportID ||
+ report.isHidden ||
(_.isEmpty(report.participantAccountIDs) && !isChatThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report))
) {
return false;
@@ -2369,12 +2632,15 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, iouRep
}
// Include reports that are relevant to the user in any view mode. Criteria include having a draft, having an outstanding IOU, or being assigned to an open task.
- if (report.hasDraft || isWaitingForIOUActionFromCurrentUser(report, iouReports) || isWaitingForTaskCompleteFromAssignee(report)) {
+ if (report.hasDraft || isWaitingForIOUActionFromCurrentUser(report) || isWaitingForTaskCompleteFromAssignee(report)) {
return true;
}
+ const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(report.reportID);
+ const isEmptyChat = !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey;
+
// Hide only chat threads that haven't been commented on (other threads are actionable)
- if (isChatThread(report) && !report.lastMessageText) {
+ if (isChatThread(report) && isEmptyChat) {
return false;
}
@@ -2399,8 +2665,8 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, iouRep
return true;
}
- // Exclude policy expense chats if the user isn't in the policy expense chat beta
- if (isPolicyExpenseChat(report) && !Permissions.canUsePolicyExpenseChat(betas)) {
+ // Hide chats between two users that haven't been commented on from the LNH
+ if (excludeEmptyChats && isEmptyChat && isChatReport(report) && !isChatRoom(report) && !isPolicyExpenseChat(report)) {
return false;
}
@@ -2668,7 +2934,7 @@ function getMoneyRequestOptions(report, reportParticipants, betas) {
// unless there are no participants at all (e.g. #admins room for a policy with only 1 admin)
// DM chats will have the Split Bill option only when there are at least 3 people in the chat.
// There is no Split Bill option for Workspace chats
- if (isChatRoom(report) || (hasMultipleParticipants && !isPolicyExpenseChat(report))) {
+ if (isChatRoom(report) || (hasMultipleParticipants && !isPolicyExpenseChat(report)) || isControlPolicyExpenseChat(report)) {
return [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT];
}
@@ -2783,19 +3049,6 @@ function isReportDataReady() {
return !_.isEmpty(allReports) && _.some(_.keys(allReports), (key) => allReports[key].reportID);
}
-/**
- * Returns the parentReport if the given report is a thread.
- *
- * @param {Object} report
- * @returns {Object}
- */
-function getParentReport(report) {
- if (!report || !report.parentReportID) {
- return {};
- }
- return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, {});
-}
-
/**
* Find the parent report action in assignee report for a task report
* Returns an empty object if assignee report is the same as the share destination report
@@ -2832,11 +3085,11 @@ function getAddWorkspaceRoomOrChatReportErrors(report) {
}
/**
- * Return true if the composer should be hidden
+ * Returns true if write actions like assign task, money request, send message should be disabled on a report
* @param {Object} report
* @returns {Boolean}
*/
-function shouldHideComposer(report) {
+function shouldDisableWriteActions(report) {
const reportErrors = getAddWorkspaceRoomOrChatReportErrors(report);
return isArchivedRoom(report) || !_.isEmpty(reportErrors) || !isAllowedToComment(report) || isAnonymousUser;
}
@@ -2874,6 +3127,23 @@ function getPolicy(policyID) {
return policy;
}
+/**
+ * @param {String} policyOwner
+ * @returns {String|null}
+ */
+function getPolicyExpenseChatReportIDByOwner(policyOwner) {
+ const policyWithOwner = _.find(allPolicies, (policy) => policy.owner === policyOwner);
+ if (!policyWithOwner) {
+ return null;
+ }
+
+ const expenseChat = _.find(allReports, (report) => isPolicyExpenseChat(report) && report.policyID === policyWithOwner.id);
+ if (!expenseChat) {
+ return null;
+ }
+ return expenseChat.reportID;
+}
+
/*
* @param {Object|null} report
* @returns {Boolean}
@@ -2903,6 +3173,144 @@ function shouldDisableRename(report, policy) {
return !_.keys(loginList).includes(policy.owner) && policy.role !== CONST.POLICY.ROLE.ADMIN;
}
+/**
+ * Returns the onyx data needed for the task assignee chat
+ * @param {Number} accountID
+ * @param {String} assigneeEmail
+ * @param {Number} assigneeAccountID
+ * @param {String} taskReportID
+ * @param {String} assigneeChatReportID
+ * @param {String} parentReportID
+ * @param {String} title
+ * @param {Object} assigneeChatReport
+ * @returns {Object}
+ */
+function getTaskAssigneeChatOnyxData(accountID, assigneeEmail, assigneeAccountID, taskReportID, assigneeChatReportID, parentReportID, title, assigneeChatReport) {
+ // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task
+ let optimisticAssigneeAddComment;
+ // Set if this is a new chat that needs to be created for the assignee
+ let optimisticChatCreatedReportAction;
+ const currentTime = DateUtils.getDBTime();
+ const optimisticData = [];
+ const successData = [];
+ const failureData = [];
+
+ // You're able to assign a task to someone you haven't chatted with before - so we need to optimistically create the chat and the chat reportActions
+ // Only add the assignee chat report to onyx if we haven't already set it optimistically
+ if (assigneeChatReport.isOptimisticReport && lodashGet(assigneeChatReport, 'pendingFields.createChat') !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
+ optimisticChatCreatedReportAction = buildOptimisticCreatedReportAction(assigneeChatReportID);
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`,
+ value: {
+ pendingFields: {
+ createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ isHidden: false,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
+ value: {[optimisticChatCreatedReportAction.reportActionID]: optimisticChatCreatedReportAction},
+ },
+ );
+
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`,
+ value: {
+ pendingFields: {
+ createChat: null,
+ },
+ isOptimisticReport: false,
+ },
+ });
+
+ failureData.push(
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
+ value: {[optimisticChatCreatedReportAction.reportActionID]: {pendingAction: null}},
+ },
+ // If we failed, we want to remove the optimistic personal details as it was likely due to an invalid login
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: {
+ [assigneeAccountID]: null,
+ },
+ },
+ );
+ }
+
+ // If you're choosing to share the task in the same DM as the assignee then we don't need to create another reportAction indicating that you've been assigned
+ if (assigneeChatReportID !== parentReportID) {
+ optimisticAssigneeAddComment = buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `Assigned a task to you: ${title}`, parentReportID);
+
+ const lastAssigneeCommentText = formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text);
+ const optimisticAssigneeReport = {
+ lastVisibleActionCreated: currentTime,
+ lastMessageText: lastAssigneeCommentText,
+ lastActorAccountID: accountID,
+ lastReadTime: currentTime,
+ };
+
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
+ value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: optimisticAssigneeAddComment.reportAction},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`,
+ value: optimisticAssigneeReport,
+ },
+ );
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
+ value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {pendingAction: null}},
+ });
+ }
+
+ return {
+ optimisticData,
+ successData,
+ failureData,
+ optimisticAssigneeAddComment,
+ optimisticChatCreatedReportAction,
+ };
+}
+
+/**
+ * Get the last 3 transactions with receipts of an IOU report that will be displayed on the report preview
+ *
+ * @param {Object} reportPreviewAction
+ * @returns {Object}
+ */
+function getReportPreviewDisplayTransactions(reportPreviewAction) {
+ const transactionIDs = lodashGet(reportPreviewAction, ['childLastReceiptTransactionIDs'], '').split(',');
+ return _.reduce(
+ transactionIDs,
+ (transactions, transactionID) => {
+ const transaction = TransactionUtils.getTransaction(transactionID);
+ if (TransactionUtils.hasReceipt(transaction)) {
+ transactions.push(transaction);
+ }
+ return transactions;
+ },
+ [],
+ );
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -2915,6 +3323,7 @@ export {
sortReportsByLastRead,
isDefaultRoom,
isAdminRoom,
+ isAdminsOnlyPostingRoom,
isAnnounceRoom,
isUserCreatedPolicyRoom,
isChatRoom,
@@ -2939,6 +3348,8 @@ export {
formatReportLastMessageText,
chatIncludesConcierge,
isPolicyExpenseChat,
+ isControlPolicyExpenseChat,
+ isControlPolicyExpenseReport,
getIconsForParticipants,
getIcons,
getRoomWelcomeMessage,
@@ -2962,6 +3373,7 @@ export {
buildOptimisticExpenseReport,
buildOptimisticIOUReportAction,
buildOptimisticReportPreview,
+ buildOptimisticModifiedExpenseReportAction,
updateReportPreview,
buildOptimisticTaskReportAction,
buildOptimisticAddCommentReportAction,
@@ -2975,6 +3387,7 @@ export {
getAllPolicyReports,
getIOUReportActionMessage,
getDisplayNameForParticipant,
+ getWorkspaceIcon,
isOptimisticPersonalDetail,
shouldDisableDetailPage,
isChatReport,
@@ -3009,19 +3422,27 @@ export {
isReportDataReady,
isSettled,
isAllowedToComment,
- getMoneyRequestAction,
getBankAccountRoute,
getParentReport,
getTaskParentReportActionIDInAssigneeReport,
getReportPreviewMessage,
- shouldHideComposer,
+ getModifiedExpenseMessage,
+ shouldDisableWriteActions,
getOriginalReportID,
canAccessReport,
getAddWorkspaceRoomOrChatReportErrors,
getReportOfflinePendingActionAndErrors,
isDM,
getPolicy,
+ getPolicyExpenseChatReportIDByOwner,
shouldDisableSettings,
shouldDisableRename,
hasSingleParticipant,
+ isOneOnOneChat,
+ getTransactionReportName,
+ getTransactionDetails,
+ getTaskAssigneeChatOnyxData,
+ areAllRequestsBeingSmartScanned,
+ getReportPreviewDisplayTransactions,
+ getTransactionsWithReceipts,
};
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index 561d91a63a1f..7facb155eff0 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -31,7 +31,10 @@ Onyx.connect({
// does not match a closed or created state.
const reportActionsForDisplay = _.filter(
actionsArray,
- (reportAction, actionKey) => ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED,
+ (reportAction, actionKey) =>
+ ReportActionsUtils.shouldReportActionBeVisible(reportAction, actionKey) &&
+ reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED &&
+ reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
);
visibleReportActionItems[reportID] = _.last(reportActionsForDisplay);
},
@@ -86,9 +89,7 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p
const isInDefaultMode = !isInGSDMode;
// Filter out all the reports that shouldn't be displayed
- const reportsToDisplay = _.filter(allReportsDict, (report) =>
- ReportUtils.shouldReportBeInOptionList(report, currentReportId, isInGSDMode, allReportsDict, betas, policies, allReportActions),
- );
+ const reportsToDisplay = _.filter(allReportsDict, (report) => ReportUtils.shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, policies, allReportActions, true));
if (_.isEmpty(reportsToDisplay)) {
// Display Concierge chat report when there is no report to be displayed
@@ -114,7 +115,7 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p
// 1. Pinned - Always sorted by reportDisplayName
// 2. Outstanding IOUs - Always sorted by iouReportAmount with the largest amounts at the top of the group
// 3. Drafts - Always sorted by reportDisplayName
- // 4. Non-archived reports
+ // 4. Non-archived reports and settled IOUs
// - Sorted by lastVisibleActionCreated in default (most recent) view mode
// - Sorted by reportDisplayName in GSD (focus) view mode
// 5. Archived reports
@@ -131,7 +132,7 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p
return;
}
- if (ReportUtils.isWaitingForIOUActionFromCurrentUser(report, allReportsDict)) {
+ if (ReportUtils.isWaitingForIOUActionFromCurrentUser(report)) {
outstandingIOUReports.push(report);
return;
}
@@ -255,14 +256,13 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
result.parentReportID = report.parentReportID || null;
result.isWaitingOnBankAccount = report.isWaitingOnBankAccount;
result.notificationPreference = report.notificationPreference || null;
-
- // If the composer is hidden then the user is not allowed to comment, same can be used to hide the draft icon.
- result.isAllowedToComment = !ReportUtils.shouldHideComposer(report);
+ result.isAllowedToComment = !ReportUtils.shouldDisableWriteActions(report);
const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat;
const subtitle = ReportUtils.getChatRoomSubtitle(report);
const login = Str.removeSMSDomain(lodashGet(personalDetail, 'login', ''));
+ const status = lodashGet(personalDetail, 'status', '');
const formattedLogin = Str.isSMSLogin(login) ? LocalePhoneNumber.formatPhoneNumber(login) : login;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
@@ -352,6 +352,12 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread);
result.displayNamesWithTooltips = displayNamesWithTooltips;
result.isLastMessageDeletedParentAction = report.isLastMessageDeletedParentAction;
+
+ if (status) {
+ result.status = status;
+ }
+ result.type = report.type;
+
return result;
}
diff --git a/src/libs/StringUtils.js b/src/libs/StringUtils.js
new file mode 100644
index 000000000000..8219f9e8077f
--- /dev/null
+++ b/src/libs/StringUtils.js
@@ -0,0 +1,12 @@
+import _ from 'lodash';
+import CONST from '../CONST';
+/**
+ * Removes diacritical marks and non-alphabetic and non-latin characters from a string.
+ * @param {String} str - The input string to be sanitized.
+ * @returns {String} The sanitized string
+ */
+function sanitizeString(str) {
+ return _.chain(str).deburr().toLower().value().replaceAll(CONST.REGEX.NON_ALPHABETIC_AND_NON_LATIN_CHARS, '');
+}
+
+export default {sanitizeString};
diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js
index f88f53467ae8..b72c6e03217d 100644
--- a/src/libs/TransactionUtils.js
+++ b/src/libs/TransactionUtils.js
@@ -1,7 +1,24 @@
+import Onyx from 'react-native-onyx';
+import {format, parseISO, isValid} from 'date-fns';
+import lodashGet from 'lodash/get';
+import _ from 'underscore';
import CONST from '../CONST';
+import ONYXKEYS from '../ONYXKEYS';
import DateUtils from './DateUtils';
import * as NumberUtils from './NumberUtils';
+let allTransactions = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (val) => {
+ if (!val) {
+ return;
+ }
+ allTransactions = _.pick(val, (transaction) => transaction);
+ },
+});
+
/**
* Optimistically generate a transaction.
*
@@ -9,16 +26,31 @@ import * as NumberUtils from './NumberUtils';
* @param {String} currency
* @param {String} reportID
* @param {String} [comment]
+ * @param {String} [created]
* @param {String} [source]
* @param {String} [originalTransactionID]
* @param {String} [merchant]
* @param {Object} [receipt]
+ * @param {String} [filename]
+ * @param {String} [existingTransactionID] When creating a distance request, an empty transaction has already been created with a transactionID. In that case, the transaction here needs to have it's transactionID match what was already generated.
* @returns {Object}
*/
-function buildOptimisticTransaction(amount, currency, reportID, comment = '', source = '', originalTransactionID = '', merchant = CONST.REPORT.TYPE.IOU, receipt = {}) {
+function buildOptimisticTransaction(
+ amount,
+ currency,
+ reportID,
+ comment = '',
+ created = '',
+ source = '',
+ originalTransactionID = '',
+ merchant = CONST.TRANSACTION.DEFAULT_MERCHANT,
+ receipt = {},
+ filename = '',
+ existingTransactionID = null,
+) {
// transactionIDs are random, positive, 64-bit numeric strings.
// Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID)
- const transactionID = NumberUtils.rand64();
+ const transactionID = existingTransactionID || NumberUtils.rand64();
const commentJSON = {comment};
if (source) {
@@ -34,13 +66,243 @@ function buildOptimisticTransaction(amount, currency, reportID, comment = '', so
currency,
reportID,
comment: commentJSON,
- merchant,
- created: DateUtils.getDBTime(),
+ merchant: merchant || CONST.TRANSACTION.DEFAULT_MERCHANT,
+ created: created || DateUtils.getDBTime(),
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
receipt,
+ filename,
+ };
+}
+
+/**
+ * @param {Object|null} transaction
+ * @returns {Boolean}
+ */
+function hasReceipt(transaction) {
+ return lodashGet(transaction, 'receipt.state', '') !== '';
+}
+
+/**
+ * Given the edit made to the money request, return an updated transaction object.
+ *
+ * @param {Object} transaction
+ * @param {Object} transactionChanges
+ * @param {Object} isFromExpenseReport
+ * @returns {Object}
+ */
+function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) {
+ // Only changing the first level fields so no need for deep clone now
+ const updatedTransaction = _.clone(transaction);
+ let shouldStopSmartscan = false;
+
+ // The comment property does not have its modifiedComment counterpart
+ if (_.has(transactionChanges, 'comment')) {
+ updatedTransaction.comment = {
+ ...updatedTransaction.comment,
+ comment: transactionChanges.comment,
+ };
+ }
+ if (_.has(transactionChanges, 'created')) {
+ updatedTransaction.modifiedCreated = transactionChanges.created;
+ shouldStopSmartscan = true;
+ }
+ if (_.has(transactionChanges, 'amount')) {
+ updatedTransaction.modifiedAmount = isFromExpenseReport ? -transactionChanges.amount : transactionChanges.amount;
+ shouldStopSmartscan = true;
+ }
+ if (_.has(transactionChanges, 'currency')) {
+ updatedTransaction.modifiedCurrency = transactionChanges.currency;
+ shouldStopSmartscan = true;
+ }
+
+ if (_.has(transactionChanges, 'merchant')) {
+ updatedTransaction.modifiedMerchant = transactionChanges.merchant;
+ shouldStopSmartscan = true;
+ }
+
+ if (shouldStopSmartscan && _.has(transaction, 'receipt') && !_.isEmpty(transaction.receipt) && lodashGet(transaction, 'receipt.state') !== CONST.IOU.RECEIPT_STATE.OPEN) {
+ updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN;
+ }
+ updatedTransaction.pendingFields = {
+ ...(_.has(transactionChanges, 'comment') && {comment: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'created') && {created: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
};
+
+ return updatedTransaction;
+}
+
+/**
+ * Retrieve the particular transaction object given its ID.
+ *
+ * @param {String} transactionID
+ * @returns {Object}
+ */
+function getTransaction(transactionID) {
+ return lodashGet(allTransactions, `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {});
+}
+
+/**
+ * Return the comment field (referred to as description in the App) from the transaction.
+ * The comment does not have its modifiedComment counterpart.
+ *
+ * @param {Object} transaction
+ * @returns {String}
+ */
+function getDescription(transaction) {
+ return lodashGet(transaction, 'comment.comment', '');
+}
+
+/**
+ * Return the amount field from the transaction, return the modifiedAmount if present.
+ *
+ * @param {Object} transaction
+ * @param {Boolean} isFromExpenseReport
+ * @returns {Number}
+ */
+function getAmount(transaction, isFromExpenseReport) {
+ // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value
+ if (!isFromExpenseReport) {
+ const amount = lodashGet(transaction, 'modifiedAmount', 0);
+ if (amount) {
+ return Math.abs(amount);
+ }
+ return Math.abs(lodashGet(transaction, 'amount', 0));
+ }
+
+ // Expense report case:
+ // The amounts are stored using an opposite sign and negative values can be set,
+ // we need to return an opposite sign than is saved in the transaction object
+ let amount = lodashGet(transaction, 'modifiedAmount', 0);
+ if (amount) {
+ return -amount;
+ }
+
+ // To avoid -0 being shown, lets only change the sign if the value is other than 0.
+ amount = lodashGet(transaction, 'amount', 0);
+ return amount ? -amount : 0;
+}
+
+/**
+ * Return the currency field from the transaction, return the modifiedCurrency if present.
+ *
+ * @param {Object} transaction
+ * @returns {String}
+ */
+function getCurrency(transaction) {
+ const currency = lodashGet(transaction, 'modifiedCurrency', '');
+ if (currency) {
+ return currency;
+ }
+ return lodashGet(transaction, 'currency', CONST.CURRENCY.USD);
+}
+
+/**
+ * Return the merchant field from the transaction, return the modifiedMerchant if present.
+ *
+ * @param {Object} transaction
+ * @returns {String}
+ */
+function getMerchant(transaction) {
+ return lodashGet(transaction, 'modifiedMerchant', null) || lodashGet(transaction, 'merchant', '');
+}
+
+/**
+ * Return the created field from the transaction, return the modifiedCreated if present.
+ *
+ * @param {Object} transaction
+ * @returns {String}
+ */
+function getCreated(transaction) {
+ const created = lodashGet(transaction, 'modifiedCreated', '') || lodashGet(transaction, 'created', '');
+ const createdDate = parseISO(created);
+ if (isValid(createdDate)) {
+ return format(createdDate, CONST.DATE.FNS_FORMAT_STRING);
+ }
+
+ return '';
+}
+
+/**
+ * Get the transactions related to a report preview with receipts
+ * Get the details linked to the IOU reportAction
+ *
+ * @param {Object} reportAction
+ * @returns {Object}
+ */
+function getLinkedTransaction(reportAction = {}) {
+ const transactionID = lodashGet(reportAction, ['originalMessage', 'IOUTransactionID'], '');
+ return allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] || {};
+}
+
+function getAllReportTransactions(reportID) {
+ return _.filter(allTransactions, (transaction) => transaction.reportID === reportID);
+}
+
+function isReceiptBeingScanned(transaction) {
+ return transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANREADY || transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANNING;
+}
+
+/**
+ * Verifies that the provided waypoints are valid
+ * @param {Object} waypoints
+ * @returns {Boolean}
+ */
+function validateWaypoints(waypoints) {
+ const waypointValues = _.values(waypoints);
+
+ // Ensure the number of waypoints is between 2 and 25
+ if (waypointValues.length < 2 || waypointValues.length > 25) {
+ return false;
+ }
+
+ for (let i = 0; i < waypointValues.length; i++) {
+ const currentWaypoint = waypointValues[i];
+ const previousWaypoint = waypointValues[i - 1];
+
+ // Check if the waypoint has a valid address
+ if (!currentWaypoint || !currentWaypoint.address || typeof currentWaypoint.address !== 'string' || currentWaypoint.address.trim() === '') {
+ return false;
+ }
+
+ // Check for adjacent waypoints with the same address
+ if (previousWaypoint && currentWaypoint.address === previousWaypoint.address) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/*
+ * @param {Object} transaction
+ * @param {Object} transaction.comment
+ * @param {String} transaction.comment.type
+ * @param {Object} [transaction.comment.customUnit]
+ * @param {String} [transaction.comment.customUnit.name]
+ * @returns {Boolean}
+ */
+function isDistanceRequest(transaction) {
+ const type = lodashGet(transaction, 'comment.type');
+ const customUnitName = lodashGet(transaction, 'comment.customUnit.name');
+ return type === CONST.TRANSACTION.TYPE.CUSTOM_UNIT && customUnitName === CONST.CUSTOM_UNITS.NAME_DISTANCE;
}
-export default {
+export {
buildOptimisticTransaction,
+ getUpdatedTransaction,
+ getTransaction,
+ getDescription,
+ getAmount,
+ getCurrency,
+ getMerchant,
+ getCreated,
+ getLinkedTransaction,
+ getAllReportTransactions,
+ hasReceipt,
+ isReceiptBeingScanned,
+ validateWaypoints,
+ isDistanceRequest,
};
diff --git a/src/libs/Url.js b/src/libs/Url.js
index 7e7c230de95f..eb96b697a8fc 100644
--- a/src/libs/Url.js
+++ b/src/libs/Url.js
@@ -1,5 +1,4 @@
-import {URL_WEBSITE_REGEX} from 'expensify-common/lib/Url';
-
+import 'react-native-url-polyfill/auto';
/**
* Add / to the end of any URL if not present
* @param {String} url
@@ -13,76 +12,44 @@ function addTrailingForwardSlash(url) {
}
/**
- * Parse href to URL object
- * @param {String} href
- * @returns {Object}
+ * Get path from URL string
+ * @param {String} url
+ * @returns {String}
*/
-function getURLObject(href) {
- const urlRegex = new RegExp(URL_WEBSITE_REGEX, 'gi');
- let match;
+function getPathFromURL(url) {
try {
- if (!href.startsWith('mailto:')) {
- match = urlRegex.exec(href);
- }
- } catch (e) {
- // eslint-disable-next-line no-console
- console.warn('Error parsing url in Url.getURLObject', {error: e});
- }
- if (!match) {
- return {
- href: undefined,
- protocol: undefined,
- hostname: undefined,
- path: undefined,
- };
- }
- const baseUrl = match[0];
- const protocol = match[1];
- return {
- href,
- protocol,
- hostname: baseUrl.replace(protocol, ''),
- path: href.startsWith(baseUrl) ? href.replace(baseUrl, '') : '',
- };
-}
-
-/**
- * Determine if we should remove w3 from hostname
- * E.g www.expensify.com should be the same as expensify.com
- * @param {String} hostname
- * @returns {Boolean}
- */
-function shouldRemoveW3FromExpensifyUrl(hostname) {
- // Since expensify.com.dev is accessible with and without www subdomain
- if (hostname === 'www.expensify.com.dev') {
- return true;
+ const parsedUrl = new URL(url);
+ const path = parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
+ return path.substring(1); // Remove the leading '/'
+ } catch (error) {
+ console.error('Error parsing URL:', error);
+ return ''; // Return empty string for invalid URLs
}
- const parts = hostname.split('.').reverse();
- const subDomain = parts[2];
- return subDomain === 'www';
}
/**
* Determine if two urls have the same origin
- * Just care about expensify url to avoid the second-level domain (www.example.co.uk)
* @param {String} url1
* @param {String} url2
* @returns {Boolean}
*/
function hasSameExpensifyOrigin(url1, url2) {
- const host1 = getURLObject(url1).hostname;
- const host2 = getURLObject(url2).hostname;
- if (!host1 || !host2) {
+ const removeW3 = (host) => host.replace(/^www\./i, '');
+ try {
+ const parsedUrl1 = new URL(url1);
+ const parsedUrl2 = new URL(url2);
+
+ return removeW3(parsedUrl1.host) === removeW3(parsedUrl2.host);
+ } catch (error) {
+ // Handle invalid URLs or other parsing errors
+ console.error('Error parsing URLs:', error);
return false;
}
- const host1WithoutW3 = shouldRemoveW3FromExpensifyUrl(host1) ? host1.replace('www.', '') : host1;
- const host2WithoutW3 = shouldRemoveW3FromExpensifyUrl(host2) ? host2.replace('www.', '') : host2;
- return host1WithoutW3 === host2WithoutW3;
}
export {
// eslint-disable-next-line import/prefer-default-export
addTrailingForwardSlash,
hasSameExpensifyOrigin,
- getURLObject,
+ getPathFromURL,
};
diff --git a/src/libs/UserUtils.js b/src/libs/UserUtils.js
index dffb5580f0d3..918c2c9bbdc6 100644
--- a/src/libs/UserUtils.js
+++ b/src/libs/UserUtils.js
@@ -88,8 +88,8 @@ function getDefaultAvatar(accountID = -1) {
}
// There are 24 possible default avatars, so we choose which one this user has based
- // on a simple hash of their login. Note that Avatar count starts at 1.
- const accountIDHashBucket = hashText(accountID.toString(), CONST.DEFAULT_AVATAR_COUNT) + 1;
+ // on a simple modulo operation of their login number. Note that Avatar count starts at 1.
+ const accountIDHashBucket = (accountID % CONST.DEFAULT_AVATAR_COUNT) + 1;
return defaultAvatars[`Avatar${accountIDHashBucket}`];
}
diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js
index a15fd74a8815..81b91e2101be 100644
--- a/src/libs/ValidationUtils.js
+++ b/src/libs/ValidationUtils.js
@@ -95,6 +95,24 @@ function isRequiredFulfilled(value) {
return Boolean(value);
}
+/**
+ * Used to add requiredField error to the fields passed.
+ *
+ * @param {Object} values
+ * @param {Array} requiredFields
+ * @returns {Object}
+ */
+function getFieldRequiredErrors(values, requiredFields) {
+ const errors = {};
+ _.each(requiredFields, (fieldKey) => {
+ if (isRequiredFulfilled(values[fieldKey])) {
+ return;
+ }
+ errors[fieldKey] = 'common.error.fieldRequired';
+ });
+ return errors;
+}
+
/**
* Validates that this is a valid expiration date. Supports the following formats:
* 1. MM/YY
@@ -451,6 +469,7 @@ export {
isValidIndustryCode,
isValidZipCode,
isRequiredFulfilled,
+ getFieldRequiredErrors,
isValidUSPhone,
isValidWebsite,
validateIdentity,
diff --git a/src/libs/__mocks__/Permissions.js b/src/libs/__mocks__/Permissions.js
index 5486d184d51b..fffaea5793d4 100644
--- a/src/libs/__mocks__/Permissions.js
+++ b/src/libs/__mocks__/Permissions.js
@@ -12,6 +12,6 @@ export default {
...jest.requireActual('../Permissions'),
canUseDefaultRooms: (betas) => _.contains(betas, CONST.BETAS.DEFAULT_ROOMS),
canUsePolicyRooms: (betas) => _.contains(betas, CONST.BETAS.POLICY_ROOMS),
- canUsePolicyExpenseChat: (betas) => _.contains(betas, CONST.BETAS.POLICY_EXPENSE_CHAT),
canUseIOUSend: (betas) => _.contains(betas, CONST.BETAS.IOU_SEND),
+ canUseCustomStatus: (betas) => _.contains(betas, CONST.BETAS.CUSTOM_STATUS),
};
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index ca03380368c2..46d21d50cc3e 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -18,6 +18,7 @@ import * as Session from './Session';
import * as ReportActionsUtils from '../ReportActionsUtils';
import Timing from './Timing';
import * as Browser from '../Browser';
+import * as SequentialQueue from '../Network/SequentialQueue';
let currentUserAccountID;
let currentUserEmail;
@@ -53,13 +54,13 @@ function confirmReadyToOpenApp() {
/**
* @param {Array} policies
- * @return {Object} map of policy id to lastUpdated
+ * @return {Array} array of policy ids
*/
-function getNonOptimisticPolicyIDToLastModifiedMap(policies) {
+function getNonOptimisticPolicyIDs(policies) {
return _.chain(policies)
- .reject((policy) => lodashGet(policy, 'pendingAction', '') === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)
- .map((policy) => [policy.id, policy.lastModified || 0])
- .object()
+ .reject((policy) => lodashGet(policy, 'pendingAction', null) === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)
+ .pluck('id')
+ .compact()
.value();
}
@@ -133,7 +134,7 @@ function getPolicyParamsForOpenOrReconnect() {
waitForCollectionCallback: true,
callback: (policies) => {
Onyx.disconnect(connectionID);
- resolve({policyIDToLastModified: JSON.stringify(getNonOptimisticPolicyIDToLastModifiedMap(policies))});
+ resolve({policyIDList: getNonOptimisticPolicyIDs(policies)});
},
});
});
@@ -182,10 +183,9 @@ function openApp() {
/**
* Fetches data when the app reconnects to the network
* @param {Number} [updateIDFrom] the ID of the Onyx update that we want to start fetching from
- * @param {Number} [updateIDTo] the ID of the Onyx update that we want to fetch up to
*/
-function reconnectApp(updateIDFrom = 0, updateIDTo = 0) {
- console.debug(`[OnyxUpdates] App reconnecting with updateIDFrom: ${updateIDFrom} and updateIDTo: ${updateIDTo}`);
+function reconnectApp(updateIDFrom = 0) {
+ console.debug(`[OnyxUpdates] App reconnecting with updateIDFrom: ${updateIDFrom}`);
getPolicyParamsForOpenOrReconnect().then((policyParams) => {
const params = {...policyParams};
@@ -204,14 +204,75 @@ function reconnectApp(updateIDFrom = 0, updateIDTo = 0) {
params.updateIDFrom = updateIDFrom;
}
- if (updateIDTo) {
- params.updateIDTo = updateIDTo;
- }
-
API.write('ReconnectApp', params, getOnyxDataForOpenOrReconnect());
});
}
+/**
+ * Fetches data when the client has discovered it missed some Onyx updates from the server
+ * @param {Number} [updateIDFrom] the ID of the Onyx update that we want to start fetching from
+ * @param {Number} [updateIDTo] the ID of the Onyx update that we want to fetch up to
+ * @return {Promise}
+ */
+function getMissingOnyxUpdates(updateIDFrom = 0, updateIDTo = 0) {
+ console.debug(`[OnyxUpdates] Fetching missing updates updateIDFrom: ${updateIDFrom} and updateIDTo: ${updateIDTo}`);
+
+ // It is SUPER BAD FORM to return promises from action methods.
+ // DO NOT FOLLOW THIS PATTERN!!!!!
+ // It was absolutely necessary in order to block OnyxUpdates while fetching the missing updates from the server or else the udpates aren't applied in the proper order.
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ return API.makeRequestWithSideEffects(
+ 'GetMissingOnyxMessages',
+ {
+ updateIDFrom,
+ updateIDTo,
+ },
+ getOnyxDataForOpenOrReconnect(),
+ );
+}
+
+// The next 40ish lines of code are used for detecting when there is a gap of OnyxUpdates between what was last applied to the client and the updates the server has.
+// When a gap is detected, the missing updates are fetched from the API.
+
+// These key needs to be separate from ONYXKEYS.ONYX_UPDATES_FROM_SERVER so that it can be updated without triggering the callback when the server IDs are updated
+let lastUpdateIDAppliedToClient = 0;
+Onyx.connect({
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
+ callback: (val) => (lastUpdateIDAppliedToClient = val),
+});
+
+Onyx.connect({
+ key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER,
+ callback: (val) => {
+ if (!val) {
+ return;
+ }
+
+ const {lastUpdateIDFromServer, previousUpdateIDFromServer} = val;
+ console.debug('[OnyxUpdates] Received lastUpdateID from server', lastUpdateIDFromServer);
+ console.debug('[OnyxUpdates] Received previousUpdateID from server', previousUpdateIDFromServer);
+ console.debug('[OnyxUpdates] Last update ID applied to the client', lastUpdateIDAppliedToClient);
+
+ // If the previous update from the server does not match the last update the client got, then the client is missing some updates.
+ // getMissingOnyxUpdates will fetch updates starting from the last update this client got and going to the last update the server sent.
+ if (lastUpdateIDAppliedToClient && previousUpdateIDFromServer && lastUpdateIDAppliedToClient < previousUpdateIDFromServer) {
+ console.debug('[OnyxUpdates] Gap detected in update IDs so fetching incremental updates');
+ Log.info('Gap detected in update IDs from server so fetching incremental updates', true, {
+ lastUpdateIDFromServer,
+ previousUpdateIDFromServer,
+ lastUpdateIDAppliedToClient,
+ });
+ SequentialQueue.pause();
+ getMissingOnyxUpdates(lastUpdateIDAppliedToClient, lastUpdateIDFromServer).finally(SequentialQueue.unpause);
+ }
+
+ if (lastUpdateIDFromServer > lastUpdateIDAppliedToClient) {
+ // Update this value so that it matches what was just received from the server
+ Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, lastUpdateIDFromServer || 0);
+ }
+ },
+});
+
/**
* This promise is used so that deeplink component know when a transition is end.
* This is necessary because we want to begin deeplink redirection after the transition is end.
@@ -316,6 +377,21 @@ function setUpPoliciesAndNavigate(session, shouldNavigateToAdminChat) {
}
}
+function redirectThirdPartyDesktopSignIn() {
+ const currentUrl = getCurrentUrl();
+ if (!currentUrl) {
+ return;
+ }
+ const url = new URL(currentUrl);
+
+ if (url.pathname === `/${ROUTES.GOOGLE_SIGN_IN}` || url.pathname === `/${ROUTES.APPLE_SIGN_IN}`) {
+ Navigation.isNavigationReady().then(() => {
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.DESKTOP_SIGN_IN_REDIRECT);
+ });
+ }
+}
+
function openProfile(personalDetails) {
const oldTimezoneData = personalDetails.timezone || {};
let newTimezoneData = oldTimezoneData;
@@ -359,13 +435,18 @@ function openProfile(personalDetails) {
);
}
-function beginDeepLinkRedirect() {
+/**
+ * @param {boolean} shouldAuthenticateWithCurrentAccount Optional, indicates whether default authentication method (shortLivedAuthToken) should be used
+ */
+function beginDeepLinkRedirect(shouldAuthenticateWithCurrentAccount = true) {
// There's no support for anonymous users on desktop
if (Session.isAnonymousUser()) {
return;
}
- if (!currentUserAccountID) {
+ // If the route that is being handled is a magic link, email and shortLivedAuthToken should not be attached to the url
+ // to prevent signing into the wrong account
+ if (!currentUserAccountID || !shouldAuthenticateWithCurrentAccount) {
Browser.openRouteInDesktopApp();
return;
}
@@ -376,8 +457,11 @@ function beginDeepLinkRedirect() {
});
}
-function beginDeepLinkRedirectAfterTransition() {
- waitForSignOnTransitionToFinish().then(beginDeepLinkRedirect);
+/**
+ * @param {boolean} shouldAuthenticateWithCurrentAccount Optional, indicates whether default authentication method (shortLivedAuthToken) should be used
+ */
+function beginDeepLinkRedirectAfterTransition(shouldAuthenticateWithCurrentAccount = true) {
+ waitForSignOnTransitionToFinish().then(() => beginDeepLinkRedirect(shouldAuthenticateWithCurrentAccount));
}
export {
@@ -386,6 +470,7 @@ export {
setSidebarLoaded,
setUpPoliciesAndNavigate,
openProfile,
+ redirectThirdPartyDesktopSignIn,
openApp,
reconnectApp,
confirmReadyToOpenApp,
diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js
index 43e00c104f48..dad3afea82c5 100644
--- a/src/libs/actions/BankAccounts.js
+++ b/src/libs/actions/BankAccounts.js
@@ -88,7 +88,7 @@ function getVBBADataForOnyx() {
key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
value: {
isLoading: false,
- errors: ErrorUtils.getMicroSecondOnyxError('paymentsPage.addBankAccountFailure'),
+ errors: ErrorUtils.getMicroSecondOnyxError('walletPage.addBankAccountFailure'),
},
},
],
@@ -165,7 +165,7 @@ function addPersonalBankAccount(account) {
key: ONYXKEYS.PERSONAL_BANK_ACCOUNT,
value: {
isLoading: false,
- errors: ErrorUtils.getMicroSecondOnyxError('paymentsPage.addBankAccountFailure'),
+ errors: ErrorUtils.getMicroSecondOnyxError('walletPage.addBankAccountFailure'),
},
},
],
diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js
new file mode 100644
index 000000000000..aa2b43824f91
--- /dev/null
+++ b/src/libs/actions/DemoActions.js
@@ -0,0 +1,92 @@
+import Onyx from 'react-native-onyx';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import CONST from '../../CONST';
+import * as API from '../API';
+import * as ReportUtils from '../ReportUtils';
+import Navigation from '../Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as Localize from '../Localize';
+
+/**
+ * @param {String} workspaceOwnerEmail email of the workspace owner
+ * @param {String} apiCommand
+ */
+function createDemoWorkspaceAndNavigate(workspaceOwnerEmail, apiCommand) {
+ // Try to navigate to existing demo workspace expense chat if it exists in Onyx
+ const demoWorkspaceChatReportID = ReportUtils.getPolicyExpenseChatReportIDByOwner(workspaceOwnerEmail);
+ if (demoWorkspaceChatReportID) {
+ // We must call goBack() to remove the demo route from nav history
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.getReportRoute(demoWorkspaceChatReportID));
+ return;
+ }
+
+ // We use makeRequestWithSideEffects here because we need to get the workspace chat report ID to navigate to it after it's created
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects(apiCommand).then((response) => {
+ // Get report updates from Onyx response data
+ const reportUpdate = _.find(response.onyxData, ({key}) => key === ONYXKEYS.COLLECTION.REPORT);
+ if (!reportUpdate) {
+ return;
+ }
+
+ // Get the policy expense chat update
+ const policyExpenseChatReport = _.find(reportUpdate.value, ({chatType}) => chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT);
+ if (!policyExpenseChatReport) {
+ return;
+ }
+
+ // Navigate to the new policy expense chat report
+ // Note: We must call goBack() to remove the demo route from history
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.getReportRoute(policyExpenseChatReport.reportID));
+ });
+}
+
+function runSbeDemo() {
+ createDemoWorkspaceAndNavigate(CONST.EMAIL.SBE, 'CreateSbeDemoWorkspace');
+}
+
+function runSaastrDemo() {
+ createDemoWorkspaceAndNavigate(CONST.EMAIL.SAASTR, 'CreateSaastrDemoWorkspace');
+}
+
+/**
+ * Runs code for specific demos, based on the provided URL
+ *
+ * @param {String} url - URL user is navigating to via deep link (or regular link in web)
+ */
+function runDemoByURL(url = '') {
+ const cleanUrl = (url || '').toLowerCase();
+
+ if (cleanUrl.endsWith(ROUTES.SAASTR)) {
+ Onyx.set(ONYXKEYS.DEMO_INFO, {
+ saastr: {
+ isBeginningDemo: true,
+ },
+ });
+ } else if (cleanUrl.endsWith(ROUTES.SBE)) {
+ Onyx.set(ONYXKEYS.DEMO_INFO, {
+ sbe: {
+ isBeginningDemo: true,
+ },
+ });
+ } else {
+ // No demo is being run, so clear out demo info
+ Onyx.set(ONYXKEYS.DEMO_INFO, null);
+ }
+}
+
+function getHeadlineKeyByDemoInfo(demoInfo = {}) {
+ if (lodashGet(demoInfo, 'saastr.isBeginningDemo')) {
+ return Localize.translateLocal('demos.saastr.signInWelcome');
+ }
+ if (lodashGet(demoInfo, 'sbe.isBeginningDemo')) {
+ return Localize.translateLocal('demos.sbe.signInWelcome');
+ }
+ return '';
+}
+
+export {runSaastrDemo, runSbeDemo, runDemoByURL, getHeadlineKeyByDemoInfo};
diff --git a/src/libs/actions/DownloadAppModal.js b/src/libs/actions/DownloadAppModal.js
new file mode 100644
index 000000000000..5dc2d3fdca22
--- /dev/null
+++ b/src/libs/actions/DownloadAppModal.js
@@ -0,0 +1,11 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+
+/**
+ * @param {Boolean} shouldShowBanner
+ */
+function setShowDownloadAppModal(shouldShowBanner) {
+ Onyx.set(ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER, shouldShowBanner);
+}
+
+export default setShowDownloadAppModal;
diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js
index de462ac283f3..84621af3a5b4 100644
--- a/src/libs/actions/EmojiPickerAction.js
+++ b/src/libs/actions/EmojiPickerAction.js
@@ -3,21 +3,21 @@ import React from 'react';
const emojiPickerRef = React.createRef();
/**
- * Show the ReportActionContextMenu modal popover.
+ * Show the EmojiPicker modal popover.
*
* @param {Function} [onModalHide=() => {}] - Run a callback when Modal hides.
* @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected.
* @param {Element} emojiPopoverAnchor - Element on which EmojiPicker is anchored
* @param {Object} [anchorOrigin] - Anchor origin for Popover
* @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show
- * @param {Object} reportAction - ReportAction for EmojiPicker
+ * @param {String} id - Unique id for EmojiPicker
*/
-function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, reportAction = {}) {
+function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, id) {
if (!emojiPickerRef.current) {
return;
}
- emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, reportAction);
+ emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id);
}
/**
@@ -33,16 +33,16 @@ function hideEmojiPicker(isNavigating) {
}
/**
- * Whether Emoji Picker is active for the Report Action.
+ * Whether Emoji Picker is active for the given id.
*
- * @param {Number|String} actionID
+ * @param {String} id
* @return {Boolean}
*/
-function isActiveReportAction(actionID) {
+function isActive(id) {
if (!emojiPickerRef.current) {
return;
}
- return emojiPickerRef.current.isActiveReportAction(actionID);
+ return emojiPickerRef.current.isActive(id);
}
function isEmojiPickerVisible() {
@@ -59,4 +59,4 @@ function resetEmojiPopoverAnchor() {
return emojiPickerRef.current.resetEmojiPopoverAnchor();
}
-export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActiveReportAction, isEmojiPickerVisible, resetEmojiPopoverAnchor};
+export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, isEmojiPickerVisible, resetEmojiPopoverAnchor};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index bbb313929f39..0479b0c17b0e 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -16,7 +16,7 @@ import * as ReportActionsUtils from '../ReportActionsUtils';
import * as IOUUtils from '../IOUUtils';
import * as OptionsListUtils from '../OptionsListUtils';
import DateUtils from '../DateUtils';
-import TransactionUtils from '../TransactionUtils';
+import * as TransactionUtils from '../TransactionUtils';
import * as ErrorUtils from '../ErrorUtils';
import * as UserUtils from '../UserUtils';
import * as Report from './Report';
@@ -72,15 +72,15 @@ Onyx.connect({
* @param {String} id
*/
function resetMoneyRequestInfo(id = '') {
- const date = currentDate || moment().format('YYYY-MM-DD');
+ const created = currentDate || moment().format('YYYY-MM-DD');
Onyx.merge(ONYXKEYS.IOU, {
id,
amount: 0,
currency: lodashGet(currentUserPersonalDetails, 'localCurrencyCode', CONST.CURRENCY.USD),
comment: '',
participants: [],
- merchant: '',
- date,
+ merchant: CONST.TRANSACTION.DEFAULT_MERCHANT,
+ created,
receiptPath: '',
receiptSource: '',
});
@@ -302,19 +302,35 @@ function buildOnyxDataForMoneyRequest(
}
/**
- * Request money from another user
+ * Gathers all the data needed to make a money request. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then
+ * it creates optimistic versions of them and uses those instead
*
* @param {Object} report
- * @param {Number} amount - always in the smallest unit of the currency
- * @param {String} currency
- * @param {String} payeeEmail
- * @param {Number} payeeAccountID
* @param {Object} participant
* @param {String} comment
+ * @param {Number} amount
+ * @param {String} currency
+ * @param {String} created
+ * @param {String} merchant
+ * @param {Number} payeeAccountID
+ * @param {String} payeeEmail
* @param {Object} [receipt]
- *
+ * @returns {Object} data
+ * @returns {String} data.payerEmail
+ * @returns {Object} data.iouReport
+ * @returns {Object} data.chatReport
+ * @returns {Object} data.transaction
+ * @returns {Object} data.iouAction
+ * @returns {Object} data.createdChatReportActionID
+ * @returns {Object} data.createdIOUReportActionID
+ * @returns {Object} data.reportPreviewAction
+ * @returns {Object} data.onyxData
+ * @returns {Object} data.onyxData.optimisticData
+ * @returns {Object} data.onyxData.successData
+ * @returns {Object} data.onyxData.failureData
+ * @param {String} [existingTransactionID]
*/
-function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, participant, comment, receipt = undefined) {
+function getMoneyRequestInformation(report, participant, comment, amount, currency, created, merchant, payeeAccountID, payeeEmail, receipt = undefined, existingTransactionID = null) {
const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login);
const payerAccountID = Number(participant.accountID);
const isPolicyExpenseChat = participant.isPolicyExpenseChat;
@@ -360,11 +376,38 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
// STEP 3: Build optimistic receipt and transaction
const receiptObject = {};
+ let filename;
if (receipt && receipt.source) {
receiptObject.source = receipt.source;
receiptObject.state = CONST.IOU.RECEIPT_STATE.SCANREADY;
+ filename = receipt.name;
+ }
+ let optimisticTransaction = TransactionUtils.buildOptimisticTransaction(
+ ReportUtils.isExpenseReport(iouReport) ? -amount : amount,
+ currency,
+ iouReport.reportID,
+ comment,
+ created,
+ '',
+ '',
+ merchant,
+ receiptObject,
+ filename,
+ existingTransactionID,
+ );
+
+ // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction
+ // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction
+ // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109.
+ // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417
+ // to remind me to do this.
+ const existingTransaction = existingTransactionID && TransactionUtils.getTransaction(existingTransactionID);
+ if (existingTransaction) {
+ optimisticTransaction = {
+ ...optimisticTransaction,
+ ...existingTransaction,
+ };
}
- const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(amount, currency, iouReport.reportID, comment, '', '', undefined, receiptObject);
// STEP 4: Build optimistic reportActions. We need:
// 1. CREATED action for the chatReport
@@ -374,7 +417,7 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
// Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat
const optimisticCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
const optimisticCreatedActionForIOU = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail);
- const optimisticIOUAction = ReportUtils.buildOptimisticIOUReportAction(
+ const iouAction = ReportUtils.buildOptimisticIOUReportAction(
CONST.IOU.REPORT_ACTION_TYPE.CREATE,
amount,
currency,
@@ -383,9 +426,18 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
optimisticTransaction.transactionID,
'',
iouReport.reportID,
+ false,
+ false,
receiptObject,
);
+ let reportPreviewAction = isNewIOUReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID);
+ if (reportPreviewAction) {
+ reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, comment, optimisticTransaction);
+ } else {
+ reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction);
+ }
+
// Add optimistic personal details for participant
const optimisticPersonalDetailListAction = isNewChatReport
? {
@@ -398,13 +450,6 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
}
: undefined;
- let reportPreviewAction = isNewIOUReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID);
- if (reportPreviewAction) {
- reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction, comment);
- } else {
- reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, iouReport, comment);
- }
-
// STEP 5: Build Onyx Data
const [optimisticData, successData, failureData] = buildOnyxDataForMoneyRequest(
chatReport,
@@ -412,14 +457,107 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
optimisticTransaction,
optimisticCreatedActionForChat,
optimisticCreatedActionForIOU,
- optimisticIOUAction,
+ iouAction,
optimisticPersonalDetailListAction,
reportPreviewAction,
isNewChatReport,
isNewIOUReport,
);
- // STEP 6: Make the request
+ return {
+ payerEmail,
+ iouReport,
+ chatReport,
+ transaction: optimisticTransaction,
+ iouAction,
+ createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : 0,
+ createdIOUReportActionID: isNewIOUReport ? optimisticCreatedActionForIOU.reportActionID : 0,
+ reportPreviewAction,
+ onyxData: {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ };
+}
+
+/**
+ * Requests money based on a distance (eg. mileage from a map)
+ *
+ * @param {Object} report
+ * @param {String} payeeEmail
+ * @param {Number} payeeAccountID
+ * @param {Object} participant
+ * @param {String} comment
+ * @param {Object[]} waypoints
+ * @param {String} waypoints[].address required and must be non empty
+ * @param {String} [waypoints[].lat] optional
+ * @param {String} [waypoints[].lng] optional
+ * @param {String} created
+ * @param {String} [transactionID]
+ */
+function createDistanceRequest(report, payeeEmail, payeeAccountID, participant, comment, waypoints, created, transactionID) {
+ const {payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation(
+ report,
+ participant,
+ comment,
+ 0,
+ 'USD',
+ created,
+ '',
+ payeeAccountID,
+ payeeEmail,
+ null,
+ transactionID,
+ );
+
+ API.write(
+ 'CreateDistanceRequest',
+ {
+ debtorEmail: payerEmail,
+ comment,
+ iouReportID: iouReport.reportID,
+ chatReportID: chatReport.reportID,
+ transactionID: transaction.transactionID,
+ reportActionID: iouAction.reportActionID,
+ createdChatReportActionID,
+ createdIOUReportActionID,
+ reportPreviewReportActionID: reportPreviewAction.reportActionID,
+ waypoints,
+ created,
+ },
+ onyxData,
+ );
+}
+
+/**
+ * Request money from another user
+ *
+ * @param {Object} report
+ * @param {Number} amount - always in the smallest unit of the currency
+ * @param {String} currency
+ * @param {String} created
+ * @param {String} merchant
+ * @param {String} payeeEmail
+ * @param {Number} payeeAccountID
+ * @param {Object} participant
+ * @param {String} comment
+ * @param {Object} [receipt]
+ */
+function requestMoney(report, amount, currency, created, merchant, payeeEmail, payeeAccountID, participant, comment, receipt = undefined) {
+ const {payerEmail, iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation(
+ report,
+ participant,
+ comment,
+ amount,
+ currency,
+ created,
+ merchant,
+ payeeAccountID,
+ payeeEmail,
+ receipt,
+ );
+
API.write(
'RequestMoney',
{
@@ -427,16 +565,18 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
amount,
currency,
comment,
+ created,
+ merchant,
iouReportID: iouReport.reportID,
chatReportID: chatReport.reportID,
- transactionID: optimisticTransaction.transactionID,
- reportActionID: optimisticIOUAction.reportActionID,
- createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : 0,
- createdIOUReportActionID: isNewIOUReport ? optimisticCreatedActionForIOU.reportActionID : 0,
+ transactionID: transaction.transactionID,
+ reportActionID: iouAction.reportActionID,
+ createdChatReportActionID,
+ createdIOUReportActionID,
reportPreviewReportActionID: reportPreviewAction.reportActionID,
receipt,
},
- {optimisticData, successData, failureData},
+ onyxData,
);
resetMoneyRequestInfo();
Navigation.dismissModal(chatReport.reportID);
@@ -460,41 +600,59 @@ function requestMoney(report, amount, currency, payeeEmail, payeeAccountID, part
* @param {Number} amount - always in the smallest unit of the currency
* @param {String} comment
* @param {String} currency
- * @param {String} existingGroupChatReportID
+ * @param {String} existingSplitChatReportID - the report ID where the split bill happens, could be a group chat or a workspace chat
*
* @return {Object}
*/
-function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingGroupChatReportID = '') {
+function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingSplitChatReportID = '') {
const currentUserEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(currentUserLogin);
const participantAccountIDs = _.map(participants, (participant) => Number(participant.accountID));
- const existingGroupChatReport = existingGroupChatReportID
- ? allReports[`${ONYXKEYS.COLLECTION.REPORT}${existingGroupChatReportID}`]
+ const existingSplitChatReport = existingSplitChatReportID
+ ? allReports[`${ONYXKEYS.COLLECTION.REPORT}${existingSplitChatReportID}`]
: ReportUtils.getChatByParticipants(participantAccountIDs);
- const groupChatReport = existingGroupChatReport || ReportUtils.buildOptimisticChatReport(participantAccountIDs);
+ const splitChatReport = existingSplitChatReport || ReportUtils.buildOptimisticChatReport(participantAccountIDs);
+ const isOwnPolicyExpenseChat = splitChatReport.isOwnPolicyExpenseChat;
// ReportID is -2 (aka "deleted") on the group transaction: https://github.com/Expensify/Auth/blob/3fa2698654cd4fbc30f9de38acfca3fbeb7842e4/auth/command/SplitTransaction.cpp#L24-L27
- const formattedParticipants = Localize.arrayToString([currentUserLogin, ..._.map(participants, (participant) => participant.login)]);
- const groupTransaction = TransactionUtils.buildOptimisticTransaction(
+ const formattedParticipants = isOwnPolicyExpenseChat
+ ? [currentUserLogin, ReportUtils.getReportName(splitChatReport)]
+ : Localize.arrayToString([currentUserLogin, ..._.map(participants, (participant) => participant.login || '')]);
+
+ const splitTransaction = TransactionUtils.buildOptimisticTransaction(
amount,
currency,
CONST.REPORT.SPLIT_REPORTID,
comment,
'',
'',
+ '',
`${Localize.translateLocal('iou.splitBill')} ${Localize.translateLocal('common.with')} ${formattedParticipants} [${DateUtils.getDBTime().slice(0, 10)}]`,
);
// Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat
- const groupCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail);
- const groupIOUReportAction = ReportUtils.buildOptimisticIOUReportAction(CONST.IOU.REPORT_ACTION_TYPE.SPLIT, amount, currency, comment, participants, groupTransaction.transactionID);
+ const splitCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail);
+ const splitIOUReportAction = ReportUtils.buildOptimisticIOUReportAction(
+ CONST.IOU.REPORT_ACTION_TYPE.SPLIT,
+ amount,
+ currency,
+ comment,
+ participants,
+ splitTransaction.transactionID,
+ '',
+ '',
+ false,
+ false,
+ {},
+ isOwnPolicyExpenseChat,
+ );
- groupChatReport.lastReadTime = DateUtils.getDBTime();
- groupChatReport.lastMessageText = groupIOUReportAction.message[0].text;
- groupChatReport.lastMessageHtml = groupIOUReportAction.message[0].html;
+ splitChatReport.lastReadTime = DateUtils.getDBTime();
+ splitChatReport.lastMessageText = splitIOUReportAction.message[0].text;
+ splitChatReport.lastMessageHtml = splitIOUReportAction.message[0].html;
// If we have an existing groupChatReport use it's pending fields, otherwise indicate that we are adding a chat
- if (!existingGroupChatReport) {
- groupChatReport.pendingFields = {
+ if (!existingSplitChatReport) {
+ splitChatReport.pendingFields = {
createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
};
}
@@ -503,45 +661,45 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
{
// Use set for new reports because it doesn't exist yet, is faster,
// and we need the data to be available when we navigate to the chat page
- onyxMethod: existingGroupChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET,
- key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`,
- value: groupChatReport,
+ onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`,
+ value: splitChatReport,
},
{
- onyxMethod: existingGroupChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`,
+ onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`,
value: {
- ...(existingGroupChatReport ? {} : {[groupCreatedReportAction.reportActionID]: groupCreatedReportAction}),
- [groupIOUReportAction.reportActionID]: groupIOUReportAction,
+ ...(existingSplitChatReport ? {} : {[splitCreatedReportAction.reportActionID]: splitCreatedReportAction}),
+ [splitIOUReportAction.reportActionID]: splitIOUReportAction,
},
},
{
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`,
- value: groupTransaction,
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
+ value: splitTransaction,
},
];
const successData = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`,
value: {
- ...(existingGroupChatReport ? {} : {[groupCreatedReportAction.reportActionID]: {pendingAction: null}}),
- [groupIOUReportAction.reportActionID]: {pendingAction: null},
+ ...(existingSplitChatReport ? {} : {[splitCreatedReportAction.reportActionID]: {pendingAction: null}}),
+ [splitIOUReportAction.reportActionID]: {pendingAction: null},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
value: {pendingAction: null},
},
];
- if (!existingGroupChatReport) {
+ if (!existingSplitChatReport) {
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`,
value: {pendingFields: {createChat: null}},
});
}
@@ -549,19 +707,19 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
const failureData = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`,
value: {
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
},
},
];
- if (existingGroupChatReport) {
+ if (existingSplitChatReport) {
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`,
value: {
- [groupIOUReportAction.reportActionID]: {
+ [splitIOUReportAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
},
},
@@ -570,7 +728,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
failureData.push(
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`,
value: {
errorFields: {
createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
@@ -579,9 +737,9 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`,
value: {
- [groupIOUReportAction.reportActionID]: {
+ [splitIOUReportAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxError(null),
},
},
@@ -590,13 +748,13 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
}
// Loop through participants creating individual chats, iouReports and reportActionIDs as needed
- const splitAmount = IOUUtils.calculateAmount(participants.length, amount, false);
- const splits = [{email: currentUserEmail, accountID: currentUserAccountID, amount: IOUUtils.calculateAmount(participants.length, amount, true)}];
+ const splitAmount = IOUUtils.calculateAmount(participants.length, amount, currency, false);
+ const splits = [{email: currentUserEmail, accountID: currentUserAccountID, amount: IOUUtils.calculateAmount(participants.length, amount, currency, true)}];
const hasMultipleParticipants = participants.length > 1;
_.each(participants, (participant) => {
- const email = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase();
- const accountID = Number(participant.accountID);
+ const email = isOwnPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase();
+ const accountID = isOwnPolicyExpenseChat ? 0 : Number(participant.accountID);
if (email === currentUserEmail) {
return;
}
@@ -609,9 +767,10 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
// If this is a split between two people only and the function
// wasn't provided with an existing group chat report id
- if (!hasMultipleParticipants && !existingGroupChatReportID) {
- oneOnOneChatReport = groupChatReport;
- shouldCreateOptimisticPersonalDetails = !existingGroupChatReport;
+ // or, if this is workspace chat, then the oneOnOneChatReport is the same as the splitChatReport
+ if ((!hasMultipleParticipants && !existingSplitChatReportID) || isOwnPolicyExpenseChat) {
+ oneOnOneChatReport = splitChatReport;
+ shouldCreateOptimisticPersonalDetails = !existingSplitChatReport;
} else {
const existingChatReport = ReportUtils.getChatByParticipants([accountID]);
isNewOneOnOneChatReport = !existingChatReport;
@@ -623,19 +782,26 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
const isNewOneOnOneIOUReport = !oneOnOneChatReport.iouReportID;
let oneOnOneIOUReport;
if (!isNewOneOnOneIOUReport) {
- oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(allReports[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`], currentUserAccountID, splitAmount, currency);
+ oneOnOneIOUReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`];
+ if (isOwnPolicyExpenseChat) {
+ // Because of the Expense reports are stored as negative values, we substract the total from the amount
+ oneOnOneIOUReport.total -= splitAmount;
+ } else {
+ oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(oneOnOneIOUReport, currentUserAccountID, splitAmount, currency);
+ }
} else {
oneOnOneIOUReport = ReportUtils.buildOptimisticIOUReport(currentUserAccountID, accountID, splitAmount, oneOnOneChatReport.reportID, currency);
}
// STEP 3: Build optimistic transaction
const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction(
- splitAmount,
+ ReportUtils.isExpenseReport(oneOnOneIOUReport) ? -splitAmount : splitAmount,
currency,
oneOnOneIOUReport.reportID,
comment,
+ '',
CONST.IOU.MONEY_REQUEST_TYPE.SPLIT,
- groupTransaction.transactionID,
+ splitTransaction.transactionID,
);
// STEP 4: Build optimistic reportActions. We need:
@@ -709,18 +875,19 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
failureData.push(...oneOnOneFailureData);
});
- const groupData = {
- chatReportID: groupChatReport.reportID,
- transactionID: groupTransaction.transactionID,
- reportActionID: groupIOUReportAction.reportActionID,
+ const splitData = {
+ chatReportID: splitChatReport.reportID,
+ transactionID: splitTransaction.transactionID,
+ reportActionID: splitIOUReportAction.reportActionID,
+ policyID: splitChatReport.policyID,
};
- if (_.isEmpty(existingGroupChatReport)) {
- groupData.createdReportActionID = groupCreatedReportAction.reportActionID;
+ if (_.isEmpty(existingSplitChatReport)) {
+ splitData.createdReportActionID = splitCreatedReportAction.reportActionID;
}
return {
- groupData,
+ splitData,
splits,
onyxData: {optimisticData, successData, failureData},
};
@@ -736,26 +903,27 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
* @param {String} existingGroupChatReportID
*/
function splitBill(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingGroupChatReportID = '') {
- const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingGroupChatReportID);
+ const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency, existingGroupChatReportID);
API.write(
'SplitBill',
{
- reportID: groupData.chatReportID,
+ reportID: splitData.chatReportID,
amount,
splits: JSON.stringify(splits),
currency,
comment,
- transactionID: groupData.transactionID,
- reportActionID: groupData.reportActionID,
- createdReportActionID: groupData.createdReportActionID,
+ transactionID: splitData.transactionID,
+ reportActionID: splitData.reportActionID,
+ createdReportActionID: splitData.createdReportActionID,
+ policyID: splitData.policyID,
},
onyxData,
);
resetMoneyRequestInfo();
Navigation.dismissModal();
- Report.notifyNewAction(groupData.chatReportID, currentUserAccountID);
+ Report.notifyNewAction(splitData.chatReportID, currentUserAccountID);
}
/**
@@ -767,26 +935,118 @@ function splitBill(participants, currentUserLogin, currentUserAccountID, amount,
* @param {String} currency
*/
function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccountID, amount, comment, currency) {
- const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency);
+ const {splitData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, currentUserAccountID, amount, comment, currency);
API.write(
'SplitBillAndOpenReport',
{
- reportID: groupData.chatReportID,
+ reportID: splitData.chatReportID,
amount,
splits: JSON.stringify(splits),
currency,
comment,
- transactionID: groupData.transactionID,
- reportActionID: groupData.reportActionID,
- createdReportActionID: groupData.createdReportActionID,
+ transactionID: splitData.transactionID,
+ reportActionID: splitData.reportActionID,
+ createdReportActionID: splitData.createdReportActionID,
},
onyxData,
);
resetMoneyRequestInfo();
- Navigation.dismissModal(groupData.chatReportID);
- Report.notifyNewAction(groupData.chatReportID, currentUserAccountID);
+ Navigation.dismissModal(splitData.chatReportID);
+ Report.notifyNewAction(splitData.chatReportID, currentUserAccountID);
+}
+
+/**
+ * @param {String} transactionID
+ * @param {Number} transactionThreadReportID
+ * @param {Object} transactionChanges
+ */
+function editMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) {
+ // STEP 1: Get all collections we're updating
+ const transactionThread = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`];
+ const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ const iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.parentReportID}`];
+ const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport);
+
+ // STEP 2: Build new modified expense report action.
+ const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport);
+ const updatedTransaction = TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport);
+ // STEP 3: Compute the IOU total and update the report preview message so LHN amount owed is correct
+ // STEP 4: Compose the optimistic data
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: updatedReportAction,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: updatedTransaction,
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: {pendingAction: null},
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ pendingFields: {
+ comment: null,
+ amount: null,
+ created: null,
+ currency: null,
+ merchant: null,
+ },
+ },
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: updatedReportAction,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: transaction,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.report}`,
+ value: iouReport,
+ },
+ ];
+
+ // STEP 6: Call the API endpoint
+ const {created, amount, currency, comment, merchant} = ReportUtils.getTransactionDetails(updatedTransaction);
+ API.write(
+ 'EditMoneyRequest',
+ {
+ transactionID,
+ reportActionID: updatedReportAction.reportActionID,
+ created,
+ amount,
+ currency,
+ comment,
+ merchant,
+ },
+ {optimisticData, successData, failureData},
+ );
}
/**
@@ -808,7 +1068,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
// STEP 2: Decide if we need to:
// 1. Delete the transactionThread - delete if there are no visible comments in the thread
- // 2. Update the iouPreview to show [Deleted request] - update if the transactionThread exists AND it isn't being deleted
+ // 2. Update the moneyRequestPreview to show [Deleted request] - update if the transactionThread exists AND it isn't being deleted
const shouldDeleteTransactionThread = transactionThreadID ? ReportActionsUtils.getLastVisibleMessage(transactionThreadID).lastMessageText.length === 0 : false;
const shouldShowDeletedRequestMessage = transactionThreadID && !shouldDeleteTransactionThread;
@@ -846,9 +1106,15 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
updatedIOUReport = {...iouReport};
// Because of the Expense reports are stored as negative values, we add the total from the amount
- updatedIOUReport.total += reportAction.originalMessage.amount;
+ updatedIOUReport.total += TransactionUtils.getAmount(transaction, true);
} else {
- updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, reportAction.actorAccountID, reportAction.originalMessage.amount, reportAction.originalMessage.currency, true);
+ updatedIOUReport = IOUUtils.updateIOUOwnerAndTotal(
+ iouReport,
+ reportAction.actorAccountID,
+ TransactionUtils.getAmount(transaction, false),
+ TransactionUtils.getCurrency(transaction),
+ true,
+ );
}
updatedIOUReport.lastMessageText = iouReportLastMessageText;
@@ -1010,8 +1276,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
* @returns {String}
*/
function buildPayPalPaymentUrl(amount, submitterPayPalMeAddress, currency) {
- const currencyUnit = CurrencyUtils.getCurrencyUnit(currency);
- return `https://paypal.me/${submitterPayPalMeAddress}/${Math.abs(amount) / currencyUnit}${currency}`;
+ return `https://paypal.me/${submitterPayPalMeAddress}/${Math.abs(amount) / 100}${currency}`;
}
/**
@@ -1239,24 +1504,17 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType
* @returns {Object}
*/
function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) {
- const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(iouReport.total, iouReport.currency, iouReport.reportID);
const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction(
CONST.IOU.REPORT_ACTION_TYPE.PAY,
iouReport.total,
iouReport.currency,
'',
[recipient],
- optimisticTransaction.transactionID,
+ '',
paymentMethodType,
iouReport.reportID,
true,
);
- const optimisticPersonalDetailsListAction = {
- accountID: Number(recipient.accountID),
- avatar: UserUtils.getDefaultAvatarURL(Number(recipient.accountID)),
- displayName: recipient.displayName || recipient.login,
- login: recipient.login,
- };
const optimisticReportPreviewAction = ReportUtils.updateReportPreview(iouReport, ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID));
@@ -1302,21 +1560,11 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
},
},
- {
- onyxMethod: Onyx.METHOD.SET,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`,
- value: optimisticTransaction,
- },
{
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD,
value: {[iouReport.policyID]: paymentMethodType},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- value: optimisticPersonalDetailsListAction,
- },
];
const successData = [
@@ -1329,13 +1577,6 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho
},
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`,
- value: {
- pendingAction: null,
- },
- },
];
const failureData = [
@@ -1357,13 +1598,6 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho
},
},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`,
- value: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
- },
- },
];
return {
@@ -1484,6 +1718,13 @@ function setMoneyRequestAmount(amount) {
Onyx.merge(ONYXKEYS.IOU, {amount});
}
+/**
+ * @param {String} created
+ */
+function setMoneyRequestCreated(created) {
+ Onyx.merge(ONYXKEYS.IOU, {created});
+}
+
/**
* @param {String} currency
*/
@@ -1498,6 +1739,13 @@ function setMoneyRequestDescription(comment) {
Onyx.merge(ONYXKEYS.IOU, {comment: comment.trim()});
}
+/**
+ * @param {String} merchant
+ */
+function setMoneyRequestMerchant(merchant) {
+ Onyx.merge(ONYXKEYS.IOU, {merchant: merchant.trim()});
+}
+
/**
* @param {Object[]} participants
*/
@@ -1515,7 +1763,7 @@ function setMoneyRequestReceipt(receiptPath, receiptSource) {
function createEmptyTransaction() {
const transactionID = NumberUtils.rand64();
- Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {});
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {transactionID});
Onyx.merge(ONYXKEYS.IOU, {transactionID});
}
@@ -1530,6 +1778,7 @@ function createEmptyTransaction() {
function navigateToNextPage(iou, iouType, reportID, report) {
const moneyRequestID = `${iouType}${reportID}`;
const shouldReset = iou.id !== moneyRequestID;
+
// If the money request ID in Onyx does not match the ID from params, we want to start a new request
// with the ID from params. We need to clear the participants in case the new request is initiated from FAB.
if (shouldReset) {
@@ -1556,6 +1805,8 @@ function navigateToNextPage(iou, iouType, reportID, report) {
}
export {
+ createDistanceRequest,
+ editMoneyRequest,
deleteMoneyRequest,
splitBill,
splitBillAndOpenReport,
@@ -1568,8 +1819,10 @@ export {
resetMoneyRequestInfo,
setMoneyRequestId,
setMoneyRequestAmount,
+ setMoneyRequestCreated,
setMoneyRequestCurrency,
setMoneyRequestDescription,
+ setMoneyRequestMerchant,
setMoneyRequestParticipants,
setMoneyRequestReceipt,
createEmptyTransaction,
diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js
index b920cb1c7ee6..06705182a626 100644
--- a/src/libs/actions/Link.js
+++ b/src/libs/actions/Link.js
@@ -1,11 +1,7 @@
import Onyx from 'react-native-onyx';
import lodashGet from 'lodash/get';
-import {Linking} from 'react-native';
import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';
-import Growl from '../Growl';
-import * as Localize from '../Localize';
-import CONST from '../../CONST';
import asyncOpenURL from '../asyncOpenURL';
import * as API from '../API';
import * as Environment from '../Environment/Environment';
@@ -23,16 +19,6 @@ Onyx.connect({
callback: (val) => (currentUserEmail = lodashGet(val, 'email', '')),
});
-/**
- * @returns {Boolean}
- */
-function showGrowlIfOffline() {
- if (isNetworkOffline) {
- Growl.show(Localize.translateLocal('session.offlineMessageRetry'), CONST.GROWL.WARNING);
- }
- return isNetworkOffline;
-}
-
/**
* @param {String} [url] the url path
* @param {String} [shortLivedAuthToken]
@@ -56,12 +42,20 @@ function buildOldDotURL(url, shortLivedAuthToken) {
});
}
+/**
+ * @param {String} url
+ * @param {Boolean} shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari.
+ */
+function openExternalLink(url, shouldSkipCustomSafariLogic = false) {
+ asyncOpenURL(Promise.resolve(), url, shouldSkipCustomSafariLogic);
+}
+
/**
* @param {String} url the url path
*/
function openOldDotLink(url) {
if (isNetworkOffline) {
- buildOldDotURL(url).then((oldDotURL) => Linking.openURL(oldDotURL));
+ buildOldDotURL(url).then((oldDotURL) => openExternalLink(oldDotURL));
return;
}
@@ -74,17 +68,4 @@ function openOldDotLink(url) {
(oldDotURL) => oldDotURL,
);
}
-
-/**
- * @param {String} url
- * @param {Boolean} shouldSkipCustomSafariLogic When true, we will use `Linking.openURL` even if the browser is Safari.
- */
-function openExternalLink(url, shouldSkipCustomSafariLogic = false) {
- if (showGrowlIfOffline()) {
- return;
- }
-
- asyncOpenURL(Promise.resolve(), url, shouldSkipCustomSafariLogic);
-}
-
export {buildOldDotURL, openOldDotLink, openExternalLink};
diff --git a/src/libs/actions/MapboxToken.js b/src/libs/actions/MapboxToken.js
index b4ee385db619..e1e643d0eabc 100644
--- a/src/libs/actions/MapboxToken.js
+++ b/src/libs/actions/MapboxToken.js
@@ -6,6 +6,7 @@ import lodashGet from 'lodash/get';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import CONST from '../../CONST';
+import * as ActiveClientManager from '../ActiveClientManager';
let authToken;
Onyx.connect({
@@ -15,9 +16,12 @@ Onyx.connect({
},
});
-let connectionID;
+let connectionIDForToken;
+let connectionIDForNetwork;
+let appStateSubscription;
let currentToken;
let refreshTimeoutID;
+let isCurrentlyFetchingToken = false;
const REFRESH_INTERVAL = 1000 * 60 * 25;
const setExpirationTimer = () => {
@@ -48,13 +52,13 @@ const clearToken = () => {
};
const init = () => {
- if (connectionID) {
+ if (connectionIDForToken) {
console.debug('[MapboxToken] init() is already listening to Onyx so returning early');
return;
}
// When the token changes in Onyx, the expiration needs to be checked so a new token can be retrieved.
- connectionID = Onyx.connect({
+ connectionIDForToken = Onyx.connect({
key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
/**
* @param {Object} token
@@ -63,6 +67,13 @@ const init = () => {
* @param {String[]} [token.errors]
*/
callback: (token) => {
+ // Only the leader should be in charge of the mapbox token, or else when you have multiple tabs open, the Onyx connection fires multiple times
+ // and it sets up duplicate refresh timers. This would be a big waste of tokens.
+ if (!ActiveClientManager.isClientTheLeader()) {
+ console.debug('[MapboxToken] This client is not the leader so ignoring onyx callback');
+ return;
+ }
+
// If the user has logged out, don't do anything and ignore changes to the access token
if (!authToken) {
console.debug('[MapboxToken] Ignoring changes to token because user signed out');
@@ -74,6 +85,7 @@ const init = () => {
if (_.isEmpty(token)) {
console.debug('[MapboxToken] Token does not exist so fetching one');
API.read('GetMapboxAccessToken');
+ isCurrentlyFetchingToken = true;
return;
}
@@ -88,21 +100,54 @@ const init = () => {
console.debug('[MapboxToken] Token is valid, setting up refresh');
setExpirationTimer();
+ isCurrentlyFetchingToken = false;
},
});
- AppState.addEventListener('change', (nextAppState) => {
- // Skip getting a new token if:
- // - The app state is not changing to active
- // - There is no current token (which means it's not been fetch yet for the first time)
- // - The token hasn't expired yet (this would just be a waste of an API call)
- // - There is no authToken (which means the user has logged out)
- if (nextAppState !== CONST.APP_STATE.ACTIVE || !currentToken || !hasTokenExpired() || !authToken) {
- return;
- }
- console.debug('[MapboxToken] Token is expired after app became active');
- clearToken();
- });
+ if (!appStateSubscription) {
+ appStateSubscription = AppState.addEventListener('change', (nextAppState) => {
+ // Skip getting a new token if:
+ // - The app state is not changing to active
+ // - There is no current token (which means it's not been fetch yet for the first time)
+ // - The token hasn't expired yet (this would just be a waste of an API call)
+ // - There is no authToken (which means the user has logged out)
+ if (nextAppState !== CONST.APP_STATE.ACTIVE || !currentToken || !hasTokenExpired() || !authToken || isCurrentlyFetchingToken) {
+ return;
+ }
+ console.debug('[MapboxToken] Token is expired after app became active');
+ clearToken();
+ });
+ }
+
+ if (!connectionIDForNetwork) {
+ let network;
+ connectionIDForNetwork = Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ callback: (val) => {
+ // When the network reconnects, check if the token has expired. If it has, then clearing the token will
+ // trigger the fetch of a new one
+ if (network && network.isOffline && val && !val.isOffline && !isCurrentlyFetchingToken && hasTokenExpired()) {
+ console.debug('[MapboxToken] Token is expired after network came online');
+ clearToken();
+ }
+ network = val;
+ },
+ });
+ }
+};
+
+const stop = () => {
+ console.debug('[MapboxToken] Stopping all listeners and timers');
+ if (connectionIDForToken) {
+ Onyx.disconnect(connectionIDForToken);
+ }
+ if (connectionIDForNetwork) {
+ Onyx.disconnect(connectionIDForNetwork);
+ }
+ if (appStateSubscription) {
+ appStateSubscription.remove();
+ }
+ clearTimeout(refreshTimeoutID);
};
-export default init;
+export {init, stop};
diff --git a/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.js b/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.js
new file mode 100644
index 000000000000..d46222189804
--- /dev/null
+++ b/src/libs/actions/MemoryOnlyKeys/MemoryOnlyKeys.js
@@ -0,0 +1,19 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../../ONYXKEYS';
+import Log from '../../Log';
+
+const memoryOnlyKeys = [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.PERSONAL_DETAILS_LIST];
+
+const enable = () => {
+ Log.info('[MemoryOnlyKeys] enabled');
+ Onyx.set(ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS, true);
+ Onyx.setMemoryOnlyKeys(memoryOnlyKeys);
+};
+
+const disable = () => {
+ Log.info('[MemoryOnlyKeys] disabled');
+ Onyx.set(ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS, false);
+ Onyx.setMemoryOnlyKeys([]);
+};
+
+export {disable, enable};
diff --git a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.js b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.js
new file mode 100644
index 000000000000..fa62268753db
--- /dev/null
+++ b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.js
@@ -0,0 +1,12 @@
+import * as MemoryOnlyKeys from '../MemoryOnlyKeys';
+
+const exposeGlobalMemoryOnlyKeysMethods = () => {
+ window.enableMemoryOnlyKeys = () => {
+ MemoryOnlyKeys.enable();
+ };
+ window.disableMemoryOnlyKeys = () => {
+ MemoryOnlyKeys.disable();
+ };
+};
+
+export default exposeGlobalMemoryOnlyKeysMethods;
diff --git a/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.js b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.js
new file mode 100644
index 000000000000..9d08b9db6aa4
--- /dev/null
+++ b/src/libs/actions/MemoryOnlyKeys/exposeGlobalMemoryOnlyKeysMethods/index.native.js
@@ -0,0 +1,6 @@
+/**
+ * This is a no-op because the global methods will only work for web and desktop
+ */
+const exposeGlobalMemoryOnlyKeysMethods = () => {};
+
+export default exposeGlobalMemoryOnlyKeysMethods;
diff --git a/src/libs/actions/OnyxUpdates.js b/src/libs/actions/OnyxUpdates.js
new file mode 100644
index 000000000000..e582016f0109
--- /dev/null
+++ b/src/libs/actions/OnyxUpdates.js
@@ -0,0 +1,22 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+
+/**
+ *
+ * @param {Number} [lastUpdateID]
+ * @param {Number} [previousUpdateID]
+ */
+function saveUpdateIDs(lastUpdateID = 0, previousUpdateID = 0) {
+ // Return early if there were no updateIDs
+ if (!lastUpdateID) {
+ return;
+ }
+
+ Onyx.merge(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, {
+ lastUpdateIDFromServer: lastUpdateID,
+ previousUpdateIDFromServer: previousUpdateID,
+ });
+}
+
+// eslint-disable-next-line import/prefer-default-export
+export {saveUpdateIDs};
diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js
index fbde06231826..95148c1d4367 100644
--- a/src/libs/actions/PaymentMethods.js
+++ b/src/libs/actions/PaymentMethods.js
@@ -13,7 +13,7 @@ import ROUTES from '../../ROUTES';
function deletePayPalMe() {
User.deletePaypalMeAddress();
- Growl.show(Localize.translateLocal('paymentsPage.deletePayPalSuccess'), CONST.GROWL.SUCCESS, 3000);
+ Growl.show(Localize.translateLocal('walletPage.deletePayPalSuccess'), CONST.GROWL.SUCCESS, 3000);
}
/**
@@ -35,7 +35,7 @@ function continueSetup() {
kycWallRef.current.continue();
}
-function openPaymentsPage() {
+function openWalletPage() {
const onyxData = {
optimisticData: [
{
@@ -70,11 +70,10 @@ function openPaymentsPage() {
* @param {Object} previousPaymentMethod
* @param {Object} currentPaymentMethod
* @param {Boolean} isOptimisticData
- * @param {string} paymentCardOnyxKey - to pass in the correct ONYX key while renaming cardList -> fundList
* @return {Array}
*
*/
-function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, isOptimisticData = true, paymentCardOnyxKey) {
+function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, isOptimisticData = true) {
const onyxData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -94,7 +93,7 @@ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMet
if (previousPaymentMethod) {
onyxData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : paymentCardOnyxKey,
+ key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
value: {
[previousPaymentMethod.methodID]: {
isDefault: !isOptimisticData,
@@ -106,7 +105,7 @@ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMet
if (currentPaymentMethod) {
onyxData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : paymentCardOnyxKey,
+ key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
value: {
[currentPaymentMethod.methodID]: {
isDefault: isOptimisticData,
@@ -125,10 +124,9 @@ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMet
* @param {Number} fundID
* @param {Object} previousPaymentMethod
* @param {Object} currentPaymentMethod
- * @param {string} paymentCardOnyxKey - pass in the correct ONYX key while renaming cardList -> fundList
*
*/
-function makeDefaultPaymentMethod(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, paymentCardOnyxKey) {
+function makeDefaultPaymentMethod(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod) {
API.write(
'MakeDefaultPaymentMethod',
{
@@ -136,8 +134,8 @@ function makeDefaultPaymentMethod(bankAccountID, fundID, previousPaymentMethod,
fundID,
},
{
- optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true, paymentCardOnyxKey),
- failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false, paymentCardOnyxKey),
+ optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true, ONYXKEYS.FUND_LIST),
+ failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false, ONYXKEYS.FUND_LIST),
},
);
}
@@ -274,17 +272,17 @@ function saveWalletTransferMethodType(filterPaymentMethodType) {
function dismissSuccessfulTransferBalancePage() {
Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
- Navigation.goBack(ROUTES.SETTINGS_PAYMENTS);
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
}
/**
* Looks through each payment method to see if there is an existing error
* @param {Object} bankList
- * @param {Object} cardList
+ * @param {Object} fundList
* @returns {Boolean}
*/
-function hasPaymentMethodError(bankList, cardList) {
- const combinedPaymentMethods = {...bankList, ...cardList};
+function hasPaymentMethodError(bankList, fundList) {
+ const combinedPaymentMethods = {...bankList, ...fundList};
return _.some(combinedPaymentMethods, (item) => !_.isEmpty(item.errors));
}
@@ -337,7 +335,7 @@ function deletePaymentCard(fundID) {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.CARD_LIST}`,
+ key: `${ONYXKEYS.FUND_LIST}`,
value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
},
],
@@ -349,7 +347,7 @@ export {
deletePayPalMe,
deletePaymentCard,
addPaymentCard,
- openPaymentsPage,
+ openWalletPage,
makeDefaultPaymentMethod,
kycWallRef,
continueSetup,
diff --git a/src/libs/actions/PersistedRequests.js b/src/libs/actions/PersistedRequests.js
index 99a2e406c15b..be30e6b3c8ed 100644
--- a/src/libs/actions/PersistedRequests.js
+++ b/src/libs/actions/PersistedRequests.js
@@ -17,7 +17,11 @@ function clear() {
* @param {Array} requestsToPersist
*/
function save(requestsToPersist) {
- persistedRequests = persistedRequests.concat(requestsToPersist);
+ if (persistedRequests.length) {
+ persistedRequests = persistedRequests.concat(requestsToPersist);
+ } else {
+ persistedRequests = requestsToPersist;
+ }
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
}
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 809491c14950..87c77e722a5c 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -13,7 +13,6 @@ import * as ErrorUtils from '../ErrorUtils';
import * as ReportUtils from '../ReportUtils';
import * as PersonalDetailsUtils from '../PersonalDetailsUtils';
import Log from '../Log';
-import Permissions from '../Permissions';
const allPolicies = {};
Onyx.connect({
@@ -66,12 +65,6 @@ Onyx.connect({
callback: (val) => (allPersonalDetails = val),
});
-let loginList;
-Onyx.connect({
- key: ONYXKEYS.LOGIN_LIST,
- callback: (val) => (loginList = val),
-});
-
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
* @param {String|null} policyID
@@ -161,16 +154,6 @@ function isAdminOfFreePolicy(policies) {
return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN);
}
-/**
- * Is the user the owner of the given policy?
- *
- * @param {Object} policy
- * @returns {Boolean}
- */
-function isPolicyOwner(policy) {
- return _.keys(loginList).includes(policy.owner);
-}
-
/**
* Check if the user has any active free policies (aka workspaces)
*
@@ -250,10 +233,9 @@ function removeMembers(accountIDs, policyID) {
*
* @param {String} policyID
* @param {Object} invitedEmailsToAccountIDs
- * @param {Array} betas
* @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
*/
-function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas) {
+function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
const workspaceMembersChats = {
onyxSuccessData: [],
onyxOptimisticData: [],
@@ -261,11 +243,6 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas) {
reportCreationData: {},
};
- // If the user is not in the beta, we don't want to create any chats
- if (!Permissions.canUsePolicyExpenseChat(betas)) {
- return workspaceMembersChats;
- }
-
_.each(invitedEmailsToAccountIDs, (accountID, email) => {
const cleanAccountID = Number(accountID);
const login = OptionsListUtils.addSMSDomainIfPhoneNumber(email);
@@ -348,16 +325,15 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas) {
* @param {Object} invitedEmailsToAccountIDs
* @param {String} welcomeNote
* @param {String} policyID
- * @param {Array} betas
*/
-function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID, betas) {
+function addMembersToWorkspace(invitedEmailsToAccountIDs, welcomeNote, policyID) {
const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`;
const logins = _.map(_.keys(invitedEmailsToAccountIDs), (memberLogin) => OptionsListUtils.addSMSDomainIfPhoneNumber(memberLogin));
const accountIDs = _.values(invitedEmailsToAccountIDs);
const newPersonalDetailsOnyxData = PersonalDetailsUtils.getNewPersonalDetailsOnyxData(logins, accountIDs);
// create onyx data for policy expense chats for each new member
- const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, betas);
+ const membersChats = createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs);
const optimisticData = [
{
@@ -600,7 +576,15 @@ function updateGeneralSettings(policyID, name, currency) {
},
];
- API.write('UpdateWorkspaceGeneralSettings', {policyID, workspaceName: name, currency}, {optimisticData, successData, failureData});
+ API.write(
+ 'UpdateWorkspaceGeneralSettings',
+ {policyID, workspaceName: name, currency},
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
}
/**
@@ -933,6 +917,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
name: workspaceName,
role: CONST.POLICY.ROLE.ADMIN,
owner: sessionEmail,
+ isPolicyExpenseChatEnabled: true,
outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD),
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
customUnits,
@@ -1157,6 +1142,13 @@ function openWorkspaceInvitePage(policyID, clientMemberEmails) {
});
}
+/**
+ * @param {String} policyID
+ */
+function openDraftWorkspaceRequest(policyID) {
+ API.read('OpenDraftWorkspaceRequest', {policyID});
+}
+
/**
* @param {String} policyID
* @param {Object} invitedEmailsToAccountIDs
@@ -1200,6 +1192,6 @@ export {
openWorkspaceInvitePage,
removeWorkspace,
setWorkspaceInviteMembersDraft,
- isPolicyOwner,
clearErrors,
+ openDraftWorkspaceRequest,
};
diff --git a/src/libs/actions/QueuedOnyxUpdates.js b/src/libs/actions/QueuedOnyxUpdates.js
index d25f44a9aa60..486108dd56cf 100644
--- a/src/libs/actions/QueuedOnyxUpdates.js
+++ b/src/libs/actions/QueuedOnyxUpdates.js
@@ -14,7 +14,7 @@ Onyx.connect({
* @returns {Promise}
*/
function queueOnyxUpdates(updates) {
- return Onyx.merge(ONYXKEYS.QUEUED_ONYX_UPDATES, updates);
+ return Onyx.set(ONYXKEYS.QUEUED_ONYX_UPDATES, [...queuedOnyxUpdates, ...updates]);
}
function clear() {
diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js
index 1474b7d4054c..ae655c1ab8cf 100644
--- a/src/libs/actions/ReimbursementAccount/navigation.js
+++ b/src/libs/actions/ReimbursementAccount/navigation.js
@@ -16,10 +16,11 @@ function goToWithdrawalAccountSetupStep(stepID, newAchData) {
/**
* Navigate to the correct bank account route based on the bank account state and type
*
- * @param {String} policyId
+ * @param {string} policyId - The policy ID associated with the bank account.
+ * @param {string} [backTo=''] - An optional return path. If provided, it will be URL-encoded and appended to the resulting URL.
*/
-function navigateToBankAccountRoute(policyId) {
- Navigation.navigate(ROUTES.getBankAccountRoute('', policyId));
+function navigateToBankAccountRoute(policyId, backTo) {
+ Navigation.navigate(ROUTES.getBankAccountRoute('', policyId, backTo));
}
export {goToWithdrawalAccountSetupStep, navigateToBankAccountRoute};
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index ec26c351de57..ade3791fc0c7 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -62,6 +62,18 @@ Onyx.connect({
},
});
+const currentReportData = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ callback: (data, key) => {
+ if (!key || !data) {
+ return;
+ }
+ const reportID = CollectionUtils.extractCollectionItemID(key);
+ currentReportData[reportID] = data;
+ },
+});
+
let isNetworkOffline = false;
Onyx.connect({
key: ONYXKEYS.NETWORK,
@@ -373,6 +385,10 @@ function addComment(reportID, text) {
addActions(reportID, text);
}
+function reportActionsExist(reportID) {
+ return allReportActions[reportID] !== undefined;
+}
+
/**
* Gets the latest page of report actions and updates the last read message
* If a chat with the passed reportID is not found, we will create a chat based on the passed participantList
@@ -388,11 +404,13 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
const optimisticReportData = {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: {
- isLoadingReportActions: true,
- isLoadingMoreReportActions: false,
- lastReadTime: DateUtils.getDBTime(),
- },
+ value: reportActionsExist(reportID)
+ ? {}
+ : {
+ isLoadingReportActions: true,
+ isLoadingMoreReportActions: false,
+ reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME),
+ },
};
const reportSuccessData = {
onyxMethod: Onyx.METHOD.MERGE,
@@ -472,7 +490,7 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
// Add optimistic personal details for new participants
const optimisticPersonalDetails = {};
- const failurePersonalDetails = {};
+ const settledPersonalDetails = {};
_.map(participantLoginList, (login, index) => {
const accountID = newReportObject.participantAccountIDs[index];
optimisticPersonalDetails[accountID] = allPersonalDetails[accountID] || {
@@ -483,7 +501,7 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
isOptimisticPersonalDetail: true,
};
- failurePersonalDetails[accountID] = allPersonalDetails[accountID] || null;
+ settledPersonalDetails[accountID] = allPersonalDetails[accountID] || null;
});
onyxData.optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
@@ -491,10 +509,15 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
value: optimisticPersonalDetails,
});
+ onyxData.successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ value: settledPersonalDetails,
+ });
onyxData.failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- value: failurePersonalDetails,
+ value: settledPersonalDetails,
});
// Add the createdReportActionID parameter to the API call
@@ -515,6 +538,8 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
}
}
+ params.clientLastReadTime = lodashGet(currentReportData, [reportID, 'lastReadTime'], '');
+
if (isFromDeepLink) {
// eslint-disable-next-line rulesdir/no-api-side-effects-method
API.makeRequestWithSideEffects('OpenReport', params, onyxData).finally(() => {
@@ -715,10 +740,12 @@ function expandURLPreview(reportID, reportActionID) {
* @param {String} reportID
*/
function readNewestAction(reportID) {
+ const lastReadTime = DateUtils.getDBTime();
API.write(
'ReadNewestAction',
{
reportID,
+ lastReadTime,
},
{
optimisticData: [
@@ -726,7 +753,7 @@ function readNewestAction(reportID) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
- lastReadTime: DateUtils.getDBTime(),
+ lastReadTime,
},
},
],
@@ -849,6 +876,20 @@ function handleReportChanged(report) {
return;
}
+ // It is possible that we optimistically created a DM/group-DM for a set of users for which a report already exists.
+ // In this case, the API will let us know by returning a preexistingReportID.
+ // We should clear out the optimistically created report and re-route the user to the preexisting report.
+ if (report && report.reportID && report.preexistingReportID) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, null);
+
+ // Only re-route them if they are still looking at the optimistically created report
+ if (Navigation.getActiveRoute().includes(`/r/${report.reportID}`)) {
+ // Pass 'FORCED_UP' type to replace new report on second login with proper one in the Navigation
+ Navigation.navigate(ROUTES.getReportRoute(report.preexistingReportID), CONST.NAVIGATION.TYPE.FORCED_UP);
+ }
+ return;
+ }
+
if (report && report.reportID) {
allReports[report.reportID] = report;
@@ -969,7 +1010,11 @@ function deleteReportComment(reportID, reportAction) {
];
// Update optimistic data for parent report action if the report is a child report
- const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(reportID, optimisticReport.lastVisibleActionCreated, CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
+ const optimisticParentReportData = ReportUtils.getOptimisticDataForParentReportAction(
+ originalReportID,
+ optimisticReport.lastVisibleActionCreated,
+ CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ );
if (!_.isEmpty(optimisticParentReportData)) {
optimisticData.push(optimisticParentReportData);
}
@@ -1720,7 +1765,7 @@ function openReportFromDeepLink(url, isAuthenticated) {
InteractionManager.runAfterInteractions(() => {
SidebarUtils.isSidebarLoadedReady().then(() => {
if (reportID) {
- Navigation.navigate(ROUTES.getReportRoute(reportID), 'UP');
+ Navigation.navigate(ROUTES.getReportRoute(reportID), CONST.NAVIGATION.TYPE.UP);
}
if (route === ROUTES.CONCIERGE) {
navigateToConciergeChat();
@@ -1729,6 +1774,10 @@ function openReportFromDeepLink(url, isAuthenticated) {
});
}
+function getCurrentUserAccountID() {
+ return currentUserAccountID;
+}
+
/**
* Leave a report by setting the state to submitted and closed
*
@@ -1774,6 +1823,10 @@ function leaveRoom(reportID) {
],
},
);
+ Navigation.dismissModal();
+ if (Navigation.getTopmostReportId() === reportID) {
+ Navigation.goBack();
+ }
navigateToConciergeChat();
}
@@ -1924,6 +1977,7 @@ export {
hasAccountIDEmojiReacted,
shouldShowReportActionNotification,
leaveRoom,
+ getCurrentUserAccountID,
setLastOpenedPublicRoom,
flagComment,
openLastOpenedPublicRoom,
diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js
index 2cb76342f43a..aa5ff229267f 100644
--- a/src/libs/actions/Session/index.js
+++ b/src/libs/actions/Session/index.js
@@ -7,7 +7,6 @@ import ONYXKEYS from '../../../ONYXKEYS';
import redirectToSignIn from '../SignInRedirect';
import CONFIG from '../../../CONFIG';
import Log from '../../Log';
-import PushNotification from '../../Notification/PushNotification';
import Timing from '../Timing';
import CONST from '../../../CONST';
import Timers from '../../Timers';
@@ -18,7 +17,6 @@ import * as API from '../../API';
import * as NetworkStore from '../../Network/NetworkStore';
import Navigation from '../../Navigation/Navigation';
import * as Device from '../Device';
-import subscribeToReportCommentPushNotifications from '../../Notification/PushNotification/subscribeToReportCommentPushNotifications';
import ROUTES from '../../../ROUTES';
import * as ErrorUtils from '../../ErrorUtils';
import * as ReportUtils from '../../ReportUtils';
@@ -37,28 +35,6 @@ Onyx.connect({
callback: (val) => (credentials = val || {}),
});
-/**
- * Manage push notification subscriptions on sign-in/sign-out.
- *
- * On Android, AuthScreens unmounts when the app is closed with the back button so we manage the
- * push subscription when the session changes here.
- */
-Onyx.connect({
- key: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID,
- callback: (notificationID) => {
- if (notificationID) {
- PushNotification.register(notificationID);
-
- // Prevent issue where report linking fails after users switch accounts without closing the app
- PushNotification.init();
- subscribeToReportCommentPushNotifications();
- } else {
- PushNotification.deregister();
- PushNotification.clearNotifications();
- }
- },
-});
-
/**
* Clears the Onyx store and redirects user to the sign in page
*/
@@ -89,11 +65,13 @@ function isAnonymousUser() {
}
function signOutAndRedirectToSignIn() {
- hideContextMenu(false);
- signOut();
- redirectToSignIn();
Log.info('Redirecting to Sign In because signOut() was called');
- if (isAnonymousUser()) {
+ hideContextMenu(false);
+ if (!isAnonymousUser()) {
+ signOut();
+ redirectToSignIn();
+ } else {
+ Navigation.navigate(ROUTES.SIGN_IN_MODAL);
Linking.getInitialURL().then((url) => {
const reportID = ReportUtils.getReportIDFromLink(url);
if (reportID) {
@@ -197,55 +175,87 @@ function resendValidateCode(login = credentials.login) {
}
/**
- * Checks the API to see if an account exists for the given login
+
+/**
+ * Constructs the state object for the BeginSignIn && BeginAppleSignIn API calls.
+ * @returns {Object}
+ */
+function signInAttemptState() {
+ return {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ ...CONST.DEFAULT_ACCOUNT_DATA,
+ isLoading: true,
+ message: null,
+ loadingForm: CONST.FORMS.LOGIN_FORM,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ loadingForm: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.CREDENTIALS,
+ value: {
+ validateCode: null,
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ loadingForm: null,
+ // eslint-disable-next-line rulesdir/prefer-localization
+ errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'),
+ },
+ },
+ ],
+ };
+}
+
+/**
+ * Checks the API to see if an account exists for the given login.
*
* @param {String} login
*/
function beginSignIn(login) {
- const optimisticData = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.ACCOUNT,
- value: {
- ...CONST.DEFAULT_ACCOUNT_DATA,
- isLoading: true,
- message: null,
- loadingForm: CONST.FORMS.LOGIN_FORM,
- },
- },
- ];
-
- const successData = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.ACCOUNT,
- value: {
- isLoading: false,
- loadingForm: null,
- },
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.CREDENTIALS,
- value: {
- validateCode: null,
- },
- },
- ];
+ const {optimisticData, successData, failureData} = signInAttemptState();
+ API.read('BeginSignIn', {email: login}, {optimisticData, successData, failureData});
+}
- const failureData = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.ACCOUNT,
- value: {
- isLoading: false,
- loadingForm: null,
- errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'),
- },
- },
- ];
+/**
+ * Given an idToken from Sign in with Apple, checks the API to see if an account
+ * exists for that email address and signs the user in if so.
+ *
+ * @param {String} idToken
+ */
+function beginAppleSignIn(idToken) {
+ const {optimisticData, successData, failureData} = signInAttemptState();
+ API.write('SignInWithApple', {idToken}, {optimisticData, successData, failureData});
+}
- API.read('BeginSignIn', {email: login}, {optimisticData, successData, failureData});
+/**
+ * Shows Google sign-in process, and if an auth token is successfully obtained,
+ * passes the token on to the Expensify API to sign in with
+ *
+ * @param {String} token
+ */
+function beginGoogleSignIn(token) {
+ const {optimisticData, successData, failureData} = signInAttemptState();
+ API.write('SignInWithGoogle', {token}, {optimisticData, successData, failureData});
}
/**
@@ -741,6 +751,8 @@ function validateTwoFactorAuth(twoFactorAuthCode) {
export {
beginSignIn,
+ beginAppleSignIn,
+ beginGoogleSignIn,
setSupportAuthToken,
checkIfActionIsAllowed,
signIn,
diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js
index eeef8f479d97..6227686b3f45 100644
--- a/src/libs/actions/Task.js
+++ b/src/libs/actions/Task.js
@@ -4,7 +4,6 @@ import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import * as ReportUtils from '../ReportUtils';
-import * as Report from './Report';
import Navigation from '../Navigation/Navigation';
import ROUTES from '../../ROUTES';
import CONST from '../../CONST';
@@ -25,6 +24,12 @@ Onyx.connect({
},
});
+let allPersonalDetails;
+Onyx.connect({
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ callback: (val) => (allPersonalDetails = val),
+});
+
/**
* Clears out the task info from the store
*/
@@ -33,47 +38,42 @@ function clearOutTaskInfo() {
}
/**
- * Assign a task to a user
- * Function title is createTask for consistency with the rest of the actions
- * and also because we can create a task without assigning it to anyone
+ * A task needs two things to be created - a title and a parent report
+ * When you create a task report, there are a few things that happen:
+ * A task report is created, along with a CreatedReportAction for that new task report
+ * A reportAction indicating that a task was created is added to the parent report (share destination)
+ * If you assign the task to someone, a reportAction is created in the chat between you and the assignee to inform them of the task
+ *
+ * So you have the following optimistic items potentially being created:
+ * 1. The task report
+ * 1a. The CreatedReportAction for the task report
+ * 2. The TaskReportAction on the parent report
+ * 3. The chat report between you and the assignee
+ * 3a. The CreatedReportAction for the assignee chat report
+ * 3b. The TaskReportAction on the assignee chat report
+ *
* @param {String} parentReportID
* @param {String} title
* @param {String} description
- * @param {String} assignee
+ * @param {String} assigneeEmail
* @param {Number} assigneeAccountID
- *
+ * @param {Object} assigneeChatReport - The chat report between you and the assignee
*/
-
-function createTaskAndNavigate(parentReportID, title, description, assignee, assigneeAccountID = 0) {
- // Create the task report
+function createTaskAndNavigate(parentReportID, title, description, assigneeEmail, assigneeAccountID = 0, assigneeChatReport = null) {
const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description);
- // Grab the assigneeChatReportID if there is an assignee and if it's not the same as the parentReportID
- // then we create an optimistic add comment report action on the assignee's chat to notify them of the task
- const assigneeChatReportID = lodashGet(ReportUtils.getChatByParticipants([assigneeAccountID]), 'reportID');
+ const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0;
const taskReportID = optimisticTaskReport.reportID;
- let optimisticAssigneeAddComment;
- if (assigneeChatReportID && assigneeChatReportID !== parentReportID) {
- optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(
- taskReportID,
- title,
- assignee,
- assigneeAccountID,
- `Assigned a task to you: ${title}`,
- parentReportID,
- );
- }
+ let assigneeChatReportOnyxData;
- // Create the CreatedReportAction on the task
+ // Parent ReportAction indicating that a task has been created
const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmail);
- const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, assigneeAccountID, `Created a task: ${title}`, parentReportID);
+ const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeEmail, assigneeAccountID, `Created a task: ${title}`, parentReportID);
optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID;
const currentTime = DateUtils.getDBTime();
-
const lastCommentText = ReportUtils.formatReportLastMessageText(optimisticAddCommentReport.reportAction.message[0].text);
-
- const optimisticReport = {
+ const optimisticParentReport = {
lastVisibleActionCreated: currentTime,
lastMessageText: lastCommentText,
lastActorAccountID: currentUserAccountID,
@@ -81,6 +81,9 @@ function createTaskAndNavigate(parentReportID, title, description, assignee, ass
lastMessageTranslationKey: '',
};
+ // We're only setting onyx data for the task report here because it's possible for the parent report to not exist yet (if you're assigning a task to someone you haven't chatted with before)
+ // So we don't want to set the parent report data until we've successfully created that chat report
+ // FOR TASK REPORT
const optimisticData = [
{
onyxMethod: Onyx.METHOD.SET,
@@ -101,18 +104,9 @@ function createTaskAndNavigate(parentReportID, title, description, assignee, ass
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`,
value: {[optimisticTaskCreatedAction.reportActionID]: optimisticTaskCreatedAction},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- value: {[optimisticAddCommentReport.reportAction.reportActionID]: optimisticAddCommentReport.reportAction},
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`,
- value: optimisticReport,
- },
];
+ // FOR TASK REPORT
const successData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -132,13 +126,9 @@ function createTaskAndNavigate(parentReportID, title, description, assignee, ass
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`,
value: {[optimisticTaskCreatedAction.reportActionID]: {pendingAction: null}},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}},
- },
];
+ // FOR TASK REPORT
const failureData = [
{
onyxMethod: Onyx.METHOD.SET,
@@ -150,48 +140,52 @@ function createTaskAndNavigate(parentReportID, title, description, assignee, ass
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticTaskReport.reportID}`,
value: {[optimisticTaskCreatedAction.reportActionID]: {pendingAction: null}},
},
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}},
- },
];
- if (optimisticAssigneeAddComment) {
- const lastAssigneeCommentText = ReportUtils.formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text);
-
- const optimisticAssigneeReport = {
- lastVisibleActionCreated: currentTime,
- lastMessageText: lastAssigneeCommentText,
- lastActorAccountID: currentUserAccountID,
- lastReadTime: currentTime,
- };
-
- optimisticData.push(
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
- value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: optimisticAssigneeAddComment.reportAction},
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`,
- value: optimisticAssigneeReport,
- },
+ if (assigneeChatReport) {
+ assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData(
+ currentUserAccountID,
+ assigneeEmail,
+ assigneeAccountID,
+ taskReportID,
+ assigneeChatReportID,
+ parentReportID,
+ title,
+ assigneeChatReport,
);
+ optimisticData.push(...assigneeChatReportOnyxData.optimisticData);
+ successData.push(...assigneeChatReportOnyxData.successData);
+ failureData.push(...assigneeChatReportOnyxData.failureData);
+ }
- successData.push({
+ // Now that we've created the optimistic chat report and chat reportActions, we can update the parent report data
+ // FOR PARENT REPORT (SHARE DESTINATION)
+ optimisticData.push(
+ {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
- value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {pendingAction: null}},
- });
-
- failureData.push({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`,
+ value: optimisticParentReport,
+ },
+ {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
- value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {pendingAction: null}},
- });
- }
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
+ value: {[optimisticAddCommentReport.reportAction.reportActionID]: optimisticAddCommentReport.reportAction},
+ },
+ );
+
+ // FOR PARENT REPORT (SHARE DESTINATION)
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
+ value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}},
+ });
+
+ // FOR PARENT REPORT (SHARE DESTINATION)
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
+ value: {[optimisticAddCommentReport.reportAction.reportActionID]: {pendingAction: null}},
+ });
API.write(
'CreateTask',
@@ -200,13 +194,17 @@ function createTaskAndNavigate(parentReportID, title, description, assignee, ass
parentReportID,
taskReportID: optimisticTaskReport.reportID,
createdTaskReportActionID: optimisticTaskCreatedAction.reportActionID,
- reportName: optimisticTaskReport.reportName,
title: optimisticTaskReport.reportName,
description: optimisticTaskReport.description,
- assignee,
+ assignee: assigneeEmail,
assigneeAccountID,
assigneeChatReportID,
- assigneeChatReportActionID: optimisticAssigneeAddComment ? optimisticAssigneeAddComment.reportAction.reportActionID : 0,
+ assigneeChatReportActionID:
+ assigneeChatReportOnyxData && assigneeChatReportOnyxData.optimisticAssigneeAddComment
+ ? assigneeChatReportOnyxData.optimisticAssigneeAddComment.reportAction.reportActionID
+ : 0,
+ assigneeChatCreatedReportActionID:
+ assigneeChatReportOnyxData && assigneeChatReportOnyxData.optimisticChatCreatedReportAction ? assigneeChatReportOnyxData.optimisticChatCreatedReportAction.reportActionID : 0,
},
{optimisticData, successData, failureData},
);
@@ -401,12 +399,9 @@ function reopenTask(taskReport, taskTitle) {
* @param {object} report
* @param {Number} ownerAccountID
* @param {Object} editedTask
- * @param {String} editedTask.title
- * @param {String} editedTask.description
- * @param {String} editedTask.assignee
- * @param {Number} editedTask.assigneeAccountID
+ * @param {Object} assigneeChatReport - The chat report between you and the assignee
*/
-function editTaskAndNavigate(report, ownerAccountID, {title, description, assignee = '', assigneeAccountID = 0}) {
+function editTaskAndNavigate(report, ownerAccountID, {title, description, assignee = '', assigneeAccountID = 0}, assigneeChatReport = null) {
// Create the EditedReportAction on the task
const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskReportAction(currentUserEmail);
@@ -416,22 +411,8 @@ function editTaskAndNavigate(report, ownerAccountID, {title, description, assign
// Description can be unset, so we default to an empty string if so
const reportDescription = (!_.isUndefined(description) ? description : lodashGet(report, 'description', '')).trim();
- // If we make a change to the assignee, we want to add a comment to the assignee's chat
- let optimisticAssigneeAddComment;
- let assigneeChatReportID;
- if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID) {
- assigneeChatReportID = lodashGet(ReportUtils.getChatByParticipants([assigneeAccountID]), ['reportID'], undefined);
-
- if (assigneeChatReportID && assigneeChatReportID !== report.parentReportID.toString()) {
- optimisticAssigneeAddComment = ReportUtils.buildOptimisticTaskCommentReportAction(
- report.reportID,
- reportName,
- assignee,
- assigneeAccountID,
- `Assigned a task to you: ${reportName}`,
- );
- }
- }
+ let assigneeChatReportOnyxData;
+ const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0;
const optimisticData = [
{
@@ -486,35 +467,22 @@ function editTaskAndNavigate(report, ownerAccountID, {title, description, assign
},
];
- if (optimisticAssigneeAddComment) {
- const currentTime = DateUtils.getDBTime();
- const lastAssigneeCommentText = ReportUtils.formatReportLastMessageText(optimisticAssigneeAddComment.reportAction.message[0].text);
-
- const optimisticAssigneeReport = {
- lastVisibleActionCreated: currentTime,
- lastMessageText: lastAssigneeCommentText,
- lastActorAccountID: ownerAccountID,
- lastReadTime: currentTime,
- };
-
- optimisticData.push(
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
- value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: optimisticAssigneeAddComment.reportAction},
- },
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`,
- value: optimisticAssigneeReport,
- },
+ // If we make a change to the assignee, we want to add a comment to the assignee's chat
+ // Check if the assignee actually changed
+ if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID && assigneeChatReport) {
+ assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData(
+ currentUserAccountID,
+ assignee,
+ assigneeAccountID,
+ report.reportID,
+ assigneeChatReportID,
+ report.parentReportID,
+ reportName,
+ assigneeChatReport,
);
-
- failureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${assigneeChatReportID}`,
- value: {[optimisticAssigneeAddComment.reportAction.reportActionID]: {pendingAction: null}},
- });
+ optimisticData.push(...assigneeChatReportOnyxData.optimisticData);
+ successData.push(...assigneeChatReportOnyxData.successData);
+ failureData.push(...assigneeChatReportOnyxData.failureData);
}
API.write(
@@ -526,7 +494,90 @@ function editTaskAndNavigate(report, ownerAccountID, {title, description, assign
assignee: assignee || report.managerEmail,
assigneeAccountID: assigneeAccountID || report.managerID,
editedTaskReportActionID: editTaskReportAction.reportActionID,
- assigneeChatReportActionID: optimisticAssigneeAddComment ? optimisticAssigneeAddComment.reportAction.reportActionID : 0,
+ assigneeChatReportID,
+ assigneeChatReportActionID:
+ assigneeChatReportOnyxData && assigneeChatReportOnyxData.optimisticAssigneeAddComment
+ ? assigneeChatReportOnyxData.optimisticAssigneeAddComment.reportAction.reportActionID
+ : 0,
+ assigneeChatCreatedReportActionID:
+ assigneeChatReportOnyxData && assigneeChatReportOnyxData.optimisticChatCreatedReportAction ? assigneeChatReportOnyxData.optimisticChatCreatedReportAction.reportActionID : 0,
+ },
+ {optimisticData, successData, failureData},
+ );
+
+ Navigation.dismissModal(report.reportID);
+}
+
+function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assigneeAccountID = 0, assigneeChatReport = null) {
+ // Create the EditedReportAction on the task
+ const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskReportAction(currentUserEmail);
+ const reportName = report.reportName.trim();
+
+ let assigneeChatReportOnyxData;
+ const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0;
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ value: {[editTaskReportAction.reportActionID]: editTaskReportAction},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`,
+ value: {
+ reportName,
+ managerID: assigneeAccountID || report.managerID,
+ managerEmail: assigneeEmail || report.managerEmail,
+ },
+ },
+ ];
+ const successData = [];
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`,
+ value: {[editTaskReportAction.reportActionID]: {pendingAction: null}},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`,
+ value: {assignee: report.managerEmail, assigneeAccountID: report.managerID},
+ },
+ ];
+
+ // If we make a change to the assignee, we want to add a comment to the assignee's chat
+ // Check if the assignee actually changed
+ if (assigneeAccountID && assigneeAccountID !== report.managerID && assigneeAccountID !== ownerAccountID && assigneeChatReport) {
+ assigneeChatReportOnyxData = ReportUtils.getTaskAssigneeChatOnyxData(
+ currentUserAccountID,
+ assigneeEmail,
+ assigneeAccountID,
+ report.reportID,
+ assigneeChatReportID,
+ report.parentReportID,
+ reportName,
+ assigneeChatReport,
+ );
+ optimisticData.push(...assigneeChatReportOnyxData.optimisticData);
+ successData.push(...assigneeChatReportOnyxData.successData);
+ failureData.push(...assigneeChatReportOnyxData.failureData);
+ }
+
+ API.write(
+ 'EditTaskAssignee',
+ {
+ taskReportID: report.reportID,
+ assignee: assigneeEmail || report.managerEmail,
+ assigneeAccountID: assigneeAccountID || report.managerID,
+ editedTaskReportActionID: editTaskReportAction.reportActionID,
+ assigneeChatReportID,
+ assigneeChatReportActionID:
+ assigneeChatReportOnyxData && assigneeChatReportOnyxData.optimisticAssigneeAddComment
+ ? assigneeChatReportOnyxData.optimisticAssigneeAddComment.reportAction.reportActionID
+ : 0,
+ assigneeChatCreatedReportActionID:
+ assigneeChatReportOnyxData && assigneeChatReportOnyxData.optimisticChatCreatedReportAction ? assigneeChatReportOnyxData.optimisticChatCreatedReportAction.reportActionID : 0,
},
{optimisticData, successData, failureData},
);
@@ -578,40 +629,65 @@ function setShareDestinationValue(shareDestination) {
Onyx.merge(ONYXKEYS.TASK, {shareDestination});
}
+/* Sets the assigneeChatReport details for the task
+ * @param {Object} chatReport
+ */
+function setAssigneeChatReport(chatReport) {
+ Onyx.merge(ONYXKEYS.TASK, {assigneeChatReport: chatReport});
+}
+
/**
* Sets the assignee value for the task and checks for an existing chat with the assignee
* If there is no existing chat, it creates an optimistic chat report
* It also sets the shareDestination as that chat report if a share destination isn't already set
- * @param {string} assignee
+ * @param {string} assigneeEmail
* @param {Number} assigneeAccountID
* @param {string} shareDestination
* @param {boolean} isCurrentUser
*/
-function setAssigneeValue(assignee, assigneeAccountID, shareDestination, isCurrentUser = false) {
- let newAssigneeAccountID = Number(assigneeAccountID);
+function setAssigneeValue(assigneeEmail, assigneeAccountID, shareDestination, isCurrentUser = false) {
+ let chatReport;
- // Generate optimistic accountID if this is a brand new user account that hasn't been created yet
- if (!newAssigneeAccountID) {
- newAssigneeAccountID = UserUtils.generateAccountID(assignee);
- }
if (!isCurrentUser) {
- let newChat = {};
- const chat = ReportUtils.getChatByParticipants([newAssigneeAccountID]);
- if (!chat) {
- newChat = ReportUtils.buildOptimisticChatReport([newAssigneeAccountID]);
+ chatReport = ReportUtils.getChatByParticipantsByLoginList([assigneeEmail]) || ReportUtils.getChatByParticipants([assigneeAccountID]);
+ if (!chatReport) {
+ chatReport = ReportUtils.buildOptimisticChatReport([assigneeAccountID]);
+ chatReport.isOptimisticReport = true;
+
+ // When assigning a task to a new user, by default we share the task in their DM
+ // However, the DM doesn't exist yet - and will be created optimistically once the task is created
+ // We don't want to show the new DM yet, because if you select an assignee and then change the assignee, the previous DM will still be shown
+ // So here, we create it optimistically to share it with the assignee, but we have to hide it until the task is created
+ chatReport.isHidden = true;
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport);
+
+ // If this is an optimistic report, we likely don't have their personal details yet so we set it here optimistically as well
+ const optimisticPersonalDetailsListAction = {
+ accountID: assigneeAccountID,
+ avatar: lodashGet(allPersonalDetails, [assigneeAccountID, 'avatar'], UserUtils.getDefaultAvatarURL(assigneeAccountID)),
+ displayName: lodashGet(allPersonalDetails, [assigneeAccountID, 'displayName'], assigneeEmail),
+ login: assigneeEmail,
+ };
+ Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[assigneeAccountID]: optimisticPersonalDetailsListAction});
}
- const reportID = chat ? chat.reportID : newChat.reportID;
+ setAssigneeChatReport(chatReport);
+
+ // If there is no share destination set, automatically set it to the assignee chat report
+ // This allows for a much quicker process when creating a new task and is likely the desired share destination most times
if (!shareDestination) {
- setShareDestinationValue(reportID);
+ setShareDestinationValue(chatReport.reportID);
}
-
- Report.openReport(reportID, [assignee], newChat);
}
// This is only needed for creation of a new task and so it should only be stored locally
- Onyx.merge(ONYXKEYS.TASK, {assignee, assigneeAccountID: newAssigneeAccountID});
+ Onyx.merge(ONYXKEYS.TASK, {assignee: assigneeEmail, assigneeAccountID});
+
+ // When we're editing the assignee, we immediately call EditTaskAndNavigate. Since setting the assignee is async,
+ // the chatReport is not yet set when EditTaskAndNavigate is called. So we return the chatReport here so that
+ // EditTaskAndNavigate can use it.
+ return chatReport;
}
/**
@@ -818,6 +894,7 @@ function clearEditTaskErrors(reportID) {
export {
createTaskAndNavigate,
editTaskAndNavigate,
+ editTaskAssigneeAndNavigate,
setTitleValue,
setDescriptionValue,
setTaskReport,
diff --git a/src/libs/actions/Transaction.js b/src/libs/actions/Transaction.js
new file mode 100644
index 000000000000..02927bf7d111
--- /dev/null
+++ b/src/libs/actions/Transaction.js
@@ -0,0 +1,190 @@
+import _ from 'underscore';
+import Onyx from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import ONYXKEYS from '../../ONYXKEYS';
+import * as CollectionUtils from '../CollectionUtils';
+import * as API from '../API';
+
+const allTransactions = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ callback: (transaction, key) => {
+ if (!key || !transaction) {
+ return;
+ }
+ const transactionID = CollectionUtils.extractCollectionItemID(key);
+ allTransactions[transactionID] = transaction;
+ },
+});
+
+/**
+ * @param {String} transactionID
+ */
+function createInitialWaypoints(transactionID) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
+ comment: {
+ waypoints: {
+ waypoint0: {},
+ waypoint1: {},
+ },
+ },
+ });
+}
+
+/**
+ * Add a stop to the transaction
+ *
+ * @param {String} transactionID
+ * @param {Number} newLastIndex
+ */
+function addStop(transactionID) {
+ const transaction = lodashGet(allTransactions, transactionID, {});
+ const existingWaypoints = lodashGet(transaction, 'comment.waypoints', {});
+ const newLastIndex = _.size(existingWaypoints);
+
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
+ comment: {
+ waypoints: {
+ [`waypoint${newLastIndex}`]: {},
+ },
+ },
+
+ // Clear the existing route so that we don't show an old route
+ routes: {
+ route0: {
+ geometry: {
+ coordinates: null,
+ },
+ },
+ },
+ });
+}
+
+/**
+ * Saves the selected waypoint to the transaction
+ * @param {String} transactionID
+ * @param {String} index
+ * @param {Object} waypoint
+ */
+function saveWaypoint(transactionID, index, waypoint) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
+ comment: {
+ waypoints: {
+ [`waypoint${index}`]: waypoint,
+ },
+ },
+ // Empty out errors when we're saving a new waypoint as this indicates the user is updating their input
+ errorFields: {
+ route: null,
+ },
+
+ // Clear the existing route so that we don't show an old route
+ routes: {
+ route0: {
+ geometry: {
+ coordinates: null,
+ },
+ },
+ },
+ });
+}
+
+function removeWaypoint(transactionID, currentIndex) {
+ // Index comes from the route params and is a string
+ const index = Number(currentIndex);
+ const transaction = lodashGet(allTransactions, transactionID, {});
+ const existingWaypoints = lodashGet(transaction, 'comment.waypoints', {});
+ const totalWaypoints = _.size(existingWaypoints);
+
+ // Prevents removing the starting or ending waypoint but clear the stored address only if there are only two waypoints
+ if (totalWaypoints === 2 && (index === 0 || index === totalWaypoints - 1)) {
+ saveWaypoint(transactionID, index, null);
+ return;
+ }
+
+ const waypointValues = _.values(existingWaypoints);
+ waypointValues.splice(index, 1);
+
+ const reIndexedWaypoints = {};
+ waypointValues.forEach((waypoint, idx) => {
+ reIndexedWaypoints[`waypoint${idx}`] = waypoint;
+ });
+
+ // Onyx.merge won't remove the null nested object values, this is a workaround
+ // to remove nested keys while also preserving other object keys
+ // Doing a deep clone of the transaction to avoid mutating the original object and running into a cache issue when using Onyx.set
+ const newTransaction = {
+ ...transaction,
+ comment: {
+ ...transaction.comment,
+ waypoints: reIndexedWaypoints,
+ },
+ // Clear the existing route so that we don't show an old route
+ routes: {
+ route0: {
+ geometry: {
+ coordinates: null,
+ },
+ },
+ },
+ };
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, newTransaction);
+}
+
+/**
+ * Gets the route for a set of waypoints
+ * Used so we can generate a map view of the provided waypoints
+ * @param {String} transactionID
+ * @param {Object} waypoints
+ */
+function getRoute(transactionID, waypoints) {
+ API.read(
+ 'GetRoute',
+ {
+ transactionID,
+ waypoints: JSON.stringify(waypoints),
+ },
+ {
+ optimisticData: [
+ {
+ // Clears any potentially stale error messages from fetching the route
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ isLoading: true,
+ },
+ errorFields: {
+ route: null,
+ },
+ },
+ },
+ ],
+ // The route and failure are sent back via pusher in the BE, we are just clearing the loading state here
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ isLoading: false,
+ },
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ isLoading: false,
+ },
+ },
+ },
+ ],
+ },
+ );
+}
+
+export {addStop, createInitialWaypoints, saveWaypoint, removeWaypoint, getRoute};
diff --git a/src/libs/actions/TwoFactorAuthActions.js b/src/libs/actions/TwoFactorAuthActions.js
index f778b1f2ceae..3bc64b8ada4c 100644
--- a/src/libs/actions/TwoFactorAuthActions.js
+++ b/src/libs/actions/TwoFactorAuthActions.js
@@ -1,14 +1,26 @@
import Onyx from 'react-native-onyx';
import ONYXKEYS from '../../ONYXKEYS';
+import Navigation from '../Navigation/Navigation';
+import ROUTES from '../../ROUTES';
/**
* Clear 2FA data if the flow is interrupted without finishing
*/
function clearTwoFactorAuthData() {
- Onyx.merge(ONYXKEYS.ACCOUNT, {recoveryCodes: '', twoFactorAuthSecretKey: ''});
+ Onyx.merge(ONYXKEYS.ACCOUNT, {recoveryCodes: '', twoFactorAuthSecretKey: '', twoFactorAuthStep: '', codesAreCopied: false});
}
-export {
- // eslint-disable-next-line import/prefer-default-export
- clearTwoFactorAuthData,
-};
+function setTwoFactorAuthStep(twoFactorAuthStep) {
+ Onyx.merge(ONYXKEYS.ACCOUNT, {twoFactorAuthStep});
+}
+
+function setCodesAreCopied() {
+ Onyx.merge(ONYXKEYS.ACCOUNT, {codesAreCopied: true});
+}
+
+function quitAndNavigateBackToSettings() {
+ clearTwoFactorAuthData();
+ Navigation.goBack(ROUTES.SETTINGS_SECURITY);
+}
+
+export {clearTwoFactorAuthData, setTwoFactorAuthStep, quitAndNavigateBackToSettings, setCodesAreCopied};
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index c0565386ce94..b77c5b278bc9 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -18,6 +18,7 @@ import * as ReportActionsUtils from '../ReportActionsUtils';
import * as ErrorUtils from '../ErrorUtils';
import * as Session from './Session';
import * as PersonalDetails from './PersonalDetails';
+import * as OnyxUpdates from './OnyxUpdates';
let currentUserAccountID = '';
let currentEmail = '';
@@ -516,7 +517,7 @@ function deletePaypalMeAddress() {
];
API.write('DeletePaypalMeAddress', {}, {optimisticData, successData});
- Growl.show(Localize.translateLocal('paymentsPage.deletePayPalSuccess'), CONST.GROWL.SUCCESS, 3000);
+ Growl.show(Localize.translateLocal('walletPage.deletePayPalSuccess'), CONST.GROWL.SUCCESS, 3000);
}
function triggerNotifications(onyxUpdates) {
@@ -547,27 +548,18 @@ function subscribeToUserEvents() {
PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.MULTIPLE_EVENTS, currentUserAccountID, (pushJSON) => {
let updates;
- // This is the old format where each update was just an array, with the new changes it's an object with
- // lastUpdateID, previousUpdateID and updates
+ // The data for this push event comes in two different formats:
+ // 1. Original format - this is what was sent before the RELIABLE_UPDATES project and will go away once RELIABLE_UPDATES is fully complete
+ // - The data is an array of objects, where each object is an onyx update
+ // Example: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]
+ // 1. Reliable updates format - this is what was sent with the RELIABLE_UPDATES project and will be the format from now on
+ // - The data is an object, containing updateIDs from the server and an array of onyx updates (this array is the same format as the original format above)
+ // Example: {lastUpdateID: 1, previousUpdateID: 0, updates: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]}
if (_.isArray(pushJSON)) {
updates = pushJSON;
} else {
updates = pushJSON.updates;
-
- // Not always we'll have the lastUpdateID and previousUpdateID properties in the pusher update
- // until we finish the migration to reliable updates. So let's check it before actually updating
- // the properties in Onyx
- if (pushJSON.lastUpdateID && pushJSON.previousUpdateID) {
- console.debug('[OnyxUpdates] Received lastUpdateID from pusher', pushJSON.lastUpdateID);
- console.debug('[OnyxUpdates] Received previousUpdateID from pusher', pushJSON.previousUpdateID);
- // Store these values in Onyx to allow App.reconnectApp() to fetch incremental updates from the server when a previous session is being reconnected to.
- Onyx.multiSet({
- [ONYXKEYS.ONYX_UPDATES.LAST_UPDATE_ID]: Number(pushJSON.lastUpdateID || 0),
- [ONYXKEYS.ONYX_UPDATES.PREVIOUS_UPDATE_ID]: Number(pushJSON.previousUpdateID || 0),
- });
- } else {
- console.debug('[OnyxUpdates] No lastUpdateID and previousUpdateID provided');
- }
+ OnyxUpdates.saveUpdateIDs(Number(pushJSON.lastUpdateID || 0), Number(pushJSON.previousUpdateID || 0));
}
_.each(updates, (multipleEvent) => {
PusherUtils.triggerMultiEventHandler(multipleEvent.eventType, multipleEvent.data);
@@ -581,6 +573,7 @@ function subscribeToUserEvents() {
if (!currentUserAccountID) {
return;
}
+
Onyx.update(pushJSON);
triggerNotifications(pushJSON);
});
diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js
index f7063fb61308..6e6fe2512dff 100644
--- a/src/libs/actions/Welcome.js
+++ b/src/libs/actions/Welcome.js
@@ -116,16 +116,24 @@ function show({routes, showCreateMenu = () => {}, showPopoverMenu = () => {}}) {
const isWorkspaceRoute = topRoute.name === 'Settings' && topRoute.params.path.includes('workspace');
const transitionRoute = _.find(routes, (route) => route.name === SCREENS.TRANSITION_BETWEEN_APPS);
const exitingToWorkspaceRoute = lodashGet(transitionRoute, 'params.exitTo', '') === 'workspace/new';
+ const openOnAdminRoom = lodashGet(topRoute, 'params.openOnAdminRoom', false);
const isDisplayingWorkspaceRoute = isWorkspaceRoute || exitingToWorkspaceRoute;
- // We want to display the Workspace chat first since that means a user is already in a Workspace and doesn't need to create another one
+ // If we already opened the workspace settings or want the admin room to stay open, do not
+ // navigate away to the workspace chat report
+ const shouldNavigateToWorkspaceChat = !isDisplayingWorkspaceRoute && !openOnAdminRoom;
+
const workspaceChatReport = _.find(
allReports,
(report) => ReportUtils.isPolicyExpenseChat(report) && report.ownerAccountID === currentUserAccountID && report.statusNum !== CONST.REPORT.STATUS.CLOSED,
);
- if (workspaceChatReport && !isDisplayingWorkspaceRoute) {
+
+ if (workspaceChatReport || openOnAdminRoom) {
// This key is only updated when we call ReconnectApp, setting it to false now allows the user to navigate normally instead of always redirecting to the workspace chat
Onyx.set(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, false);
+ }
+
+ if (shouldNavigateToWorkspaceChat && workspaceChatReport) {
Navigation.navigate(ROUTES.getReportRoute(workspaceChatReport.reportID));
// If showPopoverMenu exists and returns true then it opened the Popover Menu successfully, and we can update isFirstTimeNewExpensifyUser
diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.js
index 4d714fff4f24..a98a33b2934f 100644
--- a/src/libs/fileDownload/FileUtils.js
+++ b/src/libs/fileDownload/FileUtils.js
@@ -1,4 +1,4 @@
-import {Alert, Linking} from 'react-native';
+import {Alert, Linking, Platform} from 'react-native';
import CONST from '../../CONST';
import * as Localize from '../Localize';
import DateUtils from '../DateUtils';
@@ -146,7 +146,10 @@ const readFileAsync = (path, fileName) =>
return fetch(path)
.then((res) => {
- if (!res.ok) {
+ // For some reason, fetch is "Unable to read uploaded file"
+ // on Android even though the blob is returned, so we'll ignore
+ // in that case
+ if (!res.ok && Platform.OS !== 'android') {
throw Error(res.statusText);
}
return res.blob();
@@ -154,6 +157,9 @@ const readFileAsync = (path, fileName) =>
.then((blob) => {
const file = new File([blob], cleanFileName(fileName));
file.source = path;
+ // For some reason, the File object on iOS does not have a uri property
+ // so images aren't uploaded correctly to the backend
+ file.uri = path;
resolve(file);
})
.catch((e) => {
diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js
index 712a6ca2affb..a775576eb365 100644
--- a/src/libs/fileDownload/index.js
+++ b/src/libs/fileDownload/index.js
@@ -1,5 +1,5 @@
-import {Linking} from 'react-native';
import * as FileUtils from './FileUtils';
+import * as Link from '../actions/Link';
/**
* Downloading attachment in web, desktop
@@ -39,7 +39,7 @@ export default function fileDownload(url, fileName) {
})
.catch(() => {
// file could not be downloaded, open sourceURL in new tab
- Linking.openURL(url);
+ Link.openExternalLink(url);
return resolve();
});
});
diff --git a/src/libs/focusWithDelay.js b/src/libs/focusWithDelay.js
new file mode 100644
index 000000000000..367cc2b92f9f
--- /dev/null
+++ b/src/libs/focusWithDelay.js
@@ -0,0 +1,35 @@
+import {InteractionManager} from 'react-native';
+import ComposerFocusManager from './ComposerFocusManager';
+
+/**
+ * Create a function that focuses a text input.
+ * @param {Object} textInput the text input to focus
+ * @returns {Function} a function that focuses the text input with a configurable delay
+ */
+function focusWithDelay(textInput) {
+ /**
+ * Focus the text input
+ * @param {Boolean} [shouldDelay=false] Impose delay before focusing the text input
+ */
+ return (shouldDelay = false) => {
+ // There could be other animations running while we trigger manual focus.
+ // This prevents focus from making those animations janky.
+ InteractionManager.runAfterInteractions(() => {
+ if (!textInput) {
+ return;
+ }
+ if (!shouldDelay) {
+ textInput.focus();
+ return;
+ }
+ ComposerFocusManager.isReadyToFocus().then(() => {
+ if (!textInput) {
+ return;
+ }
+ textInput.focus();
+ });
+ });
+ };
+}
+
+export default focusWithDelay;
diff --git a/src/libs/getPlatform/index.android.js b/src/libs/getPlatform/index.android.js
deleted file mode 100644
index 1a343700a2f9..000000000000
--- a/src/libs/getPlatform/index.android.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.ANDROID;
-}
diff --git a/src/libs/getPlatform/index.android.ts b/src/libs/getPlatform/index.android.ts
new file mode 100644
index 000000000000..14ed65ace19d
--- /dev/null
+++ b/src/libs/getPlatform/index.android.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.ANDROID;
+}
diff --git a/src/libs/getPlatform/index.desktop.js b/src/libs/getPlatform/index.desktop.js
deleted file mode 100644
index c00ea00fd645..000000000000
--- a/src/libs/getPlatform/index.desktop.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.DESKTOP;
-}
diff --git a/src/libs/getPlatform/index.desktop.ts b/src/libs/getPlatform/index.desktop.ts
new file mode 100644
index 000000000000..c9f5720c541d
--- /dev/null
+++ b/src/libs/getPlatform/index.desktop.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.DESKTOP;
+}
diff --git a/src/libs/getPlatform/index.ios.js b/src/libs/getPlatform/index.ios.js
deleted file mode 100644
index d91604a5c41a..000000000000
--- a/src/libs/getPlatform/index.ios.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.IOS;
-}
diff --git a/src/libs/getPlatform/index.ios.ts b/src/libs/getPlatform/index.ios.ts
new file mode 100644
index 000000000000..8c21189b92e7
--- /dev/null
+++ b/src/libs/getPlatform/index.ios.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.IOS;
+}
diff --git a/src/libs/getPlatform/index.ts b/src/libs/getPlatform/index.ts
new file mode 100644
index 000000000000..c53963f5b80f
--- /dev/null
+++ b/src/libs/getPlatform/index.ts
@@ -0,0 +1,6 @@
+import CONST from '../../CONST';
+import Platform from './types';
+
+export default function getPlatform(): Platform {
+ return CONST.PLATFORM.WEB;
+}
diff --git a/src/libs/getPlatform/index.website.js b/src/libs/getPlatform/index.website.js
deleted file mode 100644
index 8b7e99c13c10..000000000000
--- a/src/libs/getPlatform/index.website.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import CONST from '../../CONST';
-
-export default function getPlatform() {
- return CONST.PLATFORM.WEB;
-}
diff --git a/src/libs/getPlatform/types.ts b/src/libs/getPlatform/types.ts
new file mode 100644
index 000000000000..ceddb4e17224
--- /dev/null
+++ b/src/libs/getPlatform/types.ts
@@ -0,0 +1,6 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+
+type Platform = ValueOf;
+
+export default Platform;
diff --git a/src/libs/isInputAutoFilled.js b/src/libs/isInputAutoFilled.js
index bfd967e850ba..8daa72484647 100644
--- a/src/libs/isInputAutoFilled.js
+++ b/src/libs/isInputAutoFilled.js
@@ -6,7 +6,7 @@ import isSelectorSupported from './isSelectorSupported';
* @return {Boolean}
*/
export default function isInputAutoFilled(input) {
- if (!input.matches) return false;
+ if (!input || !input.matches) return false;
if (isSelectorSupported(':autofill')) {
return input.matches(':-webkit-autofill') || input.matches(':autofill');
}
diff --git a/src/libs/searchCountryOptions.js b/src/libs/searchCountryOptions.js
index 84351665111d..5aa8a052374e 100644
--- a/src/libs/searchCountryOptions.js
+++ b/src/libs/searchCountryOptions.js
@@ -1,5 +1,5 @@
-import _ from 'underscore';
-import CONST from '../CONST';
+import _ from 'lodash';
+import StringUtils from './StringUtils';
/**
* Searches the countries/states data and returns sorted results based on the search query
@@ -8,15 +8,15 @@ import CONST from '../CONST';
* @returns {Object[]} An array of countries/states sorted based on the search query
*/
function searchCountryOptions(searchValue, countriesData) {
- const trimmedSearchValue = searchValue.toLowerCase().replaceAll(CONST.REGEX.NON_ALPHABETIC_AND_NON_LATIN_CHARS, '');
- if (trimmedSearchValue.length === 0) {
+ const trimmedSearchValue = StringUtils.sanitizeString(searchValue);
+ if (_.isEmpty(trimmedSearchValue)) {
return [];
}
- const filteredData = _.filter(countriesData, (country) => country.searchValue.includes(trimmedSearchValue));
+ const filteredData = _.filter(countriesData, (country) => _.includes(country.searchValue, trimmedSearchValue));
// sort by country code
- return _.sortBy(filteredData, (country) => (country.value.toLowerCase() === trimmedSearchValue ? -1 : 1));
+ return _.sortBy(filteredData, (country) => (_.toLower(country.value) === trimmedSearchValue ? -1 : 1));
}
export default searchCountryOptions;
diff --git a/src/libs/shouldReopenOnfido/index.android.js b/src/libs/shouldReopenOnfido/index.android.js
new file mode 100644
index 000000000000..ff3177babdde
--- /dev/null
+++ b/src/libs/shouldReopenOnfido/index.android.js
@@ -0,0 +1 @@
+export default true;
diff --git a/src/libs/shouldReopenOnfido/index.js b/src/libs/shouldReopenOnfido/index.js
new file mode 100644
index 000000000000..33136544dba2
--- /dev/null
+++ b/src/libs/shouldReopenOnfido/index.js
@@ -0,0 +1 @@
+export default false;
diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js
index 96140b33928f..8ef8b71b90d0 100644
--- a/src/pages/AddPersonalBankAccountPage.js
+++ b/src/pages/AddPersonalBankAccountPage.js
@@ -91,7 +91,7 @@ class AddPersonalBankAccountPage extends React.Component {
if (exitReportID) {
Navigation.dismissModal(exitReportID);
} else {
- Navigation.goBack(ROUTES.SETTINGS_PAYMENTS);
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
}
}
diff --git a/src/pages/DemoSetupPage.js b/src/pages/DemoSetupPage.js
new file mode 100644
index 000000000000..53739820142b
--- /dev/null
+++ b/src/pages/DemoSetupPage.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {useFocusEffect} from '@react-navigation/native';
+import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator';
+import CONST from '../CONST';
+import * as DemoActions from '../libs/actions/DemoActions';
+import Navigation from '../libs/Navigation/Navigation';
+
+const propTypes = {
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ /** The exact route name used to get to this screen */
+ name: PropTypes.string.isRequired,
+ }).isRequired,
+};
+
+/*
+ * This is a "utility page", that does this:
+ * - Looks at the current route
+ * - Determines if there's a demo command we need to call
+ * - If not, routes back to home
+ */
+function DemoSetupPage(props) {
+ useFocusEffect(() => {
+ // Depending on the route that the user hit to get here, run a specific demo flow
+ if (props.route.name === CONST.DEMO_PAGES.SAASTR) {
+ DemoActions.runSaastrDemo();
+ } else if (props.route.name === CONST.DEMO_PAGES.SBE) {
+ DemoActions.runSbeDemo();
+ } else {
+ Navigation.goBack();
+ }
+ });
+
+ return ;
+}
+
+DemoSetupPage.propTypes = propTypes;
+DemoSetupPage.displayName = 'DemoSetupPage';
+
+export default DemoSetupPage;
diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js
new file mode 100644
index 000000000000..d087d86ebdf5
--- /dev/null
+++ b/src/pages/EditRequestAmountPage.js
@@ -0,0 +1,79 @@
+import React, {useCallback, useRef} from 'react';
+import {InteractionManager} from 'react-native';
+import {useFocusEffect} from '@react-navigation/native';
+import PropTypes from 'prop-types';
+import ScreenWrapper from '../components/ScreenWrapper';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import Navigation from '../libs/Navigation/Navigation';
+import useLocalize from '../hooks/useLocalize';
+import MoneyRequestAmountForm from './iou/steps/MoneyRequestAmountForm';
+import ROUTES from '../ROUTES';
+
+const propTypes = {
+ /** Transaction default amount value */
+ defaultAmount: PropTypes.number.isRequired,
+
+ /** Transaction default currency value */
+ defaultCurrency: PropTypes.string.isRequired,
+
+ /** Callback to fire when the Save button is pressed */
+ onSubmit: PropTypes.func.isRequired,
+
+ /** reportID for the transaction thread */
+ reportID: PropTypes.string.isRequired,
+};
+
+function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, reportID}) {
+ const {translate} = useLocalize();
+ const textInput = useRef(null);
+
+ const focusTextInput = () => {
+ // Component may not be initialized due to navigation transitions
+ // Wait until interactions are complete before trying to focus
+ InteractionManager.runAfterInteractions(() => {
+ // Focus text input
+ if (!textInput.current) {
+ return;
+ }
+
+ textInput.current.focus();
+ });
+ };
+
+ const navigateToCurrencySelectionPage = () => {
+ // Remove query from the route and encode it.
+ const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, ''));
+ Navigation.navigate(ROUTES.getEditRequestCurrencyRoute(reportID, defaultCurrency, activeRoute));
+ };
+
+ useFocusEffect(
+ useCallback(() => {
+ focusTextInput();
+ }, []),
+ );
+
+ return (
+
+
+ (textInput.current = e)}
+ onCurrencyButtonPress={navigateToCurrencySelectionPage}
+ onSubmitButtonPress={onSubmit}
+ />
+
+ );
+}
+
+EditRequestAmountPage.propTypes = propTypes;
+EditRequestAmountPage.displayName = 'EditRequestAmountPage';
+
+export default EditRequestAmountPage;
diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js
new file mode 100644
index 000000000000..79633c214486
--- /dev/null
+++ b/src/pages/EditRequestCreatedPage.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ScreenWrapper from '../components/ScreenWrapper';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import Form from '../components/Form';
+import ONYXKEYS from '../ONYXKEYS';
+import styles from '../styles/styles';
+import Navigation from '../libs/Navigation/Navigation';
+import useLocalize from '../hooks/useLocalize';
+import NewDatePicker from '../components/NewDatePicker';
+
+const propTypes = {
+ /** Transaction defailt created value */
+ defaultCreated: PropTypes.string.isRequired,
+
+ /** Callback to fire when the Save button is pressed */
+ onSubmit: PropTypes.func.isRequired,
+};
+
+function EditRequestCreatedPage({defaultCreated, onSubmit}) {
+ const {translate} = useLocalize();
+
+ return (
+
+
+
+
+ );
+}
+
+EditRequestCreatedPage.propTypes = propTypes;
+EditRequestCreatedPage.displayName = 'EditRequestCreatedPage';
+
+export default EditRequestCreatedPage;
diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js
index 34f88f29dc28..1db81c88daae 100644
--- a/src/pages/EditRequestDescriptionPage.js
+++ b/src/pages/EditRequestDescriptionPage.js
@@ -2,7 +2,6 @@ import React, {useRef} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import TextInput from '../components/TextInput';
-import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
import ScreenWrapper from '../components/ScreenWrapper';
import HeaderWithBackButton from '../components/HeaderWithBackButton';
import Form from '../components/Form';
@@ -10,18 +9,18 @@ import ONYXKEYS from '../ONYXKEYS';
import styles from '../styles/styles';
import Navigation from '../libs/Navigation/Navigation';
import CONST from '../CONST';
+import useLocalize from '../hooks/useLocalize';
const propTypes = {
- ...withLocalizePropTypes,
-
- /** Transaction description default value */
+ /** Transaction default description value */
defaultDescription: PropTypes.string.isRequired,
/** Callback to fire when the Save button is pressed */
onSubmit: PropTypes.func.isRequired,
};
-function EditRequestDescriptionPage(props) {
+function EditRequestDescriptionPage({defaultDescription, onSubmit}) {
+ const {translate} = useLocalize();
const descriptionInputRef = useRef(null);
return (
descriptionInputRef.current && descriptionInputRef.current.focus()}
>
Navigation.goBack()}
/>
@@ -59,4 +59,4 @@ function EditRequestDescriptionPage(props) {
EditRequestDescriptionPage.propTypes = propTypes;
EditRequestDescriptionPage.displayName = 'EditRequestDescriptionPage';
-export default withLocalize(EditRequestDescriptionPage);
+export default EditRequestDescriptionPage;
diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js
new file mode 100644
index 000000000000..5c128599e74a
--- /dev/null
+++ b/src/pages/EditRequestMerchantPage.js
@@ -0,0 +1,61 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import TextInput from '../components/TextInput';
+import ScreenWrapper from '../components/ScreenWrapper';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import Form from '../components/Form';
+import ONYXKEYS from '../ONYXKEYS';
+import styles from '../styles/styles';
+import Navigation from '../libs/Navigation/Navigation';
+import CONST from '../CONST';
+import useLocalize from '../hooks/useLocalize';
+
+const propTypes = {
+ /** Transaction default merchant value */
+ defaultMerchant: PropTypes.string.isRequired,
+
+ /** Callback to fire when the Save button is pressed */
+ onSubmit: PropTypes.func.isRequired,
+};
+
+function EditRequestMerchantPage({defaultMerchant, onSubmit}) {
+ const {translate} = useLocalize();
+ const merchantInputRef = useRef(null);
+ return (
+ merchantInputRef.current && merchantInputRef.current.focus()}
+ >
+
+
+
+ );
+}
+
+EditRequestMerchantPage.propTypes = propTypes;
+EditRequestMerchantPage.displayName = 'EditRequestMerchantPage';
+
+export default EditRequestMerchantPage;
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index d2280ec78fd7..83b0019315e4 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -1,20 +1,25 @@
-import React from 'react';
+import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
+import compose from '../libs/compose';
import CONST from '../CONST';
import Navigation from '../libs/Navigation/Navigation';
-import compose from '../libs/compose';
-import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
import ONYXKEYS from '../ONYXKEYS';
import * as ReportActionsUtils from '../libs/ReportActionsUtils';
+import * as ReportUtils from '../libs/ReportUtils';
+import * as TransactionUtils from '../libs/TransactionUtils';
+import * as Policy from '../libs/actions/Policy';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../components/withCurrentUserPersonalDetails';
import EditRequestDescriptionPage from './EditRequestDescriptionPage';
+import EditRequestMerchantPage from './EditRequestMerchantPage';
+import EditRequestCreatedPage from './EditRequestCreatedPage';
+import EditRequestAmountPage from './EditRequestAmountPage';
import reportPropTypes from './reportPropTypes';
-import * as ReportUtils from '../libs/ReportUtils';
+import * as IOU from '../libs/actions/IOU';
+import * as CurrencyUtils from '../libs/CurrencyUtils';
const propTypes = {
- ...withLocalizePropTypes,
-
/** Route from navigation */
route: PropTypes.shape({
/** Params from the route */
@@ -29,33 +34,135 @@ const propTypes = {
/** The report object for the thread report */
report: reportPropTypes,
+
+ /** The parent report object for the thread report */
+ parentReport: reportPropTypes,
+
+ /** The policy object for the current route */
+ policy: PropTypes.shape({
+ /** The name of the policy */
+ name: PropTypes.string,
+
+ /** The URL for the policy avatar */
+ avatar: PropTypes.string,
+ }),
+
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user email */
+ email: PropTypes.string,
+ }),
+
+ ...withCurrentUserPersonalDetailsPropTypes,
};
const defaultProps = {
report: {},
+ parentReport: {},
+ policy: null,
+ session: {
+ email: null,
+ },
};
-function EditRequestPage(props) {
- const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
- const moneyRequestReportAction = ReportUtils.getMoneyRequestAction(parentReportAction);
- const transactionDescription = moneyRequestReportAction.comment;
- const field = lodashGet(props, ['route', 'params', 'field'], '');
+function EditRequestPage({report, route, parentReport, policy, session}) {
+ const parentReportAction = ReportActionsUtils.getParentReportAction(report);
+ const transaction = TransactionUtils.getLinkedTransaction(parentReportAction);
+ const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription, merchant: transactionMerchant} = ReportUtils.getTransactionDetails(transaction);
+
+ const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency;
+
+ // Take only the YYYY-MM-DD value
+ const transactionCreated = TransactionUtils.getCreated(transaction);
+ const fieldToEdit = lodashGet(route, ['params', 'field'], '');
+
+ const isDeleted = ReportActionsUtils.isDeletedAction(parentReportAction);
+ const isSettled = ReportUtils.isSettled(parentReport.reportID);
- function updateTransactionWithChanges(changes) {
- // Update the transaction...
- // eslint-disable-next-line no-console
- console.log({changes});
+ const isAdmin = Policy.isAdminOfFreePolicy([policy]) && ReportUtils.isExpenseReport(parentReport);
+ const isRequestor = ReportUtils.isMoneyRequestReport(parentReport) && lodashGet(session, 'accountID', null) === parentReportAction.actorAccountID;
+ const canEdit = !isSettled && !isDeleted && (isAdmin || isRequestor);
- // Note: The "modal" we are dismissing is the MoneyRequestAmountPage
+ // Dismiss the modal when the request is paid or deleted
+ useEffect(() => {
+ if (canEdit) {
+ return;
+ }
+ Navigation.dismissModal();
+ }, [canEdit]);
+
+ // Update the transaction object and close the modal
+ function editMoneyRequest(transactionChanges) {
+ IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges);
Navigation.dismissModal();
}
- if (field === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) {
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) {
return (
{
- updateTransactionWithChanges(changes);
+ onSubmit={(transactionChanges) => {
+ // In case the comment hasn't been changed, do not make the API request.
+ if (transactionChanges.comment.trim() === transactionDescription) {
+ Navigation.dismissModal();
+ return;
+ }
+ editMoneyRequest({comment: transactionChanges.comment.trim()});
+ }}
+ />
+ );
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) {
+ return (
+ {
+ // In case the date hasn't been changed, do not make the API request.
+ if (transactionChanges.created === transactionCreated) {
+ Navigation.dismissModal();
+ return;
+ }
+ editMoneyRequest(transactionChanges);
+ }}
+ />
+ );
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) {
+ return (
+ {
+ const amount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(transactionChanges));
+ // In case the amount hasn't been changed, do not make the API request.
+ if (amount === transactionAmount && transactionCurrency === defaultCurrency) {
+ Navigation.dismissModal();
+ return;
+ }
+ // Temporarily disabling currency editing and it will be enabled as a quick follow up
+ editMoneyRequest({
+ amount,
+ currency: defaultCurrency,
+ });
+ }}
+ />
+ );
+ }
+
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.MERCHANT) {
+ return (
+ {
+ // In case the merchant hasn't been changed, do not make the API request.
+ if (transactionChanges.merchant.trim() === transactionMerchant) {
+ Navigation.dismissModal();
+ return;
+ }
+ editMoneyRequest({merchant: transactionChanges.merchant.trim()});
}}
/>
);
@@ -68,10 +175,18 @@ EditRequestPage.displayName = 'EditRequestPage';
EditRequestPage.propTypes = propTypes;
EditRequestPage.defaultProps = defaultProps;
export default compose(
- withLocalize,
+ withCurrentUserPersonalDetails,
withOnyx({
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`,
},
}),
+ withOnyx({
+ parentReport: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report ? report.parentReportID : '0'}`,
+ },
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
+ },
+ }),
)(EditRequestPage);
diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js
index cdf2ad16ac1b..9804edd63fcd 100644
--- a/src/pages/EnablePayments/AdditionalDetailsStep.js
+++ b/src/pages/EnablePayments/AdditionalDetailsStep.js
@@ -18,7 +18,6 @@ import TextLink from '../../components/TextLink';
import TextInput from '../../components/TextInput';
import * as Wallet from '../../libs/actions/Wallet';
import * as ValidationUtils from '../../libs/ValidationUtils';
-import * as ErrorUtils from '../../libs/ErrorUtils';
import AddressForm from '../ReimbursementAccount/AddressForm';
import DatePicker from '../../components/DatePicker';
import Form from '../../components/Form';
@@ -70,29 +69,6 @@ const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
};
-const INPUT_IDS = {
- LEGAL_FIRST_NAME: 'legalFirstName',
- LEGAL_LAST_NAME: 'legalLastName',
- PHONE_NUMBER: 'phoneNumber',
- DOB: 'dob',
- SSN: 'ssn',
- ADDRESS: {
- street: 'addressStreet',
- city: 'addressCity',
- state: 'addressState',
- zipCode: 'addressZip',
- },
-};
-const errorTranslationKeys = {
- legalFirstName: 'bankAccount.error.firstName',
- legalLastName: 'bankAccount.error.lastName',
- phoneNumber: 'bankAccount.error.phoneNumber',
- dob: 'bankAccount.error.dob',
- age: 'bankAccount.error.age',
- ssn: 'bankAccount.error.ssnLast4',
- ssnFull9: 'additionalDetailsStep.ssnFull9Error',
-};
-
const fieldNameTranslationKeys = {
legalFirstName: 'additionalDetailsStep.legalFirstNameLabel',
legalLastName: 'additionalDetailsStep.legalLastNameLabel',
@@ -113,50 +89,37 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP
* @returns {Object}
*/
const validate = (values) => {
- const errors = {};
-
- if (_.isEmpty(values[INPUT_IDS.LEGAL_FIRST_NAME])) {
- errors[INPUT_IDS.LEGAL_FIRST_NAME] = errorTranslationKeys.legalFirstName;
- }
-
- if (_.isEmpty(values[INPUT_IDS.LEGAL_LAST_NAME])) {
- errors[INPUT_IDS.LEGAL_LAST_NAME] = errorTranslationKeys.legalLastName;
- }
-
- if (!ValidationUtils.isValidPastDate(values[INPUT_IDS.DOB]) || !ValidationUtils.meetsMaximumAgeRequirement(values[INPUT_IDS.DOB])) {
- ErrorUtils.addErrorMessage(errors, INPUT_IDS.DOB, errorTranslationKeys.dob);
- } else if (!ValidationUtils.meetsMinimumAgeRequirement(values[INPUT_IDS.DOB])) {
- ErrorUtils.addErrorMessage(errors, INPUT_IDS.DOB, errorTranslationKeys.age);
- }
+ const requiredFields = ['legalFirstName', 'legalLastName', 'addressStreet', 'addressCity', 'addressZipCode', 'phoneNumber', 'dob', 'ssn', 'addressState'];
+ const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
- if (!ValidationUtils.isValidAddress(values[INPUT_IDS.ADDRESS.street]) || _.isEmpty(values[INPUT_IDS.ADDRESS.street])) {
- errors[INPUT_IDS.ADDRESS.street] = 'bankAccount.error.addressStreet';
- }
-
- if (_.isEmpty(values[INPUT_IDS.ADDRESS.city])) {
- errors[INPUT_IDS.ADDRESS.city] = 'bankAccount.error.addressCity';
+ if (values.dob) {
+ if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) {
+ errors.dob = 'bankAccount.error.dob';
+ } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) {
+ errors.dob = 'bankAccount.error.age';
+ }
}
- if (_.isEmpty(values[INPUT_IDS.ADDRESS.state])) {
- errors[INPUT_IDS.ADDRESS.state] = 'bankAccount.error.addressState';
+ if (values.addressStreet && !ValidationUtils.isValidAddress(values.addressStreet)) {
+ errors.addressStreet = 'bankAccount.error.addressStreet';
}
- if (!ValidationUtils.isValidZipCode(values[INPUT_IDS.ADDRESS.zipCode])) {
- errors[INPUT_IDS.ADDRESS.zipCode] = 'bankAccount.error.zipCode';
+ if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) {
+ errors.addressZipCode = 'bankAccount.error.zipCode';
}
- if (!ValidationUtils.isValidUSPhone(values[INPUT_IDS.PHONE_NUMBER], true)) {
- errors[INPUT_IDS.PHONE_NUMBER] = errorTranslationKeys.phoneNumber;
+ if (values.phoneNumber && !ValidationUtils.isValidUSPhone(values.phoneNumber, true)) {
+ errors.phoneNumber = 'bankAccount.error.phoneNumber';
}
// walletAdditionalDetails stores errors returned by the server. If the server returns an SSN error
// then the user needs to provide the full 9 digit SSN.
if (walletAdditionalDetails.errorCode === CONST.WALLET.ERROR.SSN) {
- if (!ValidationUtils.isValidSSNFullNine(values[INPUT_IDS.SSN])) {
- errors[INPUT_IDS.SSN] = errorTranslationKeys.ssnFull9;
+ if (values.ssn && !ValidationUtils.isValidSSNFullNine(values.ssn)) {
+ errors.ssn = 'additionalDetailsStep.ssnFull9Error';
}
- } else if (!ValidationUtils.isValidSSNLastFour(values[INPUT_IDS.SSN])) {
- errors[INPUT_IDS.SSN] = errorTranslationKeys.ssn;
+ } else if (values.ssn && !ValidationUtils.isValidSSNLastFour(values.ssn)) {
+ errors.ssn = 'bankAccount.error.ssnLast4';
}
return errors;
@@ -167,17 +130,16 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP
*/
const activateWallet = (values) => {
const personalDetails = {
- phoneNumber: parsePhoneNumber(values[INPUT_IDS.PHONE_NUMBER], {regionCode: CONST.COUNTRY.US}).number.significant,
- legalFirstName: values[INPUT_IDS.LEGAL_FIRST_NAME],
- legalLastName: values[INPUT_IDS.LEGAL_LAST_NAME],
- addressStreet: values[INPUT_IDS.ADDRESS.street],
- addressCity: values[INPUT_IDS.ADDRESS.city],
- addressState: values[INPUT_IDS.ADDRESS.state],
- addressZip: values[INPUT_IDS.ADDRESS.zipCode],
- dob: values[INPUT_IDS.DOB],
- ssn: values[INPUT_IDS.SSN],
+ phoneNumber: parsePhoneNumber(values.phoneNumber, {regionCode: CONST.COUNTRY.US}).number.significant,
+ legalFirstName: values.legalFirstName,
+ legalLastName: values.legalLastName,
+ addressStreet: values.addressStreet,
+ addressCity: values.addressCity,
+ addressState: values.addressState,
+ addressZip: values.addressZipCode,
+ dob: values.dob,
+ ssn: values.ssn,
};
-
// Attempt to set the personal details
Wallet.updatePersonalDetails(personalDetails);
};
@@ -222,7 +184,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP
style={[styles.mh5, styles.flexGrow1]}
>
Navigation.goBack(ROUTES.SETTINGS_PAYMENTS)}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
>
diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js
index d94a57da88eb..85ceb03b01d5 100644
--- a/src/pages/EnablePayments/OnfidoPrivacy.js
+++ b/src/pages/EnablePayments/OnfidoPrivacy.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, {useRef} from 'react';
import {View} from 'react-native';
import lodashGet from 'lodash/get';
import _ from 'underscore';
@@ -35,57 +35,53 @@ const defaultProps = {
},
};
-class OnfidoPrivacy extends React.Component {
- constructor(props) {
- super(props);
+function OnfidoPrivacy({walletOnfidoData, translate, form}) {
+ const {isLoading = false, hasAcceptedPrivacyPolicy} = walletOnfidoData;
- this.openOnfidoFlow = this.openOnfidoFlow.bind(this);
- }
+ const formRef = useRef(null);
- openOnfidoFlow() {
+ const openOnfidoFlow = () => {
BankAccounts.openOnfidoFlow();
- }
+ };
- render() {
- let onfidoError = ErrorUtils.getLatestErrorMessage(this.props.walletOnfidoData) || '';
- const onfidoFixableErrors = lodashGet(this.props, 'walletOnfidoData.fixableErrors', []);
- onfidoError += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : '';
+ let onfidoError = ErrorUtils.getLatestErrorMessage(walletOnfidoData) || '';
+ const onfidoFixableErrors = lodashGet(walletOnfidoData, 'fixableErrors', []);
+ onfidoError += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : '';
- return (
-
- {!this.props.walletOnfidoData.hasAcceptedPrivacyPolicy ? (
- <>
- (this.form = el)}>
-
-
- {this.props.translate('onfidoStep.acceptTerms')}
- {this.props.translate('onfidoStep.facialScan')}
- {', '}
- {this.props.translate('common.privacy')}
- {` ${this.props.translate('common.and')} `}
- {this.props.translate('common.termsOfService')} .
-
-
-
-
- {
- this.form.scrollTo({y: 0, animated: true});
- }}
- message={onfidoError}
- isLoading={this.props.walletOnfidoData.isLoading}
- buttonText={onfidoError ? this.props.translate('onfidoStep.tryAgain') : this.props.translate('common.continue')}
- containerStyles={[styles.mh0, styles.mv0, styles.mb0]}
- />
-
- >
- ) : null}
- {this.props.walletOnfidoData.hasAcceptedPrivacyPolicy && this.props.walletOnfidoData.isLoading ? : null}
-
- );
- }
+ return (
+
+ {!hasAcceptedPrivacyPolicy ? (
+ <>
+
+
+
+ {translate('onfidoStep.acceptTerms')}
+ {translate('onfidoStep.facialScan')}
+ {', '}
+ {translate('common.privacy')}
+ {` ${translate('common.and')} `}
+ {translate('common.termsOfService')} .
+
+
+
+
+ {
+ form.scrollTo({y: 0, animated: true});
+ }}
+ message={onfidoError}
+ isLoading={isLoading}
+ buttonText={onfidoError ? translate('onfidoStep.tryAgain') : translate('common.continue')}
+ containerStyles={[styles.mh0, styles.mv0, styles.mb0]}
+ />
+
+ >
+ ) : null}
+ {hasAcceptedPrivacyPolicy && isLoading ? : null}
+
+ );
}
OnfidoPrivacy.propTypes = propTypes;
diff --git a/src/pages/ErrorPage/NotFoundPage.js b/src/pages/ErrorPage/NotFoundPage.js
index 98d8d77cf821..f0121cd8f3ef 100644
--- a/src/pages/ErrorPage/NotFoundPage.js
+++ b/src/pages/ErrorPage/NotFoundPage.js
@@ -6,10 +6,7 @@ import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoun
function NotFoundPage() {
return (
-
+
);
}
diff --git a/src/pages/GetAssistancePage.js b/src/pages/GetAssistancePage.js
index 6281b8cd769f..34f996936654 100644
--- a/src/pages/GetAssistancePage.js
+++ b/src/pages/GetAssistancePage.js
@@ -1,5 +1,5 @@
import React from 'react';
-import {View, ScrollView, Linking} from 'react-native';
+import {View, ScrollView} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
@@ -68,7 +68,7 @@ function GetAssistancePage(props) {
if (guideCalendarLink) {
menuItems.splice(1, 0, {
title: props.translate('getAssistancePage.scheduleSetupCall'),
- onPress: () => Linking.openURL(guideCalendarLink),
+ onPress: () => Link.openExternalLink(guideCalendarLink),
icon: Expensicons.Phone,
shouldShowRightIcon: true,
iconRight: Expensicons.NewWindow,
diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js
index 94f84aae6943..4a753c8632bd 100755
--- a/src/pages/NewChatPage.js
+++ b/src/pages/NewChatPage.js
@@ -27,7 +27,7 @@ const propTypes = {
betas: PropTypes.arrayOf(PropTypes.string),
/** All of the personal details for everyone */
- personalDetails: personalDetailsPropType,
+ personalDetails: PropTypes.objectOf(personalDetailsPropType),
/** All reports shared with the user */
reports: PropTypes.objectOf(reportPropTypes),
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 6500eef0548f..b5ef85e14cbb 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -34,8 +34,8 @@ import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator
import BlockingView from '../components/BlockingViews/BlockingView';
import * as Illustrations from '../components/Icon/Illustrations';
import variables from '../styles/variables';
-import ROUTES from '../ROUTES';
import * as ValidationUtils from '../libs/ValidationUtils';
+import Permissions from '../libs/Permissions';
const matchType = PropTypes.shape({
params: PropTypes.shape({
@@ -133,11 +133,18 @@ function ProfilePage(props) {
// If the API returns an error for some reason there won't be any details and isLoading will get set to false, so we want to show a blocking screen
const shouldShowBlockingView = !hasMinimumDetails && !isLoading;
+ const statusEmojiCode = lodashGet(details, 'status.emojiCode', '');
+ const statusText = lodashGet(details, 'status.text', '');
+ const hasStatus = !!statusEmojiCode && Permissions.canUseCustomStatus(props.betas);
+ const statusContent = `${statusEmojiCode} ${statusText}`;
+
+ const navigateBackTo = lodashGet(props.route, 'params.backTo', '');
+
return (
Navigation.goBack(ROUTES.HOME)}
+ onBackButtonPress={() => Navigation.goBack(navigateBackTo)}
/>
)}
+ {hasStatus && (
+
+
+ {props.translate('statusPage.status')}
+
+ {statusContent}
+
+ )}
+
{login ? (
)}
@@ -248,5 +269,8 @@ export default compose(
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
}),
)(ProfilePage);
diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js
index 6083661b0d0d..1ef43843571c 100644
--- a/src/pages/ReimbursementAccount/ACHContractStep.js
+++ b/src/pages/ReimbursementAccount/ACHContractStep.js
@@ -154,7 +154,7 @@ function ACHContractStep(props) {
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT}
/>
+
+ {!isSmallScreenWidth && }
+
+
+
+
+ {isEmojiSuggestionsMenuVisible && (
+ setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))}
+ highlightedEmojiIndex={highlightedEmojiIndex}
+ emojis={suggestionValues.suggestedEmojis}
+ comment={value}
+ updateComment={(newComment) => setValue(newComment)}
+ colonIndex={suggestionValues.colonIndex}
+ prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)}
+ onSelect={insertSelectedEmoji}
+ isComposerFullSize={isComposerFullSize}
+ preferredSkinToneIndex={preferredSkinTone}
+ isEmojiPickerLarge={suggestionValues.isAutoSuggestionPickerLarge}
+ composerHeight={composerHeight}
+ shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
+ containerRef={containerRef}
+ />
+ )}
+ {isMentionSuggestionsMenuVisible && (
+ setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}))}
+ highlightedMentionIndex={highlightedMentionIndex}
+ mentions={suggestionValues.suggestedMentions}
+ comment={value}
+ updateComment={(newComment) => setValue(newComment)}
+ colonIndex={suggestionValues.colonIndex}
+ prefix={suggestionValues.mentionPrefix}
+ onSelect={insertSelectedMention}
+ isComposerFullSize={isComposerFullSize}
+ isMentionPickerLarge={suggestionValues.isAutoSuggestionPickerLarge}
+ composerHeight={composerHeight}
+ shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
+ containerRef={containerRef}
+ />
+ )}
+
+ );
}
ReportActionCompose.propTypes = propTypes;
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 3c71b8bc62f3..e5b199d1c994 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -1,6 +1,6 @@
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import React, {useState, useRef, useEffect, memo, useCallback} from 'react';
+import React, {useState, useRef, useEffect, memo, useCallback, useContext} from 'react';
import {InteractionManager, View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -64,6 +64,8 @@ import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils';
import ReportActionItemBasicMessage from './ReportActionItemBasicMessage';
import * as store from '../../../libs/actions/ReimbursementAccount/store';
import * as BankAccounts from '../../../libs/actions/BankAccounts';
+import ReportScreenContext from '../ReportScreenContext';
+import Permissions from '../../../libs/Permissions';
const propTypes = {
...windowDimensionsPropTypes,
@@ -125,24 +127,29 @@ function ReportActionItem(props) {
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(ReportScreenContext);
const textInputRef = useRef();
const popoverAnchorRef = useRef();
const downloadedPreviews = useRef([]);
+ const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action);
+ const originalReport = props.report.reportID === originalReportID ? props.report : ReportUtils.getReport(originalReportID);
useEffect(
() => () => {
- // ReportActionContextMenu and EmojiPicker are global component,
- // we use showContextMenu and showEmojiPicker to show them,
- // so we should also hide them when the current component is destroyed
+ // ReportActionContextMenu, EmojiPicker and PopoverReactionList are global components,
+ // we should also hide them when the current component is destroyed
if (ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) {
ReportActionContextMenu.hideContextMenu();
ReportActionContextMenu.hideDeleteModal();
}
- if (EmojiPickerAction.isActiveReportAction(props.action.reportActionID)) {
+ if (EmojiPickerAction.isActive(props.action.reportActionID)) {
EmojiPickerAction.hideEmojiPicker(true);
}
+ if (reactionListRef.current && reactionListRef.current.isActiveReportAction(props.action.reportActionID)) {
+ reactionListRef.current.hideReactionList();
+ }
},
- [props.action.reportActionID],
+ [props.action.reportActionID, reactionListRef],
);
const isDraftEmpty = !props.draftMessage;
@@ -155,6 +162,10 @@ function ReportActionItem(props) {
}, [isDraftEmpty]);
useEffect(() => {
+ if (!Permissions.canUseLinkPreviews()) {
+ return;
+ }
+
const urls = ReportActionsUtils.extractLinksFromMessageHtml(props.action);
if (_.isEqual(downloadedPreviews.current, urls) || props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
return;
@@ -204,7 +215,6 @@ function ReportActionItem(props) {
}
setIsContextMenuActive(true);
-
const selection = SelectionScraper.getCurrentSelection();
ReportActionContextMenu.showContextMenu(
ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION,
@@ -216,11 +226,11 @@ function ReportActionItem(props) {
props.draftMessage,
() => {},
toggleContextMenuFromActiveReportAction,
- ReportUtils.isArchivedRoom(props.report),
- ReportUtils.chatIncludesChronos(props.report),
+ ReportUtils.isArchivedRoom(originalReport),
+ ReportUtils.chatIncludesChronos(originalReport),
);
},
- [props.draftMessage, props.action, props.report, toggleContextMenuFromActiveReportAction],
+ [props.draftMessage, props.action, props.report.reportID, toggleContextMenuFromActiveReportAction, originalReport],
);
const toggleReaction = useCallback(
@@ -233,16 +243,17 @@ function ReportActionItem(props) {
/**
* Get the content of ReportActionItem
* @param {Boolean} hovered whether the ReportActionItem is hovered
+ * @param {Boolean} hasErrors whether the report action has any errors
* @returns {Object} child component(s)
*/
- const renderItemContent = (hovered = false) => {
+ const renderItemContent = (hovered = 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 IOUPreview for when request was created, bill was split or money was sent
+ // Show the MoneyRequestPreview for when request was created, bill was split or money was sent
if (
props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
originalMessage &&
@@ -312,10 +323,10 @@ function ReportActionItem(props) {
) : null}
);
+ } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) {
+ children = ;
} else {
- const message = _.last(lodashGet(props.action, 'message', [{}]));
const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision);
- const isAttachment = _.has(props.action, 'isAttachment') ? props.action.isAttachment : ReportUtils.isReportMessageAttachment(message);
children = (
{isHidden ? props.translate('moderation.revealMessage') : props.translate('moderation.hideMessage')}
@@ -380,7 +392,7 @@ function ReportActionItem(props) {
return (
<>
{children}
- {!isHidden && !_.isEmpty(props.action.linkMetadata) && (
+ {Permissions.canUseLinkPreviews() && !isHidden && !_.isEmpty(props.action.linkMetadata) && (
!_.isEmpty(item))} />
@@ -390,6 +402,7 @@ function ReportActionItem(props) {
{
if (Session.isAnonymousUser()) {
hideContextMenu(false);
@@ -425,10 +438,11 @@ function ReportActionItem(props) {
* Get ReportActionItem with a proper wrapper
* @param {Boolean} hovered whether the ReportActionItem is hovered
* @param {Boolean} isWhisper whether the ReportActionItem is a whisper
+ * @param {Boolean} hasErrors whether the report action has any errors
* @returns {Object} report action item
*/
- const renderReportActionItem = (hovered, isWhisper) => {
- const content = renderItemContent(hovered || isContextMenuActive);
+ const renderReportActionItem = (hovered, isWhisper, hasErrors) => {
+ const content = renderItemContent(hovered || isContextMenuActive, hasErrors);
if (props.draftMessage) {
return {content} ;
@@ -455,29 +469,44 @@ function ReportActionItem(props) {
};
if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
- const parentReport = ReportActionsUtils.getParentReportAction(props.report);
- if (ReportActionsUtils.isTransactionThread(parentReport)) {
+ const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
+ if (ReportActionsUtils.isTransactionThread(parentReportAction)) {
return (
-
+
+
+
+
+
);
}
if (ReportUtils.isTaskReport(props.report)) {
return (
-
+
+
+
);
}
if (ReportUtils.isExpenseReport(props.report) || ReportUtils.isIOUReport(props.report)) {
return (
-
+
+
+
);
}
return (
@@ -524,11 +553,11 @@ function ReportActionItem(props) {
)}
- {renderReportActionItem(hovered, isWhisper)}
+ {renderReportActionItem(hovered, isWhisper, hasErrors)}
diff --git a/src/pages/home/report/ReportActionItemCreated.js b/src/pages/home/report/ReportActionItemCreated.js
index b7716e22a851..4ae4fe81e4ac 100644
--- a/src/pages/home/report/ReportActionItemCreated.js
+++ b/src/pages/home/report/ReportActionItemCreated.js
@@ -89,7 +89,10 @@ function ReportActionItemCreated(props) {
/>
-
+
diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js
index 14a5eea2676e..7dcb2b51dbf3 100644
--- a/src/pages/home/report/ReportActionItemMessage.js
+++ b/src/pages/home/report/ReportActionItemMessage.js
@@ -5,6 +5,7 @@ import _ from 'underscore';
import lodashGet from 'lodash/get';
import styles from '../../../styles/styles';
import ReportActionItemFragment from './ReportActionItemFragment';
+import * as ReportUtils from '../../../libs/ReportUtils';
import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
import reportActionPropTypes from './reportActionPropTypes';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
@@ -13,6 +14,9 @@ const propTypes = {
/** The report action */
action: PropTypes.shape(reportActionPropTypes).isRequired,
+ /** Should the comment have the appearance of being grouped with the previous comment? */
+ displayAsGroup: PropTypes.bool.isRequired,
+
/** Additional styles to add after local styles. */
style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
@@ -29,10 +33,12 @@ const defaultProps = {
};
function ReportActionItemMessage(props) {
+ const messages = _.compact(props.action.previousMessage || props.action.message);
+ const isAttachment = ReportUtils.isReportMessageAttachment(_.last(messages));
return (
-
+
{!props.isHidden ? (
- _.map(_.compact(props.action.previousMessage || props.action.message), (fragment, index) => (
+ _.map(messages, (fragment, index) => (
{
// Skip if this is not the focused message so the other edit composer stays focused.
// In small screen devices, when EmojiPicker is shown, the current edit message will lose focus, we need to check this case as well.
- if (!isFocusedRef.current && !EmojiPickerAction.isActiveReportAction(props.action.reportActionID)) {
+ if (!isFocusedRef.current && !EmojiPickerAction.isActive(props.action.reportActionID)) {
return;
}
@@ -165,7 +174,7 @@ function ReportActionItemMessageEdit(props) {
*/
const updateDraft = useCallback(
(newDraftInput) => {
- const {text: newDraft = '', emojis = []} = EmojiUtils.replaceEmojis(newDraftInput, props.preferredSkinTone, props.preferredLocale);
+ const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, props.preferredLocale);
if (!_.isEmpty(emojis)) {
insertedEmojis.current = [...insertedEmojis.current, ...emojis];
@@ -203,6 +212,7 @@ function ReportActionItemMessageEdit(props) {
debouncedSaveDraft.cancel();
Report.saveReportActionDraft(props.reportID, props.action.reportActionID, '');
ComposerActions.setShouldShowComposeInput(true);
+ ReportActionComposeFocusManager.clear();
ReportActionComposeFocusManager.focus();
// Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report.
@@ -230,6 +240,21 @@ function ReportActionItemMessageEdit(props) {
const trimmedNewDraft = draft.trim();
+ const report = ReportUtils.getReport(props.reportID);
+
+ // Updates in child message should cause the parent draft message to change
+ if (report.parentReportActionID && lodashGet(props.action, 'childType', '') === CONST.REPORT.TYPE.CHAT) {
+ if (lodashGet(props.drafts, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${report.parentReportID}_${props.action.reportActionID}`], undefined)) {
+ Report.saveReportActionDraft(report.parentReportID, props.action.reportActionID, trimmedNewDraft);
+ }
+ }
+ // Updates in the parent message should cause the child draft message to change
+ if (props.action.childReportID) {
+ if (lodashGet(props.drafts, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.action.childReportID}_${props.action.reportActionID}`], undefined)) {
+ Report.saveReportActionDraft(props.action.childReportID, props.action.reportActionID, trimmedNewDraft);
+ }
+ }
+
// When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting.
if (!trimmedNewDraft) {
ReportActionContextMenu.showDeleteModal(props.reportID, props.action, false, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus()));
@@ -237,7 +262,7 @@ function ReportActionItemMessageEdit(props) {
}
Report.editReportComment(props.reportID, props.action, trimmedNewDraft);
deleteDraft();
- }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID]);
+ }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID, props.drafts]);
/**
* @param {String} emoji
@@ -271,6 +296,11 @@ function ReportActionItemMessageEdit(props) {
[deleteDraft, isKeyboardShown, isSmallScreenWidth, publishDraft],
);
+ /**
+ * Focus the composer text input
+ */
+ const focus = focusWithDelay(textInputRef.current);
+
return (
<>
@@ -346,10 +376,13 @@ function ReportActionItemMessageEdit(props) {
InteractionManager.runAfterInteractions(() => textInputRef.current.focus())}
+ onModalHide={() => {
+ setIsFocused(true);
+ focus(true);
+ }}
onEmojiSelected={addEmojiToTextBox}
nativeID={emojiButtonID}
- reportAction={props.action}
+ emojiPickerID={props.action.reportActionID}
/>
@@ -386,7 +419,12 @@ ReportActionItemMessageEdit.propTypes = propTypes;
ReportActionItemMessageEdit.defaultProps = defaultProps;
ReportActionItemMessageEdit.displayName = 'ReportActionItemMessageEdit';
-export default withLocalize(
+export default compose(
+ withLocalize,
+ withReportActionsDrafts({
+ propName: 'drafts',
+ }),
+)(
React.forwardRef((props, ref) => (
{
function ReportActionItemSingle(props) {
const actorAccountID = props.action.actorAccountID;
let {displayName} = props.personalDetailsList[actorAccountID] || {};
- const {avatar, login, pendingFields} = props.personalDetailsList[actorAccountID] || {};
+ const {avatar, login, pendingFields, status} = props.personalDetailsList[actorAccountID] || {};
let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(props.report) && !actorAccountID;
let avatarSource = UserUtils.getAvatar(avatar, actorAccountID);
@@ -105,7 +111,7 @@ function ReportActionItemSingle(props) {
// If this is a report preview, display names and avatars of both people involved
let secondaryAvatar = {};
- const displayAllActors = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport;
+ const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]);
const primaryDisplayName = displayName;
if (displayAllActors) {
const secondaryUserDetails = props.personalDetailsList[props.iouReport.ownerAccountID] || {};
@@ -138,9 +144,14 @@ function ReportActionItemSingle(props) {
if (isWorkspaceActor) {
showWorkspaceDetails(props.report.reportID);
} else {
+ // Show participants page IOU report preview
+ if (displayAllActors) {
+ Navigation.navigate(ROUTES.getReportParticipantsRoute(props.iouReport.reportID));
+ return;
+ }
showUserDetails(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID);
}
- }, [isWorkspaceActor, props.report.reportID, actorAccountID, props.action.delegateAccountID]);
+ }, [isWorkspaceActor, props.report.reportID, actorAccountID, props.action.delegateAccountID, props.iouReport, displayAllActors]);
const shouldDisableDetailPage = useMemo(
() => !isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID),
@@ -189,6 +200,10 @@ function ReportActionItemSingle(props) {
);
};
+ const hasEmojiStatus = !displayAllActors && status && status.emojiCode && Permissions.canUseCustomStatus(props.betas);
+ const formattedDate = DateUtils.getStatusUntilDate(lodashGet(status, 'clearAfter'));
+ const statusText = lodashGet(status, 'text', '');
+ const statusTooltipText = formattedDate ? `${statusText} (${formattedDate})` : statusText;
return (
@@ -228,6 +243,14 @@ function ReportActionItemSingle(props) {
/>
))}
+ {Boolean(hasEmojiStatus) && (
+
+ {`${status.emojiCode}`}
+
+ )}
) : null}
@@ -241,4 +264,12 @@ ReportActionItemSingle.propTypes = propTypes;
ReportActionItemSingle.defaultProps = defaultProps;
ReportActionItemSingle.displayName = 'ReportActionItemSingle';
-export default compose(withLocalize, withPersonalDetails())(ReportActionItemSingle);
+export default compose(
+ withLocalize,
+ withPersonalDetails(),
+ withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ }),
+)(ReportActionItemSingle);
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index fa94db11c4a2..7f897ee825fb 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -1,14 +1,15 @@
import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useState} from 'react';
+import React, {useCallback, useEffect, useState, useRef, useMemo} from 'react';
import Animated, {useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
import _ from 'underscore';
import InvertedFlatList from '../../../components/InvertedFlatList';
import compose from '../../../libs/compose';
import styles from '../../../styles/styles';
import * as ReportUtils from '../../../libs/ReportUtils';
+import * as Report from '../../../libs/actions/Report';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails';
-import {withNetwork, withPersonalDetails} from '../../../components/OnyxProvider';
+import {withPersonalDetails} from '../../../components/OnyxProvider';
import ReportActionItem from './ReportActionItem';
import ReportActionItemParentAction from './ReportActionItemParentAction';
import ReportActionsSkeletonView from '../../../components/ReportActionsSkeletonView';
@@ -17,14 +18,13 @@ import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
import reportActionPropTypes from './reportActionPropTypes';
import CONST from '../../../CONST';
import reportPropTypes from '../../reportPropTypes';
-import networkPropTypes from '../../../components/networkPropTypes';
-import withLocalize from '../../../components/withLocalize';
+import useLocalize from '../../../hooks/useLocalize';
+import useNetwork from '../../../hooks/useNetwork';
+import DateUtils from '../../../libs/DateUtils';
+import FloatingMessageCounter from './FloatingMessageCounter';
import useReportScrollManager from '../../../hooks/useReportScrollManager';
const propTypes = {
- /** Position of the "New" line marker */
- newMarkerReportActionID: PropTypes.string,
-
/** The report currently being looked at */
report: reportPropTypes.isRequired,
@@ -41,14 +41,11 @@ const propTypes = {
onLayout: PropTypes.func.isRequired,
/** Callback executed on scroll */
- onScroll: PropTypes.func.isRequired,
+ onScroll: PropTypes.func,
/** Function to load more chats */
loadMoreChats: PropTypes.func.isRequired,
- /** Information about the network */
- network: networkPropTypes.isRequired,
-
/** The policy object for the current route */
policy: PropTypes.shape({
/** The name of the policy */
@@ -63,13 +60,20 @@ const propTypes = {
};
const defaultProps = {
- newMarkerReportActionID: '',
personalDetails: {},
+ onScroll: () => {},
mostRecentIOUReportActionID: '',
isLoadingMoreReportActions: false,
...withCurrentUserPersonalDetailsDefaultProps,
};
+const VERTICAL_OFFSET_THRESHOLD = 200;
+const MSG_VISIBLE_THRESHOLD = 250;
+
+// Seems that there is an architecture issue that prevents us from using the reportID with useRef
+// the useRef value gets reset when the reportID changes, so we use a global variable to keep track
+let prevReportID = null;
+
/**
* Create a unique key for each action in the FlatList.
* We use the reportActionID that is a string representation of a random 64-bit int, which should be
@@ -82,36 +86,136 @@ function keyExtractor(item) {
return item.reportActionID;
}
-function ReportActionsList(props) {
+function isMessageUnread(message, lastReadTime) {
+ return Boolean(message && lastReadTime && message.created && lastReadTime < message.created);
+}
+
+function ReportActionsList({
+ report,
+ sortedReportActions,
+ windowHeight,
+ onScroll,
+ mostRecentIOUReportActionID,
+ isSmallScreenWidth,
+ personalDetailsList,
+ currentUserPersonalDetails,
+ hasOutstandingIOU,
+ loadMoreChats,
+ onLayout,
+ isComposerFullSize,
+}) {
const reportScrollManager = useReportScrollManager();
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
const opacity = useSharedValue(0);
+ const userActiveSince = useRef(null);
+ const currentUnreadMarker = useRef(null);
+ const scrollingVerticalOffset = useRef(0);
+ const readActionSkipped = useRef(false);
+ const reportActionSize = useRef(sortedReportActions.length);
+
+ // Considering that renderItem is enclosed within a useCallback, marking it as "read" twice will retain the value as "true," preventing the useCallback from re-executing.
+ // However, if we create and listen to an object, it will lead to a new useCallback execution.
+ const [messageManuallyMarked, setMessageManuallyMarked] = useState({read: false});
+ const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false);
const animatedStyles = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
+
useEffect(() => {
opacity.value = withTiming(1, {duration: 100});
}, [opacity]);
const [skeletonViewHeight, setSkeletonViewHeight] = useState(0);
- const windowHeight = props.windowHeight;
+ useEffect(() => {
+ // If the reportID changes, we reset the userActiveSince to null, we need to do it because
+ // the parent component is sending the previous reportID even when the user isn't active
+ // on the report
+ if (userActiveSince.current && prevReportID && prevReportID !== report.reportID) {
+ userActiveSince.current = null;
+ } else {
+ userActiveSince.current = DateUtils.getDBTime();
+ }
+ prevReportID = report.reportID;
+ }, [report.reportID]);
+
+ useEffect(() => {
+ if (!userActiveSince.current || report.reportID !== prevReportID) {
+ return;
+ }
+
+ if (ReportUtils.isUnread(report)) {
+ if (scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD) {
+ Report.readNewestAction(report.reportID);
+ } else {
+ readActionSkipped.current = true;
+ }
+ }
+
+ if (currentUnreadMarker.current || reportActionSize.current === sortedReportActions.length) {
+ return;
+ }
+
+ reportActionSize.current = sortedReportActions.length;
+ currentUnreadMarker.current = null;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sortedReportActions.length, report.reportID]);
+
+ useEffect(() => {
+ const didManuallyMarkReportAsUnread = report.lastReadTime < DateUtils.getDBTime() && ReportUtils.isUnread(report);
+ if (!didManuallyMarkReportAsUnread) {
+ setMessageManuallyMarked({read: false});
+ return;
+ }
+
+ // Clearing the current unread marker so that it can be recalculated
+ currentUnreadMarker.current = null;
+ setMessageManuallyMarked({read: true});
+
+ // We only care when a new lastReadTime is set in the report
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [report.lastReadTime]);
+
+ /**
+ * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages.
+ */
+ const handleUnreadFloatingButton = () => {
+ if (scrollingVerticalOffset.current > VERTICAL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !!currentUnreadMarker.current) {
+ setIsFloatingMessageCounterVisible(true);
+ }
+
+ if (scrollingVerticalOffset.current < VERTICAL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible) {
+ if (readActionSkipped.current) {
+ readActionSkipped.current = false;
+ Report.readNewestAction(report.reportID);
+ }
+ setIsFloatingMessageCounterVisible(false);
+ }
+ };
+
+ const trackVerticalScrolling = (event) => {
+ scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y;
+ handleUnreadFloatingButton();
+ onScroll(event);
+ };
+
+ const scrollToBottomAndMarkReportAsRead = () => {
+ reportScrollManager.scrollToBottom();
+ readActionSkipped.current = false;
+ Report.readNewestAction(report.reportID);
+ };
/**
* Calculates the ideal number of report actions to render in the first render, based on the screen height and on
* the height of the smallest report action possible.
* @return {Number}
*/
- const calculateInitialNumToRender = useCallback(() => {
+ const initialNumToRender = useMemo(() => {
const minimumReportActionHeight = styles.chatItem.paddingTop + styles.chatItem.paddingBottom + variables.fontSizeNormalHeight;
const availableHeight = windowHeight - (CONST.CHAT_FOOTER_MIN_HEIGHT + variables.contentHeaderHeight);
return Math.ceil(availableHeight / minimumReportActionHeight);
}, [windowHeight]);
- const report = props.report;
- const hasOutstandingIOU = props.report.hasOutstandingIOU;
- const newMarkerReportActionID = props.newMarkerReportActionID;
- const sortedReportActions = props.sortedReportActions;
- const mostRecentIOUReportActionID = props.mostRecentIOUReportActionID;
-
/**
* @param {Object} args
* @param {Number} args.index
@@ -119,13 +223,31 @@ function ReportActionsList(props) {
*/
const renderItem = useCallback(
({item: reportAction, index}) => {
- // When the new indicator should not be displayed we explicitly set it to null
- const shouldDisplayNewMarker = reportAction.reportActionID === newMarkerReportActionID;
+ let shouldDisplayNewMarker = false;
+
+ if (!currentUnreadMarker.current) {
+ const nextMessage = sortedReportActions[index + 1];
+ const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
+ shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
+
+ if (!messageManuallyMarked.read) {
+ shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
+ }
+ const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
+
+ if (!currentUnreadMarker.current && shouldDisplayNewMarker && canDisplayMarker) {
+ currentUnreadMarker.current = reportAction.reportActionID;
+ }
+ } else {
+ shouldDisplayNewMarker = reportAction.reportActionID === currentUnreadMarker.current;
+ }
+
const shouldDisplayParentAction =
reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED &&
ReportUtils.isChatThread(report) &&
!ReportActionsUtils.isTransactionThread(ReportActionsUtils.getParentReportAction(report));
- const shouldHideThreadDividerLine = sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === newMarkerReportActionID;
+ const shouldHideThreadDividerLine = sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === currentUnreadMarker.current;
+
return shouldDisplayParentAction ? (
);
},
- [report, hasOutstandingIOU, newMarkerReportActionID, sortedReportActions, mostRecentIOUReportActionID],
+ [report, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarked],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
// To notify there something changes we can use extraData prop to flatlist
- const extraData = [props.isSmallScreenWidth ? props.newMarkerReportActionID : undefined, ReportUtils.isArchivedRoom(props.report)];
- const hideComposer = ReportUtils.shouldHideComposer(props.report);
- const shouldShowReportRecipientLocalTime =
- ReportUtils.canShowReportRecipientLocalTime(props.personalDetails, props.report, props.currentUserPersonalDetails.accountID) && !props.isComposerFullSize;
+ const extraData = [isSmallScreenWidth ? currentUnreadMarker.current : undefined, ReportUtils.isArchivedRoom(report)];
+ const hideComposer = ReportUtils.shouldDisableWriteActions(report);
+ const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize;
return (
-
- {
- if (props.report.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(props.sortedReportActions) || {};
- if (props.report.isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
- return (
-
- );
- }
- return null;
- }}
- keyboardShouldPersistTaps="handled"
- onLayout={(event) => {
- setSkeletonViewHeight(event.nativeEvent.layout.height);
- props.onLayout(event);
- }}
- onScroll={props.onScroll}
- extraData={extraData}
+ <>
+
-
+
+ {
+ if (report.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 (report.isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ return (
+
+ );
+ }
+
+ return null;
+ }}
+ keyboardShouldPersistTaps="handled"
+ onLayout={(event) => {
+ setSkeletonViewHeight(event.nativeEvent.layout.height);
+ onLayout(event);
+ }}
+ onScroll={trackVerticalScrolling}
+ extraData={extraData}
+ />
+
+ >
);
}
@@ -208,4 +336,4 @@ ReportActionsList.propTypes = propTypes;
ReportActionsList.defaultProps = defaultProps;
ReportActionsList.displayName = 'ReportActionsList';
-export default compose(withWindowDimensions, withLocalize, withPersonalDetails(), withNetwork(), withCurrentUserPersonalDetails)(ReportActionsList);
+export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(ReportActionsList);
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index 8ba49c179dc3..da475e61f749 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -1,26 +1,21 @@
-import React, {useRef, useState, useEffect, useContext, useMemo, useCallback} from 'react';
+import React, {useRef, useEffect, useContext, useMemo} from 'react';
import PropTypes from 'prop-types';
import _ from 'underscore';
import lodashGet from 'lodash/get';
-import lodashCloneDeep from 'lodash/cloneDeep';
import {useIsFocused} from '@react-navigation/native';
import * as Report from '../../../libs/actions/Report';
import reportActionPropTypes from './reportActionPropTypes';
-import Visibility from '../../../libs/Visibility';
import Timing from '../../../libs/actions/Timing';
import CONST from '../../../CONST';
import compose from '../../../libs/compose';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
import useCopySelectionHelper from '../../../hooks/useCopySelectionHelper';
-import useReportScrollManager from '../../../hooks/useReportScrollManager';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import Performance from '../../../libs/Performance';
import {withNetwork} from '../../../components/OnyxProvider';
-import FloatingMessageCounter from './FloatingMessageCounter';
import networkPropTypes from '../../../components/networkPropTypes';
import ReportActionsList from './ReportActionsList';
import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
-import * as ReportUtils from '../../../libs/ReportUtils';
import reportPropTypes from '../../reportPropTypes';
import PopoverReactionList from './ReactionList/PopoverReactionList';
import getIsReportFullyVisible from '../../../libs/getIsReportFullyVisible';
@@ -58,40 +53,16 @@ const defaultProps = {
policy: null,
};
-// In the component we are subscribing to the arrival of new actions.
-// As there is the possibility that there are multiple instances of a ReportScreen
-// for the same report, we only ever want one subscription to be active, as
-// the subscriptions could otherwise be conflicting.
-const newActionUnsubscribeMap = {};
-
function ReportActionsView(props) {
const context = useContext(ReportScreenContext);
useCopySelectionHelper();
- const {scrollToBottom} = useReportScrollManager();
-
const didLayout = useRef(false);
const didSubscribeToReportTypingEvents = useRef(false);
const hasCachedActions = useRef(_.size(props.reportActions) > 0);
- const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false);
-
- // We use the newMarkerReport ID in a network subscription, we don't want to constantly re-create
- // the subscription (as we want to avoid loosing events), so we use a ref to store the value in addition.
- // As the value is also needed for UI updates, we also store it in state.
- const [newMarkerReportActionID, _setNewMarkerReportActionID] = useState(ReportUtils.getNewMarkerReportActionID(props.report, props.reportActions));
- const newMarkerReportActionIDRef = useRef(newMarkerReportActionID);
- const setNewMarkerReportActionID = useCallback((value) => {
- newMarkerReportActionIDRef.current = value;
- _setNewMarkerReportActionID(value);
- }, []);
-
- const currentScrollOffset = useRef(0);
const mostRecentIOUReportActionID = useRef(ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions));
-
- const prevReportActionsRef = useRef(props.reportActions);
- const prevReportRef = useRef(props.report);
const prevNetworkRef = useRef(props.network);
const prevIsSmallScreenWidthRef = useRef(props.isSmallScreenWidth);
@@ -112,85 +83,11 @@ function ReportActionsView(props) {
Report.openReport(reportID);
};
- useEffect(() => {
- const unsubscribeVisibilityListener = Visibility.onVisibilityChange(() => {
- if (!isReportFullyVisible) {
- return;
- }
- // If the app user becomes active and they have no unread actions we clear the new marker to sync their device
- // e.g. they could have read these messages on another device and only just become active here
- const hasUnreadActions = ReportUtils.isUnread(props.report);
- if (!hasUnreadActions) {
- setNewMarkerReportActionID('');
- }
- });
- return () => {
- if (!unsubscribeVisibilityListener) {
- return;
- }
- unsubscribeVisibilityListener();
- };
- }, [isReportFullyVisible, isFocused, props.report, setNewMarkerReportActionID]);
-
useEffect(() => {
openReportIfNecessary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- useEffect(() => {
- // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function?
- // Answer: On web, when navigating to another report screen, the previous report screen doesn't get unmounted,
- // meaning that the cleanup might not get called. When we then open a report we had open already previosuly, a new
- // ReportScreen will get created. Thus, we have to cancel the earlier subscription of the previous screen,
- // because the two subscriptions could conflict!
- // In case we return to the previous screen (e.g. by web back navigation) the useEffect for that screen would
- // fire again, as the focus has changed and will set up the subscription correctly again.
- const previousSubUnsubscribe = newActionUnsubscribeMap[reportID];
- if (previousSubUnsubscribe) {
- previousSubUnsubscribe();
- }
-
- // This callback is triggered when a new action arrives via Pusher and the event is emitted from Report.js. This allows us to maintain
- // a single source of truth for the "new action" event instead of trying to derive that a new action has appeared from looking at props.
- const unsubscribe = Report.subscribeToNewActionEvent(reportID, (isFromCurrentUser, newActionID) => {
- const isNewMarkerReportActionIDSet = !_.isEmpty(newMarkerReportActionIDRef.current);
-
- // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where
- // they are now in the list.
- if (isFromCurrentUser) {
- scrollToBottom();
- // If the current user sends a new message in the chat we clear the new marker since they have "read" the report
- setNewMarkerReportActionID('');
- } else if (isReportFullyVisible) {
- // We use the scroll position to determine whether the report should be marked as read and the new line indicator reset.
- // If the user is scrolled up and no new line marker is set we will set it otherwise we will do nothing so the new marker
- // stays in it's previous position.
- if (currentScrollOffset.current === 0) {
- Report.readNewestAction(reportID);
- setNewMarkerReportActionID('');
- } else if (!isNewMarkerReportActionIDSet) {
- // The report is not in view and we received a comment from another user while the new marker is not set
- // so we will set the new marker now.
- setNewMarkerReportActionID(newActionID);
- }
- } else if (!isNewMarkerReportActionIDSet) {
- setNewMarkerReportActionID(newActionID);
- }
- });
- const cleanup = () => {
- if (unsubscribe) {
- unsubscribe();
- }
- Report.unsubscribeFromReportChannel(reportID);
- };
-
- newActionUnsubscribeMap[reportID] = cleanup;
-
- return () => {
- cleanup();
- };
- }, [isReportFullyVisible, reportID, scrollToBottom, setNewMarkerReportActionID]);
-
useEffect(() => {
const prevNetwork = prevNetworkRef.current;
// When returning from offline to online state we want to trigger a request to OpenReport which
@@ -216,7 +113,6 @@ function ReportActionsView(props) {
const didScreenSizeIncrease = prevIsSmallScreenWidth && !props.isSmallScreenWidth;
const didReportBecomeVisible = isReportFullyVisible && didScreenSizeIncrease;
if (didReportBecomeVisible) {
- setNewMarkerReportActionID(ReportUtils.isUnread(props.report) ? ReportUtils.getNewMarkerReportActionID(props.report, props.reportActions) : '');
openReportIfNecessary();
}
// update ref with current state
@@ -224,47 +120,6 @@ function ReportActionsView(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isSmallScreenWidth, props.report, props.reportActions, isReportFullyVisible]);
- useEffect(() => {
- const prevReportActions = prevReportActionsRef.current;
- // If the report is unread, we want to check if the number of actions has decreased. If so, then it seems that one of them was deleted. In this case, if the deleted action was the
- // one marking the unread point, we need to recalculate which action should be the unread marker.
- if (prevReportActions && ReportUtils.isUnread(props.report) && prevReportActions.length > props.report.length)
- setNewMarkerReportActionID(ReportUtils.getNewMarkerReportActionID(props.report, props.reportActions));
-
- prevReportActionsRef.current = props.reportActions;
- }, [props.report, props.reportActions, setNewMarkerReportActionID]);
-
- useEffect(() => {
- // If the last unread message was deleted, remove the *New* green marker and the *New Messages* notification at scroll just as the deletion starts.
- if (
- !(
- ReportUtils.isUnread(props.report) &&
- props.reportActions.length > 0 &&
- props.reportActions[0].pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE &&
- !props.network.isOffline
- )
- ) {
- return;
- }
- const reportActionsWithoutPendingOne = lodashCloneDeep(props.reportActions);
- reportActionsWithoutPendingOne.shift();
- if (newMarkerReportActionID !== ReportUtils.getNewMarkerReportActionID(props.report, reportActionsWithoutPendingOne)) {
- setNewMarkerReportActionID(ReportUtils.getNewMarkerReportActionID(props.report, reportActionsWithoutPendingOne));
- }
- }, [props.report, props.reportActions, props.network, newMarkerReportActionID, setNewMarkerReportActionID]);
-
- useEffect(() => {
- const prevReport = prevReportRef.current;
- // Checks to see if a report comment has been manually "marked as unread". All other times when the lastReadTime
- // changes it will be because we marked the entire report as read.
- const didManuallyMarkReportAsUnread = prevReport && prevReport.lastReadTime !== props.report.lastReadTime && ReportUtils.isUnread(props.report);
- if (didManuallyMarkReportAsUnread) {
- setNewMarkerReportActionID(ReportUtils.getNewMarkerReportActionID(props.report, props.reportActions));
- }
- // update ref with current report
- prevReportRef.current = props.report;
- }, [props.report, props.reportActions, setNewMarkerReportActionID]);
-
useEffect(() => {
// Ensures subscription event succeeds when the report/workspace room is created optimistically.
// Check if the optimistic `OpenReport` or `AddWorkspaceRoom` has succeeded by confirming
@@ -298,33 +153,6 @@ function ReportActionsView(props) {
Report.readOldestAction(reportID, oldestReportAction.reportActionID);
};
- const scrollToBottomAndMarkReportAsRead = () => {
- scrollToBottom();
- Report.readNewestAction(reportID);
- };
-
- /**
- * Show/hide the new floating message counter when user is scrolling back/forth in the history of messages.
- */
- const toggleFloatingMessageCounter = () => {
- if (currentScrollOffset.current < -200 && !isFloatingMessageCounterVisible) {
- setIsFloatingMessageCounterVisible(true);
- }
-
- if (currentScrollOffset.current > -200 && isFloatingMessageCounterVisible) {
- setIsFloatingMessageCounterVisible(false);
- }
- };
-
- /**
- * keeps track of the Scroll offset of the main messages list
- *
- * @param {Object} {nativeEvent}
- */
- const trackScroll = ({nativeEvent}) => {
- currentScrollOffset.current = -nativeEvent.contentOffset.y;
- toggleFloatingMessageCounter();
- };
/**
* Runs when the FlatList finishes laying out
*/
@@ -352,19 +180,13 @@ function ReportActionsView(props) {
return (
<>
-
diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js
index ff4b503762e9..343f2fb87333 100644
--- a/src/pages/home/report/ReportFooter.js
+++ b/src/pages/home/report/ReportFooter.js
@@ -58,7 +58,7 @@ function ReportFooter(props) {
const isAnonymousUser = Session.isAnonymousUser();
const isSmallSizeLayout = props.windowWidth - (props.isSmallScreenWidth ? 0 : variables.sideBarWidth) < variables.anonymousReportFooterBreakpoint;
- const hideComposer = ReportUtils.shouldHideComposer(props.report);
+ const hideComposer = ReportUtils.shouldDisableWriteActions(props.report);
return (
<>
diff --git a/src/pages/home/report/withReportOrNotFound.js b/src/pages/home/report/withReportOrNotFound.js
index 1db297c4b883..5829ac7a6015 100644
--- a/src/pages/home/report/withReportOrNotFound.js
+++ b/src/pages/home/report/withReportOrNotFound.js
@@ -46,12 +46,30 @@ export default function (WrappedComponent) {
// eslint-disable-next-line rulesdir/no-negated-variables
function WithReportOrNotFound(props) {
- if (props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID)) {
+ const contentShown = React.useRef(false);
+
+ 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 (_.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas)) {
+
+ if (shouldShowNotFoundPage) {
return ;
}
+
+ if (!contentShown.current) {
+ contentShown.current = true;
+ }
+
const rest = _.omit(props, ['forwardedRef']);
return (
{
+ if (isCreateMenuOpen) {
+ // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon
+ return;
+ }
+
+ Navigation.setShouldPopAllStateOnUP();
+ Navigation.navigate(ROUTES.SETTINGS_STATUS);
+ }, [isCreateMenuOpen]);
+
+ return (
+
+
+
+
+ {emojiStatus}
+
+
+
+
+
+ );
+}
+
+AvatarWithOptionalStatus.propTypes = propTypes;
+AvatarWithOptionalStatus.defaultProps = defaultProps;
+AvatarWithOptionalStatus.displayName = 'AvatarWithOptionalStatus';
+export default AvatarWithOptionalStatus;
diff --git a/src/pages/home/sidebar/PressableAvatarWithIndicator.js b/src/pages/home/sidebar/PressableAvatarWithIndicator.js
new file mode 100644
index 000000000000..ef6e663ce705
--- /dev/null
+++ b/src/pages/home/sidebar/PressableAvatarWithIndicator.js
@@ -0,0 +1,64 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import lodashGet from 'lodash/get';
+import React, {useCallback} from 'react';
+import PropTypes from 'prop-types';
+import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails';
+import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
+import AvatarWithIndicator from '../../../components/AvatarWithIndicator';
+import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
+import Navigation from '../../../libs/Navigation/Navigation';
+import * as UserUtils from '../../../libs/UserUtils';
+import useLocalize from '../../../hooks/useLocalize';
+import ROUTES from '../../../ROUTES';
+import CONST from '../../../CONST';
+import personalDetailsPropType from '../../personalDetailsPropType';
+
+const propTypes = {
+ /** Whether the create menu is open or not */
+ isCreateMenuOpen: PropTypes.bool,
+
+ /** The personal details of the person who is logged in */
+ currentUserPersonalDetails: personalDetailsPropType,
+};
+
+const defaultProps = {
+ isCreateMenuOpen: false,
+ currentUserPersonalDetails: {
+ pendingFields: {avatar: ''},
+ accountID: '',
+ avatar: '',
+ },
+};
+
+function PressableAvatarWithIndicator({isCreateMenuOpen, currentUserPersonalDetails}) {
+ const {translate} = useLocalize();
+
+ const showSettingsPage = useCallback(() => {
+ if (isCreateMenuOpen) {
+ // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon
+ return;
+ }
+
+ Navigation.navigate(ROUTES.SETTINGS);
+ }, [isCreateMenuOpen]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+PressableAvatarWithIndicator.propTypes = propTypes;
+PressableAvatarWithIndicator.defaultProps = defaultProps;
+PressableAvatarWithIndicator.displayName = 'PressableAvatarWithIndicator';
+export default withCurrentUserPersonalDetails(PressableAvatarWithIndicator);
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 5747ed0e1d4a..e54db8b2892d 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -4,6 +4,7 @@ import React from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
import styles from '../../../styles/styles';
import * as StyleUtils from '../../../styles/StyleUtils';
import ONYXKEYS from '../../../ONYXKEYS';
@@ -13,16 +14,13 @@ import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import Icon from '../../../components/Icon';
import * as Expensicons from '../../../components/Icon/Expensicons';
-import AvatarWithIndicator from '../../../components/AvatarWithIndicator';
import Tooltip from '../../../components/Tooltip';
import CONST from '../../../CONST';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import * as App from '../../../libs/actions/App';
-import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails';
import withWindowDimensions from '../../../components/withWindowDimensions';
import LHNOptionsList from '../../../components/LHNOptionsList/LHNOptionsList';
import SidebarUtils from '../../../libs/SidebarUtils';
-import OfflineWithFeedback from '../../../components/OfflineWithFeedback';
import Header from '../../../components/Header';
import defaultTheme from '../../../styles/themes/default';
import OptionsListSkeletonView from '../../../components/OptionsListSkeletonView';
@@ -30,12 +28,12 @@ import variables from '../../../styles/variables';
import LogoComponent from '../../../../assets/images/expensify-wordmark.svg';
import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
import * as Session from '../../../libs/actions/Session';
-import Button from '../../../components/Button';
-import * as UserUtils from '../../../libs/UserUtils';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
import onyxSubscribe from '../../../libs/onyxSubscribe';
-import personalDetailsPropType from '../../personalDetailsPropType';
import * as ReportActionContextMenu from '../report/ContextMenu/ReportActionContextMenu';
+import withCurrentReportID from '../../../components/withCurrentReportID';
+import OptionRowLHNData from '../../../components/LHNOptionsList/OptionRowLHNData';
+import SignInOrAvatarWithOptionalStatus from './SignInOrAvatarWithOptionalStatus';
const basePropTypes = {
/** Toggles the navigation menu open and closed */
@@ -55,18 +53,24 @@ const propTypes = {
isLoading: PropTypes.bool.isRequired,
- currentUserPersonalDetails: personalDetailsPropType,
-
priorityMode: PropTypes.oneOf(_.values(CONST.PRIORITY_MODE)),
+ /** The top most report id */
+ currentReportID: PropTypes.string,
+
+ /* Onyx Props */
+ report: PropTypes.shape({
+ /** reportID (only present when there is a matching report) */
+ reportID: PropTypes.string,
+ }),
+
...withLocalizePropTypes,
};
const defaultProps = {
- currentUserPersonalDetails: {
- avatar: '',
- },
priorityMode: CONST.PRIORITY_MODE.DEFAULT,
+ currentReportID: '',
+ report: {},
};
class SidebarLinks extends React.PureComponent {
@@ -74,7 +78,6 @@ class SidebarLinks extends React.PureComponent {
super(props);
this.showSearchPage = this.showSearchPage.bind(this);
- this.showSettingsPage = this.showSettingsPage.bind(this);
this.showReportPage = this.showReportPage.bind(this);
if (this.props.isSmallScreenWidth) {
@@ -133,15 +136,6 @@ class SidebarLinks extends React.PureComponent {
Navigation.navigate(ROUTES.SEARCH);
}
- showSettingsPage() {
- if (this.props.isCreateMenuOpen) {
- // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon
- return;
- }
-
- Navigation.navigate(ROUTES.SETTINGS);
- }
-
/**
* Show Report page with selected report id
*
@@ -161,6 +155,8 @@ class SidebarLinks extends React.PureComponent {
}
render() {
+ const viewMode = this.props.priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT;
+
return (
-
- {Session.isAnonymousUser() ? (
-
- Session.signOutAndRedirectToSignIn()}
- />
-
- ) : (
-
-
-
- )}
-
+
{this.props.isLoading ? (
-
+ <>
+ {lodashGet(this.props.report, 'reportID') && (
+
+ )}
+
+ >
) : (
)}
@@ -230,5 +214,14 @@ class SidebarLinks extends React.PureComponent {
SidebarLinks.propTypes = propTypes;
SidebarLinks.defaultProps = defaultProps;
-export default compose(withLocalize, withCurrentUserPersonalDetails, withWindowDimensions)(SidebarLinks);
+export default compose(
+ withLocalize,
+ withWindowDimensions,
+ withCurrentReportID,
+ withOnyx({
+ report: {
+ key: ({currentReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`,
+ },
+ }),
+)(SidebarLinks);
export {basePropTypes};
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index 58a7e676403a..a919607938c7 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -15,6 +15,7 @@ import CONST from '../../../CONST';
import useLocalize from '../../../hooks/useLocalize';
import styles from '../../../styles/styles';
import withNavigationFocus from '../../../components/withNavigationFocus';
+import * as SessionUtils from '../../../libs/SessionUtils';
const propTypes = {
...basePropTypes,
@@ -39,8 +40,8 @@ const propTypes = {
),
),
- /** Whether the personal details are loading. When false it means they are ready to be used. */
- isPersonalDetailsLoading: PropTypes.bool,
+ /** Whether the reports are loading. When false it means they are ready to be used. */
+ isLoadingReportData: PropTypes.bool,
/** The chat priority mode */
priorityMode: PropTypes.string,
@@ -56,13 +57,13 @@ const propTypes = {
const defaultProps = {
chatReports: {},
allReportActions: {},
+ isLoadingReportData: true,
priorityMode: CONST.PRIORITY_MODE.DEFAULT,
- isPersonalDetailsLoading: true,
betas: [],
policies: [],
};
-function SidebarLinksData({isFocused, allReportActions, betas, chatReports, currentReportID, insets, isPersonalDetailsLoading, isSmallScreenWidth, onLinkClick, policies, priorityMode}) {
+function SidebarLinksData({isFocused, allReportActions, betas, chatReports, currentReportID, insets, isLoadingReportData, isSmallScreenWidth, onLinkClick, policies, priorityMode}) {
const {translate} = useLocalize();
const reportIDsRef = useRef([]);
@@ -75,7 +76,7 @@ function SidebarLinksData({isFocused, allReportActions, betas, chatReports, curr
return reportIDs;
}, [allReportActions, betas, chatReports, currentReportID, policies, priorityMode]);
- const isLoading = _.isEmpty(chatReports) || isPersonalDetailsLoading;
+ const isLoading = SessionUtils.didUserLogInDuringSession() && isLoadingReportData;
return (
participantAccountIDs: report.participantAccountIDs,
hasDraft: report.hasDraft,
isPinned: report.isPinned,
+ isHidden: report.isHidden,
errorFields: {
addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom,
},
@@ -177,9 +179,8 @@ export default compose(
key: ONYXKEYS.COLLECTION.REPORT,
selector: chatReportSelector,
},
- isPersonalDetailsLoading: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- selector: _.isEmpty,
+ isLoadingReportData: {
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
priorityMode: {
key: ONYXKEYS.NVP_PRIORITY_MODE,
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index 698a72a205cc..3d54306b6248 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -3,8 +3,6 @@ import {View} from 'react-native';
import styles from '../../../../styles/styles';
import SidebarLinksData from '../SidebarLinksData';
import ScreenWrapper from '../../../../components/ScreenWrapper';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ROUTES from '../../../../ROUTES';
import Timing from '../../../../libs/actions/Timing';
import CONST from '../../../../CONST';
import Performance from '../../../../libs/Performance';
@@ -17,13 +15,6 @@ const propTypes = {
...windowDimensionsPropTypes,
};
-/**
- * Function called when avatar is clicked
- */
-const navigateToSettings = () => {
- Navigation.navigate(ROUTES.SETTINGS);
-};
-
/**
* Function called when a pinned chat is selected.
*/
@@ -50,7 +41,6 @@ function BaseSidebarScreen(props) {
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index e692e4668f07..abade067f4fc 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -36,6 +36,7 @@ const policySelector = (policy) =>
policy && {
type: policy.type,
role: policy.role,
+ isPolicyExpenseChatEnabled: policy.isPolicyExpenseChatEnabled,
pendingAction: policy.pendingAction,
};
diff --git a/src/pages/home/sidebar/SignInButton.js b/src/pages/home/sidebar/SignInButton.js
new file mode 100644
index 000000000000..24b90d778445
--- /dev/null
+++ b/src/pages/home/sidebar/SignInButton.js
@@ -0,0 +1,33 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import React from 'react';
+import {View} from 'react-native';
+import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback';
+import Button from '../../../components/Button';
+import styles from '../../../styles/styles';
+import * as Session from '../../../libs/actions/Session';
+import useLocalize from '../../../hooks/useLocalize';
+import CONST from '../../../CONST';
+
+function SignInButton() {
+ const {translate} = useLocalize();
+
+ return (
+
+
+
+
+
+ );
+}
+
+SignInButton.displayName = 'SignInButton';
+export default SignInButton;
diff --git a/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js b/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
new file mode 100644
index 000000000000..2599ac6b6942
--- /dev/null
+++ b/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
@@ -0,0 +1,63 @@
+/* eslint-disable rulesdir/onyx-props-must-have-default */
+import React from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails';
+import personalDetailsPropType from '../../personalDetailsPropType';
+import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
+import AvatarWithOptionalStatus from './AvatarWithOptionalStatus';
+import SignInButton from './SignInButton';
+import * as Session from '../../../libs/actions/Session';
+import Permissions from '../../../libs/Permissions';
+import compose from '../../../libs/compose';
+import ONYXKEYS from '../../../ONYXKEYS';
+
+const propTypes = {
+ /** The personal details of the person who is logged in */
+ currentUserPersonalDetails: personalDetailsPropType,
+
+ /** Whether the create menu is open or not */
+ isCreateMenuOpen: PropTypes.bool,
+
+ /** Beta features list */
+ betas: PropTypes.arrayOf(PropTypes.string),
+};
+
+const defaultProps = {
+ betas: [],
+ isCreateMenuOpen: false,
+ currentUserPersonalDetails: {
+ status: {emojiCode: ''},
+ },
+};
+
+function SignInOrAvatarWithOptionalStatus({currentUserPersonalDetails, isCreateMenuOpen, betas}) {
+ const statusEmojiCode = lodashGet(currentUserPersonalDetails, 'status.emojiCode', '');
+ const emojiStatus = Permissions.canUseCustomStatus(betas) ? statusEmojiCode : '';
+
+ if (Session.isAnonymousUser()) {
+ return ;
+ }
+ if (emojiStatus) {
+ return (
+
+ );
+ }
+ return ;
+}
+
+SignInOrAvatarWithOptionalStatus.propTypes = propTypes;
+SignInOrAvatarWithOptionalStatus.defaultProps = defaultProps;
+SignInOrAvatarWithOptionalStatus.displayName = 'SignInOrAvatarWithOptionalStatus';
+export default compose(
+ withCurrentUserPersonalDetails,
+ withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ }),
+)(SignInOrAvatarWithOptionalStatus);
diff --git a/src/pages/iou/DistanceRequestPage.js b/src/pages/iou/DistanceRequestPage.js
new file mode 100644
index 000000000000..706ddc43b018
--- /dev/null
+++ b/src/pages/iou/DistanceRequestPage.js
@@ -0,0 +1,47 @@
+import React, {useEffect} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import * as IOU from '../../libs/actions/IOU';
+import ONYXKEYS from '../../ONYXKEYS';
+import DistanceRequest from '../../components/DistanceRequest';
+import reportPropTypes from '../reportPropTypes';
+
+const propTypes = {
+ /** The transactionID of this request */
+ transactionID: PropTypes.string,
+
+ /** The report on which the request is initiated on */
+ report: reportPropTypes,
+};
+
+const defaultProps = {
+ transactionID: '',
+ report: {},
+};
+
+// This component is responsible for getting the transactionID from the IOU key, or creating the transaction if it doesn't exist yet, and then passing the transactionID.
+// You can't use Onyx props in the withOnyx mapping, so we need to set up and access the transactionID here, and then pass it down so that DistanceRequest can subscribe to the transaction.
+function DistanceRequestPage({report, transactionID}) {
+ useEffect(() => {
+ if (transactionID) {
+ return;
+ }
+ IOU.createEmptyTransaction();
+ }, [transactionID]);
+
+ return (
+
+ );
+}
+
+DistanceRequestPage.displayName = 'DistanceRequestPage';
+DistanceRequestPage.propTypes = propTypes;
+DistanceRequestPage.defaultProps = defaultProps;
+export default withOnyx({
+ // We must provide a default value for transactionID here, otherwise the component won't mount
+ // because withOnyx returns null until all the keys are defined
+ transactionID: {key: ONYXKEYS.IOU, selector: (iou) => (iou && iou.transactionID) || ''},
+})(DistanceRequestPage);
diff --git a/src/pages/iou/MoneyRequestCategoryPage.js b/src/pages/iou/MoneyRequestCategoryPage.js
new file mode 100644
index 000000000000..80b88a762609
--- /dev/null
+++ b/src/pages/iou/MoneyRequestCategoryPage.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import lodashGet from 'lodash/get';
+import {withOnyx} from 'react-native-onyx';
+import ROUTES from '../../ROUTES';
+import Navigation from '../../libs/Navigation/Navigation';
+import useLocalize from '../../hooks/useLocalize';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import CategoryPicker from '../../components/CategoryPicker';
+import ONYXKEYS from '../../ONYXKEYS';
+import reportPropTypes from '../reportPropTypes';
+
+const propTypes = {
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ /** Route specific parameters used on this screen via route :iouType/new/category/:reportID? */
+ params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.string,
+
+ /** The report ID of the IOU */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ /** The report currently being used */
+ report: reportPropTypes,
+};
+
+const defaultProps = {
+ report: {},
+};
+
+function MoneyRequestCategoryPage({route, report}) {
+ const {translate} = useLocalize();
+
+ const reportID = lodashGet(route, 'params.reportID', '');
+ const iouType = lodashGet(route, 'params.iouType', '');
+
+ const navigateBack = () => {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+MoneyRequestCategoryPage.displayName = 'MoneyRequestCategoryPage';
+MoneyRequestCategoryPage.propTypes = propTypes;
+MoneyRequestCategoryPage.defaultProps = defaultProps;
+
+export default withOnyx({
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
+ },
+})(MoneyRequestCategoryPage);
diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js
new file mode 100644
index 000000000000..3fcd3aba263c
--- /dev/null
+++ b/src/pages/iou/MoneyRequestDatePage.js
@@ -0,0 +1,120 @@
+import React, {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import Form from '../../components/Form';
+import ONYXKEYS from '../../ONYXKEYS';
+import styles from '../../styles/styles';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import * as IOU from '../../libs/actions/IOU';
+import optionPropTypes from '../../components/optionPropTypes';
+import NewDatePicker from '../../components/NewDatePicker';
+import useLocalize from '../../hooks/useLocalize';
+
+const propTypes = {
+ /** Onyx Props */
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ iou: PropTypes.shape({
+ id: PropTypes.string,
+ amount: PropTypes.number,
+ comment: PropTypes.string,
+ created: PropTypes.string,
+ participants: PropTypes.arrayOf(optionPropTypes),
+ receiptPath: PropTypes.string,
+ }),
+
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** Which field we are editing */
+ field: PropTypes.string,
+
+ /** reportID for the "transaction thread" */
+ threadReportID: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ iou: {
+ id: '',
+ amount: 0,
+ comment: '',
+ created: '',
+ participants: [],
+ receiptPath: '',
+ },
+};
+
+function MoneyRequestDatePage({iou, route}) {
+ const {translate} = useLocalize();
+ const iouType = lodashGet(route, 'params.iouType', '');
+ const reportID = lodashGet(route, 'params.reportID', '');
+
+ useEffect(() => {
+ const moneyRequestId = `${iouType}${reportID}`;
+ const shouldReset = iou.id !== moneyRequestId;
+ if (shouldReset) {
+ IOU.resetMoneyRequestInfo(moneyRequestId);
+ }
+
+ if (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset) {
+ Navigation.goBack(ROUTES.getMoneyRequestRoute(iouType, reportID), true);
+ }
+ }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID]);
+
+ function navigateBack() {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
+ }
+
+ /**
+ * Sets the money request comment by saving it to Onyx.
+ *
+ * @param {Object} value
+ * @param {String} value.moneyRequestCreated
+ */
+ function updateDate(value) {
+ IOU.setMoneyRequestCreated(value.moneyRequestCreated);
+ navigateBack();
+ }
+
+ return (
+
+ navigateBack()}
+ />
+ updateDate(value)}
+ submitButtonText={translate('common.save')}
+ enabledWhenOffline
+ >
+
+
+
+ );
+}
+
+MoneyRequestDatePage.propTypes = propTypes;
+MoneyRequestDatePage.defaultProps = defaultProps;
+
+export default withOnyx({
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+})(MoneyRequestDatePage);
diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js
index 07e4b295f85f..2e6e459f1d96 100644
--- a/src/pages/iou/MoneyRequestDescriptionPage.js
+++ b/src/pages/iou/MoneyRequestDescriptionPage.js
@@ -1,11 +1,10 @@
-import React, {Component} from 'react';
+import React, {useEffect, useRef} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
import lodashGet from 'lodash/get';
import TextInput from '../../components/TextInput';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import ScreenWrapper from '../../components/ScreenWrapper';
import HeaderWithBackButton from '../../components/HeaderWithBackButton';
import Form from '../../components/Form';
@@ -13,14 +12,12 @@ import ONYXKEYS from '../../ONYXKEYS';
import styles from '../../styles/styles';
import Navigation from '../../libs/Navigation/Navigation';
import ROUTES from '../../ROUTES';
-import compose from '../../libs/compose';
import * as IOU from '../../libs/actions/IOU';
import optionPropTypes from '../../components/optionPropTypes';
import CONST from '../../CONST';
+import useLocalize from '../../hooks/useLocalize';
const propTypes = {
- ...withLocalizePropTypes,
-
/** Onyx Props */
/** Holds data related to Money Request view state, rather than the underlying Money Request data. */
iou: PropTypes.shape({
@@ -28,7 +25,20 @@ const propTypes = {
amount: PropTypes.number,
comment: PropTypes.string,
participants: PropTypes.arrayOf(optionPropTypes),
+ receiptPath: PropTypes.string,
}),
+
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** Which field we are editing */
+ field: PropTypes.string,
+
+ /** reportID for the "transaction thread" */
+ threadReportID: PropTypes.string,
+ }),
+ }).isRequired,
};
const defaultProps = {
@@ -37,44 +47,30 @@ const defaultProps = {
amount: 0,
comment: '',
participants: [],
+ receiptPath: '',
},
};
-class MoneyRequestDescriptionPage extends Component {
- constructor(props) {
- super(props);
+function MoneyRequestDescriptionPage({iou, route}) {
+ const {translate} = useLocalize();
+ const inputRef = useRef(null);
+ const iouType = lodashGet(route, 'params.iouType', '');
+ const reportID = lodashGet(route, 'params.reportID', '');
- this.updateComment = this.updateComment.bind(this);
- this.navigateBack = this.navigateBack.bind(this);
- this.iouType = lodashGet(props.route, 'params.iouType', '');
- this.reportID = lodashGet(props.route, 'params.reportID', '');
- }
-
- componentDidMount() {
- const moneyRequestId = `${this.iouType}${this.reportID}`;
- const shouldReset = this.props.iou.id !== moneyRequestId;
+ useEffect(() => {
+ const moneyRequestId = `${iouType}${reportID}`;
+ const shouldReset = iou.id !== moneyRequestId;
if (shouldReset) {
IOU.resetMoneyRequestInfo(moneyRequestId);
}
- if (_.isEmpty(this.props.iou.participants) || (this.props.iou.amount === 0 && !this.props.iou.receiptPath) || shouldReset) {
- Navigation.goBack(ROUTES.getMoneyRequestRoute(this.iouType, this.reportID), true);
+ if (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset) {
+ Navigation.goBack(ROUTES.getMoneyRequestRoute(iouType, reportID), true);
}
- }
+ }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID]);
- // eslint-disable-next-line rulesdir/prefer-early-return
- componentDidUpdate(prevProps) {
- // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
- if (_.isEmpty(this.props.iou.participants) || (this.props.iou.amount === 0 && !this.props.iou.receiptPath) || prevProps.iou.id !== this.props.iou.id) {
- // The ID is cleared on completing a request. In that case, we will do nothing.
- if (this.props.iou.id) {
- Navigation.goBack(ROUTES.getMoneyRequestRoute(this.iouType, this.reportID), true);
- }
- }
- }
-
- navigateBack() {
- Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(this.iouType, this.reportID));
+ function navigateBack() {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
}
/**
@@ -83,52 +79,49 @@ class MoneyRequestDescriptionPage extends Component {
* @param {Object} value
* @param {String} value.moneyRequestComment
*/
- updateComment(value) {
+ function updateComment(value) {
IOU.setMoneyRequestDescription(value.moneyRequestComment);
- this.navigateBack();
+ navigateBack();
}
- render() {
- return (
- this.descriptionInputRef && this.descriptionInputRef.focus()}
+ return (
+ inputRef.current && inputRef.current.focus()}
+ >
+ navigateBack()}
+ />
+ updateComment(value)}
+ submitButtonText={translate('common.save')}
+ enabledWhenOffline
>
-
-
-
- (this.descriptionInputRef = el)}
- />
-
-
-
- );
- }
+
+ (inputRef.current = el)}
+ />
+
+
+
+ );
}
MoneyRequestDescriptionPage.propTypes = propTypes;
MoneyRequestDescriptionPage.defaultProps = defaultProps;
-export default compose(
- withLocalize,
- withOnyx({
- iou: {key: ONYXKEYS.IOU},
- }),
-)(MoneyRequestDescriptionPage);
+export default withOnyx({
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+})(MoneyRequestDescriptionPage);
diff --git a/src/pages/iou/MoneyRequestMerchantPage.js b/src/pages/iou/MoneyRequestMerchantPage.js
new file mode 100644
index 000000000000..e2d04288353f
--- /dev/null
+++ b/src/pages/iou/MoneyRequestMerchantPage.js
@@ -0,0 +1,131 @@
+import React, {useEffect, useRef} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import TextInput from '../../components/TextInput';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import Form from '../../components/Form';
+import ONYXKEYS from '../../ONYXKEYS';
+import styles from '../../styles/styles';
+import Navigation from '../../libs/Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import * as IOU from '../../libs/actions/IOU';
+import optionPropTypes from '../../components/optionPropTypes';
+import CONST from '../../CONST';
+import useLocalize from '../../hooks/useLocalize';
+
+const propTypes = {
+ /** Onyx Props */
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ iou: PropTypes.shape({
+ id: PropTypes.string,
+ amount: PropTypes.number,
+ comment: PropTypes.string,
+ created: PropTypes.string,
+ merchant: PropTypes.string,
+ participants: PropTypes.arrayOf(optionPropTypes),
+ receiptPath: PropTypes.string,
+ }),
+
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** Which field we are editing */
+ field: PropTypes.string,
+
+ /** reportID for the "transaction thread" */
+ threadReportID: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ iou: {
+ id: '',
+ amount: 0,
+ comment: '',
+ merchant: '',
+ participants: [],
+ receiptPath: '',
+ },
+};
+
+function MoneyRequestMerchantPage({iou, route}) {
+ const {translate} = useLocalize();
+ const inputRef = useRef(null);
+ const iouType = lodashGet(route, 'params.iouType', '');
+ const reportID = lodashGet(route, 'params.reportID', '');
+
+ useEffect(() => {
+ const moneyRequestId = `${iouType}${reportID}`;
+ const shouldReset = iou.id !== moneyRequestId;
+ if (shouldReset) {
+ IOU.resetMoneyRequestInfo(moneyRequestId);
+ }
+
+ if (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset) {
+ Navigation.goBack(ROUTES.getMoneyRequestRoute(iouType, reportID), true);
+ }
+ }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID]);
+
+ function navigateBack() {
+ Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID));
+ }
+
+ /**
+ * Sets the money request comment by saving it to Onyx.
+ *
+ * @param {Object} value
+ * @param {String} value.moneyRequestMerchant
+ */
+ function updateMerchant(value) {
+ IOU.setMoneyRequestMerchant(value.moneyRequestMerchant);
+ navigateBack();
+ }
+
+ return (
+ inputRef.current && inputRef.current.focus()}
+ >
+ navigateBack()}
+ />
+ updateMerchant(value)}
+ submitButtonText={translate('common.save')}
+ enabledWhenOffline
+ >
+
+ (inputRef.current = el)}
+ />
+
+
+
+ );
+}
+
+MoneyRequestMerchantPage.propTypes = propTypes;
+MoneyRequestMerchantPage.defaultProps = defaultProps;
+
+export default withOnyx({
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+})(MoneyRequestMerchantPage);
diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js
index 307d2aeb35a9..4dd688950fbd 100644
--- a/src/pages/iou/MoneyRequestSelectorPage.js
+++ b/src/pages/iou/MoneyRequestSelectorPage.js
@@ -15,7 +15,7 @@ import Navigation from '../../libs/Navigation/Navigation';
import styles from '../../styles/styles';
import ReceiptSelector from './ReceiptSelector';
import * as IOU from '../../libs/actions/IOU';
-import DistanceRequest from '../../components/DistanceRequest';
+import DistanceRequestPage from './DistanceRequestPage';
import DragAndDropProvider from '../../components/DragAndDrop/Provider';
import usePermissions from '../../hooks/usePermissions';
import OnyxTabNavigator, {TopTab} from '../../libs/Navigation/OnyxTabNavigator';
@@ -77,7 +77,10 @@ function MoneyRequestSelectorPage(props) {
};
return (
-
+
{({safeAreaPaddingBottomStyle}) => (
@@ -112,7 +115,7 @@ function MoneyRequestSelectorPage(props) {
{canUseDistanceRequests && (
)}
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
index c674878c2c73..392e96887f8b 100644
--- a/src/pages/iou/ReceiptSelector/index.js
+++ b/src/pages/iou/ReceiptSelector/index.js
@@ -19,7 +19,7 @@ import Receipt from '../../../libs/actions/Receipt';
import useWindowDimensions from '../../../hooks/useWindowDimensions';
import useLocalize from '../../../hooks/useLocalize';
import {DragAndDropContext} from '../../../components/DragAndDrop/Provider';
-import ReceiptUtils from '../../../libs/ReceiptUtils';
+import * as ReceiptUtils from '../../../libs/ReceiptUtils';
const propTypes = {
/** Information shown to the user when a receipt is not valid */
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 7eeab6e493bd..730e5d1e1dfb 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -38,7 +38,9 @@ const propTypes = {
iou: PropTypes.shape({
id: PropTypes.string,
amount: PropTypes.number,
- currency: PropTypes.string,
+ comment: PropTypes.string,
+ created: PropTypes.string,
+ merchant: PropTypes.string,
participants: PropTypes.arrayOf(participantPropTypes),
}),
};
@@ -54,6 +56,8 @@ const defaultProps = {
iou: {
id: '',
amount: 0,
+ merchant: '',
+ created: '',
currency: CONST.CURRENCY.USD,
participants: [],
},
diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js
index b638da091874..b11b16fa3c1b 100644
--- a/src/pages/iou/SplitBillDetailsPage.js
+++ b/src/pages/iou/SplitBillDetailsPage.js
@@ -3,7 +3,6 @@ import _ from 'underscore';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
-import lodashGet from 'lodash/get';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import * as OptionsListUtils from '../../libs/OptionsListUtils';
@@ -18,6 +17,8 @@ import withReportAndReportActionOrNotFound from '../home/report/withReportAndRep
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import CONST from '../../CONST';
import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import * as TransactionUtils from '../../libs/TransactionUtils';
+import * as ReportUtils from '../../libs/ReportUtils';
const propTypes = {
/* Onyx Props */
@@ -52,16 +53,26 @@ const defaultProps = {
function SplitBillDetailsPage(props) {
const reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`];
+ const transaction = TransactionUtils.getLinkedTransaction(reportAction);
const participantAccountIDs = reportAction.originalMessage.participantAccountIDs;
- const participants = OptionsListUtils.getParticipantsOptions(
- _.map(participantAccountIDs, (accountID) => ({accountID, selected: true})),
- props.personalDetails,
- );
+
+ // In case this is workspace split bill, we manually add the workspace as the second participant of the split bill
+ // because we don't save any accountID in the report action's originalMessage other than the payee's accountID
+ let participants;
+ if (ReportUtils.isPolicyExpenseChat(props.report)) {
+ participants = [
+ ...OptionsListUtils.getParticipantsOptions([{accountID: participantAccountIDs[0], selected: true}], props.personalDetails),
+ ...OptionsListUtils.getPolicyExpenseReportOptions({...props.report, selected: true}),
+ ];
+ } else {
+ participants = OptionsListUtils.getParticipantsOptions(
+ _.map(participantAccountIDs, (accountID) => ({accountID, selected: true})),
+ props.personalDetails,
+ );
+ }
const payeePersonalDetails = props.personalDetails[reportAction.actorAccountID];
const participantsExcludingPayee = _.filter(participants, (participant) => participant.accountID !== reportAction.actorAccountID);
- const splitAmount = parseInt(lodashGet(reportAction, 'originalMessage.amount', 0), 10);
- const splitComment = lodashGet(reportAction, 'originalMessage.comment');
- const splitCurrency = lodashGet(reportAction, 'originalMessage.currency');
+ const {amount: splitAmount, currency: splitCurrency, comment: splitComment} = ReportUtils.getTransactionDetails(transaction);
return (
diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js
new file mode 100644
index 000000000000..a3f8f40f5372
--- /dev/null
+++ b/src/pages/iou/WaypointEditor.js
@@ -0,0 +1,173 @@
+import React, {useRef} from 'react';
+import lodashGet from 'lodash/get';
+import {View} from 'react-native';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import AddressSearch from '../../components/AddressSearch';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import Navigation from '../../libs/Navigation/Navigation';
+import ONYXKEYS from '../../ONYXKEYS';
+import Form from '../../components/Form';
+import styles from '../../styles/styles';
+import compose from '../../libs/compose';
+import CONST from '../../CONST';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import * as Transaction from '../../libs/actions/Transaction';
+import * as ValidationUtils from '../../libs/ValidationUtils';
+import ROUTES from '../../ROUTES';
+import {withNetwork} from '../../components/OnyxProvider';
+import networkPropTypes from '../../components/networkPropTypes';
+import transactionPropTypes from '../../components/transactionPropTypes';
+
+const propTypes = {
+ /** The transactionID of the IOU */
+ transactionID: PropTypes.string.isRequired,
+
+ /** Route params */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ /** IOU type */
+ iouType: PropTypes.string,
+
+ /** Index of the waypoint being edited */
+ waypointIndex: PropTypes.string,
+ }),
+ }),
+
+ /** The optimistic transaction for this request */
+ transaction: transactionPropTypes,
+
+ /** Information about the network */
+ network: networkPropTypes.isRequired,
+
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ route: {
+ params: {
+ waypointIndex: '',
+ },
+ },
+ transaction: {},
+};
+
+function WaypointEditor({transactionID, route: {params: {iouType = '', waypointIndex = ''} = {}} = {}, network, translate, transaction}) {
+ const textInput = useRef(null);
+ const currentWaypoint = lodashGet(transaction, `comment.waypoints.waypoint${waypointIndex}`, {});
+ const waypointAddress = lodashGet(currentWaypoint, 'address', '');
+
+ const validate = (values) => {
+ const errors = {};
+ const waypointValue = values[`waypoint${waypointIndex}`] || '';
+ if (network.isOffline && waypointValue !== '' && !ValidationUtils.isValidAddress(waypointValue)) {
+ errors[`waypoint${waypointIndex}`] = 'bankAccount.error.address';
+ }
+
+ // If the user is online and they are trying to save a value without using the autocomplete, show an error message instructing them to use a selected address instead.
+ // That enables us to save the address with coordinates when it is selected
+ if (!network.isOffline && waypointValue !== '') {
+ errors[`waypoint${waypointIndex}`] = 'distance.errors.selectSuggestedAddress';
+ }
+
+ return errors;
+ };
+
+ const onSubmit = (values) => {
+ const waypointValue = values[`waypoint${waypointIndex}`] || '';
+
+ // Allows letting you set a waypoint to an empty value
+ if (waypointValue === '') {
+ Transaction.removeWaypoint(transactionID, waypointIndex);
+ }
+
+ // While the user is offline, the auto-complete address search will not work
+ // Therefore, we're going to save the waypoint as just the address, and the lat/long will be filled in on the backend
+ if (network.isOffline && waypointValue) {
+ const waypoint = {
+ address: waypointValue,
+ };
+
+ Transaction.saveWaypoint(transactionID, waypointIndex, waypoint);
+ }
+
+ // Other flows will be handled by selecting a waypoint with selectWaypoint as this is mainly for the offline flow
+ Navigation.goBack(ROUTES.getMoneyRequestDistanceTabRoute(iouType));
+ };
+
+ const selectWaypoint = (values) => {
+ const waypoint = {
+ lat: values.lat,
+ lng: values.lng,
+ address: values.address,
+ };
+
+ Transaction.saveWaypoint(transactionID, waypointIndex, waypoint);
+ Navigation.goBack(ROUTES.getMoneyRequestDistanceTabRoute(iouType));
+ };
+
+ return (
+ textInput.current && textInput.current.focus()}
+ shouldEnableMaxHeight
+ >
+ {
+ Navigation.goBack(ROUTES.getMoneyRequestDistanceTabRoute(iouType));
+ }}
+ />
+
+
+ (textInput.current = e)}
+ hint={!network.isOffline ? translate('distance.errors.selectSuggestedAddress') : ''}
+ containerStyles={[styles.mt4]}
+ label={translate('distance.address')}
+ defaultValue={waypointAddress}
+ onPress={selectWaypoint}
+ maxInputLength={CONST.FORM_CHARACTER_LIMIT}
+ renamedInputKeys={{
+ address: `waypoint${waypointIndex}`,
+ city: null,
+ country: null,
+ street: null,
+ street2: null,
+ zipCode: null,
+ lat: null,
+ lng: null,
+ state: null,
+ }}
+ />
+
+
+
+ );
+}
+
+WaypointEditor.displayName = 'WaypointEditor';
+WaypointEditor.propTypes = propTypes;
+WaypointEditor.defaultProps = defaultProps;
+export default compose(
+ withLocalize,
+ withNetwork(),
+ withOnyx({
+ transaction: {
+ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
+ selector: (transaction) => (transaction ? {transactionID: transaction.transactionID, comment: {waypoints: lodashGet(transaction, 'comment.waypoints')}} : null),
+ },
+ }),
+)(WaypointEditor);
diff --git a/src/pages/iou/WaypointEditorPage.js b/src/pages/iou/WaypointEditorPage.js
new file mode 100644
index 000000000000..fc659c7806ab
--- /dev/null
+++ b/src/pages/iou/WaypointEditorPage.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import WaypointEditor from './WaypointEditor';
+import ONYXKEYS from '../../ONYXKEYS';
+
+const propTypes = {
+ /** The transactionID of this request */
+ transactionID: PropTypes.string,
+
+ /** Route params */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ /** IOU type */
+ iouType: PropTypes.string,
+
+ /** Index of the waypoint being edited */
+ waypointIndex: PropTypes.string,
+ }),
+ }),
+};
+
+const defaultProps = {
+ transactionID: '',
+ route: {
+ params: {
+ iouType: '',
+ waypointIndex: '',
+ },
+ },
+};
+
+// This component is responsible for grabbing the transactionID from the IOU key
+// You can't use Onyx props in the withOnyx mapping, so we need to set up and access the transactionID here, and then pass it down so that WaypointEditor can subscribe to the transaction.
+function WaypointEditorPage({transactionID, route}) {
+ return (
+
+ );
+}
+
+WaypointEditorPage.displayName = 'WaypointEditorPage';
+WaypointEditorPage.propTypes = propTypes;
+WaypointEditorPage.defaultProps = defaultProps;
+export default withOnyx({
+ // We must provide a default value for transactionID here, otherwise the component won't mount
+ // because withOnyx returns null until all the keys are defined
+ transactionID: {key: ONYXKEYS.IOU, selector: (iou) => (iou && iou.transactionID) || ''},
+})(WaypointEditorPage);
diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index 173ef4574d9c..3fc7b986583d 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -59,11 +59,11 @@ const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
const NUM_PAD_VIEW_ID = 'numPadView';
function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress}) {
- const {translate, toLocaleDigit, fromLocaleDigit, numberFormat} = useLocalize();
+ const {translate, toLocaleDigit, numberFormat} = useLocalize();
const textInput = useRef(null);
- const selectedAmountAsString = amount ? CurrencyUtils.convertToWholeUnit(currency, amount).toString() : '';
+ const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : '';
const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString);
const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true);
@@ -93,32 +93,22 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
}
};
- /**
- * Convert amount to whole unit and update selection
- *
- * @param {String} currencyCode
- * @param {Number} amountInCurrencyUnits
- */
- const saveAmountToState = (currencyCode, amountInCurrencyUnits) => {
- if (!currencyCode || !amountInCurrencyUnits) {
+ useEffect(() => {
+ if (!currency || !amount) {
return;
}
- const amountAsStringForState = CurrencyUtils.convertToWholeUnit(currencyCode, amountInCurrencyUnits).toString();
+ const amountAsStringForState = CurrencyUtils.convertToFrontendAmount(amount).toString();
setCurrentAmount(amountAsStringForState);
setSelection({
start: amountAsStringForState.length,
end: amountAsStringForState.length,
});
- };
-
- useEffect(() => {
- saveAmountToState(currency, amount);
// we want to update the state only when the amount is changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [amount]);
/**
- * Sets the state according to amount that is passed
+ * Sets the selection and the amount accordingly to the value passed to the input
* @param {String} newAmount - Changed amount from user input
*/
const setNewAmount = (newAmount) => {
@@ -128,13 +118,13 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
// Use a shallow copy of selection to trigger setSelection
// More info: https://github.com/Expensify/App/issues/16385
if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces)) {
- setCurrentAmount((prevAmount) => prevAmount);
setSelection((prevSelection) => ({...prevSelection}));
return;
}
setCurrentAmount((prevAmount) => {
- setSelection((prevSelection) => getNewSelection(prevSelection, prevAmount.length, newAmountWithoutSpaces.length));
- return MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
+ const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces);
+ setSelection((prevSelection) => getNewSelection(prevSelection, prevAmount.length, strippedAmount.length));
+ return strippedAmount;
});
};
@@ -176,20 +166,8 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
}
}, []);
- /**
- * Update amount on amount change
- * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit
- *
- * @param {String} text - Changed text from user input
- */
- const updateAmount = (text) => {
- const newAmount = MoneyRequestUtils.addLeadingZero(MoneyRequestUtils.replaceAllDigits(text, fromLocaleDigit));
- setNewAmount(newAmount);
- };
-
/**
* Submit amount and navigate to a proper page
- *
*/
const submitAndNavigateToNextPage = useCallback(() => {
onSubmitButtonPress(currentAmount);
@@ -207,7 +185,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
>
{
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 00b223522927..c22a81cd7299 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -21,6 +21,7 @@ import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultPro
import reportPropTypes from '../../reportPropTypes';
import personalDetailsPropType from '../../personalDetailsPropType';
import * as FileUtils from '../../../libs/fileDownload/FileUtils';
+import * as Policy from '../../../libs/actions/Policy';
const propTypes = {
report: reportPropTypes,
@@ -29,9 +30,10 @@ const propTypes = {
iou: PropTypes.shape({
id: PropTypes.string,
amount: PropTypes.number,
- receiptPath: PropTypes.string,
- currency: PropTypes.string,
comment: PropTypes.string,
+ created: PropTypes.string,
+ currency: PropTypes.string,
+ merchant: PropTypes.string,
participants: PropTypes.arrayOf(
PropTypes.shape({
accountID: PropTypes.number,
@@ -41,6 +43,7 @@ const propTypes = {
selected: PropTypes.bool,
}),
),
+ receiptPath: PropTypes.string,
}),
/** Personal details of all users */
@@ -57,6 +60,8 @@ const defaultProps = {
amount: 0,
currency: CONST.CURRENCY.USD,
comment: '',
+ merchant: '',
+ created: '',
participants: [],
},
...withCurrentUserPersonalDetailsDefaultProps,
@@ -74,6 +79,14 @@ function MoneyRequestConfirmPage(props) {
[props.iou.participants, props.personalDetails],
);
+ useEffect(() => {
+ const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat);
+ if (policyExpenseChat) {
+ Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
useEffect(() => {
// ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
if (prevMoneyRequestId.current !== props.iou.id) {
@@ -121,6 +134,8 @@ function MoneyRequestConfirmPage(props) {
props.report,
props.iou.amount,
props.iou.currency,
+ props.iou.created,
+ props.iou.merchant,
props.currentUserPersonalDetails.login,
props.currentUserPersonalDetails.accountID,
selectedParticipants[0],
@@ -128,7 +143,7 @@ function MoneyRequestConfirmPage(props) {
receipt,
);
},
- [props.report, props.iou.amount, props.iou.currency, props.currentUserPersonalDetails.login, props.currentUserPersonalDetails.accountID],
+ [props.report, props.iou.amount, props.iou.currency, props.iou.created, props.iou.merchant, props.currentUserPersonalDetails.login, props.currentUserPersonalDetails.accountID],
);
const createTransaction = useCallback(
@@ -250,8 +265,7 @@ function MoneyRequestConfirmPage(props) {
policyID={props.report.policyID}
bankAccountRoute={ReportUtils.getBankAccountRoute(props.report)}
iouMerchant={props.iou.merchant}
- iouModifiedMerchant={props.iou.modifiedMerchant}
- iouDate={props.iou.date}
+ iouCreated={props.iou.created}
/>
)}
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
index a72e37c7b7f4..9f59c55f6337 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js
@@ -34,6 +34,9 @@ const propTypes = {
/** The type of IOU report, i.e. bill, request, send */
iouType: PropTypes.string.isRequired,
+ /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */
+ selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]).isRequired,
+
...withLocalizePropTypes,
};
@@ -84,6 +87,9 @@ class MoneyRequestParticipantsSelector extends Component {
// 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.
this.props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST,
+
+ // We don't want to include any P2P options like personal details or reports that are not workspace chats for certain features.
+ this.props.selectedTab !== CONST.TAB.DISTANCE,
);
}
@@ -183,5 +189,8 @@ export default compose(
betas: {
key: ONYXKEYS.BETAS,
},
+ selectedTab: {
+ key: `${ONYXKEYS.SELECTED_TAB}_${CONST.TAB.RECEIPT_TAB_ID}`,
+ },
}),
)(MoneyRequestParticipantsSelector);
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js
index cfa7c0a8678c..2ebddbdd8741 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js
@@ -150,7 +150,7 @@ function MoneyRequestParticipantsSplitSelector({betas, participants, personalDet
Boolean(newChatOptions.userToInvite),
searchTerm,
maxParticipantsReached,
- _.some((participant) => participant.searchText.toLowerCase().includes(searchTerm.toLowerCase())),
+ _.some(participants, (participant) => participant.searchText.toLowerCase().includes(searchTerm.toLowerCase())),
);
const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails);
diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js
index ce59840e106d..e0199d72b01a 100644
--- a/src/pages/iou/steps/NewRequestAmountPage.js
+++ b/src/pages/iou/steps/NewRequestAmountPage.js
@@ -37,6 +37,8 @@ const propTypes = {
id: PropTypes.string,
amount: PropTypes.number,
currency: PropTypes.string,
+ merchant: PropTypes.string,
+ created: PropTypes.string,
participants: PropTypes.arrayOf(
PropTypes.shape({
accountID: PropTypes.number,
@@ -60,6 +62,8 @@ const defaultProps = {
iou: {
id: '',
amount: 0,
+ created: '',
+ merchant: '',
currency: CONST.CURRENCY.USD,
participants: [],
},
@@ -99,15 +103,17 @@ function NewRequestAmountPage({route, iou, report}) {
// Check and dismiss modal
useEffect(() => {
- if (!ReportUtils.shouldHideComposer(report)) {
+ 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 many bugs.
+ // 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(() => {
if (isEditing) {
+ // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request
if (prevMoneyRequestID.current !== iou.id) {
// The ID is cleared on completing a request. In that case, we will do nothing.
if (!iou.id) {
@@ -143,7 +149,7 @@ function NewRequestAmountPage({route, iou, report}) {
};
const navigateToNextPage = (currentAmount) => {
- const amountInSmallestCurrencyUnits = CurrencyUtils.convertToSmallestUnit(currency, Number.parseFloat(currentAmount));
+ const amountInSmallestCurrencyUnits = CurrencyUtils.convertToBackendAmount(Number.parseFloat(currentAmount));
IOU.setMoneyRequestAmount(amountInSmallestCurrencyUnits);
IOU.setMoneyRequestCurrency(currency);
@@ -175,6 +181,7 @@ function NewRequestAmountPage({route, iou, report}) {
return (
{({safeAreaPaddingBottomStyle}) => (
diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js
index 0b5074bfc0ce..4af48b896fe0 100644
--- a/src/pages/settings/AboutPage/AboutPage.js
+++ b/src/pages/settings/AboutPage/AboutPage.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
import React from 'react';
-import {View, ScrollView} from 'react-native';
+import {ScrollView, View} from 'react-native';
+import DeviceInfo from 'react-native-device-info';
import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
@@ -28,6 +29,17 @@ const propTypes = {
...windowDimensionsPropTypes,
};
+function getFlavor() {
+ const bundleId = DeviceInfo.getBundleId();
+ if (bundleId.includes('dev')) {
+ return ' Develop';
+ }
+ if (bundleId.includes('adhoc')) {
+ return ' Ad-Hoc';
+ }
+ return '';
+}
+
function AboutPage(props) {
let popoverAnchor;
const menuItems = [
@@ -88,7 +100,7 @@ function AboutPage(props) {
selectable
style={[styles.textLabel, styles.alignSelfCenter, styles.mt6, styles.mb2, styles.colorMuted]}
>
- v{Environment.isInternalTestBuild() ? `${pkg.version} PR:${CONST.PULL_REQUEST_NUMBER}` : pkg.version}
+ v{Environment.isInternalTestBuild() ? `${pkg.version} PR:${CONST.PULL_REQUEST_NUMBER}${getFlavor()}` : `${pkg.version}${getFlavor()}`}
{props.translate('initialSettingsPage.aboutPage.description')}
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index 825da39244d8..783c69a08ed9 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -80,10 +80,7 @@ const propTypes = {
/** List of bank accounts */
bankAccountList: PropTypes.objectOf(bankAccountPropTypes),
- /** List of cards */
- cardList: PropTypes.objectOf(cardPropTypes),
-
- /** List of cards */
+ /** List of user's cards */
fundList: PropTypes.objectOf(cardPropTypes),
/** Bank account attached to free plan */
@@ -121,7 +118,6 @@ const defaultProps = {
betas: [],
walletTerms: {},
bankAccountList: {},
- cardList: null,
fundList: null,
loginList: {},
allPolicyMembers: {},
@@ -184,7 +180,7 @@ function InitialSettingsPage(props) {
: null;
const profileBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(props.loginList);
- const paymentCardList = props.fundList || props.cardList || {};
+ const paymentCardList = props.fundList || {};
return [
{
@@ -228,10 +224,10 @@ function InitialSettingsPage(props) {
},
},
{
- translationKey: 'common.payments',
+ translationKey: 'common.wallet',
icon: Expensicons.Wallet,
action: () => {
- Navigation.navigate(ROUTES.SETTINGS_PAYMENTS);
+ Navigation.navigate(ROUTES.SETTINGS_WALLET);
},
brickRoadIndicator:
PaymentMethods.hasPaymentMethodError(props.bankAccountList, paymentCardList) || !_.isEmpty(props.userWallet.errors) || !_.isEmpty(props.walletTerms.errors)
@@ -266,7 +262,6 @@ function InitialSettingsPage(props) {
}, [
props.allPolicyMembers,
props.bankAccountList,
- props.cardList,
props.fundList,
props.loginList,
props.network.isOffline,
@@ -289,7 +284,7 @@ function InitialSettingsPage(props) {
<>
{_.map(getDefaultMenuItems, (item, index) => {
const keyTitle = item.translationKey ? translate(item.translationKey) : item.title;
- const isPaymentItem = item.translationKey === 'common.payments';
+ const isPaymentItem = item.translationKey === 'common.wallet';
return (
;
-}
-
-PaymentsPage.displayName = 'PaymentsPage';
-
-export default PaymentsPage;
diff --git a/src/pages/settings/Payments/PaymentsPage/index.native.js b/src/pages/settings/Payments/PaymentsPage/index.native.js
deleted file mode 100644
index b0b43a8e6661..000000000000
--- a/src/pages/settings/Payments/PaymentsPage/index.native.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import BasePaymentsPage from './BasePaymentsPage';
-
-export default BasePaymentsPage;
diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js
index 7075c998a56d..0eb93853726a 100644
--- a/src/pages/settings/Preferences/LanguagePage.js
+++ b/src/pages/settings/Preferences/LanguagePage.js
@@ -7,7 +7,7 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import * as App from '../../../libs/actions/App';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
...withLocalizePropTypes,
@@ -30,7 +30,7 @@ function LanguagePage(props) {
title={props.translate('languagePage.language')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)}
/>
- App.setLocaleAndNavigate(language.value)}
initiallyFocusedOptionKey={_.find(localesToLanguages, (locale) => locale.isSelected).keyForList}
diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js
index 17f86c6eb1d8..b32987e242de 100644
--- a/src/pages/settings/Preferences/PriorityModePage.js
+++ b/src/pages/settings/Preferences/PriorityModePage.js
@@ -12,7 +12,7 @@ import * as User from '../../../libs/actions/User';
import CONST from '../../../CONST';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
/** The chat priority mode */
@@ -52,7 +52,7 @@ function PriorityModePage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PREFERENCES)}
/>
{props.translate('priorityModePage.explainerText')}
- mode.isSelected).keyForList}
diff --git a/src/pages/settings/Preferences/ThemePage.js b/src/pages/settings/Preferences/ThemePage.js
index b3084e4c909c..a260caa283e3 100644
--- a/src/pages/settings/Preferences/ThemePage.js
+++ b/src/pages/settings/Preferences/ThemePage.js
@@ -7,7 +7,7 @@ import ScreenWrapper from '../../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
import styles from '../../../styles/styles';
import ONYXKEYS from '../../../ONYXKEYS';
import CONST from '../../../CONST';
@@ -45,7 +45,7 @@ function ThemePage(props) {
{props.translate('themePage.chooseThemeBelowOrSync')}
- User.updateTheme(theme.value)}
initiallyFocusedOptionKey={_.find(localesToThemes, (theme) => theme.isSelected).keyForList}
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
index 9f6d2db591b9..973a0475846d 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
@@ -88,6 +88,12 @@ function BaseValidateCodeForm(props) {
useEffect(() => {
Session.clearAccountMessages();
+ if (!validateLoginError) {
+ return;
+ }
+ User.clearContactMethodErrors(props.contactMethod, 'validateLogin');
+ // contactMethod is not added as a dependency since it does not change between renders
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js
index 9767825820cd..0d7e1c09454d 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js
@@ -33,23 +33,23 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
const draftText = lodashGet(draftStatus, 'text');
const defaultEmoji = draftEmojiCode || currentUserEmojiCode;
- const defaultText = draftText || currentUserStatusText;
- const customStatus = defaultEmoji ? `${defaultEmoji} ${defaultText}` : '';
+ const defaultText = draftEmojiCode ? draftText : currentUserStatusText;
+ const customStatus = draftEmojiCode ? `${draftEmojiCode} ${draftText}` : `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`;
const hasDraftStatus = !!draftEmojiCode || !!draftText;
- const updateStatus = useCallback(() => {
- const endOfDay = moment().endOf('day').toDate();
- User.updateCustomStatus({text: defaultText, emojiCode: defaultEmoji, clearAfter: endOfDay.toISOString()});
-
- User.clearDraftCustomStatus();
- Navigation.goBack(ROUTES.SETTINGS);
- }, [defaultText, defaultEmoji]);
-
const clearStatus = () => {
User.clearCustomStatus();
User.clearDraftCustomStatus();
};
+ const navigateBackToSettingsPage = useCallback(() => Navigation.goBack(ROUTES.SETTINGS_PROFILE, false, true), []);
+ const updateStatus = useCallback(() => {
+ const endOfDay = moment().endOf('day').format('YYYY-MM-DD HH:mm:ss');
+ User.updateCustomStatus({text: defaultText, emojiCode: defaultEmoji, clearAfter: endOfDay});
+
+ User.clearDraftCustomStatus();
+ Navigation.goBack(ROUTES.SETTINGS_PROFILE);
+ }, [defaultText, defaultEmoji]);
const footerComponent = useMemo(
() =>
hasDraftStatus ? (
@@ -67,7 +67,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
return (
Navigation.goBack(ROUTES.SETTINGS_PROFILE)}
+ onBackButtonPress={navigateBackToSettingsPage}
backgroundColor={themeColors.midtone}
image={MobileBackgroundImage}
footer={footerComponent}
@@ -78,7 +78,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
Navigation.navigate(ROUTES.SETTINGS_STATUS_SET)}
diff --git a/src/pages/settings/Profile/CustomStatus/StatusSetPage.js b/src/pages/settings/Profile/CustomStatus/StatusSetPage.js
index a3c4aae83bb1..7074ce45a7bb 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusSetPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusSetPage.js
@@ -42,7 +42,7 @@ function StatusSetPage({draftStatus, currentUserPersonalDetails}) {
const defaultText = lodashGet(draftStatus, 'text') || lodashGet(currentUserPersonalDetails, 'status.text', '');
const onSubmit = (value) => {
- User.updateDraftCustomStatus({text: value.statusText, emojiCode: value.emojiCode});
+ User.updateDraftCustomStatus({text: value.statusText.trim(), emojiCode: value.emojiCode});
Navigation.goBack(ROUTES.SETTINGS_STATUS);
};
@@ -55,7 +55,7 @@ function StatusSetPage({draftStatus, currentUserPersonalDetails}) {
@@ -74,7 +74,7 @@ function StatusSetPage({draftStatus, currentUserPersonalDetails}) {
accessibilityLabel={INPUT_IDS.STATUS_TEXT}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={defaultText}
- maxLength={100}
+ maxLength={CONST.STATUS_TEXT_MAX_LENGTH}
autoFocus
shouldDelayFocus
/>
diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
index fa73d2b1a69a..90c469c4e25d 100644
--- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js
@@ -45,15 +45,14 @@ function DateOfBirthPage({translate, privatePersonalDetails}) {
* @returns {Object} - An object containing the errors for each inputID
*/
const validate = useCallback((values) => {
- const errors = {};
+ const requiredFields = ['dob'];
+ const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
+
const minimumAge = CONST.DATE_BIRTH.MIN_AGE;
const maximumAge = CONST.DATE_BIRTH.MAX_AGE;
-
- if (!values.dob || !ValidationUtils.isValidDate(values.dob)) {
- errors.dob = 'common.error.fieldRequired';
- }
const dateError = ValidationUtils.getAgeRequirementError(values.dob, minimumAge, maximumAge);
- if (dateError) {
+
+ if (values.dob && dateError) {
errors.dob = dateError;
}
diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js
index 90f934658def..50a231523834 100644
--- a/src/pages/settings/Profile/PronounsPage.js
+++ b/src/pages/settings/Profile/PronounsPage.js
@@ -12,7 +12,7 @@ import compose from '../../../libs/compose';
import CONST from '../../../CONST';
import ROUTES from '../../../ROUTES';
import Navigation from '../../../libs/Navigation/Navigation';
-import SelectionListRadio from '../../../components/SelectionListRadio';
+import SelectionList from '../../../components/SelectionList';
const propTypes = {
...withLocalizePropTypes,
@@ -104,7 +104,7 @@ function PronounsPage(props) {
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_PROFILE)}
/>
{props.translate('pronounsPage.isShownOnProfile')}
- lodashGet(currentUserPer
function TimezoneSelectPage(props) {
const {translate} = useLocalize();
- const timezone = useRef(getUserTimezone(props.currentUserPersonalDetails));
+ const timezone = getUserTimezone(props.currentUserPersonalDetails);
const allTimezones = useRef(
- _.chain(moment.tz.names())
+ _.chain(TIMEZONES)
.filter((tz) => !tz.startsWith('Etc/GMT'))
.map((text) => ({
text,
keyForList: getKey(text),
- isSelected: text === timezone.current.selected,
+ isSelected: text === timezone.selected,
}))
.value(),
);
- const [timezoneInputText, setTimezoneInputText] = useState(timezone.current.selected);
+ const [timezoneInputText, setTimezoneInputText] = useState(timezone.selected);
const [timezoneOptions, setTimezoneOptions] = useState(allTimezones.current);
/**
@@ -71,13 +71,13 @@ function TimezoneSelectPage(props) {
title={translate('timezonePage.timezone')}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_TIMEZONE)}
/>
- tz.text === timezone.current.selected)[0], 'keyForList')}
+ sections={[{data: timezoneOptions, indexOffset: 0, isDisabled: timezone.automatic}]}
+ initiallyFocusedOptionKey={_.get(_.filter(timezoneOptions, (tz) => tz.text === timezone.selected)[0], 'keyForList')}
/>
);
diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js
index 9ec5a52b8d3a..b2370156228a 100644
--- a/src/pages/settings/Security/CloseAccountPage.js
+++ b/src/pages/settings/Security/CloseAccountPage.js
@@ -2,7 +2,6 @@ import React, {useState, useEffect} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
-import _ from 'underscore';
import Str from 'expensify-common/lib/str';
import HeaderWithBackButton from '../../../components/HeaderWithBackButton';
import Navigation from '../../../libs/Navigation/Navigation';
@@ -20,6 +19,7 @@ import ONYXKEYS from '../../../ONYXKEYS';
import Form from '../../../components/Form';
import CONST from '../../../CONST';
import ConfirmModal from '../../../components/ConfirmModal';
+import * as ValidationUtils from '../../../libs/ValidationUtils';
const propTypes = {
/** Session of currently logged in user */
@@ -63,10 +63,11 @@ function CloseAccountPage(props) {
};
const validate = (values) => {
+ const requiredFields = ['phoneOrEmail'];
const userEmailOrPhone = props.formatPhoneNumber(props.session.email);
- const errors = {};
+ const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
- if (_.isEmpty(values.phoneOrEmail) || userEmailOrPhone.toLowerCase() !== values.phoneOrEmail.toLowerCase()) {
+ if (values.phoneOrEmail && userEmailOrPhone.toLowerCase() !== values.phoneOrEmail.toLowerCase()) {
errors.phoneOrEmail = 'closeAccountPage.enterYourDefaultContactMethod';
}
return errors;
diff --git a/src/pages/settings/Security/SecuritySettingsPage.js b/src/pages/settings/Security/SecuritySettingsPage.js
index 65fa265c425a..90b006669527 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.js
+++ b/src/pages/settings/Security/SecuritySettingsPage.js
@@ -13,7 +13,6 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal
import MenuItem from '../../../components/MenuItem';
import compose from '../../../libs/compose';
import ONYXKEYS from '../../../ONYXKEYS';
-import * as Session from '../../../libs/actions/Session';
const propTypes = {
...withLocalizePropTypes,
@@ -36,14 +35,7 @@ function SecuritySettingsPage(props) {
{
translationKey: 'twoFactorAuth.headerTitle',
icon: Expensicons.Shield,
- action: () => {
- if (props.account.requiresTwoFactorAuth) {
- Navigation.navigate(ROUTES.SETTINGS_2FA_IS_ENABLED);
- } else {
- Session.toggleTwoFactorAuth(true);
- Navigation.navigate(ROUTES.SETTINGS_2FA_CODES);
- }
- },
+ action: () => Navigation.navigate(ROUTES.SETTINGS_2FA),
},
{
translationKey: 'closeAccountPage.closeAccount',
diff --git a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js
deleted file mode 100644
index 6780080ff382..000000000000
--- a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js
+++ /dev/null
@@ -1,149 +0,0 @@
-import React, {useEffect, useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import {ActivityIndicator, View} from 'react-native';
-import {ScrollView} from 'react-native-gesture-handler';
-import _ from 'underscore';
-import PropTypes from 'prop-types';
-import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ScreenWrapper from '../../../../components/ScreenWrapper';
-import * as Expensicons from '../../../../components/Icon/Expensicons';
-import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
-import compose from '../../../../libs/compose';
-import ROUTES from '../../../../ROUTES';
-import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
-import * as Illustrations from '../../../../components/Icon/Illustrations';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions';
-import styles from '../../../../styles/styles';
-import FixedFooter from '../../../../components/FixedFooter';
-import Button from '../../../../components/Button';
-import PressableWithDelayToggle from '../../../../components/Pressable/PressableWithDelayToggle';
-import Text from '../../../../components/Text';
-import Section from '../../../../components/Section';
-import ONYXKEYS from '../../../../ONYXKEYS';
-import Clipboard from '../../../../libs/Clipboard';
-import themeColors from '../../../../styles/themes/default';
-import localFileDownload from '../../../../libs/localFileDownload';
-import * as TwoFactorAuthActions from '../../../../libs/actions/TwoFactorAuthActions';
-
-const propTypes = {
- ...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
- account: PropTypes.shape({
- /** User recovery codes for setting up 2-FA */
- recoveryCodes: PropTypes.string,
-
- /** If recovery codes are loading */
- isLoading: PropTypes.bool,
- }),
-};
-
-const defaultProps = {
- account: {
- recoveryCodes: '',
- },
-};
-
-function CodesPage(props) {
- const [isNextButtonDisabled, setIsNextButtonDisabled] = useState(true);
-
- // Here, this eslint rule will make the unmount effect unreadable, possibly confusing with mount
- // eslint-disable-next-line arrow-body-style
- useEffect(() => {
- return () => {
- TwoFactorAuthActions.clearTwoFactorAuthData();
- };
- }, []);
-
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_SECURITY)}
- />
-
-
-
-
- {props.translate('twoFactorAuth.codesLoseAccess')}
-
-
- {props.account.isLoading ? (
-
-
-
- ) : (
- <>
-
- {Boolean(props.account.recoveryCodes) &&
- _.map(props.account.recoveryCodes.split(', '), (code) => (
-
- {code}
-
- ))}
-
-
- {
- Clipboard.setString(props.account.recoveryCodes);
- setIsNextButtonDisabled(false);
- }}
- styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCodesButton]}
- textStyles={[styles.buttonMediumText]}
- />
- {
- localFileDownload('two-factor-auth-codes', props.account.recoveryCodes);
- setIsNextButtonDisabled(false);
- }}
- inline={false}
- styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCodesButton]}
- textStyles={[styles.buttonMediumText]}
- />
-
- >
- )}
-
-
-
-
- Navigation.navigate(ROUTES.SETTINGS_2FA_VERIFY)}
- isDisabled={isNextButtonDisabled}
- />
-
-
-
- );
-}
-
-CodesPage.propTypes = propTypes;
-CodesPage.defaultProps = defaultProps;
-
-export default compose(
- withLocalize,
- withWindowDimensions,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
-)(CodesPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/DisablePage.js b/src/pages/settings/Security/TwoFactorAuth/DisablePage.js
deleted file mode 100644
index 1369c4d13e65..000000000000
--- a/src/pages/settings/Security/TwoFactorAuth/DisablePage.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import React, {useEffect} from 'react';
-import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ScreenWrapper from '../../../../components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
-import ROUTES from '../../../../ROUTES';
-import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
-import * as Illustrations from '../../../../components/Icon/Illustrations';
-import styles from '../../../../styles/styles';
-import BlockingView from '../../../../components/BlockingViews/BlockingView';
-import FixedFooter from '../../../../components/FixedFooter';
-import Button from '../../../../components/Button';
-import * as Session from '../../../../libs/actions/Session';
-import variables from '../../../../styles/variables';
-
-const propTypes = {
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {};
-
-function DisablePage(props) {
- useEffect(() => {
- Session.toggleTwoFactorAuth(false);
- }, []);
-
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_SECURITY)}
- />
-
-
-
-
- Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
- />
-
-
-
- );
-}
-
-DisablePage.propTypes = propTypes;
-DisablePage.defaultProps = defaultProps;
-
-export default withLocalize(DisablePage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/IsEnabledPage.js b/src/pages/settings/Security/TwoFactorAuth/IsEnabledPage.js
deleted file mode 100644
index 4d49edac1fe4..000000000000
--- a/src/pages/settings/Security/TwoFactorAuth/IsEnabledPage.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import React, {useState} from 'react';
-import {View, ScrollView} from 'react-native';
-import Text from '../../../../components/Text';
-import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ScreenWrapper from '../../../../components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
-import ROUTES from '../../../../ROUTES';
-import Section from '../../../../components/Section';
-import * as Illustrations from '../../../../components/Icon/Illustrations';
-import * as Expensicons from '../../../../components/Icon/Expensicons';
-import themeColors from '../../../../styles/themes/default';
-import styles from '../../../../styles/styles';
-import ConfirmModal from '../../../../components/ConfirmModal';
-import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
-
-const defaultProps = {};
-
-function IsEnabledPage(props) {
- const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
-
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_SECURITY)}
- />
-
-
- {
- setIsConfirmModalVisible(true);
- },
- icon: Expensicons.Close,
- iconFill: themeColors.danger,
- wrapperStyle: [styles.cardMenuItem],
- },
- ]}
- containerStyles={[styles.twoFactorAuthSection]}
- >
-
- {props.translate('twoFactorAuth.whatIsTwoFactorAuth')}
-
-
- {
- setIsConfirmModalVisible(false);
- Navigation.navigate(ROUTES.SETTINGS_2FA_DISABLE);
- }}
- onCancel={() => setIsConfirmModalVisible(false)}
- onModalHide={() => setIsConfirmModalVisible(false)}
- isVisible={isConfirmModalVisible}
- prompt={props.translate('twoFactorAuth.disableTwoFactorAuthConfirmation')}
- confirmText={props.translate('twoFactorAuth.disable')}
- cancelText={props.translate('common.cancel')}
- shouldShowCancelButton
- danger
- />
-
-
-
- );
-}
-
-IsEnabledPage.propTypes = withLocalizePropTypes;
-IsEnabledPage.defaultProps = defaultProps;
-
-export default withLocalize(IsEnabledPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js b/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js
new file mode 100644
index 000000000000..16f9489d87b0
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapper.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import HeaderWithBackButton from '../../../../../components/HeaderWithBackButton';
+import ScreenWrapper from '../../../../../components/ScreenWrapper';
+import FullPageOfflineBlockingView from '../../../../../components/BlockingViews/FullPageOfflineBlockingView';
+import * as TwoFactorAuthActions from '../../../../../libs/actions/TwoFactorAuthActions';
+import StepWrapperPropTypes from './StepWrapperPropTypes';
+import AnimatedStep from '../../../../../components/AnimatedStep';
+import styles from '../../../../../styles/styles';
+import useAnimatedStepContext from '../../../../../components/AnimatedStep/useAnimatedStepContext';
+
+function StepWrapper({
+ title = '',
+ stepCounter = null,
+ onBackButtonPress = TwoFactorAuthActions.quitAndNavigateBackToSettings,
+ children = null,
+ shouldEnableKeyboardAvoidingView = true,
+ onEntryTransitionEnd,
+}) {
+ const shouldShowStepCounter = Boolean(stepCounter);
+
+ const {animationDirection} = useAnimatedStepContext();
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+StepWrapper.propTypes = StepWrapperPropTypes;
+
+export default StepWrapper;
diff --git a/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapperPropTypes.js b/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapperPropTypes.js
new file mode 100644
index 000000000000..3c06cc7bca52
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/StepWrapper/StepWrapperPropTypes.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+
+export default {
+ /** Title of the Header */
+ title: PropTypes.string,
+
+ /** Data to display a step counter in the header */
+ stepCounter: PropTypes.shape({
+ /** Current step */
+ step: PropTypes.number,
+ /** Total number of steps */
+ total: PropTypes.number,
+ /** Text to display next to the step counter */
+ text: PropTypes.string,
+ }),
+
+ /** Method to trigger when pressing back button of the header */
+ onBackButtonPress: PropTypes.func,
+
+ /** Called when navigated Screen's transition is finished. It does not fire when user exits the page. */
+ onEntryTransitionEnd: PropTypes.func,
+
+ /** Children components */
+ children: PropTypes.node,
+
+ /** Flag to indicate if the keyboard avoiding view should be enabled */
+ shouldEnableKeyboardAvoidingView: PropTypes.bool,
+};
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
new file mode 100644
index 000000000000..52d7a9806f69
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js
@@ -0,0 +1,125 @@
+import React, {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {ActivityIndicator, View} from 'react-native';
+import {ScrollView} from 'react-native-gesture-handler';
+import _ from 'underscore';
+import * as Expensicons from '../../../../../components/Icon/Expensicons';
+import * as Illustrations from '../../../../../components/Icon/Illustrations';
+import styles from '../../../../../styles/styles';
+import FixedFooter from '../../../../../components/FixedFooter';
+import Button from '../../../../../components/Button';
+import PressableWithDelayToggle from '../../../../../components/Pressable/PressableWithDelayToggle';
+import Text from '../../../../../components/Text';
+import Section from '../../../../../components/Section';
+import ONYXKEYS from '../../../../../ONYXKEYS';
+import Clipboard from '../../../../../libs/Clipboard';
+import themeColors from '../../../../../styles/themes/default';
+import localFileDownload from '../../../../../libs/localFileDownload';
+import * as Session from '../../../../../libs/actions/Session';
+import CONST from '../../../../../CONST';
+import useTwoFactorAuthContext from '../TwoFactorAuthContext/useTwoFactorAuth';
+import useLocalize from '../../../../../hooks/useLocalize';
+import useWindowDimensions from '../../../../../hooks/useWindowDimensions';
+import StepWrapper from '../StepWrapper/StepWrapper';
+import {defaultAccount, TwoFactorAuthPropTypes} from '../TwoFactorAuthPropTypes';
+import * as TwoFactorAuthActions from '../../../../../libs/actions/TwoFactorAuthActions';
+
+function CodesStep({account = defaultAccount}) {
+ const {translate} = useLocalize();
+ const {isExtraSmallScreenWidth, isSmallScreenWidth} = useWindowDimensions();
+
+ const {setStep} = useTwoFactorAuthContext();
+
+ useEffect(() => {
+ if (account.recoveryCodes) {
+ return;
+ }
+ Session.toggleTwoFactorAuth(true);
+ }, [account.recoveryCodes]);
+
+ return (
+
+
+
+
+ {translate('twoFactorAuth.codesLoseAccess')}
+
+
+ {account.isLoading ? (
+
+
+
+ ) : (
+ <>
+
+ {Boolean(account.recoveryCodes) &&
+ _.map(account.recoveryCodes.split(', '), (code) => (
+
+ {code}
+
+ ))}
+
+
+ {
+ Clipboard.setString(account.recoveryCodes);
+ TwoFactorAuthActions.setCodesAreCopied();
+ }}
+ styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCodesButton]}
+ textStyles={[styles.buttonMediumText]}
+ />
+ {
+ localFileDownload('two-factor-auth-codes', account.recoveryCodes);
+ TwoFactorAuthActions.setCodesAreCopied();
+ }}
+ inline={false}
+ styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCodesButton]}
+ textStyles={[styles.buttonMediumText]}
+ />
+
+ >
+ )}
+
+
+
+
+ setStep(CONST.TWO_FACTOR_AUTH_STEPS.VERIFY)}
+ isDisabled={!account.codesAreCopied}
+ />
+
+
+ );
+}
+
+CodesStep.propTypes = TwoFactorAuthPropTypes;
+
+// eslint-disable-next-line rulesdir/onyx-props-must-have-default
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+})(CodesStep);
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js
new file mode 100644
index 000000000000..2f3b87e69a6e
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/DisabledStep.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import * as Illustrations from '../../../../../components/Icon/Illustrations';
+import styles from '../../../../../styles/styles';
+import BlockingView from '../../../../../components/BlockingViews/BlockingView';
+import FixedFooter from '../../../../../components/FixedFooter';
+import Button from '../../../../../components/Button';
+import variables from '../../../../../styles/variables';
+import StepWrapper from '../StepWrapper/StepWrapper';
+import useLocalize from '../../../../../hooks/useLocalize';
+import * as TwoFactorAuthActions from '../../../../../libs/actions/TwoFactorAuthActions';
+
+function DisabledStep() {
+ const {translate} = useLocalize();
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default DisabledStep;
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js
new file mode 100644
index 000000000000..584d6195bbe6
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/EnabledStep.js
@@ -0,0 +1,66 @@
+import React, {useState} from 'react';
+import {Text, View, ScrollView} from 'react-native';
+import Section from '../../../../../components/Section';
+import * as Illustrations from '../../../../../components/Icon/Illustrations';
+import * as Expensicons from '../../../../../components/Icon/Expensicons';
+import themeColors from '../../../../../styles/themes/default';
+import styles from '../../../../../styles/styles';
+import ConfirmModal from '../../../../../components/ConfirmModal';
+import * as Session from '../../../../../libs/actions/Session';
+import StepWrapper from '../StepWrapper/StepWrapper';
+import CONST from '../../../../../CONST';
+import useLocalize from '../../../../../hooks/useLocalize';
+import useTwoFactorAuthContext from '../TwoFactorAuthContext/useTwoFactorAuth';
+
+function EnabledStep() {
+ const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
+
+ const {setStep} = useTwoFactorAuthContext();
+
+ const {translate} = useLocalize();
+
+ return (
+
+
+ {
+ setIsConfirmModalVisible(true);
+ },
+ icon: Expensicons.Close,
+ iconFill: themeColors.danger,
+ wrapperStyle: [styles.cardMenuItem],
+ },
+ ]}
+ containerStyles={[styles.twoFactorAuthSection]}
+ >
+
+ {translate('twoFactorAuth.whatIsTwoFactorAuth')}
+
+
+ {
+ setIsConfirmModalVisible(false);
+ setStep(CONST.TWO_FACTOR_AUTH_STEPS.DISABLED);
+ Session.toggleTwoFactorAuth(false);
+ }}
+ onCancel={() => setIsConfirmModalVisible(false)}
+ onModalHide={() => setIsConfirmModalVisible(false)}
+ isVisible={isConfirmModalVisible}
+ prompt={translate('twoFactorAuth.disableTwoFactorAuthConfirmation')}
+ confirmText={translate('twoFactorAuth.disable')}
+ cancelText={translate('common.cancel')}
+ shouldShowCancelButton
+ danger
+ />
+
+
+ );
+}
+
+export default EnabledStep;
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.js
new file mode 100644
index 000000000000..308efecf3415
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import ConfirmationPage from '../../../../../components/ConfirmationPage';
+import * as TwoFactorAuthActions from '../../../../../libs/actions/TwoFactorAuthActions';
+import * as LottieAnimations from '../../../../../components/LottieAnimations';
+import CONST from '../../../../../CONST';
+import StepWrapper from '../StepWrapper/StepWrapper';
+import useTwoFactorAuthContext from '../TwoFactorAuthContext/useTwoFactorAuth';
+import useLocalize from '../../../../../hooks/useLocalize';
+
+function SuccessStep() {
+ const {setStep} = useTwoFactorAuthContext();
+
+ const {translate} = useLocalize();
+
+ return (
+
+ {
+ TwoFactorAuthActions.clearTwoFactorAuthData();
+ setStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED);
+ }}
+ />
+
+ );
+}
+
+export default SuccessStep;
diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js
new file mode 100644
index 000000000000..8c3d9689cfcd
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/Steps/VerifyStep.js
@@ -0,0 +1,136 @@
+import React, {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {ScrollView, View} from 'react-native';
+import * as Session from '../../../../../libs/actions/Session';
+import styles from '../../../../../styles/styles';
+import Button from '../../../../../components/Button';
+import Text from '../../../../../components/Text';
+import ONYXKEYS from '../../../../../ONYXKEYS';
+import TextLink from '../../../../../components/TextLink';
+import Clipboard from '../../../../../libs/Clipboard';
+import FixedFooter from '../../../../../components/FixedFooter';
+import * as Expensicons from '../../../../../components/Icon/Expensicons';
+import PressableWithDelayToggle from '../../../../../components/Pressable/PressableWithDelayToggle';
+import TwoFactorAuthForm from '../TwoFactorAuthForm';
+import QRCode from '../../../../../components/QRCode';
+import expensifyLogo from '../../../../../../assets/images/expensify-logo-round-transparent.png';
+import CONST from '../../../../../CONST';
+import StepWrapper from '../StepWrapper/StepWrapper';
+import useTwoFactorAuthContext from '../TwoFactorAuthContext/useTwoFactorAuth';
+import useLocalize from '../../../../../hooks/useLocalize';
+import {defaultAccount, TwoFactorAuthPropTypes} from '../TwoFactorAuthPropTypes';
+
+const TROUBLESHOOTING_LINK = 'https://community.expensify.com/discussion/7736/faq-troubleshooting-two-factor-authentication-issues/p1?new=1';
+
+function VerifyStep({account = defaultAccount}) {
+ const {translate} = useLocalize();
+
+ const formRef = React.useRef(null);
+
+ const {setStep} = useTwoFactorAuthContext();
+
+ useEffect(() => {
+ Session.clearAccountMessages();
+ }, []);
+
+ useEffect(() => {
+ if (!account.requiresTwoFactorAuth) {
+ return;
+ }
+ setStep(CONST.TWO_FACTOR_AUTH_STEPS.SUCCESS);
+ }, [account.requiresTwoFactorAuth, setStep]);
+
+ /**
+ * Splits the two-factor auth secret key in 4 chunks
+ *
+ * @param {String} secret
+ * @returns {string}
+ */
+ function splitSecretInChunks(secret) {
+ if (secret.length !== 16) {
+ return secret;
+ }
+
+ return `${secret.slice(0, 4)} ${secret.slice(4, 8)} ${secret.slice(8, 12)} ${secret.slice(12, secret.length)}`;
+ }
+
+ /**
+ * Builds the URL string to generate the QRCode, using the otpauth:// protocol,
+ * so it can be detected by authenticator apps
+ *
+ * @returns {string}
+ */
+ function buildAuthenticatorUrl() {
+ return `otpauth://totp/Expensify:${account.primaryLogin}?secret=${account.twoFactorAuthSecretKey}&issuer=Expensify`;
+ }
+
+ return (
+ setStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES, CONST.ANIMATION_DIRECTION.OUT)}
+ onEntryTransitionEnd={() => formRef.current && formRef.current.focus()}
+ >
+
+
+
+ {translate('twoFactorAuth.scanCode')}
+ {translate('twoFactorAuth.authenticatorApp')} .
+
+
+
+
+ {translate('twoFactorAuth.addKey')}
+
+ {Boolean(account.twoFactorAuthSecretKey) && {splitSecretInChunks(account.twoFactorAuthSecretKey)} }
+ Clipboard.setString(account.twoFactorAuthSecretKey)}
+ styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCopyCodeButton]}
+ textStyles={[styles.buttonMediumText]}
+ />
+
+ {translate('twoFactorAuth.enterCode')}
+
+
+
+
+
+
+ {
+ if (!formRef.current) {
+ return;
+ }
+ formRef.current.validateAndSubmitForm();
+ }}
+ />
+
+
+ );
+}
+
+VerifyStep.propTypes = TwoFactorAuthPropTypes;
+
+// eslint-disable-next-line rulesdir/onyx-props-must-have-default
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+})(VerifyStep);
diff --git a/src/pages/settings/Security/TwoFactorAuth/SuccessPage.js b/src/pages/settings/Security/TwoFactorAuth/SuccessPage.js
deleted file mode 100644
index 3febc0e98c38..000000000000
--- a/src/pages/settings/Security/TwoFactorAuth/SuccessPage.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from 'react';
-import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ScreenWrapper from '../../../../components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
-import ROUTES from '../../../../ROUTES';
-import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
-import * as LottieAnimations from '../../../../components/LottieAnimations';
-import ConfirmationPage from '../../../../components/ConfirmationPage';
-
-const defaultProps = {};
-
-function SuccessPage(props) {
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_SECURITY)}
- />
-
- Navigation.navigate(ROUTES.SETTINGS_2FA_IS_ENABLED)}
- />
-
-
- );
-}
-
-SuccessPage.propTypes = withLocalizePropTypes;
-SuccessPage.defaultProps = defaultProps;
-
-export default withLocalize(SuccessPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/index.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/index.js
new file mode 100644
index 000000000000..9a2287c775f3
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/index.js
@@ -0,0 +1,4 @@
+import {createContext} from 'react';
+
+const TwoFactorAuthContext = createContext();
+export default TwoFactorAuthContext;
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/useTwoFactorAuth.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/useTwoFactorAuth.js
new file mode 100644
index 000000000000..c0e45a21b050
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthContext/useTwoFactorAuth.js
@@ -0,0 +1,6 @@
+import {useContext} from 'react';
+import TwoFactorAuthContext from './index';
+
+export default function useTwoFactorAuthContext() {
+ return useContext(TwoFactorAuthContext);
+}
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage.js
new file mode 100644
index 000000000000..99aeb4b11f89
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import AnimatedStepProvider from '../../../../components/AnimatedStep/AnimatedStepProvider';
+import TwoFactorAuthSteps from './TwoFactorAuthSteps';
+
+function TwoFactorAuthPage() {
+ return (
+
+
+
+ );
+}
+
+export default TwoFactorAuthPage;
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js
new file mode 100644
index 000000000000..a505ca51f1e3
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthPropTypes.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+
+const TwoFactorAuthPropTypes = {
+ account: PropTypes.shape({
+ /** Whether this account has 2FA enabled or not */
+ requiresTwoFactorAuth: PropTypes.bool,
+
+ /** Secret key to enable 2FA within the authenticator app */
+ twoFactorAuthSecretKey: PropTypes.string,
+
+ /** User primary login to attach to the authenticator QRCode */
+ primaryLogin: PropTypes.string,
+
+ /** User is submitting the authentication code */
+ isLoading: PropTypes.bool,
+
+ /** Server-side errors in the submitted authentication code */
+ errors: PropTypes.objectOf(PropTypes.string),
+ }),
+};
+
+const defaultAccount = {
+ requiresTwoFactorAuth: false,
+ twoFactorAuthStep: '',
+};
+
+export {TwoFactorAuthPropTypes, defaultAccount};
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
new file mode 100644
index 000000000000..e0094267742b
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
@@ -0,0 +1,78 @@
+import React, {useCallback, useEffect, useState} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import CodesStep from './Steps/CodesStep';
+import DisabledStep from './Steps/DisabledStep';
+import EnabledStep from './Steps/EnabledStep';
+import VerifyStep from './Steps/VerifyStep';
+import SuccessStep from './Steps/SuccessStep';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import CONST from '../../../../CONST';
+import * as TwoFactorAuthActions from '../../../../libs/actions/TwoFactorAuthActions';
+import TwoFactorAuthContext from './TwoFactorAuthContext';
+import {defaultAccount, TwoFactorAuthPropTypes} from './TwoFactorAuthPropTypes';
+import useAnimatedStepContext from '../../../../components/AnimatedStep/useAnimatedStepContext';
+
+function TwoFactorAuthSteps({account = defaultAccount}) {
+ const [currentStep, setCurrentStep] = useState(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
+
+ const {setAnimationDirection} = useAnimatedStepContext();
+
+ useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []);
+
+ useEffect(() => {
+ if (account.twoFactorAuthStep) {
+ setCurrentStep(account.twoFactorAuthStep);
+ return;
+ }
+ if (account.requiresTwoFactorAuth) {
+ setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED);
+ } else {
+ setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
+ }
+ // we don't want to trigger the hook every time the step changes, only when the requiresTwoFactorAuth changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [account.requiresTwoFactorAuth]);
+
+ const handleSetStep = useCallback(
+ (step, animationDirection = CONST.ANIMATION_DIRECTION.IN) => {
+ setAnimationDirection(animationDirection);
+ TwoFactorAuthActions.setTwoFactorAuthStep(step);
+ setCurrentStep(step);
+ },
+ [setAnimationDirection],
+ );
+
+ const renderStep = () => {
+ switch (currentStep) {
+ case CONST.TWO_FACTOR_AUTH_STEPS.CODES:
+ return ;
+ case CONST.TWO_FACTOR_AUTH_STEPS.VERIFY:
+ return ;
+ case CONST.TWO_FACTOR_AUTH_STEPS.SUCCESS:
+ return ;
+ case CONST.TWO_FACTOR_AUTH_STEPS.ENABLED:
+ return ;
+ case CONST.TWO_FACTOR_AUTH_STEPS.DISABLED:
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+ {renderStep()}
+
+ );
+}
+
+TwoFactorAuthSteps.propTypes = TwoFactorAuthPropTypes;
+
+// eslint-disable-next-line rulesdir/onyx-props-must-have-default
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+})(TwoFactorAuthSteps);
diff --git a/src/pages/settings/Security/TwoFactorAuth/VerifyPage.js b/src/pages/settings/Security/TwoFactorAuth/VerifyPage.js
deleted file mode 100644
index e67f0469db62..000000000000
--- a/src/pages/settings/Security/TwoFactorAuth/VerifyPage.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import React, {useEffect} from 'react';
-import {withOnyx} from 'react-native-onyx';
-import {ScrollView, View} from 'react-native';
-import PropTypes from 'prop-types';
-import HeaderWithBackButton from '../../../../components/HeaderWithBackButton';
-import Navigation from '../../../../libs/Navigation/Navigation';
-import ScreenWrapper from '../../../../components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
-import compose from '../../../../libs/compose';
-import * as Session from '../../../../libs/actions/Session';
-import ROUTES from '../../../../ROUTES';
-import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
-import styles from '../../../../styles/styles';
-import Button from '../../../../components/Button';
-import Text from '../../../../components/Text';
-import ONYXKEYS from '../../../../ONYXKEYS';
-import TextLink from '../../../../components/TextLink';
-import Clipboard from '../../../../libs/Clipboard';
-import FixedFooter from '../../../../components/FixedFooter';
-import * as Expensicons from '../../../../components/Icon/Expensicons';
-import PressableWithDelayToggle from '../../../../components/Pressable/PressableWithDelayToggle';
-import TwoFactorAuthForm from './TwoFactorAuthForm';
-import QRCode from '../../../../components/QRCode';
-import expensifyLogo from '../../../../../assets/images/expensify-logo-round-transparent.png';
-import CONST from '../../../../CONST';
-
-const propTypes = {
- ...withLocalizePropTypes,
- account: PropTypes.shape({
- /** Whether this account has 2FA enabled or not */
- requiresTwoFactorAuth: PropTypes.bool,
-
- /** Secret key to enable 2FA within the authenticator app */
- twoFactorAuthSecretKey: PropTypes.string,
-
- /** User primary login to attach to the authenticator QRCode */
- primaryLogin: PropTypes.string,
-
- /** User is submitting the authentication code */
- isLoading: PropTypes.bool,
-
- /** Server-side errors in the submitted authentication code */
- errors: PropTypes.objectOf(PropTypes.string),
- }),
-};
-
-const defaultProps = {
- account: {
- requiresTwoFactorAuth: false,
- twoFactorAuthSecretKey: '',
- primaryLogin: '',
- isLoading: false,
- errors: {},
- },
-};
-
-function VerifyPage(props) {
- const formRef = React.useRef(null);
-
- useEffect(() => {
- Session.clearAccountMessages();
- }, []);
-
- useEffect(() => {
- if (!props.account.requiresTwoFactorAuth) {
- return;
- }
- Navigation.navigate(ROUTES.SETTINGS_2FA_SUCCESS);
- }, [props.account.requiresTwoFactorAuth]);
-
- /**
- * Splits the two-factor auth secret key in 4 chunks
- *
- * @param {String} secret
- * @returns {string}
- */
- function splitSecretInChunks(secret) {
- if (secret.length !== 16) {
- return secret;
- }
-
- return `${secret.slice(0, 4)} ${secret.slice(4, 8)} ${secret.slice(8, 12)} ${secret.slice(12, secret.length)}`;
- }
-
- /**
- * Builds the URL string to generate the QRCode, using the otpauth:// protocol,
- * so it can be detected by authenticator apps
- *
- * @returns {string}
- */
- function buildAuthenticatorUrl() {
- return `otpauth://totp/Expensify:${props.account.primaryLogin}?secret=${props.account.twoFactorAuthSecretKey}&issuer=Expensify`;
- }
-
- return (
- formRef.current && formRef.current.focus()}
- >
- Navigation.goBack(ROUTES.SETTINGS_2FA_CODES)}
- />
-
-
-
-
- {props.translate('twoFactorAuth.scanCode')}
-
- {' '}
- {props.translate('twoFactorAuth.authenticatorApp')}
-
- .
-
-
-
-
- {props.translate('twoFactorAuth.addKey')}
-
- {Boolean(props.account.twoFactorAuthSecretKey) && {splitSecretInChunks(props.account.twoFactorAuthSecretKey)} }
- Clipboard.setString(props.account.twoFactorAuthSecretKey)}
- styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCopyCodeButton]}
- textStyles={[styles.buttonMediumText]}
- />
-
- {props.translate('twoFactorAuth.enterCode')}
-
-
-
-
-
-
- {
- if (!formRef.current) {
- return;
- }
- formRef.current.validateAndSubmitForm();
- }}
- />
-
-
-
- );
-}
-
-VerifyPage.propTypes = propTypes;
-VerifyPage.defaultProps = defaultProps;
-
-export default compose(
- withLocalize,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
-)(VerifyPage);
diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Wallet/AddDebitCardPage.js
similarity index 86%
rename from src/pages/settings/Payments/AddDebitCardPage.js
rename to src/pages/settings/Wallet/AddDebitCardPage.js
index 4d9d53c98870..5bc41b1f7307 100644
--- a/src/pages/settings/Payments/AddDebitCardPage.js
+++ b/src/pages/settings/Wallet/AddDebitCardPage.js
@@ -20,18 +20,24 @@ import Form from '../../../components/Form';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
import usePrevious from '../../../hooks/usePrevious';
+import NotFoundPage from '../../ErrorPage/NotFoundPage';
+import Permissions from '../../../libs/Permissions';
const propTypes = {
/* Onyx Props */
formData: PropTypes.shape({
setupComplete: PropTypes.bool,
}),
+
+ /** List of betas available to current user */
+ betas: PropTypes.arrayOf(PropTypes.string),
};
const defaultProps = {
formData: {
setupComplete: false,
},
+ betas: [],
};
function DebitCardPage(props) {
@@ -60,36 +66,33 @@ function DebitCardPage(props) {
* @returns {Boolean}
*/
const validate = (values) => {
- const errors = {};
+ const requiredFields = ['nameOnCard', 'cardNumber', 'expirationDate', 'securityCode', 'addressStreet', 'addressZipCode', 'addressState'];
+ const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
- if (!values.nameOnCard || !ValidationUtils.isValidLegalName(values.nameOnCard)) {
+ if (values.nameOnCard && !ValidationUtils.isValidLegalName(values.nameOnCard)) {
errors.nameOnCard = 'addDebitCardPage.error.invalidName';
}
- if (!values.cardNumber || !ValidationUtils.isValidDebitCard(values.cardNumber.replace(/ /g, ''))) {
+ if (values.cardNumber && !ValidationUtils.isValidDebitCard(values.cardNumber.replace(/ /g, ''))) {
errors.cardNumber = 'addDebitCardPage.error.debitCardNumber';
}
- if (!values.expirationDate || !ValidationUtils.isValidExpirationDate(values.expirationDate)) {
+ if (values.expirationDate && !ValidationUtils.isValidExpirationDate(values.expirationDate)) {
errors.expirationDate = 'addDebitCardPage.error.expirationDate';
}
- if (!values.securityCode || !ValidationUtils.isValidSecurityCode(values.securityCode)) {
+ if (values.securityCode && !ValidationUtils.isValidSecurityCode(values.securityCode)) {
errors.securityCode = 'addDebitCardPage.error.securityCode';
}
- if (!values.addressStreet || !ValidationUtils.isValidAddress(values.addressStreet)) {
+ if (values.addressStreet && !ValidationUtils.isValidAddress(values.addressStreet)) {
errors.addressStreet = 'addDebitCardPage.error.addressStreet';
}
- if (!values.addressZipCode || !ValidationUtils.isValidZipCode(values.addressZipCode)) {
+ if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) {
errors.addressZipCode = 'addDebitCardPage.error.addressZipCode';
}
- if (!values.addressState || !values.addressState) {
- errors.addressState = 'addDebitCardPage.error.addressState';
- }
-
if (!values.acceptTerms) {
errors.acceptTerms = 'common.error.acceptTerms';
}
@@ -97,6 +100,10 @@ function DebitCardPage(props) {
return errors;
};
+ if (!Permissions.canUseWallet(props.betas)) {
+ return ;
+ }
+
return (
nameOnCardRef.current && nameOnCardRef.current.focus()}
@@ -104,7 +111,7 @@ function DebitCardPage(props) {
>
Navigation.goBack(ROUTES.SETTINGS_PAYMENTS)}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
{
- const errors = {};
- if (!ValidationUtils.isValidPaypalUsername(values.payPalMeUsername)) {
+ const requiredFields = ['payPalMeUsername'];
+ const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
+
+ if (values.payPalMeUsername && !ValidationUtils.isValidPaypalUsername(values.payPalMeUsername)) {
errors.payPalMeUsername = 'addPayPalMePage.formatError';
}
@@ -59,7 +62,7 @@ function AddPayPalMePage(props) {
(values) => {
User.addPaypalMeAddress(values.payPalMeUsername);
Growl.show(growlMessageOnSave, CONST.GROWL.SUCCESS, 3000);
- Navigation.goBack(ROUTES.SETTINGS_PAYMENTS);
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
},
[growlMessageOnSave],
);
@@ -68,7 +71,7 @@ function AddPayPalMePage(props) {
payPalMeInput.current && payPalMeInput.current.focus()}>
Navigation.goBack(ROUTES.SETTINGS_PAYMENTS)}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
Linking.openURL('https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies')}
+ onPress={() => Link.openExternalLink('https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies')}
>
{
PaymentMethods.saveWalletTransferAccountTypeAndID(accountType, accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? account.bankAccountID : account.fundID);
- Navigation.goBack(ROUTES.SETTINGS_PAYMENTS_TRANSFER_BALANCE);
+ Navigation.goBack(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
};
/**
@@ -55,7 +55,7 @@ function ChooseTransferAccountPage(props) {
Navigation.goBack(ROUTES.SETTINGS_PAYMENTS_TRANSFER_BALANCE)}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE)}
/>
{
- const paymentCardList = fundList || cardList || {};
+ const paymentCardList = fundList || {};
// Hide any billing cards that are not P2P debit cards for now because you cannot make them your default method, or delete them
const filteredCardList = _.filter(paymentCardList, (card) => card.accountData.additionalData.isP2PDebitCard);
let combinedPaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList, filteredCardList, payPalMeData);
@@ -198,7 +180,7 @@ function PaymentMethodList(props) {
}));
return combinedPaymentMethods;
- }, [actionPaymentMethodType, activePaymentMethodID, bankAccountList, filterType, network, onPress, payPalMeData, cardList, fundList]);
+ }, [actionPaymentMethodType, activePaymentMethodID, bankAccountList, filterType, network, onPress, payPalMeData, fundList]);
/**
* Render placeholder when there are no payments methods
@@ -286,9 +268,6 @@ export default compose(
bankAccountList: {
key: ONYXKEYS.BANK_ACCOUNT_LIST,
},
- cardList: {
- key: ONYXKEYS.CARD_LIST,
- },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
diff --git a/src/pages/settings/Payments/TransferBalancePage.js b/src/pages/settings/Wallet/TransferBalancePage.js
similarity index 96%
rename from src/pages/settings/Payments/TransferBalancePage.js
rename to src/pages/settings/Wallet/TransferBalancePage.js
index c2a97ea62d36..b52695c2e922 100644
--- a/src/pages/settings/Payments/TransferBalancePage.js
+++ b/src/pages/settings/Wallet/TransferBalancePage.js
@@ -49,10 +49,7 @@ const propTypes = {
}),
),
- /** List of card objects */
- cardList: PropTypes.objectOf(cardPropTypes),
-
- /** List of card objects */
+ /** List of user's card objects */
fundList: PropTypes.objectOf(cardPropTypes),
/** Wallet balance transfer props */
@@ -63,14 +60,13 @@ const propTypes = {
const defaultProps = {
bankAccountList: {},
- cardList: null,
fundList: null,
userWallet: {},
walletTransfer: {},
};
function TransferBalancePage(props) {
- const paymentCardList = props.fundList || props.cardList || {};
+ const paymentCardList = props.fundList || {};
const paymentTypes = [
{
@@ -123,7 +119,7 @@ function TransferBalancePage(props) {
return;
}
- Navigation.navigate(ROUTES.SETTINGS_PAYMENTS_CHOOSE_TRANSFER_ACCOUNT);
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT);
}
useEffect(() => {
@@ -179,14 +175,13 @@ function TransferBalancePage(props) {
shouldShow={!shouldShowTransferView}
titleKey="notFound.pageNotFound"
subtitleKey="transferAmountPage.notHereSubTitle"
- shouldShowLink
- linkKey="transferAmountPage.goToPayment"
- onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_PAYMENTS)}
+ linkKey="transferAmountPage.goToWallet"
+ onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
>
Navigation.goBack(ROUTES.SETTINGS_PAYMENTS)}
+ onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)}
/>
@@ -266,9 +261,6 @@ export default compose(
bankAccountList: {
key: ONYXKEYS.BANK_ACCOUNT_LIST,
},
- cardList: {
- key: ONYXKEYS.CARD_LIST,
- },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Wallet/WalletPage/BaseWalletPage.js
similarity index 94%
rename from src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js
rename to src/pages/settings/Wallet/WalletPage/BaseWalletPage.js
index 359809dda0f1..72e31ec72152 100644
--- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js
+++ b/src/pages/settings/Wallet/WalletPage/BaseWalletPage.js
@@ -22,7 +22,7 @@ import AddPaymentMethodMenu from '../../../../components/AddPaymentMethodMenu';
import CONST from '../../../../CONST';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import KYCWall from '../../../../components/KYCWall';
-import {propTypes, defaultProps} from './paymentsPagePropTypes';
+import {propTypes, defaultProps} from './walletPagePropTypes';
import {withNetwork} from '../../../../components/OnyxProvider';
import * as PaymentUtils from '../../../../libs/PaymentUtils';
import OfflineWithFeedback from '../../../../components/OfflineWithFeedback';
@@ -33,7 +33,7 @@ import variables from '../../../../styles/variables';
import useLocalize from '../../../../hooks/useLocalize';
import useWindowDimensions from '../../../../hooks/useWindowDimensions';
-function BasePaymentsPage(props) {
+function BaseWalletPage(props) {
const {translate} = useLocalize();
const {isSmallScreenWidth, windowWidth} = useWindowDimensions();
const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false);
@@ -128,6 +128,11 @@ function BasePaymentsPage(props) {
* @param {String|Number} methodID
*/
const paymentMethodPressed = (nativeEvent, accountType, account, isDefault, methodID) => {
+ if (shouldShowAddPaymentMenu) {
+ setShouldShowAddPaymentMenu(false);
+ return;
+ }
+
paymentMethodButtonRef.current = nativeEvent.currentTarget;
// The delete/default menu
@@ -221,17 +226,16 @@ function BasePaymentsPage(props) {
);
const makeDefaultPaymentMethod = useCallback(() => {
- const paymentCardList = props.fundList || props.cardList || {};
- const paymentCardOnyxKey = props.fundList ? ONYXKEYS.FUND_LIST : ONYXKEYS.CARD_LIST;
+ const paymentCardList = props.fundList || {};
// Find the previous default payment method so we can revert if the MakeDefaultPaymentMethod command errors
const paymentMethods = PaymentUtils.formatPaymentMethods(props.bankAccountList, paymentCardList);
const previousPaymentMethod = _.find(paymentMethods, (method) => method.isDefault);
const currentPaymentMethod = _.find(paymentMethods, (method) => method.methodID === paymentMethod.methodID);
if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.BANK_ACCOUNT) {
- PaymentMethods.makeDefaultPaymentMethod(paymentMethod.selectedPaymentMethod.bankAccountID, null, previousPaymentMethod, currentPaymentMethod, paymentCardOnyxKey);
+ PaymentMethods.makeDefaultPaymentMethod(paymentMethod.selectedPaymentMethod.bankAccountID, null, previousPaymentMethod, currentPaymentMethod);
} else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) {
- PaymentMethods.makeDefaultPaymentMethod(null, paymentMethod.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod, paymentCardOnyxKey);
+ PaymentMethods.makeDefaultPaymentMethod(null, paymentMethod.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod);
}
resetSelectedPaymentMethodData();
}, [
@@ -240,7 +244,6 @@ function BasePaymentsPage(props) {
paymentMethod.selectedPaymentMethod.fundID,
paymentMethod.selectedPaymentMethodType,
props.bankAccountList,
- props.cardList,
props.fundList,
resetSelectedPaymentMethodData,
]);
@@ -257,7 +260,7 @@ function BasePaymentsPage(props) {
}, [paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethod.fundID, paymentMethod.selectedPaymentMethodType, resetSelectedPaymentMethodData]);
const navigateToTransferBalancePage = () => {
- Navigation.navigate(ROUTES.SETTINGS_PAYMENTS_TRANSFER_BALANCE);
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
};
const navigateToAddPaypalRoute = () => {
@@ -296,8 +299,9 @@ function BasePaymentsPage(props) {
addDebitCardRoute={ROUTES.SETTINGS_ADD_DEBIT_CARD}
popoverPlacement="bottom"
>
- {(triggerKYCFlow) => (
+ {(triggerKYCFlow, buttonRef) => (
)}
- {translate('paymentsPage.paymentMethodsTitle')}
+ {translate('walletPage.paymentMethodsTitle')}
>
),
[props.betas, props.network.isOffline, props.userWallet.currentBalance, props.walletTerms.errors, shouldShowLoadingSpinner, translate],
);
useEffect(() => {
- PaymentMethods.openPaymentsPage();
+ PaymentMethods.openWalletPage();
}, []);
useEffect(() => {
@@ -333,7 +337,7 @@ function BasePaymentsPage(props) {
if (props.network.isOffline) {
return;
}
- PaymentMethods.openPaymentsPage();
+ PaymentMethods.openWalletPage();
}, [props.network.isOffline]);
useEffect(() => {
@@ -353,7 +357,7 @@ function BasePaymentsPage(props) {
if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.BANK_ACCOUNT && _.isEmpty(props.bankAccountList[paymentMethod.methodID])) {
shouldResetPaymentMethodData = true;
- } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD && _.isEmpty(props.cardList[paymentMethod.methodID])) {
+ } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD && _.isEmpty(props.fundList[paymentMethod.methodID])) {
shouldResetPaymentMethodData = true;
} else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PAYPAL && _.isEmpty(props.payPalMeData)) {
shouldResetPaymentMethodData = true;
@@ -364,7 +368,7 @@ function BasePaymentsPage(props) {
hideDefaultDeleteMenu();
}
}
- }, [hideDefaultDeleteMenu, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, props.bankAccountList, props.cardList, props.payPalMeData, shouldShowDefaultDeleteMenu]);
+ }, [hideDefaultDeleteMenu, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, props.bankAccountList, props.fundList, props.payPalMeData, shouldShowDefaultDeleteMenu]);
const isPayPalMeSelected = paymentMethod.formattedSelectedPaymentMethod.type === CONST.PAYMENT_METHODS.PAYPAL;
const shouldShowMakeDefaultButton =
@@ -379,7 +383,7 @@ function BasePaymentsPage(props) {
return (
Navigation.goBack(ROUTES.SETTINGS)}
/>
@@ -439,7 +443,7 @@ function BasePaymentsPage(props) {
setShouldShowDefaultDeleteMenu(false);
makeDefaultPaymentMethod();
}}
- text={translate('paymentsPage.setDefaultConfirmation')}
+ text={translate('walletPage.setDefaultConfirmation')}
/>
)}
{isPayPalMeSelected && (
@@ -468,8 +472,8 @@ function BasePaymentsPage(props) {
}}
onCancel={hideDefaultDeleteMenu}
contentStyles={!isSmallScreenWidth ? [styles.sidebarPopover, styles.willChangeTransform] : undefined}
- title={translate('paymentsPage.deleteAccount')}
- prompt={translate('paymentsPage.deleteConfirmation')}
+ title={translate('walletPage.deleteAccount')}
+ prompt={translate('walletPage.deleteConfirmation')}
confirmText={translate('common.delete')}
cancelText={translate('common.cancel')}
anchorPosition={{
@@ -485,9 +489,9 @@ function BasePaymentsPage(props) {
);
}
-BasePaymentsPage.propTypes = propTypes;
-BasePaymentsPage.defaultProps = defaultProps;
-BasePaymentsPage.displayName = BasePaymentsPage;
+BaseWalletPage.propTypes = propTypes;
+BaseWalletPage.defaultProps = defaultProps;
+BaseWalletPage.displayName = BaseWalletPage;
export default compose(
withNetwork(),
@@ -504,9 +508,6 @@ export default compose(
bankAccountList: {
key: ONYXKEYS.BANK_ACCOUNT_LIST,
},
- cardList: {
- key: ONYXKEYS.CARD_LIST,
- },
fundList: {
key: ONYXKEYS.FUND_LIST,
},
@@ -520,4 +521,4 @@ export default compose(
key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
},
}),
-)(BasePaymentsPage);
+)(BaseWalletPage);
diff --git a/src/pages/settings/Wallet/WalletPage/index.js b/src/pages/settings/Wallet/WalletPage/index.js
new file mode 100644
index 000000000000..23a3b44d0801
--- /dev/null
+++ b/src/pages/settings/Wallet/WalletPage/index.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import BaseWalletPage from './BaseWalletPage';
+
+function WalletPage() {
+ return ;
+}
+
+WalletPage.displayName = 'WalletPage';
+
+export default WalletPage;
diff --git a/src/pages/settings/Wallet/WalletPage/index.native.js b/src/pages/settings/Wallet/WalletPage/index.native.js
new file mode 100644
index 000000000000..d8e291e66aa6
--- /dev/null
+++ b/src/pages/settings/Wallet/WalletPage/index.native.js
@@ -0,0 +1,3 @@
+import BaseWalletPage from './BaseWalletPage';
+
+export default BaseWalletPage;
diff --git a/src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js b/src/pages/settings/Wallet/WalletPage/walletPagePropTypes.js
similarity index 93%
rename from src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js
rename to src/pages/settings/Wallet/WalletPage/walletPagePropTypes.js
index 6e52e89e9ce9..5e3f1109f8fb 100644
--- a/src/pages/settings/Payments/PaymentsPage/paymentsPagePropTypes.js
+++ b/src/pages/settings/Wallet/WalletPage/walletPagePropTypes.js
@@ -29,10 +29,7 @@ const propTypes = {
/** List of bank accounts */
bankAccountList: PropTypes.objectOf(bankAccountPropTypes),
- /** List of cards */
- cardList: PropTypes.objectOf(cardPropTypes),
-
- /** List of cards */
+ /** List of user's cards */
fundList: PropTypes.objectOf(cardPropTypes),
/** Information about the user accepting the terms for payments */
@@ -51,7 +48,6 @@ const defaultProps = {
shouldListenForResize: false,
userWallet: {},
bankAccountList: {},
- cardList: null,
fundList: null,
walletTerms: {},
payPalMeData: {},
diff --git a/src/pages/settings/Payments/walletTransferPropTypes.js b/src/pages/settings/Wallet/walletTransferPropTypes.js
similarity index 100%
rename from src/pages/settings/Payments/walletTransferPropTypes.js
rename to src/pages/settings/Wallet/walletTransferPropTypes.js
diff --git a/src/pages/signin/AppleSignInDesktopPage/index.js b/src/pages/signin/AppleSignInDesktopPage/index.js
new file mode 100644
index 000000000000..9ec74c1c9c8f
--- /dev/null
+++ b/src/pages/signin/AppleSignInDesktopPage/index.js
@@ -0,0 +1,8 @@
+/* This component's alternate implementation is a screen made for the sign-in
+ * flow when the desktop app opens the web app to continue signing in, and only
+ * works when rendered in the web app. */
+function AppleSignInDesktopPage() {
+ return null;
+}
+
+export default AppleSignInDesktopPage;
diff --git a/src/pages/signin/AppleSignInDesktopPage/index.website.js b/src/pages/signin/AppleSignInDesktopPage/index.website.js
new file mode 100644
index 000000000000..10887e0ebdee
--- /dev/null
+++ b/src/pages/signin/AppleSignInDesktopPage/index.website.js
@@ -0,0 +1,9 @@
+import React from 'react';
+import ThirdPartySignInPage from '../ThirdPartySignInPage';
+import CONST from '../../../CONST';
+
+function AppleSignInDesktopPage() {
+ return ;
+}
+
+export default AppleSignInDesktopPage;
diff --git a/src/pages/signin/DesktopRedirectPage.js b/src/pages/signin/DesktopRedirectPage.js
new file mode 100644
index 000000000000..ef6421da23bd
--- /dev/null
+++ b/src/pages/signin/DesktopRedirectPage.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import * as App from '../../libs/actions/App';
+import DeeplinkRedirectLoadingIndicator from '../../components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator';
+
+/**
+ * Landing page for when a user enters third party login flow on desktop.
+ * Allows user to open the link in browser if they accidentally canceled the auto-prompt.
+ * Also allows them to continue to the web app if they want to use it there.
+ *
+ * @returns {React.Component}
+ */
+function DesktopRedirectPage() {
+ return ;
+}
+
+DesktopRedirectPage.displayName = 'DesktopRedirectPage';
+
+export default DesktopRedirectPage;
diff --git a/src/pages/signin/DesktopSignInRedirectPage/index.js b/src/pages/signin/DesktopSignInRedirectPage/index.js
new file mode 100644
index 000000000000..6ba0f5268d14
--- /dev/null
+++ b/src/pages/signin/DesktopSignInRedirectPage/index.js
@@ -0,0 +1,8 @@
+/* This component's alternate implementation is a screen made for the sign-in
+ * flow when the desktop app opens the web app to continue signing in, and only
+ * works when rendered in the web app. */
+function DesktopSignInRedirectPage() {
+ return null;
+}
+
+export default DesktopSignInRedirectPage;
diff --git a/src/pages/signin/DesktopSignInRedirectPage/index.website.js b/src/pages/signin/DesktopSignInRedirectPage/index.website.js
new file mode 100644
index 000000000000..bec9b5107359
--- /dev/null
+++ b/src/pages/signin/DesktopSignInRedirectPage/index.website.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import DesktopRedirectPage from '../DesktopRedirectPage';
+
+function DesktopSignInRedirectPage() {
+ return ;
+}
+
+export default DesktopSignInRedirectPage;
diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.js b/src/pages/signin/GoogleSignInDesktopPage/index.js
new file mode 100644
index 000000000000..62f3f64d7347
--- /dev/null
+++ b/src/pages/signin/GoogleSignInDesktopPage/index.js
@@ -0,0 +1,8 @@
+/* This component's alternate implementation is a screen made for the signin
+ * flow when the desktop app opens the web app to continue signing in, and only
+ * works when rendered in the web app. */
+function GoogleSignInDesktopPage() {
+ return null;
+}
+
+export default GoogleSignInDesktopPage;
diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.website.js b/src/pages/signin/GoogleSignInDesktopPage/index.website.js
new file mode 100644
index 000000000000..691b82448e31
--- /dev/null
+++ b/src/pages/signin/GoogleSignInDesktopPage/index.website.js
@@ -0,0 +1,9 @@
+import React from 'react';
+import ThirdPartySignInPage from '../ThirdPartySignInPage';
+import CONST from '../../../CONST';
+
+function GoogleSignInDesktopPage() {
+ return ;
+}
+
+export default GoogleSignInDesktopPage;
diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js
similarity index 68%
rename from src/pages/signin/LoginForm.js
rename to src/pages/signin/LoginForm/BaseLoginForm.js
index 5f973f7113c1..ccf67844e7f6 100644
--- a/src/pages/signin/LoginForm.js
+++ b/src/pages/signin/LoginForm/BaseLoginForm.js
@@ -1,39 +1,46 @@
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
import Str from 'expensify-common/lib/str';
import {parsePhoneNumber} from 'awesome-phonenumber';
-import styles from '../../styles/styles';
-import Text from '../../components/Text';
-import * as Session from '../../libs/actions/Session';
-import ONYXKEYS from '../../ONYXKEYS';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
-import compose from '../../libs/compose';
-import canFocusInputOnScreenFocus from '../../libs/canFocusInputOnScreenFocus';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import TextInput from '../../components/TextInput';
-import * as ValidationUtils from '../../libs/ValidationUtils';
-import * as LoginUtils from '../../libs/LoginUtils';
-import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../components/withToggleVisibilityView';
-import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
-import {withNetwork} from '../../components/OnyxProvider';
-import networkPropTypes from '../../components/networkPropTypes';
-import * as ErrorUtils from '../../libs/ErrorUtils';
-import DotIndicatorMessage from '../../components/DotIndicatorMessage';
-import * as CloseAccount from '../../libs/actions/CloseAccount';
-import CONST from '../../CONST';
-import isInputAutoFilled from '../../libs/isInputAutoFilled';
-import * as PolicyUtils from '../../libs/PolicyUtils';
-import Log from '../../libs/Log';
-import withNavigationFocus, {withNavigationFocusPropTypes} from '../../components/withNavigationFocus';
-import usePrevious from '../../hooks/usePrevious';
+import styles from '../../../styles/styles';
+import Text from '../../../components/Text';
+import * as Session from '../../../libs/actions/Session';
+import ONYXKEYS from '../../../ONYXKEYS';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
+import compose from '../../../libs/compose';
+import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import TextInput from '../../../components/TextInput';
+import * as ValidationUtils from '../../../libs/ValidationUtils';
+import * as LoginUtils from '../../../libs/LoginUtils';
+import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../../components/withToggleVisibilityView';
+import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton';
+import {withNetwork} from '../../../components/OnyxProvider';
+import networkPropTypes from '../../../components/networkPropTypes';
+import * as ErrorUtils from '../../../libs/ErrorUtils';
+import DotIndicatorMessage from '../../../components/DotIndicatorMessage';
+import * as CloseAccount from '../../../libs/actions/CloseAccount';
+import CONST from '../../../CONST';
+import CONFIG from '../../../CONFIG';
+import AppleSignIn from '../../../components/SignInButtons/AppleSignIn';
+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 usePrevious from '../../../hooks/usePrevious';
+import * as MemoryOnlyKeys from '../../../libs/actions/MemoryOnlyKeys/MemoryOnlyKeys';
const propTypes = {
/** Should we dismiss the keyboard when transitioning away from the page? */
blurOnSubmit: PropTypes.bool,
+ /** A reference so we can expose if the form input is focused */
+ innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+
/* Onyx Props */
/** The details about the account that the user is signing in with */
@@ -69,13 +76,7 @@ const defaultProps = {
account: {},
closeAccount: {},
blurOnSubmit: false,
-};
-
-/**
- * Enables experimental "memory only keys" mode in Onyx
- */
-const setEnableMemoryOnlyKeys = () => {
- window.enableMemoryOnlyKeys();
+ innerRef: () => {},
};
function LoginForm(props) {
@@ -108,6 +109,10 @@ function LoginForm(props) {
[props.account, props.closeAccount, input, setFormError, setLogin],
);
+ function getSignInWithStyles() {
+ return props.isSmallScreenWidth ? [styles.mt1] : [styles.mt5, styles.mb5];
+ }
+
/**
* Check that all the form fields are valid, then trigger the submit callback
*/
@@ -142,7 +147,7 @@ function LoginForm(props) {
// If the user has entered a guide email, then we are going to enable an experimental Onyx mode to help with performance
if (PolicyUtils.isExpensifyGuideTeam(loginTrim)) {
Log.info('Detected guide email in login field, setting memory only keys.');
- setEnableMemoryOnlyKeys();
+ MemoryOnlyKeys.enable();
}
setFormError(null);
@@ -176,6 +181,12 @@ function LoginForm(props) {
input.current.focus();
}, [props.blurOnSubmit, props.isVisible, prevIsVisible]);
+ useImperativeHandle(props.innerRef, () => ({
+ isInputFocused() {
+ return input.current && input.current.isFocused();
+ },
+ }));
+
const formErrorText = useMemo(() => (formError ? translate(formError) : ''), [formError, translate]);
const serverErrorText = useMemo(() => ErrorUtils.getLatestErrorMessage(props.account), [props.account]);
const hasError = !_.isEmpty(serverErrorText);
@@ -228,6 +239,28 @@ function LoginForm(props) {
isAlertVisible={!_.isEmpty(serverErrorText)}
containerStyles={[styles.mh0]}
/>
+ {
+ // This feature has a few behavioral differences in development mode. To prevent confusion
+ // for developers about possible regressions, we won't render buttons in development mode.
+ // For more information about these differences and how to test in development mode,
+ // see`Expensify/App/contributingGuides/APPLE_GOOGLE_SIGNIN.md`
+ CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.DEV && (
+
+
+ {props.translate('common.signInWith')}
+
+
+
+
+
+
+
+ )
+ }
)
}
@@ -249,4 +282,12 @@ export default compose(
withLocalize,
withToggleVisibilityView,
withNetwork(),
-)(LoginForm);
+)(
+ forwardRef((props, ref) => (
+
+ )),
+);
diff --git a/src/pages/signin/LoginForm/index.js b/src/pages/signin/LoginForm/index.js
new file mode 100644
index 000000000000..b9dfbb8dfbb5
--- /dev/null
+++ b/src/pages/signin/LoginForm/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import BaseLoginForm from './BaseLoginForm';
+
+const propTypes = {
+ /** Function used to scroll to the top of the page */
+ scrollPageToTop: PropTypes.func,
+};
+const defaultProps = {
+ scrollPageToTop: undefined,
+};
+
+function LoginForm(props) {
+ return (
+
+ );
+}
+
+LoginForm.displayName = 'LoginForm';
+LoginForm.propTypes = propTypes;
+LoginForm.defaultProps = defaultProps;
+
+export default LoginForm;
diff --git a/src/pages/signin/LoginForm/index.native.js b/src/pages/signin/LoginForm/index.native.js
new file mode 100644
index 000000000000..dc55ad68e53b
--- /dev/null
+++ b/src/pages/signin/LoginForm/index.native.js
@@ -0,0 +1,48 @@
+import React, {useEffect, useRef} from 'react';
+import PropTypes from 'prop-types';
+import BaseLoginForm from './BaseLoginForm';
+import AppStateMonitor from '../../../libs/AppStateMonitor';
+
+const propTypes = {
+ /** Function used to scroll to the top of the page */
+ scrollPageToTop: PropTypes.func,
+};
+const defaultProps = {
+ scrollPageToTop: undefined,
+};
+
+function LoginForm(props) {
+ const loginFormRef = useRef();
+ const {scrollPageToTop} = props;
+
+ useEffect(() => {
+ if (!scrollPageToTop) {
+ return;
+ }
+
+ const unsubscribeToBecameActiveListener = AppStateMonitor.addBecameActiveListener(() => {
+ const isInputFocused = loginFormRef.current && loginFormRef.current.isInputFocused();
+ if (!isInputFocused) {
+ return;
+ }
+
+ scrollPageToTop();
+ });
+
+ return unsubscribeToBecameActiveListener;
+ }, [scrollPageToTop]);
+
+ return (
+ (loginFormRef.current = ref)}
+ />
+ );
+}
+
+LoginForm.displayName = 'LoginForm';
+LoginForm.propTypes = propTypes;
+LoginForm.defaultProps = defaultProps;
+
+export default LoginForm;
diff --git a/src/pages/signin/SignInHeroCopy.js b/src/pages/signin/SignInHeroCopy.js
index 204876c25398..93951c0b9236 100644
--- a/src/pages/signin/SignInHeroCopy.js
+++ b/src/pages/signin/SignInHeroCopy.js
@@ -1,4 +1,5 @@
import {View} from 'react-native';
+import PropTypes from 'prop-types';
import React from 'react';
import Text from '../../components/Text';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
@@ -9,10 +10,16 @@ import styles from '../../styles/styles';
import variables from '../../styles/variables';
const propTypes = {
+ /** Override the green headline copy */
+ customHeadline: PropTypes.string,
+
...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
+const defaultProps = {
+ customHeadline: '',
+};
function SignInHeroCopy(props) {
return (
@@ -23,7 +30,7 @@ function SignInHeroCopy(props) {
props.isLargeScreenWidth && StyleUtils.getFontSizeStyle(variables.fontSizeSignInHeroLarge),
]}
>
- {props.translate('login.hero.header')}
+ {props.customHeadline || props.translate('login.hero.header')}
{props.translate('login.hero.body')}
@@ -32,5 +39,6 @@ function SignInHeroCopy(props) {
SignInHeroCopy.displayName = 'SignInHeroCopy';
SignInHeroCopy.propTypes = propTypes;
+SignInHeroCopy.defaultProps = defaultProps;
export default compose(withWindowDimensions, withLocalize)(SignInHeroCopy);
diff --git a/src/pages/signin/SignInModal.js b/src/pages/signin/SignInModal.js
new file mode 100644
index 000000000000..0cd566a47327
--- /dev/null
+++ b/src/pages/signin/SignInModal.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import SignInPage from './SignInPage';
+import ScreenWrapper from '../../components/ScreenWrapper';
+import HeaderWithBackButton from '../../components/HeaderWithBackButton';
+import Navigation from '../../libs/Navigation/Navigation';
+import styles from '../../styles/styles';
+import * as Session from '../../libs/actions/Session';
+
+const propTypes = {};
+
+const defaultProps = {};
+
+function SignInModal() {
+ if (!Session.isAnonymousUser()) {
+ // Sign in in RHP is only for anonymous users
+ Navigation.isNavigationReady().then(() => {
+ Navigation.dismissModal();
+ });
+ }
+ return (
+
+ Navigation.goBack()} />
+
+
+ );
+}
+
+SignInModal.propTypes = propTypes;
+SignInModal.defaultProps = defaultProps;
+SignInModal.displayName = 'SignInModal';
+
+export default SignInModal;
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index f6eb4f9306f3..21a92bce41c0 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react';
+import React, {useEffect, useRef} from 'react';
import PropTypes from 'prop-types';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
@@ -19,6 +19,7 @@ import * as StyleUtils from '../../styles/StyleUtils';
import useLocalize from '../../hooks/useLocalize';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import Log from '../../libs/Log';
+import * as DemoActions from '../../libs/actions/DemoActions';
const propTypes = {
/** The details about the account that the user is signing in with */
@@ -45,11 +46,23 @@ const propTypes = {
twoFactorAuthCode: PropTypes.string,
validateCode: PropTypes.string,
}),
+
+ /** Whether or not the sign in page is being rendered in the RHP modal */
+ isInModal: PropTypes.bool,
+
+ /** Information about any currently running demos */
+ demoInfo: PropTypes.shape({
+ saastr: PropTypes.shape({
+ isBeginningDemo: PropTypes.bool,
+ }),
+ }),
};
const defaultProps = {
account: {},
credentials: {},
+ isInModal: false,
+ demoInfo: {},
};
/**
@@ -77,10 +90,12 @@ function getRenderOptions({hasLogin, hasValidateCode, hasAccount, isPrimaryLogin
};
}
-function SignInPage({credentials, account}) {
+function SignInPage({credentials, account, isInModal, demoInfo}) {
const {translate, formatPhoneNumber} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
+ const shouldShowSmallScreen = isSmallScreenWidth || isInModal;
const safeAreaInsets = useSafeAreaInsets();
+ const signInPageLayoutRef = useRef();
useEffect(() => Performance.measureTTI(), []);
useEffect(() => {
@@ -100,8 +115,10 @@ function SignInPage({credentials, account}) {
let welcomeHeader = '';
let welcomeText = '';
+ const customHeadline = DemoActions.getHeadlineKeyByDemoInfo(demoInfo);
+ const headerText = customHeadline || translate('login.hero.header');
if (shouldShowLoginForm) {
- welcomeHeader = isSmallScreenWidth ? translate('login.hero.header') : translate('welcomeText.getStarted');
+ welcomeHeader = isSmallScreenWidth ? headerText : translate('welcomeText.getStarted');
welcomeText = isSmallScreenWidth ? translate('welcomeText.getStarted') : '';
} else if (shouldShowValidateCodeForm) {
if (account.requiresTwoFactorAuth) {
@@ -114,19 +131,19 @@ function SignInPage({credentials, account}) {
// replacing spaces with "hard spaces" to prevent breaking the number
const userLoginToDisplay = Str.isSMSLogin(userLogin) ? formatPhoneNumber(userLogin).replace(/ /g, '\u00A0') : userLogin;
if (account.validated) {
- welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack');
- welcomeText = isSmallScreenWidth
+ welcomeHeader = shouldShowSmallScreen ? '' : translate('welcomeText.welcomeBack');
+ welcomeText = shouldShowSmallScreen
? `${translate('welcomeText.welcomeBack')} ${translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay})}`
: translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay});
} else {
- welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcome');
- welcomeText = isSmallScreenWidth
+ welcomeHeader = shouldShowSmallScreen ? '' : translate('welcomeText.welcome');
+ welcomeText = shouldShowSmallScreen
? `${translate('welcomeText.welcome')} ${translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay})}`
: translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay});
}
}
} else if (shouldShowUnlinkLoginForm || shouldShowEmailDeliveryFailurePage) {
- welcomeHeader = isSmallScreenWidth ? translate('login.hero.header') : translate('welcomeText.welcomeBack');
+ 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) {
@@ -137,18 +154,24 @@ function SignInPage({credentials, account}) {
}
return (
-
+ // Bottom SafeAreaView is removed so that login screen svg displays correctly on mobile.
+ // The SVG should flow under the Home Indicator on iOS.
+
{/* LoginForm must use the isVisible prop. This keeps it mounted, but visually hidden
so that password managers can access the values. Conditionally rendering this component will break this feature. */}
{shouldShowValidateCodeForm && }
{shouldShowUnlinkLoginForm && }
@@ -165,4 +188,5 @@ SignInPage.displayName = 'SignInPage';
export default withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
credentials: {key: ONYXKEYS.CREDENTIALS},
+ demoInfo: {key: ONYXKEYS.DEMO_INFO},
})(SignInPage);
diff --git a/src/pages/signin/SignInPageHero.js b/src/pages/signin/SignInPageHero.js
index 3af7a8fb1a1e..eb2a275a49f7 100644
--- a/src/pages/signin/SignInPageHero.js
+++ b/src/pages/signin/SignInPageHero.js
@@ -1,4 +1,5 @@
import {View} from 'react-native';
+import PropTypes from 'prop-types';
import React from 'react';
import * as StyleUtils from '../../styles/StyleUtils';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
@@ -8,9 +9,16 @@ import styles from '../../styles/styles';
import variables from '../../styles/variables';
const propTypes = {
+ /** Override the green headline copy */
+ customHeadline: PropTypes.string,
+
...windowDimensionsPropTypes,
};
+const defaultProps = {
+ customHeadline: '',
+};
+
function SignInPageHero(props) {
return (
-
+
);
@@ -33,5 +41,6 @@ function SignInPageHero(props) {
SignInPageHero.displayName = 'SignInPageHero';
SignInPageHero.propTypes = propTypes;
+SignInPageHero.defaultProps = defaultProps;
export default withWindowDimensions(SignInPageHero);
diff --git a/src/pages/signin/SignInPageLayout/Footer.js b/src/pages/signin/SignInPageLayout/Footer.js
index 35e63b0699b3..62733f3cec9e 100644
--- a/src/pages/signin/SignInPageLayout/Footer.js
+++ b/src/pages/signin/SignInPageLayout/Footer.js
@@ -157,11 +157,12 @@ function Footer(props) {
const pageFooterWrapper = [styles.footerWrapper, imageDirection, imageStyle, isVertical ? styles.pl10 : {}];
const footerColumns = [styles.footerColumnsContainer, columnDirection];
const footerColumn = isVertical ? [styles.p4] : [styles.p4, props.isMediumScreenWidth ? styles.w50 : styles.w25];
+ const footerWrapper = isVertical ? [StyleUtils.getBackgroundColorStyle(themeColors.signInPage), styles.overflowHidden] : [];
return (
-
- {props.isSmallScreenWidth ? (
+
+ {isVertical ? (
diff --git a/src/pages/signin/SignInPageLayout/SignInPageContent.js b/src/pages/signin/SignInPageLayout/SignInPageContent.js
index 6fcfe6e98881..ea069cac2d92 100755
--- a/src/pages/signin/SignInPageLayout/SignInPageContent.js
+++ b/src/pages/signin/SignInPageLayout/SignInPageContent.js
@@ -45,8 +45,7 @@ function SignInPageContent(props) {
>
{/* This empty view creates margin on the top of the sign in form which will shrink and grow depending on if the keyboard is open or not */}
-
-
+
diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js
index 12f223de33f7..13c8c1d9ab07 100644
--- a/src/pages/signin/SignInPageLayout/index.js
+++ b/src/pages/signin/SignInPageLayout/index.js
@@ -1,4 +1,4 @@
-import React, {useRef, useEffect} from 'react';
+import React, {forwardRef, useRef, useEffect, useImperativeHandle} from 'react';
import {View, ScrollView} from 'react-native';
import {withSafeAreaInsets} from 'react-native-safe-area-context';
import PropTypes from 'prop-types';
@@ -35,20 +35,35 @@ const propTypes = {
/** Whether to show welcome header on a particular page */
shouldShowWelcomeHeader: PropTypes.bool.isRequired,
+ /** A reference so we can expose scrollPageToTop */
+ innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+
+ /** Whether or not the sign in page is being rendered in the RHP modal */
+ isInModal: PropTypes.bool.isRequired,
+
+ /** Override the green headline copy */
+ customHeadline: PropTypes.string,
+
...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
+const defaultProps = {
+ innerRef: () => {},
+ customHeadline: '',
+};
+
function SignInPageLayout(props) {
const scrollViewRef = useRef();
const prevPreferredLocale = usePrevious(props.preferredLocale);
let containerStyles = [styles.flex1, styles.signInPageInner];
let contentContainerStyles = [styles.flex1, styles.flexRow];
+ const shouldShowSmallScreen = props.isSmallScreenWidth || props.isInModal;
// To scroll on both mobile and web, we need to set the container height manually
const containerHeight = props.windowHeight - props.insets.top - props.insets.bottom;
- if (props.isSmallScreenWidth) {
+ if (shouldShowSmallScreen) {
containerStyles = [styles.flex1];
contentContainerStyles = [styles.flex1, styles.flexColumn];
}
@@ -60,6 +75,10 @@ function SignInPageLayout(props) {
scrollViewRef.current.scrollTo({y: 0, animated});
};
+ useImperativeHandle(props.innerRef, () => ({
+ scrollPageToTop,
+ }));
+
useEffect(() => {
if (prevPreferredLocale !== props.preferredLocale) {
return;
@@ -70,7 +89,7 @@ function SignInPageLayout(props) {
return (
- {!props.isSmallScreenWidth ? (
+ {!shouldShowSmallScreen ? (
-
+
@@ -121,7 +140,7 @@ function SignInPageLayout(props) {
keyboardShouldPersistTaps="handled"
ref={scrollViewRef}
>
-
+
(
+
+ )),
+);
diff --git a/src/pages/signin/ThirdPartySignInPage.js b/src/pages/signin/ThirdPartySignInPage.js
new file mode 100644
index 000000000000..fa6aa4d5895e
--- /dev/null
+++ b/src/pages/signin/ThirdPartySignInPage.js
@@ -0,0 +1,109 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {ActivityIndicator, View} from 'react-native';
+import {SafeAreaView} from 'react-native-safe-area-context';
+import {withOnyx} from 'react-native-onyx';
+import styles from '../../styles/styles';
+import compose from '../../libs/compose';
+import SignInPageLayout from './SignInPageLayout';
+import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
+import Text from '../../components/Text';
+import TextLink from '../../components/TextLink';
+import AppleSignIn from '../../components/SignInButtons/AppleSignIn';
+import GoogleSignIn from '../../components/SignInButtons/GoogleSignIn';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
+import ROUTES from '../../ROUTES';
+import Navigation from '../../libs/Navigation/Navigation';
+import CONST from '../../CONST';
+import ONYXKEYS from '../../ONYXKEYS';
+
+const propTypes = {
+ /** Which sign in provider we are using. */
+ signInProvider: PropTypes.oneOf([CONST.SIGN_IN_METHOD.APPLE, CONST.SIGN_IN_METHOD.GOOGLE]).isRequired,
+
+ /** State for the account */
+ account: PropTypes.shape({
+ /** Whether or not the user is loading */
+ isLoading: PropTypes.bool,
+ }),
+
+ ...withLocalizePropTypes,
+
+ ...windowDimensionsPropTypes,
+};
+
+const defaultProps = {
+ account: {},
+};
+
+/* Dedicated screen that the desktop app links to on the web app, as Apple/Google
+ * sign-in cannot work fully within Electron, so we escape to web and redirect
+ * to desktop once we have an Expensify auth token.
+ */
+function ThirdPartySignInPage(props) {
+ const goBack = () => {
+ Navigation.navigate(ROUTES.HOME);
+ };
+
+ return (
+
+ {props.account.isLoading ? (
+
+
+
+ ) : (
+
+ {props.signInProvider === CONST.SIGN_IN_METHOD.APPLE ? : }
+ {props.translate('thirdPartySignIn.redirectToDesktopMessage')}
+ {props.translate('thirdPartySignIn.goBackMessage', {provider: props.signInProvider})}
+
+ {props.translate('common.goBack')}.
+
+
+ {props.translate('thirdPartySignIn.signInAgreementMessage')}
+
+ {` ${props.translate('common.termsOfService')}`}
+
+ {` ${props.translate('common.and')} `}
+
+ {props.translate('common.privacy')}
+
+ .
+
+
+ )}
+
+ );
+}
+
+ThirdPartySignInPage.propTypes = propTypes;
+ThirdPartySignInPage.defaultProps = defaultProps;
+ThirdPartySignInPage.displayName = 'ThirdPartySignInPage';
+
+export default compose(
+ withLocalize,
+ withWindowDimensions,
+ withOnyx({
+ account: {
+ key: ONYXKEYS.ACCOUNT,
+ },
+ }),
+)(ThirdPartySignInPage);
diff --git a/src/pages/tasks/NewTaskDescriptionPage.js b/src/pages/tasks/NewTaskDescriptionPage.js
index 520af1491e0a..dacf368a574e 100644
--- a/src/pages/tasks/NewTaskDescriptionPage.js
+++ b/src/pages/tasks/NewTaskDescriptionPage.js
@@ -16,6 +16,7 @@ import ROUTES from '../../ROUTES';
import * as Task from '../../libs/actions/Task';
import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange';
import CONST from '../../CONST';
+import * as Browser from '../../libs/Browser';
const propTypes = {
/** Beta features list */
@@ -78,7 +79,7 @@ function NewTaskDescriptionPage(props) {
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
ref={(el) => (inputRef.current = el)}
autoGrowHeight
- submitOnEnter
+ submitOnEnter={!Browser.isMobile()}
containerStyles={[styles.autoGrowHeightMultilineInput]}
textAlignVertical="top"
/>
diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js
index 52b43a5c49d5..be3b76e2b3ad 100644
--- a/src/pages/tasks/NewTaskDetailsPage.js
+++ b/src/pages/tasks/NewTaskDetailsPage.js
@@ -16,6 +16,7 @@ import Permissions from '../../libs/Permissions';
import ROUTES from '../../ROUTES';
import * as Task from '../../libs/actions/Task';
import CONST from '../../CONST';
+import * as Browser from '../../libs/Browser';
const propTypes = {
/** Beta features list */
@@ -109,7 +110,7 @@ function NewTaskDetailsPage(props) {
label={props.translate('newTaskPage.descriptionOptional')}
accessibilityLabel={props.translate('newTaskPage.descriptionOptional')}
autoGrowHeight
- submitOnEnter
+ submitOnEnter={!Browser.isMobile()}
containerStyles={[styles.autoGrowHeightMultilineInput]}
textAlignVertical="top"
value={taskDescription}
diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js
index 4b1aa0c0101b..f8f076a1630c 100644
--- a/src/pages/tasks/NewTaskPage.js
+++ b/src/pages/tasks/NewTaskPage.js
@@ -126,7 +126,7 @@ function NewTaskPage(props) {
}
shouldClearOutTaskInfoOnUnmount.current = true;
- Task.createTaskAndNavigate(parentReport.reportID, props.task.title, props.task.description, props.task.assignee, props.task.assigneeAccountID);
+ Task.createTaskAndNavigate(parentReport.reportID, props.task.title, props.task.description, props.task.assignee, props.task.assigneeAccountID, props.task.assigneeChatReport);
}
if (!Permissions.canUseTasks(props.betas)) {
@@ -135,10 +135,11 @@ function NewTaskPage(props) {
}
return (
-
+
Task.dismissModalAndClearOutTaskInfo()}
+ shouldShowLink={false}
>
{
const {recentReports, personalDetails, userToInvite, currentUserOption} = OptionsListUtils.getNewChatOptions(
@@ -86,6 +87,8 @@ function TaskAssigneeSelectorModal(props) {
[],
CONST.EXPENSIFY_EMAILS,
false,
+ true,
+ false,
);
setHeaderMessage(OptionsListUtils.getHeaderMessage(recentReports?.length + personalDetails?.length !== 0 || currentUserOption, Boolean(userToInvite), searchValue));
@@ -94,7 +97,10 @@ function TaskAssigneeSelectorModal(props) {
setFilteredRecentReports(recentReports);
setFilteredPersonalDetails(personalDetails);
setFilteredCurrentUserOption(currentUserOption);
- }, [props, searchValue]);
+ if (isLoading) {
+ setIsLoading(false);
+ }
+ }, [props, searchValue, isLoading]);
useEffect(() => {
const debouncedSearch = _.debounce(updateOptions, 200);
@@ -172,15 +178,10 @@ function TaskAssigneeSelectorModal(props) {
// Check to see if we're editing a task and if so, update the assignee
if (report) {
- // There was an issue where sometimes a new assignee didn't have a DM thread
- // This would cause the app to crash, so we need to make sure we have a DM thread
- Task.setAssigneeValue(option.login, option.accountID, props.route.params.reportID, OptionsListUtils.isCurrentUser(option));
+ const assigneeChatReport = Task.setAssigneeValue(option.login, option.accountID, props.route.params.reportID, OptionsListUtils.isCurrentUser(option));
// Pass through the selected assignee
- Task.editTaskAndNavigate(report, props.session.accountID, {
- assignee: option.login,
- assigneeAccountID: option.accountID,
- });
+ Task.editTaskAssigneeAndNavigate(props.task.report, props.session.accountID, option.login, option.accountID, assigneeChatReport);
}
};
@@ -200,7 +201,7 @@ function TaskAssigneeSelectorModal(props) {
onChangeText={onChangeText}
headerMessage={headerMessage}
showTitleTooltip
- shouldShowOptions={didScreenTransitionEnd}
+ shouldShowOptions={didScreenTransitionEnd && !isLoading}
textInputLabel={props.translate('optionsSelector.nameEmailOrPhoneNumber')}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
/>
diff --git a/src/pages/tasks/TaskDescriptionPage.js b/src/pages/tasks/TaskDescriptionPage.js
index 5f7cd0b132ab..d1679ac104ce 100644
--- a/src/pages/tasks/TaskDescriptionPage.js
+++ b/src/pages/tasks/TaskDescriptionPage.js
@@ -14,6 +14,7 @@ import compose from '../../libs/compose';
import * as Task from '../../libs/actions/Task';
import CONST from '../../CONST';
import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange';
+import * as Browser from '../../libs/Browser';
const propTypes = {
/** Current user session */
@@ -72,7 +73,7 @@ function TaskDescriptionPage(props) {
defaultValue={(props.report && props.report.description) || ''}
ref={(el) => (inputRef.current = el)}
autoGrowHeight
- submitOnEnter
+ submitOnEnter={!Browser.isMobile()}
containerStyles={[styles.autoGrowHeightMultilineInput]}
textAlignVertical="top"
/>
diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js
index 8272406b4cbe..15e3f03964e6 100644
--- a/src/pages/tasks/TaskShareDestinationSelectorModal.js
+++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js
@@ -27,7 +27,7 @@ const propTypes = {
betas: PropTypes.arrayOf(PropTypes.string),
/** All of the personal details for everyone */
- personalDetails: personalDetailsPropType,
+ personalDetails: PropTypes.objectOf(personalDetailsPropType),
/** All reports shared with the user */
reports: PropTypes.objectOf(reportPropTypes),
@@ -49,11 +49,7 @@ function TaskShareDestinationSelectorModal(props) {
const filteredReports = useMemo(() => {
const reports = {};
_.keys(props.reports).forEach((reportKey) => {
- if (
- !ReportUtils.isAllowedToComment(props.reports[reportKey]) ||
- ReportUtils.isArchivedRoom(props.reports[reportKey]) ||
- ReportUtils.isExpensifyOnlyParticipantInReport(props.reports[reportKey])
- ) {
+ if (ReportUtils.shouldDisableWriteActions(props.reports[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(props.reports[reportKey])) {
return;
}
reports[reportKey] = props.reports[reportKey];
diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js
index 10732a661dfc..0d6599631b8d 100644
--- a/src/pages/workspace/WorkspaceInitialPage.js
+++ b/src/pages/workspace/WorkspaceInitialPage.js
@@ -155,7 +155,10 @@ function WorkspaceInitialPage(props) {
{
translationKey: 'workspace.common.bankAccount',
icon: Expensicons.Bank,
- action: () => (policy.outputCurrency === CONST.CURRENCY.USD ? ReimbursementAccount.navigateToBankAccountRoute(policy.id) : setIsCurrencyModalOpen(true)),
+ action: () =>
+ policy.outputCurrency === CONST.CURRENCY.USD
+ ? ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRoute().replace(/\?.*/, ''))
+ : setIsCurrencyModalOpen(true),
brickRoadIndicator: !_.isEmpty(props.reimbursementAccount.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '',
},
];
@@ -183,7 +186,7 @@ function WorkspaceInitialPage(props) {
{({safeAreaPaddingBottomStyle}) => (
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- shouldShow={_.isEmpty(props.policy) || !Policy.isPolicyOwner(props.policy)}
+ shouldShow={_.isEmpty(props.policy) || !PolicyUtils.isPolicyAdmin(props.policy)}
subtitleKey={_.isEmpty(props.policy) ? undefined : 'workspace.common.notAuthorized'}
>
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
>
@@ -247,9 +244,6 @@ export default compose(
allPersonalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
invitedEmailsToAccountIDsDraft: {
key: ({route}) => `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`,
},
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index bff4a55d94a0..b22421167478 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -1,4 +1,4 @@
-import React, {useState, useEffect, useMemo} from 'react';
+import React, {useEffect, useMemo, useState} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
@@ -12,17 +12,15 @@ import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
import * as Policy from '../../libs/actions/Policy';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
-import OptionsSelector from '../../components/OptionsSelector';
import * as OptionsListUtils from '../../libs/OptionsListUtils';
import CONST from '../../CONST';
-import {policyPropTypes, policyDefaultProps} from './withPolicy';
-import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
+import withPolicy, {policyDefaultProps, policyPropTypes} from './withPolicy';
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import ROUTES from '../../ROUTES';
-import * as Browser from '../../libs/Browser';
import * as PolicyUtils from '../../libs/PolicyUtils';
import useNetwork from '../../hooks/useNetwork';
import useLocalize from '../../hooks/useLocalize';
+import SelectionList from '../../components/SelectionList';
const personalDetailsPropTypes = PropTypes.shape({
/** The login of the person (either email or phone number) */
@@ -52,12 +50,14 @@ const propTypes = {
}),
}).isRequired,
+ isLoadingReportData: PropTypes.bool,
...policyPropTypes,
};
const defaultProps = {
personalDetails: {},
betas: [],
+ isLoadingReportData: true,
...policyDefaultProps,
};
@@ -87,10 +87,10 @@ function WorkspaceInvitePage(props) {
// Update selectedOptions with the latest personalDetails and policyMembers information
const detailsMap = {};
- _.forEach(inviteOptions.personalDetails, (detail) => (detailsMap[detail.login] = detail));
+ _.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] : option);
+ newSelectedOptions.push(_.has(detailsMap, option.login) ? {...detailsMap[option.login], isSelected: true} : option);
});
setUserToInvite(inviteOptions.userToInvite);
@@ -114,20 +114,21 @@ function WorkspaceInvitePage(props) {
// 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: personalDetailsWithoutSelected,
- shouldShow: !_.isEmpty(personalDetailsWithoutSelected),
+ data: personalDetailsFormatted,
+ shouldShow: !_.isEmpty(personalDetailsFormatted),
indexOffset,
});
- indexOffset += personalDetailsWithoutSelected.length;
+ indexOffset += personalDetailsFormatted.length;
if (hasUnselectedUserToInvite) {
sections.push({
title: undefined,
- data: [userToInvite],
+ data: [OptionsListUtils.formatMemberForList(userToInvite, false)],
shouldShow: true,
indexOffset,
});
@@ -145,7 +146,7 @@ function WorkspaceInvitePage(props) {
if (isOptionInList) {
newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
} else {
- newSelectedOptions = [...selectedOptions, option];
+ newSelectedOptions = [...selectedOptions, {...option, isSelected: true}];
}
setSelectedOptions(newSelectedOptions);
@@ -169,7 +170,7 @@ function WorkspaceInvitePage(props) {
const invitedEmailsToAccountIDs = {};
_.each(selectedOptions, (option) => {
const login = option.login || '';
- const accountID = lodashGet(option, 'participantsList[0].accountID');
+ const accountID = lodashGet(option, 'accountID', '');
if (!login.toLowerCase().trim() || !accountID) {
return;
}
@@ -199,9 +200,10 @@ function WorkspaceInvitePage(props) {
{({didScreenTransitionEnd}) => {
const sections = didScreenTransitionEnd ? getSections() : [];
+
return (
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
>
@@ -215,27 +217,16 @@ function WorkspaceInvitePage(props) {
Navigation.goBack(ROUTES.getWorkspaceMembersRoute(props.route.params.policyID));
}}
/>
-
-
-
+
{
const newErrors = {};
- const ownerAccountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins([props.policy.owner]));
+ const ownerAccountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins(props.policy.owner ? [props.policy.owner] : []));
_.each(selectedEmployees, (member) => {
if (member !== ownerAccountID && member !== props.session.accountID) {
return;
@@ -131,41 +126,6 @@ function WorkspaceMembersPage(props) {
getWorkspaceMembers();
}, [props.network.isOffline, prevIsOffline, getWorkspaceMembers]);
- /**
- * This function will iterate through the details of each policy member to check if the
- * search string matches with any detail and return that filter.
- * @param {Array} policyMembersPersonalDetails - This is the list of policy members
- * @param {*} search - This is the string that the user has entered
- * @returns {Array} - The list of policy members that have anything similar to the searchValue
- */
- const getMemberOptions = (policyMembersPersonalDetails, search) => {
- // If no search value, we return all members.
- if (_.isEmpty(search)) {
- return policyMembersPersonalDetails;
- }
-
- // We will filter through each policy member details to determine if they should be shown
- return _.filter(policyMembersPersonalDetails, (member) => {
- let memberDetails = '';
- if (member.login) {
- memberDetails += ` ${member.login.toLowerCase()}`;
- }
- if (member.firstName) {
- memberDetails += ` ${member.firstName.toLowerCase()}`;
- }
- if (member.lastName) {
- memberDetails += ` ${member.lastName.toLowerCase()}`;
- }
- if (member.displayName) {
- memberDetails += ` ${member.displayName.toLowerCase()}`;
- }
- if (member.phoneNumber) {
- memberDetails += ` ${member.phoneNumber.toLowerCase()}`;
- }
- return OptionsListUtils.isSearchStringMatch(search, memberDetails);
- });
- };
-
/**
* Open the modal to invite a user
*/
@@ -205,8 +165,16 @@ function WorkspaceMembersPage(props) {
* @param {Object} memberList
*/
const toggleAllUsers = (memberList) => {
- const accountIDList = _.map(_.keys(memberList), (memberAccountID) => Number(memberAccountID));
- setSelectedEmployees((prevSelected) => (!_.every(accountIDList, (memberAccountID) => _.contains(prevSelected, memberAccountID)) ? accountIDList : []));
+ const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
+ const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, Number(member.keyForList)));
+
+ if (everyoneSelected) {
+ setSelectedEmployees([]);
+ } else {
+ const everyAccountId = _.map(enabledAccounts, (member) => Number(member.keyForList));
+ setSelectedEmployees(everyAccountId);
+ }
+
validateSelection();
};
@@ -251,9 +219,9 @@ function WorkspaceMembersPage(props) {
// Add or remove the user if the checkbox is enabled
if (_.contains(selectedEmployees, Number(accountID))) {
- removeUser(accountID);
+ removeUser(Number(accountID));
} else {
- addUser(accountID);
+ addUser(Number(accountID));
}
},
[selectedEmployees, addUser, removeUser],
@@ -282,223 +250,158 @@ function WorkspaceMembersPage(props) {
* @returns {Boolean}
*/
const isDeletedPolicyMember = (policyMember) => !props.network.isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && _.isEmpty(policyMember.errors);
-
- /**
- * Render a workspace member component
- *
- * @param {Object} args
- * @param {Object} args.item
- * @param {Number} args.index
- *
- * @returns {React.Component}
- */
- const renderItem = useCallback(
- ({item}) => {
- const disabled = props.session.email === item.login || item.role === CONST.POLICY.ROLE.ADMIN || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
- const hasError = !_.isEmpty(item.errors) || errors[item.accountID];
- const isChecked = _.contains(selectedEmployees, Number(item.accountID));
- return (
- dismissError(item)}
- pendingAction={item.pendingAction}
- errors={item.errors}
- >
- toggleUser(item.accountID, item.pendingAction)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{
- checked: isChecked,
- }}
- accessibilityLabel={props.formatPhoneNumber(item.displayName)}
- hoverDimmingValue={1}
- pressDimmingValue={0.7}
- >
- toggleUser(item.accountID, item.pendingAction)}
- accessibilityLabel={item.displayName}
- />
-
- toggleUser(item.accountID, item.pendingAction)}
- />
-
- {(props.session.accountID === item.accountID || item.role === CONST.POLICY.ROLE.ADMIN) && (
-
- {props.translate('common.admin')}
-
- )}
-
- {!_.isEmpty(errors[item.accountID]) && (
-
- )}
-
- );
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [selectedEmployees, errors, props.session.accountID, dismissError, toggleUser],
- );
-
const policyOwner = lodashGet(props.policy, 'owner');
const currentUserLogin = lodashGet(props.currentUserPersonalDetails, 'login');
- const removableMembers = {};
- let data = [];
- _.each(props.policyMembers, (policyMember, accountID) => {
- if (isDeletedPolicyMember(policyMember)) {
- return;
- }
- const details = props.personalDetails[accountID];
- if (!details) {
- Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
- return;
- }
- data.push({
- ...policyMember,
- ...details,
- });
- });
- data = _.sortBy(data, (value) => value.displayName.toLowerCase());
- data = getMemberOptions(data, searchValue.trim().toLowerCase());
-
- // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
- // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
- // see random people added to their policy, but guides having access to the policies help set them up.
- if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
- data = _.reject(data, (member) => PolicyUtils.isExpensifyTeam(member.login || member.displayName));
- }
-
- _.each(data, (member) => {
- if (member.accountID === props.session.accountID || member.login === props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return;
- }
- removableMembers[member.accountID] = member;
- });
const policyID = lodashGet(props.route, 'params.policyID');
const policyName = lodashGet(props.policy, 'name');
+ const getMemberOptions = () => {
+ let result = [];
+
+ _.each(props.policyMembers, (policyMember, accountID) => {
+ if (isDeletedPolicyMember(policyMember)) {
+ return;
+ }
+
+ const details = props.personalDetails[accountID];
+
+ if (!details) {
+ Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy 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;
+ }
+ }
+
+ // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
+ // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
+ // see random people added to their policy, but guides having access to the policies help set them up.
+ if (PolicyUtils.isExpensifyTeam(details.login || details.displayName)) {
+ if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
+ return;
+ }
+ }
+
+ const isAdmin = props.session.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
+
+ result.push({
+ keyForList: accountID,
+ isSelected: _.contains(selectedEmployees, Number(accountID)),
+ isDisabled: accountID === props.session.accountID || details.login === props.policy.owner || policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
+ text: props.formatPhoneNumber(details.displayName),
+ alternateText: props.formatPhoneNumber(details.login),
+ rightElement: isAdmin ? (
+
+ {props.translate('common.admin')}
+
+ ) : null,
+ avatar: {
+ source: UserUtils.getAvatar(details.avatar, accountID),
+ name: details.login,
+ type: CONST.ICON_TYPE_AVATAR,
+ },
+ errors: policyMember.errors,
+ pendingAction: policyMember.pendingAction,
+ });
+ });
+
+ result = _.sortBy(result, (value) => value.text.toLowerCase());
+
+ return result;
+ };
+
+ const data = getMemberOptions();
+ const headerMessage = searchValue.trim() && !data.length ? props.translate('workspace.common.memberNotFound') : '';
+
return (
- {({safeAreaPaddingBottomStyle}) => (
- Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- >
- {
- setSearchValue('');
- Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID));
- }}
- shouldShowGetAssistanceButton
- guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
- />
- setRemoveMembersConfirmModalVisible(false)}
- prompt={props.translate('workspace.people.removeMembersPrompt')}
- confirmText={props.translate('common.remove')}
- cancelText={props.translate('common.cancel')}
- />
-
-
-
-
-
-
-
-
- {data.length > 0 ? (
-
-
- toggleAllUsers(removableMembers)}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.CHECKBOX}
- accessibilityState={{
- checked: !_.isEmpty(removableMembers) && _.every(_.keys(removableMembers), (accountID) => _.contains(selectedEmployees, Number(accountID))),
- }}
- accessibilityLabel={props.translate('workspace.people.selectAll')}
- hoverDimmingValue={1}
- pressDimmingValue={0.7}
- >
- _.contains(selectedEmployees, Number(accountID)))}
- onPress={() => toggleAllUsers(removableMembers)}
- accessibilityLabel={props.translate('workspace.people.selectAll')}
- />
-
-
- {props.translate('workspace.people.selectAll')}
-
-
- item.login}
- showsVerticalScrollIndicator
- style={[styles.ph5, styles.pb5]}
- contentContainerStyle={safeAreaPaddingBottomStyle}
- keyboardShouldPersistTaps="handled"
- />
-
- ) : (
-
- {props.translate('workspace.common.memberNotFound')}
-
- )}
+ Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
+ >
+ {
+ setSearchValue('');
+ Navigation.goBack(ROUTES.getWorkspaceInitialRoute(policyID));
+ }}
+ shouldShowGetAssistanceButton
+ guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS}
+ />
+ setRemoveMembersConfirmModalVisible(false)}
+ prompt={props.translate('workspace.people.removeMembersPrompt')}
+ confirmText={props.translate('common.remove')}
+ cancelText={props.translate('common.cancel')}
+ />
+
+
+
+
-
- )}
+
+ toggleUser(item.keyForList)}
+ onSelectAll={() => toggleAllUsers(data)}
+ onDismissError={dismissError}
+ showLoadingPlaceholder={!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers)}
+ initiallyFocusedOptionKey={lodashGet(
+ _.find(data, (item) => !item.isDisabled),
+ 'keyForList',
+ undefined,
+ )}
+ />
+
+
+
);
}
@@ -510,7 +413,7 @@ WorkspaceMembersPage.displayName = 'WorkspaceMembersPage';
export default compose(
withLocalize,
withWindowDimensions,
- withPolicyAndFullscreenLoading,
+ withPolicy,
withNetwork(),
withOnyx({
personalDetails: {
@@ -519,6 +422,9 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
+ isLoadingReportData: {
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ },
}),
withCurrentUserPersonalDetails,
)(WorkspaceMembersPage);
diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.js
index 801277b89c9a..fa04f5cfc49e 100644
--- a/src/pages/workspace/WorkspacePageWithSections.js
+++ b/src/pages/workspace/WorkspacePageWithSections.js
@@ -5,8 +5,8 @@ import {withOnyx} from 'react-native-onyx';
import lodashGet from 'lodash/get';
import _ from 'underscore';
import styles from '../../styles/styles';
+import * as PolicyUtils from '../../libs/PolicyUtils';
import Navigation from '../../libs/Navigation/Navigation';
-import * as Policy from '../../libs/actions/Policy';
import compose from '../../libs/compose';
import ROUTES from '../../ROUTES';
import HeaderWithBackButton from '../../components/HeaderWithBackButton';
@@ -105,7 +105,7 @@ function WorkspacePageWithSections({backButtonRoute, children, footer, guidesCal
>
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- shouldShow={_.isEmpty(policy) || !Policy.isPolicyOwner(policy)}
+ shouldShow={_.isEmpty(policy) || !PolicyUtils.isPolicyAdmin(policy)}
subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'}
>
{
- // eslint-disable-next-line rulesdir/prefer-actions-set-data
- Onyx.set(ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS, true);
- Onyx.setMemoryOnlyKeys([ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.POLICY, ONYXKEYS.PERSONAL_DETAILS_LIST]);
- };
-
- window.disableMemoryOnlyKeys = () => {
- // eslint-disable-next-line rulesdir/prefer-actions-set-data
- Onyx.set(ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS, false);
- Onyx.setMemoryOnlyKeys([]);
- };
+ exposeGlobalMemoryOnlyKeysMethods();
Device.setDeviceID();
diff --git a/src/setup/platformSetup/index.native.js b/src/setup/platformSetup/index.native.js
index d164600c7706..470bef78848f 100644
--- a/src/setup/platformSetup/index.native.js
+++ b/src/setup/platformSetup/index.native.js
@@ -21,10 +21,5 @@ export default function () {
PushNotification.init();
subscribeToReportCommentPushNotifications();
- // Setup Flipper plugins when on dev
- if (__DEV__ && typeof jest === 'undefined') {
- require('flipper-plugin-bridgespy-client');
- }
-
Performance.setupPerformanceObserver();
}
diff --git a/src/stories/SelectionList.stories.js b/src/stories/SelectionList.stories.js
new file mode 100644
index 000000000000..c4510611306e
--- /dev/null
+++ b/src/stories/SelectionList.stories.js
@@ -0,0 +1,397 @@
+import React, {useMemo, useState} from 'react';
+import _ from 'underscore';
+import {View} from 'react-native';
+import SelectionList from '../components/SelectionList';
+import CONST from '../CONST';
+import styles from '../styles/styles';
+import Text from '../components/Text';
+
+/**
+ * We use the Component Story Format for writing stories. Follow the docs here:
+ *
+ * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
+ */
+const story = {
+ title: 'Components/SelectionList',
+ component: SelectionList,
+};
+
+const SECTIONS = [
+ {
+ data: [
+ {
+ text: 'Option 1',
+ keyForList: 'option-1',
+ isSelected: false,
+ },
+ {
+ text: 'Option 2',
+ keyForList: 'option-2',
+ isSelected: false,
+ },
+ {
+ text: 'Option 3',
+ keyForList: 'option-3',
+ isSelected: false,
+ },
+ ],
+ indexOffset: 0,
+ isDisabled: false,
+ },
+ {
+ data: [
+ {
+ text: 'Option 4',
+ keyForList: 'option-4',
+ isSelected: false,
+ },
+ {
+ text: 'Option 5',
+ keyForList: 'option-5',
+ isSelected: false,
+ },
+ {
+ text: 'Option 6',
+ keyForList: 'option-6',
+ isSelected: false,
+ },
+ ],
+ indexOffset: 3,
+ isDisabled: false,
+ },
+];
+
+function Default(args) {
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ const isSelected = selectedIndex === index + section.indexOffset;
+ return {...item, isSelected};
+ });
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+Default.args = {
+ sections: SECTIONS,
+ onSelectRow: () => {},
+ initiallyFocusedOptionKey: 'option-2',
+};
+
+function WithTextInput(args) {
+ const [searchText, setSearchText] = useState('');
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.reduce(
+ section.data,
+ (memo, item, index) => {
+ if (!item.text.toLowerCase().includes(searchText.trim().toLowerCase())) {
+ return memo;
+ }
+
+ const isSelected = selectedIndex === index + section.indexOffset;
+ memo.push({...item, isSelected});
+ return memo;
+ },
+ [],
+ );
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+
+ return (
+
+ );
+}
+
+WithTextInput.args = {
+ sections: SECTIONS,
+ textInputLabel: 'Option list',
+ textInputPlaceholder: 'Search something...',
+ textInputMaxLength: 4,
+ keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD,
+ initiallyFocusedOptionKey: 'option-2',
+ onSelectRow: () => {},
+ onChangeText: () => {},
+};
+
+function WithHeaderMessage(props) {
+ return (
+
+ );
+}
+
+WithHeaderMessage.args = {
+ ...WithTextInput.args,
+ headerMessage: 'No results found',
+ sections: [],
+};
+
+function WithAlternateText(args) {
+ const [selectedIndex, setSelectedIndex] = useState(1);
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ const isSelected = selectedIndex === index + section.indexOffset;
+
+ return {
+ ...item,
+ alternateText: `Alternate ${index + 1}`,
+ isSelected,
+ };
+ });
+
+ return {...section, data};
+ });
+
+ const onSelectRow = (item) => {
+ _.forEach(sections, (section) => {
+ const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
+
+ if (newSelectedIndex >= 0) {
+ setSelectedIndex(newSelectedIndex + section.indexOffset);
+ }
+ });
+ };
+ return (
+
+ );
+}
+
+WithAlternateText.args = {
+ ...Default.args,
+};
+
+function MultipleSelection(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section) => {
+ const data = _.map(section.data, (item, index) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = index + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+MultipleSelection.args = {
+ ...Default.args,
+ canSelectMultiple: true,
+ onSelectAll: () => {},
+};
+
+function WithSectionHeader(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section, sectionIndex) => {
+ const data = _.map(section.data, (item, itemIndex) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = itemIndex + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data, title: `Section ${sectionIndex + 1}`};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+WithSectionHeader.args = {
+ ...MultipleSelection.args,
+};
+
+function WithConfirmButton(args) {
+ const [selectedIds, setSelectedIds] = useState(['option-1', 'option-2']);
+
+ const memo = useMemo(() => {
+ const allIds = [];
+
+ const sections = _.map(args.sections, (section, sectionIndex) => {
+ const data = _.map(section.data, (item, itemIndex) => {
+ allIds.push(item.keyForList);
+ const isSelected = _.contains(selectedIds, item.keyForList);
+ const isAdmin = itemIndex + section.indexOffset === 0;
+
+ return {
+ ...item,
+ isSelected,
+ alternateText: `${item.keyForList}@email.com`,
+ accountID: item.keyForList,
+ login: item.text,
+ rightElement: isAdmin && (
+
+ Admin
+
+ ),
+ };
+ });
+
+ return {...section, data, title: `Section ${sectionIndex + 1}`};
+ });
+
+ return {sections, allIds};
+ }, [args.sections, selectedIds]);
+
+ const onSelectRow = (item) => {
+ const newSelectedIds = _.contains(selectedIds, item.keyForList) ? _.without(selectedIds, item.keyForList) : [...selectedIds, item.keyForList];
+ setSelectedIds(newSelectedIds);
+ };
+
+ const onSelectAll = () => {
+ if (selectedIds.length === memo.allIds.length) {
+ setSelectedIds([]);
+ } else {
+ setSelectedIds(memo.allIds);
+ }
+ };
+
+ return (
+
+ );
+}
+
+WithConfirmButton.args = {
+ ...MultipleSelection.args,
+ onConfirm: () => {},
+ confirmButtonText: 'Confirm',
+};
+
+export {Default, WithTextInput, WithHeaderMessage, WithAlternateText, MultipleSelection, WithSectionHeader, WithConfirmButton};
+export default story;
diff --git a/src/stories/SelectionListRadio.stories.js b/src/stories/SelectionListRadio.stories.js
deleted file mode 100644
index 698e4743f25a..000000000000
--- a/src/stories/SelectionListRadio.stories.js
+++ /dev/null
@@ -1,207 +0,0 @@
-import React, {useState} from 'react';
-import _ from 'underscore';
-import SelectionListRadio from '../components/SelectionListRadio';
-import CONST from '../CONST';
-
-/**
- * We use the Component Story Format for writing stories. Follow the docs here:
- *
- * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format
- */
-const story = {
- title: 'Components/SelectionListRadio',
- component: SelectionListRadio,
-};
-
-const SECTIONS = [
- {
- data: [
- {
- text: 'Option 1',
- keyForList: 'option-1',
- isSelected: false,
- },
- {
- text: 'Option 2',
- keyForList: 'option-2',
- isSelected: false,
- },
- {
- text: 'Option 3',
- keyForList: 'option-3',
- isSelected: false,
- },
- ],
- indexOffset: 0,
- isDisabled: false,
- },
- {
- data: [
- {
- text: 'Option 4',
- keyForList: 'option-4',
- isSelected: false,
- },
- {
- text: 'Option 5',
- keyForList: 'option-5',
- isSelected: false,
- },
- {
- text: 'Option 6',
- keyForList: 'option-6',
- isSelected: false,
- },
- ],
- indexOffset: 3,
- isDisabled: false,
- },
-];
-
-function Default(args) {
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.map(section.data, (item, index) => {
- const isSelected = selectedIndex === index + section.indexOffset;
- return {...item, isSelected};
- });
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
-
- return (
-
- );
-}
-
-Default.args = {
- sections: SECTIONS,
- initiallyFocusedOptionKey: 'option-2',
-};
-
-function WithTextInput(args) {
- const [searchText, setSearchText] = useState('');
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.reduce(
- section.data,
- (memo, item, index) => {
- if (!item.text.toLowerCase().includes(searchText.trim().toLowerCase())) {
- return memo;
- }
-
- const isSelected = selectedIndex === index + section.indexOffset;
- memo.push({...item, isSelected});
- return memo;
- },
- [],
- );
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
-
- return (
-
- );
-}
-
-WithTextInput.args = {
- sections: SECTIONS,
- textInputLabel: 'Option list',
- textInputPlaceholder: 'Search something...',
- textInputMaxLength: 4,
- keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD,
- initiallyFocusedOptionKey: 'option-2',
-};
-
-function WithHeaderMessage(props) {
- return (
-
- );
-}
-
-WithHeaderMessage.args = {
- ...WithTextInput.args,
- headerMessage: 'No results found',
- sections: [],
-};
-
-function WithAlternateText(args) {
- const [selectedIndex, setSelectedIndex] = useState(1);
-
- const sections = _.map(args.sections, (section) => {
- const data = _.map(section.data, (item, index) => {
- const isSelected = selectedIndex === index + section.indexOffset;
-
- return {
- ...item,
- alternateText: `Alternate ${index + 1}`,
- isSelected,
- };
- });
-
- return {...section, data};
- });
-
- const onSelectRow = (item) => {
- _.forEach(sections, (section) => {
- const newSelectedIndex = _.findIndex(section.data, (option) => option.keyForList === item.keyForList);
-
- if (newSelectedIndex >= 0) {
- setSelectedIndex(newSelectedIndex + section.indexOffset);
- }
- });
- };
- return (
-
- );
-}
-
-WithAlternateText.args = {
- ...Default.args,
-};
-
-export {Default, WithTextInput, WithHeaderMessage, WithAlternateText};
-export default story;
diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js
index fe910389c39c..5016e6c30877 100644
--- a/src/styles/StyleUtils.js
+++ b/src/styles/StyleUtils.js
@@ -1050,6 +1050,17 @@ function getAutoCompleteSuggestionItemStyle(highlightedEmojiIndex, rowHeight, ho
];
}
+/**
+ * Gets the correct position for the base auto complete suggestion container
+ *
+ * @param {Object} parentContainerLayout
+ * @returns {Object}
+ */
+
+function getBaseAutoCompleteSuggestionContainerStyle({left, bottom, width}) {
+ return {position: 'fixed', bottom, left, width};
+}
+
/**
* Gets the correct position for auto complete suggestion container
*
@@ -1302,6 +1313,22 @@ function getCheckboxContainerStyle(size, borderRadius) {
};
}
+/**
+ * Returns style object for the dropbutton height
+ * @param {String} buttonSize
+ * @returns {Object}
+ */
+function getDropDownButtonHeight(buttonSize) {
+ if (buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE) {
+ return {
+ height: variables.componentSizeLarge,
+ };
+ }
+ return {
+ height: variables.componentSizeNormal,
+ };
+}
+
export {
getAvatarSize,
getAvatarWidthStyle,
@@ -1351,6 +1378,7 @@ export {
getReportWelcomeBackgroundImageStyle,
getReportWelcomeTopMarginStyle,
getReportWelcomeContainerStyle,
+ getBaseAutoCompleteSuggestionContainerStyle,
getAutoCompleteSuggestionItemStyle,
getAutoCompleteSuggestionContainerStyle,
getColoredBackgroundStyle,
@@ -1375,4 +1403,5 @@ export {
getMenuItemTextContainerStyle,
getDisabledLinkStyles,
getCheckboxContainerStyle,
+ getDropDownButtonHeight,
};
diff --git a/src/styles/getModalStyles/getBaseModalStyles.js b/src/styles/getModalStyles/getBaseModalStyles.js
index b7a3317963ca..c7697b1de6ea 100644
--- a/src/styles/getModalStyles/getBaseModalStyles.js
+++ b/src/styles/getModalStyles/getBaseModalStyles.js
@@ -237,6 +237,7 @@ export default (type, windowDimensions, popoverAnchorPosition = {}, innerContain
translateX: isSmallScreenWidth ? windowWidth : variables.sideBarWidth,
},
};
+ hideBackdrop = true;
swipeDirection = undefined;
shouldAddBottomSafeAreaPadding = true;
shouldAddTopSafeAreaPadding = true;
diff --git a/src/styles/italic/index.android.js b/src/styles/italic/index.android.js
deleted file mode 100644
index 92f6d65241bb..000000000000
--- a/src/styles/italic/index.android.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const italic = 'normal';
-
-export default italic;
diff --git a/src/styles/italic/index.android.ts b/src/styles/italic/index.android.ts
new file mode 100644
index 000000000000..bbd9deb8cf8d
--- /dev/null
+++ b/src/styles/italic/index.android.ts
@@ -0,0 +1,5 @@
+import ItalicStyles from './types';
+
+const italic: ItalicStyles = 'normal';
+
+export default italic;
diff --git a/src/styles/italic/index.js b/src/styles/italic/index.js
deleted file mode 100644
index 8e8433c7cc05..000000000000
--- a/src/styles/italic/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-const italic = 'italic';
-
-export default italic;
diff --git a/src/styles/italic/index.ts b/src/styles/italic/index.ts
new file mode 100644
index 000000000000..02d6c46423f6
--- /dev/null
+++ b/src/styles/italic/index.ts
@@ -0,0 +1,5 @@
+import ItalicStyles from './types';
+
+const italic: ItalicStyles = 'italic';
+
+export default italic;
diff --git a/src/styles/italic/types.ts b/src/styles/italic/types.ts
new file mode 100644
index 000000000000..61e0328e52b6
--- /dev/null
+++ b/src/styles/italic/types.ts
@@ -0,0 +1,6 @@
+import {CSSProperties} from 'react';
+import {TextStyle} from 'react-native';
+
+type ItalicStyles = (TextStyle | CSSProperties)['fontStyle'];
+
+export default ItalicStyles;
diff --git a/src/styles/overflowXHidden/index.js b/src/styles/overflowXHidden/index.js
deleted file mode 100644
index 6cdd34a05eb0..000000000000
--- a/src/styles/overflowXHidden/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- overflowX: 'hidden',
-};
diff --git a/src/styles/overflowXHidden/index.native.js b/src/styles/overflowXHidden/index.native.js
deleted file mode 100644
index ff8b4c56321a..000000000000
--- a/src/styles/overflowXHidden/index.native.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/src/styles/overflowXHidden/index.native.ts b/src/styles/overflowXHidden/index.native.ts
new file mode 100644
index 000000000000..3a2f61893d94
--- /dev/null
+++ b/src/styles/overflowXHidden/index.native.ts
@@ -0,0 +1,5 @@
+import OverflowXHiddenStyles from './types';
+
+const overflowXHidden: OverflowXHiddenStyles = {};
+
+export default overflowXHidden;
diff --git a/src/styles/overflowXHidden/index.ts b/src/styles/overflowXHidden/index.ts
new file mode 100644
index 000000000000..6807be275be9
--- /dev/null
+++ b/src/styles/overflowXHidden/index.ts
@@ -0,0 +1,7 @@
+import OverflowXHiddenStyles from './types';
+
+const overflowXHidden: OverflowXHiddenStyles = {
+ overflowX: 'hidden',
+};
+
+export default overflowXHidden;
diff --git a/src/styles/overflowXHidden/types.ts b/src/styles/overflowXHidden/types.ts
new file mode 100644
index 000000000000..7ac572f0e651
--- /dev/null
+++ b/src/styles/overflowXHidden/types.ts
@@ -0,0 +1,5 @@
+import {CSSProperties} from 'react';
+
+type OverflowXHiddenStyles = Partial>;
+
+export default OverflowXHiddenStyles;
diff --git a/src/styles/pointerEventsAuto/index.js b/src/styles/pointerEventsAuto/index.js
deleted file mode 100644
index add748e52fe5..000000000000
--- a/src/styles/pointerEventsAuto/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- pointerEvents: 'auto',
-};
diff --git a/src/styles/pointerEventsAuto/index.native.js b/src/styles/pointerEventsAuto/index.native.js
deleted file mode 100644
index ff8b4c56321a..000000000000
--- a/src/styles/pointerEventsAuto/index.native.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/src/styles/pointerEventsAuto/index.native.ts b/src/styles/pointerEventsAuto/index.native.ts
new file mode 100644
index 000000000000..cbefa643d1e0
--- /dev/null
+++ b/src/styles/pointerEventsAuto/index.native.ts
@@ -0,0 +1,5 @@
+import PointerEventsAutoStyles from './types';
+
+const pointerEventsAuto: PointerEventsAutoStyles = {};
+
+export default pointerEventsAuto;
diff --git a/src/styles/pointerEventsAuto/index.ts b/src/styles/pointerEventsAuto/index.ts
new file mode 100644
index 000000000000..8abda90caafe
--- /dev/null
+++ b/src/styles/pointerEventsAuto/index.ts
@@ -0,0 +1,7 @@
+import PointerEventsAutoStyles from './types';
+
+const pointerEventsAuto: PointerEventsAutoStyles = {
+ pointerEvents: 'auto',
+};
+
+export default pointerEventsAuto;
diff --git a/src/styles/pointerEventsAuto/types.ts b/src/styles/pointerEventsAuto/types.ts
new file mode 100644
index 000000000000..7c9f0164c936
--- /dev/null
+++ b/src/styles/pointerEventsAuto/types.ts
@@ -0,0 +1,5 @@
+import {CSSProperties} from 'react';
+
+type PointerEventsAutoStyles = Partial>;
+
+export default PointerEventsAutoStyles;
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 581410e0e5a5..79c58c12db6d 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -230,6 +230,11 @@ const styles = {
color: themeColors.textSupporting,
},
+ appIconBorderRadius: {
+ overflow: 'hidden',
+ borderRadius: 12,
+ },
+
unitCol: {
margin: 0,
padding: 0,
@@ -564,10 +569,9 @@ const styles = {
},
buttonDivider: {
- width: 1,
- alignSelf: 'stretch',
- backgroundColor: themeColors.appBG,
- marginVertical: 1,
+ height: variables.dropDownButtonDividerHeight,
+ borderWidth: 0.7,
+ borderColor: themeColors.text,
},
noBorderRadius: {
@@ -1162,6 +1166,13 @@ const styles = {
marginBottom: 4,
},
+ desktopRedirectPage: {
+ backgroundColor: themeColors.appBG,
+ minHeight: '100%',
+ flex: 1,
+ alignItems: 'center',
+ },
+
signInPage: {
backgroundColor: themeColors.highlightBG,
minHeight: '100%',
@@ -1213,6 +1224,11 @@ const styles = {
minHeight: 24,
},
+ signInPageContentTopSpacerSmallScreens: {
+ maxHeight: 132,
+ minHeight: 45,
+ },
+
signInPageLeftContainer: {
paddingLeft: 40,
paddingRight: 40,
@@ -1223,11 +1239,11 @@ const styles = {
},
signInPageWelcomeFormContainer: {
- maxWidth: 300,
+ maxWidth: CONST.SIGN_IN_FORM_WIDTH,
},
signInPageWelcomeTextContainer: {
- width: 300,
+ width: CONST.SIGN_IN_FORM_WIDTH,
},
changeExpensifyLoginLinkContainer: {
@@ -2107,11 +2123,10 @@ const styles = {
outline: 'none',
},
- pdfPasswordForm: {
- wideScreenWidth: {
- width: 350,
- },
- },
+ getPDFPasswordFormStyle: (isSmallScreenWidth) => ({
+ width: isSmallScreenWidth ? '100%' : 350,
+ ...(isSmallScreenWidth && flex.flex1),
+ }),
modalCenterContentContainer: {
flex: 1,
@@ -2358,7 +2373,6 @@ const styles = {
roomHeaderAvatar: {
backgroundColor: themeColors.appBG,
- marginLeft: -16,
borderRadius: 100,
borderColor: themeColors.componentBG,
borderWidth: 4,
@@ -2643,19 +2657,18 @@ const styles = {
maxWidth: variables.sideBarWidth,
},
- iouPreviewBox: {
+ moneyRequestPreviewBox: {
backgroundColor: themeColors.cardBG,
borderRadius: variables.componentBorderRadiusLarge,
- padding: 16,
maxWidth: variables.sideBarWidth,
width: '100%',
},
- iouPreviewBoxHover: {
- backgroundColor: themeColors.border,
+ moneyRequestPreviewBoxText: {
+ padding: 16,
},
- iouPreviewBoxLoading: {
+ moneyRequestPreviewBoxLoading: {
// When a new IOU request arrives it is very briefly in a loading state, so set the minimum height of the container to 94 to match the rendered height after loading.
// Otherwise, the IOU request pay button will not be fully visible and the user will have to scroll up to reveal the entire IOU request container.
// See https://github.com/Expensify/App/issues/10283.
@@ -2663,7 +2676,7 @@ const styles = {
width: '100%',
},
- iouPreviewBoxAvatar: {
+ moneyRequestPreviewBoxAvatar: {
marginRight: -10,
marginBottom: 0,
},
@@ -2903,7 +2916,7 @@ const styles = {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
- ...spacing.pt2,
+ ...spacing.ph5,
},
peopleRowBorderBottom: {
@@ -3601,6 +3614,52 @@ const styles = {
textAlign: 'center',
},
+ loginButtonRow: {
+ justifyContent: 'center',
+ width: '100%',
+ ...flex.flexRow,
+ },
+
+ loginButtonRowSmallScreen: {
+ justifyContent: 'center',
+ width: '100%',
+ marginBottom: 10,
+ ...flex.flexRow,
+ },
+
+ appleButtonContainer: {
+ width: 40,
+ height: 40,
+ marginRight: 20,
+ },
+
+ signInIconButton: {
+ margin: 10,
+ marginTop: 0,
+ padding: 2,
+ },
+
+ googleButtonContainer: {
+ colorScheme: 'light',
+ width: 40,
+ height: 40,
+ marginLeft: 12,
+ alignItems: 'center',
+ overflow: 'hidden',
+ },
+
+ googlePillButtonContainer: {
+ colorScheme: 'light',
+ height: 40,
+ width: 219,
+ },
+
+ thirdPartyLoadingContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: 450,
+ },
+
tabSelectorButton: (isSelected) => ({
height: 40,
padding: 12,
@@ -3618,7 +3677,7 @@ const styles = {
},
tabText: (isSelected) => ({
- marginHorizontal: 8,
+ marginLeft: 8,
fontFamily: isSelected ? fontFamily.EXP_NEUE_BOLD : fontFamily.EXP_NEUE,
fontWeight: isSelected ? fontWeightBold : 400,
color: isSelected ? themeColors.textLight : themeColors.textSupporting,
@@ -3643,6 +3702,23 @@ const styles = {
willChange: 'transform',
},
+ dropDownButtonCartIconContainerPadding: {
+ paddingRight: 0,
+ paddingLeft: 0,
+ },
+
+ dropDownButtonArrowContain: {
+ marginLeft: 12,
+ marginRight: 14,
+ },
+
+ dropDownButtonCartIconView: {
+ borderTopRightRadius: variables.buttonBorderRadius,
+ borderBottomRightRadius: variables.buttonBorderRadius,
+ ...flex.flexRow,
+ ...flex.alignItemsCenter,
+ },
+
emojiPickerButtonDropdown: {
justifyContent: 'center',
backgroundColor: themeColors.activeComponentBG,
@@ -3666,6 +3742,61 @@ const styles = {
margin: 20,
},
+ reportPreviewBox: {
+ backgroundColor: themeColors.cardBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ maxWidth: variables.sideBarWidth,
+ width: '100%',
+ },
+
+ reportPreviewBoxHoverBorder: {
+ borderColor: themeColors.border,
+ backgroundColor: themeColors.border,
+ },
+
+ reportPreviewBoxBody: {
+ padding: 16,
+ },
+
+ reportActionItemImages: {
+ flexDirection: 'row',
+ borderWidth: 2,
+ borderColor: themeColors.cardBG,
+ borderTopLeftRadius: variables.componentBorderRadiusLarge,
+ borderTopRightRadius: variables.componentBorderRadiusLarge,
+ overflow: 'hidden',
+ height: 200,
+ },
+
+ reportActionItemImage: {
+ borderWidth: 1,
+ borderColor: themeColors.cardBG,
+ flex: 1,
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+
+ reportActionItemImagesMore: {
+ position: 'absolute',
+ borderRadius: 18,
+ backgroundColor: themeColors.cardBG,
+ width: 36,
+ height: 36,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+
+ moneyRequestHeaderStatusBarBadge: {
+ padding: 8,
+ borderRadius: variables.componentBorderRadiusMedium,
+ marginRight: 16,
+ backgroundColor: themeColors.border,
+ },
+
staticHeaderImage: {
minHeight: 240,
},
@@ -3674,9 +3805,77 @@ const styles = {
flexDirection: 'row',
alignItems: 'center',
},
+
rotate90: {
transform: [{rotate: '90deg'}],
},
+
+ emojiStatusLHN: {
+ fontSize: 22,
+ },
+ sidebarStatusAvatarContainer: {
+ height: 44,
+ width: 84,
+ backgroundColor: themeColors.componentBG,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ borderRadius: 42,
+ paddingHorizontal: 2,
+ marginVertical: -2,
+ marginRight: -2,
+ },
+ sidebarStatusAvatar: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+
+ moneyRequestViewImage: {
+ ...spacing.mh5,
+ ...spacing.mv3,
+ overflow: 'hidden',
+ borderWidth: 2,
+ borderColor: themeColors.cardBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ height: 200,
+ maxWidth: 400,
+ },
+
+ distanceRequestContainer: (maxHeight) => ({
+ ...flex.flexShrink2,
+ minHeight: variables.baseMenuItemHeight,
+ maxHeight,
+ }),
+
+ mapViewContainer: {
+ ...flex.flex1,
+ ...spacing.p4,
+ ...spacing.flex1,
+ minHeight: 300,
+ maxHeight: 500,
+ },
+
+ mapView: {
+ flex: 1,
+ borderRadius: 20,
+ overflow: 'hidden',
+ },
+
+ mapDirection: {
+ width: 7,
+ color: Colors.green,
+ },
+
+ mapPendingView: {
+ backgroundColor: themeColors.highlightBG,
+ ...flex.flex1,
+ borderRadius: variables.componentBorderRadiusLarge,
+ },
+ userReportStatusEmoji: {
+ fontSize: variables.fontSizeNormal,
+ marginRight: 4,
+ },
};
export default styles;
diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js
index bc794f93dfab..76a0980653f1 100644
--- a/src/styles/themes/default.js
+++ b/src/styles/themes/default.js
@@ -78,6 +78,7 @@ const darkTheme = {
mentionBG: colors.blue600,
ourMentionText: colors.green100,
ourMentionBG: colors.green600,
+ starDefaultBG: 'rgb(254, 228, 94)',
};
darkTheme.PAGE_BACKGROUND_COLORS = {
diff --git a/src/styles/utilities/flex.js b/src/styles/utilities/flex.ts
similarity index 93%
rename from src/styles/utilities/flex.js
rename to src/styles/utilities/flex.ts
index 6cdd0a5b1d6a..6c7541a3ef46 100644
--- a/src/styles/utilities/flex.js
+++ b/src/styles/utilities/flex.ts
@@ -1,3 +1,5 @@
+import {ViewStyle} from 'react-native';
+
/**
* Flex layout utility styles with Bootstrap inspired naming.
*
@@ -115,6 +117,10 @@ export default {
flexGrow: 4,
},
+ flexShrink2: {
+ flexShrink: 2,
+ },
+
flexShrink1: {
flexShrink: 1,
},
@@ -130,4 +136,4 @@ export default {
flexBasis0: {
flexBasis: 0,
},
-};
+} satisfies Record;
diff --git a/src/styles/utilities/overflow.js b/src/styles/utilities/overflow.js
index c190abfa912b..430525f99e65 100644
--- a/src/styles/utilities/overflow.js
+++ b/src/styles/utilities/overflow.js
@@ -18,8 +18,8 @@ export default {
overflow: 'scroll',
},
- overscrollBehaviorNone: {
- overscrollBehavior: 'none',
+ overscrollBehaviorXNone: {
+ overscrollBehaviorX: 'none',
},
overflowAuto,
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index a77ede807794..47b523d89ac2 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -133,6 +133,10 @@ export default {
marginLeft: 16,
},
+ mln4: {
+ marginLeft: -16,
+ },
+
ml5: {
marginLeft: 20,
},
@@ -395,6 +399,10 @@ export default {
paddingLeft: 8,
},
+ pl3: {
+ paddingLeft: 12,
+ },
+
pl5: {
paddingLeft: 20,
},
@@ -479,6 +487,22 @@ export default {
gap: 4,
},
+ gap2: {
+ gap: 8,
+ },
+
+ gap3: {
+ gap: 12,
+ },
+
+ gap4: {
+ gap: 16,
+ },
+
+ gap5: {
+ gap: 20,
+ },
+
gap7: {
gap: 28,
},
diff --git a/src/styles/utilities/writingDirection.js b/src/styles/utilities/writingDirection.ts
similarity index 80%
rename from src/styles/utilities/writingDirection.js
rename to src/styles/utilities/writingDirection.ts
index d9c630c86912..1d9a32122373 100644
--- a/src/styles/utilities/writingDirection.js
+++ b/src/styles/utilities/writingDirection.ts
@@ -1,3 +1,4 @@
+import {TextStyle} from 'react-native';
/**
* Writing direction utility styles.
* Note: writingDirection isn't supported on Android. Unicode controls are being used for Android
@@ -10,4 +11,4 @@ export default {
ltr: {
writingDirection: 'ltr',
},
-};
+} satisfies Record;
diff --git a/src/styles/variables.js b/src/styles/variables.js
index a771aa95906e..40e29ca3cf6e 100644
--- a/src/styles/variables.js
+++ b/src/styles/variables.js
@@ -28,6 +28,7 @@ export default {
componentBorderRadiusLarge: 16,
componentBorderRadiusCard: 12,
componentBorderRadiusRounded: 24,
+ downloadAppModalAppIconSize: 48,
buttonBorderRadius: 100,
avatarSizeLargeBordered: 88,
avatarSizeLarge: 80,
@@ -143,6 +144,7 @@ export default {
addPaymentPopoverTopSpacing: 8,
addPaymentPopoverRightSpacing: 13,
anonymousReportFooterBreakpoint: 650,
+ dropDownButtonDividerHeight: 28,
// The height of the empty list is 14px (2px for borders and 12px for vertical padding)
// This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility
@@ -150,4 +152,6 @@ export default {
hoverDimValue: 1,
pressDimValue: 0.8,
qrShareHorizontalPadding: 32,
+
+ baseMenuItemHeight: 64,
};
diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts
new file mode 100644
index 000000000000..29f21d703466
--- /dev/null
+++ b/src/types/onyx/Account.ts
@@ -0,0 +1,52 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
+
+type Account = {
+ /** URL to the assigned guide's appointment booking calendar */
+ guideCalendarLink?: string;
+
+ /** User recovery codes for setting up 2-FA */
+ recoveryCodes?: string;
+
+ /** Secret key to enable 2FA within the authenticator app */
+ twoFactorAuthSecretKey?: string;
+
+ /** Whether this account has 2FA enabled or not */
+ requiresTwoFactorAuth?: boolean;
+
+ /** Whether the account is validated */
+ validated?: boolean;
+
+ /** The primaryLogin associated with the account */
+ primaryLogin?: string;
+
+ /** The message to be displayed when code requested */
+ message?: string;
+
+ /** Accounts that are on a domain with an Approved Accountant */
+ doesDomainHaveApprovedAccountant?: boolean;
+
+ /** Form that is being loaded */
+ loadingForm?: ValueOf;
+
+ /** Whether the user forgot their password */
+ forgotPassword?: boolean;
+
+ /** Whether the account exists */
+ accountExists?: boolean;
+
+ /** Is the account / domain under domain control? */
+ domainControlled?: boolean;
+
+ /** Whether the validation code has expired */
+ validateCodeExpired?: boolean;
+
+ /** Whether a sign is loading */
+ isLoading?: boolean;
+
+ errors?: OnyxCommon.Errors;
+ success?: string;
+};
+
+export default Account;
diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts
new file mode 100644
index 000000000000..ccaaa7ebab78
--- /dev/null
+++ b/src/types/onyx/BankAccount.ts
@@ -0,0 +1,72 @@
+type AdditionalData = {
+ isP2PDebitCard?: boolean;
+ beneficialOwners?: string[];
+ currency?: string;
+ bankName?: string;
+ fieldsType?: string;
+ country?: string;
+};
+
+type AccountData = {
+ /** The masked bank account number */
+ accountNumber?: string;
+
+ /** The name of the institution (bank of america, etc */
+ addressName?: string;
+
+ /** Can we use this account to pay other people? */
+ allowDebit?: boolean;
+
+ /** Can we use this account to receive money from other people? */
+ defaultCredit?: boolean;
+
+ /** Is a saving account */
+ isSavings?: boolean;
+
+ /** Return whether or not this bank account has been risk checked */
+ riskChecked?: boolean;
+
+ /** Account routing number */
+ routingNumber?: string;
+
+ /** The status of the bank account */
+ state?: string;
+
+ /** All user emails that have access to this bank account */
+ sharees?: string[];
+
+ processor?: string;
+
+ /** The bankAccountID in the bankAccounts db */
+ bankAccountID?: number;
+
+ /** All data related to the bank account */
+ additionalData?: AdditionalData;
+
+ /** The bank account type */
+ type?: string;
+};
+
+type BankAccount = {
+ /** The bank account type */
+ accountType?: string;
+
+ /** string like 'Account ending in XXXX' */
+ description?: string;
+
+ isDefault?: boolean;
+
+ /** string like 'bankAccount-{}' where is the bankAccountID */
+ key?: string;
+
+ /** Alias for bankAccountID */
+ methodID?: number;
+
+ /** Alias for addressName */
+ title?: string;
+
+ /** All data related to the bank account */
+ accountData?: AccountData;
+};
+
+export default BankAccount;
diff --git a/src/types/onyx/Beta.ts b/src/types/onyx/Beta.ts
new file mode 100644
index 000000000000..d40d05a9ed3d
--- /dev/null
+++ b/src/types/onyx/Beta.ts
@@ -0,0 +1,6 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+
+type Beta = ValueOf;
+
+export default Beta;
diff --git a/src/types/onyx/BlockedFromConcierge.ts b/src/types/onyx/BlockedFromConcierge.ts
new file mode 100644
index 000000000000..4eebd537604c
--- /dev/null
+++ b/src/types/onyx/BlockedFromConcierge.ts
@@ -0,0 +1,9 @@
+type BlockedFromConcierge = {
+ /** The date that the user will be unblocked */
+ expiresAt: string;
+
+ /** Number of times the user has been blocked. */
+ count: 1 | 2 | 3;
+};
+
+export default BlockedFromConcierge;
diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts
new file mode 100644
index 000000000000..71ef3d4d98e7
--- /dev/null
+++ b/src/types/onyx/Card.ts
@@ -0,0 +1,32 @@
+type AdditionalData = {
+ isBillingCard?: boolean;
+ isP2PDebitCard?: boolean;
+};
+
+type AccountData = {
+ additionalData?: AdditionalData;
+ addressName?: string;
+ addressState?: string;
+ addressStreet?: string;
+ addressZip?: number;
+ cardMonth?: number;
+
+ /** The masked credit card number */
+ cardNumber?: string;
+
+ cardYear?: number;
+ created?: string;
+ currency?: string;
+ fundID?: number;
+};
+
+type Card = {
+ accountData?: AccountData;
+ accountType?: string;
+ description?: string;
+ key?: string;
+ methodID?: number;
+ title?: string;
+};
+
+export default Card;
diff --git a/src/types/onyx/Credentials.ts b/src/types/onyx/Credentials.ts
new file mode 100644
index 000000000000..f6a9ce669ad0
--- /dev/null
+++ b/src/types/onyx/Credentials.ts
@@ -0,0 +1,15 @@
+type Credentials = {
+ /** The email/phone the user logged in with */
+ login?: string;
+ password?: string;
+ twoFactorAuthCode?: string;
+
+ /** The validate code */
+ validateCode?: string;
+
+ autoGeneratedLogin?: string;
+ autoGeneratedPassword?: string;
+ accountID?: number;
+};
+
+export default Credentials;
diff --git a/src/types/onyx/Currency.ts b/src/types/onyx/Currency.ts
new file mode 100644
index 000000000000..770fcc33a9a9
--- /dev/null
+++ b/src/types/onyx/Currency.ts
@@ -0,0 +1,15 @@
+type Currency = {
+ /** Symbol for the currency */
+ symbol: string;
+
+ /** Name of the currency */
+ name: string;
+
+ /** ISO4217 Code for the currency */
+ ISO4217: string;
+
+ /** Number of decimals the currency can have, if this is missing, we assume it has 2 decimals */
+ decimals?: number;
+};
+
+export default Currency;
diff --git a/src/types/onyx/CustomStatusDraft.ts b/src/types/onyx/CustomStatusDraft.ts
new file mode 100644
index 000000000000..b2801a1d89e0
--- /dev/null
+++ b/src/types/onyx/CustomStatusDraft.ts
@@ -0,0 +1,12 @@
+type CustomStatusDraft = {
+ /** The emoji code of the draft status */
+ emojiCode: string;
+
+ /** The text of the draft status */
+ text: string;
+
+ /** ISO 8601 format string, which represents the time when the status should be cleared */
+ clearAfter: string;
+};
+
+export default CustomStatusDraft;
diff --git a/src/types/onyx/Download.ts b/src/types/onyx/Download.ts
new file mode 100644
index 000000000000..9c6c2f61f716
--- /dev/null
+++ b/src/types/onyx/Download.ts
@@ -0,0 +1,6 @@
+type Download = {
+ /** If a file download is happening */
+ isDownloading: boolean;
+};
+
+export default Download;
diff --git a/src/types/onyx/Form.ts b/src/types/onyx/Form.ts
new file mode 100644
index 000000000000..cda8c3c1017e
--- /dev/null
+++ b/src/types/onyx/Form.ts
@@ -0,0 +1,21 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type Form = {
+ /** Controls the loading state of the form */
+ isLoading?: boolean;
+
+ /** Server side errors keyed by microtime */
+ errors?: OnyxCommon.Errors;
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
+};
+
+type AddDebitCardForm = Form & {
+ /** Whether or not the form has been submitted */
+ setupComplete: boolean;
+};
+
+export default Form;
+
+export type {AddDebitCardForm};
diff --git a/src/types/onyx/FrequentlyUsedEmoji.ts b/src/types/onyx/FrequentlyUsedEmoji.ts
new file mode 100644
index 000000000000..2276095df9b5
--- /dev/null
+++ b/src/types/onyx/FrequentlyUsedEmoji.ts
@@ -0,0 +1,18 @@
+type FrequentlyUsedEmoji = {
+ /** The emoji code */
+ code: string;
+
+ /** The name of the emoji */
+ name: string;
+
+ /** The number of times the emoji has been used */
+ count: number;
+
+ /** The timestamp in UNIX format when the emoji was last used */
+ lastUpdatedAt: number;
+
+ /** The emoji skin tone type */
+ types?: string[];
+};
+
+export default FrequentlyUsedEmoji;
diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts
new file mode 100644
index 000000000000..373b5d990160
--- /dev/null
+++ b/src/types/onyx/IOU.ts
@@ -0,0 +1,19 @@
+type Participant = {
+ accountID: number;
+ login?: string;
+ isPolicyExpenseChat?: boolean;
+ isOwnPolicyExpenseChat?: boolean;
+ selected?: boolean;
+ reportID?: string;
+};
+
+type IOU = {
+ id: string;
+ amount?: number;
+ /** Selected Currency Code of the current IOU */
+ currency?: string;
+ comment?: string;
+ participants?: Participant[];
+};
+
+export default IOU;
diff --git a/src/types/onyx/Login.ts b/src/types/onyx/Login.ts
new file mode 100644
index 000000000000..60ea5985315e
--- /dev/null
+++ b/src/types/onyx/Login.ts
@@ -0,0 +1,20 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type Login = {
+ /** Phone/Email associated with user */
+ partnerUserID?: string;
+
+ /** Value of partner name */
+ partnerName?: string;
+
+ /** Date login was validated, used to show info indicator status */
+ validatedDate?: string;
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
+
+ /** Field-specific pending states for offline UI status */
+ pendingFields?: OnyxCommon.ErrorFields;
+};
+
+export default Login;
diff --git a/src/types/onyx/MapboxAccessToken.ts b/src/types/onyx/MapboxAccessToken.ts
new file mode 100644
index 000000000000..bea23bcf86c4
--- /dev/null
+++ b/src/types/onyx/MapboxAccessToken.ts
@@ -0,0 +1,7 @@
+type MapboxAccessToken = {
+ token: string;
+ expiration: string;
+ errors: string[];
+};
+
+export default MapboxAccessToken;
diff --git a/src/types/onyx/Modal.ts b/src/types/onyx/Modal.ts
new file mode 100644
index 000000000000..126b56d4c504
--- /dev/null
+++ b/src/types/onyx/Modal.ts
@@ -0,0 +1,9 @@
+type Modal = {
+ /** Indicates when an Alert modal is about to be visible */
+ willAlertModalBecomeVisible?: boolean;
+
+ /** Indicates if there is a modal currently visible or not */
+ isVisible?: boolean;
+};
+
+export default Modal;
diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts
new file mode 100644
index 000000000000..5af4c1170c3f
--- /dev/null
+++ b/src/types/onyx/Network.ts
@@ -0,0 +1,12 @@
+type Network = {
+ /** Is the network currently offline or not */
+ isOffline?: boolean;
+
+ /** Should the network be forced offline */
+ shouldForceOffline?: boolean;
+
+ /** Whether we should fail all network requests */
+ shouldFailAllRequests?: boolean;
+};
+
+export default Network;
diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts
new file mode 100644
index 000000000000..36e9c6ae74ea
--- /dev/null
+++ b/src/types/onyx/OnyxCommon.ts
@@ -0,0 +1,17 @@
+import {ValueOf} from 'type-fest';
+import * as React from 'react';
+import CONST from '../../CONST';
+
+type PendingAction = ValueOf;
+
+type ErrorFields = Record>;
+
+type Errors = Record;
+
+type Icon = {
+ source: React.ReactNode | string;
+ type: 'avatar' | 'workspace';
+ name: string;
+};
+
+export type {Icon, PendingAction, ErrorFields, Errors};
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
new file mode 100644
index 000000000000..d52489c36da9
--- /dev/null
+++ b/src/types/onyx/OriginalMessage.ts
@@ -0,0 +1,142 @@
+// TODO: Remove this after CONST.ts is migrated to TS
+/* eslint-disable @typescript-eslint/no-duplicate-type-constituents */
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+
+type OriginalMessageIOU = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.IOU;
+ originalMessage: {
+ /** The ID of the iou transaction */
+ IOUTransactionID?: string;
+
+ IOUReportID?: number;
+ amount: number;
+ comment?: string;
+ currency: string;
+ lastModified?: string;
+ participantAccountIDs?: number[];
+ participants?: string[];
+ type: string;
+ };
+};
+
+type FlagSeverityName = 'spam' | 'inconsiderate' | 'bullying' | 'intimidation' | 'harassment' | 'assault';
+type FlagSeverity = {
+ accountID: number;
+ timestamp: string;
+};
+
+type Decision = {
+ decision: string;
+ timestamp: string;
+};
+
+type User = {
+ accountID: number;
+ skinTone: number;
+};
+
+type Reaction = {
+ emoji: string;
+ users: User[];
+};
+
+type OriginalMessageAddComment = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT;
+ originalMessage: {
+ html: string;
+ lastModified?: string;
+ taskReportID?: string;
+ edits?: string[];
+ childReportID?: string;
+ isDeletedParentAction?: boolean;
+ flags?: Record;
+ moderationDecisions?: Decision[];
+ whisperedTo: number[];
+ reactions?: Reaction[];
+ };
+};
+
+type OriginalMessageClosed = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.CLOSED;
+ originalMessage: {
+ policyName: string;
+ reason: 'default' | 'accountClosed' | 'accountMerged' | 'removedPolicy' | 'policyDeleted';
+ lastModified?: string;
+ };
+};
+
+type OriginalMessageCreated = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.CREATED;
+ originalMessage: unknown;
+};
+
+type OriginalMessageRenamed = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.RENAMED;
+ originalMessage: {
+ html: string;
+ lastModified: string;
+ oldName: string;
+ newName: string;
+ };
+};
+
+type ChronosOOOTimestamp = {
+ date: string;
+ timezone: string;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ timezone_type: number;
+};
+
+type ChronosOOOEvent = {
+ id: string;
+ lengthInDays: number;
+ summary: string;
+ start: ChronosOOOTimestamp;
+ end: ChronosOOOTimestamp;
+};
+
+type OriginalMessageChronosOOOList = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST;
+ originalMessage: {
+ edits: string[];
+ events: ChronosOOOEvent[];
+ html: string;
+ lastModified: string;
+ };
+};
+
+type OriginalMessageReportPreview = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
+ originalMessage: {
+ linkedReportID: string;
+ };
+};
+
+type OriginalMessagePolicyChangeLog = {
+ actionName: ValueOf;
+ originalMessage: unknown;
+};
+
+type OriginalMessagePolicyTask = {
+ actionName:
+ | typeof CONST.REPORT.ACTIONS.TYPE.TASKEDITED
+ | typeof CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED
+ | typeof CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED
+ | typeof CONST.REPORT.ACTIONS.TYPE.TASKREOPENED;
+ originalMessage: unknown;
+};
+
+type OriginalMessage =
+ | OriginalMessageIOU
+ | OriginalMessageAddComment
+ | OriginalMessageClosed
+ | OriginalMessageCreated
+ | OriginalMessageRenamed
+ | OriginalMessageChronosOOOList
+ | OriginalMessageReportPreview
+ | OriginalMessagePolicyChangeLog
+ | OriginalMessagePolicyTask;
+
+export default OriginalMessage;
+export type {Reaction};
diff --git a/src/types/onyx/Paypal.ts b/src/types/onyx/Paypal.ts
new file mode 100644
index 000000000000..26236a63b7fc
--- /dev/null
+++ b/src/types/onyx/Paypal.ts
@@ -0,0 +1,27 @@
+import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
+
+type PaypalAccountData = {
+ username: string;
+};
+
+type Paypal = {
+ /** This is always 'PayPal.me' */
+ title: string;
+
+ /** The paypalMe address */
+ description: string;
+
+ /** This is always 'payPalMe' */
+ methodID: typeof CONST.PAYMENT_METHODS.PAYPAL;
+
+ /** This is always 'payPalMe' */
+ accountType: typeof CONST.PAYMENT_METHODS.PAYPAL;
+
+ key: string;
+ isDefault: boolean;
+ pendingAction?: OnyxCommon.PendingAction;
+ accountData: PaypalAccountData;
+};
+
+export default Paypal;
diff --git a/src/types/onyx/PersonalBankAccount.ts b/src/types/onyx/PersonalBankAccount.ts
new file mode 100644
index 000000000000..06f505a04196
--- /dev/null
+++ b/src/types/onyx/PersonalBankAccount.ts
@@ -0,0 +1,15 @@
+type PersonalBankAccount = {
+ /** An error message to display to the user */
+ error?: string;
+
+ /** Whether we should show the view that the bank account was successfully added */
+ shouldShowSuccess?: boolean;
+
+ /** Whether the form is loading */
+ isLoading?: boolean;
+
+ /** The account ID of the selected bank account from Plaid */
+ plaidAccountID?: string;
+};
+
+export default PersonalBankAccount;
diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts
new file mode 100644
index 000000000000..64911dbfecb1
--- /dev/null
+++ b/src/types/onyx/PersonalDetails.ts
@@ -0,0 +1,45 @@
+type PersonalDetails = {
+ /** ID of the current user from their personal details */
+ accountID: number;
+
+ /** First name of the current user from their personal details */
+ firstName?: string;
+
+ /** Last name of the current user from their personal details */
+ lastName?: string;
+
+ /** Display name of the current user from their personal details */
+ displayName: string;
+
+ /** Is current user validated */
+ validated?: boolean;
+
+ /** Phone number of the current user from their personal details */
+ phoneNumber?: string;
+
+ /** Avatar URL of the current user from their personal details */
+ avatar: string;
+
+ /** Flag to set when Avatar uploading */
+ avatarUploading?: boolean;
+
+ /** Login of the current user from their personal details */
+ login?: string;
+
+ /** Pronouns of the current user from their personal details */
+ pronouns?: string;
+
+ /** Local currency for the user */
+ localCurrencyCode?: string;
+
+ /** Timezone of the current user from their personal details */
+ timezone?: {
+ /** Value of selected timezone */
+ selected?: string;
+
+ /** Whether timezone is automatically set */
+ automatic?: boolean;
+ };
+};
+
+export default PersonalDetails;
diff --git a/src/types/onyx/PlaidBankAccount.ts b/src/types/onyx/PlaidBankAccount.ts
new file mode 100644
index 000000000000..d89e8ac3082d
--- /dev/null
+++ b/src/types/onyx/PlaidBankAccount.ts
@@ -0,0 +1,24 @@
+type PlaidBankAccount = {
+ /** Masked account number */
+ accountNumber: string;
+
+ /** Name of account */
+ addressName: string;
+
+ /** Is the account a savings account? */
+ isSavings: boolean;
+
+ /** Unique identifier for this account in Plaid */
+ plaidAccountID: string;
+
+ /** Routing number for the account */
+ routingNumber: string;
+
+ /** Last 4 digits of the account number */
+ mask: string;
+
+ /** Plaid access token, used to then retrieve Assets and Balances */
+ plaidAccessToken: string;
+};
+
+export default PlaidBankAccount;
diff --git a/src/types/onyx/PlaidData.ts b/src/types/onyx/PlaidData.ts
new file mode 100644
index 000000000000..a4a6d8e6fe8c
--- /dev/null
+++ b/src/types/onyx/PlaidData.ts
@@ -0,0 +1,21 @@
+import PlaidBankAccount from './PlaidBankAccount';
+import * as OnyxCommon from './OnyxCommon';
+
+type PlaidData = {
+ /** Name of the bank */
+ bankName?: string;
+
+ /**
+ * Access token returned by Plaid once the user has logged into their bank.
+ * This token can be used along with internal credentials to query for Plaid Balance or Assets
+ */
+ plaidAccessToken: string;
+
+ /** List of plaid bank accounts */
+ bankAccounts?: PlaidBankAccount[];
+
+ isLoading?: boolean;
+ errors: OnyxCommon.Errors;
+};
+
+export default PlaidData;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
new file mode 100644
index 000000000000..cacbb5d15199
--- /dev/null
+++ b/src/types/onyx/Policy.ts
@@ -0,0 +1,37 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
+
+type Policy = {
+ /** The ID of the policy */
+ id?: string;
+
+ /** The name of the policy */
+ name?: string;
+
+ /** The current user's role in the policy */
+ role?: ValueOf;
+
+ /** The policy type */
+ type?: ValueOf;
+
+ /** The email of the policy owner */
+ owner?: string;
+
+ /** The output currency for the policy */
+ outputCurrency?: string;
+
+ /** The URL for the policy avatar */
+ avatar?: string;
+
+ /** Error objects keyed by field name containing errors keyed by microtime */
+ errorFields?: OnyxCommon.ErrorFields;
+
+ pendingAction?: OnyxCommon.PendingAction;
+ errors: OnyxCommon.Errors;
+ isFromFullPolicy?: boolean;
+ lastModified?: string;
+ customUnits?: Record;
+};
+
+export default Policy;
diff --git a/src/types/onyx/PolicyMember.ts b/src/types/onyx/PolicyMember.ts
new file mode 100644
index 000000000000..055465020c36
--- /dev/null
+++ b/src/types/onyx/PolicyMember.ts
@@ -0,0 +1,17 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type PolicyMember = {
+ /** Role of the user in the policy */
+ role?: string;
+
+ /**
+ * Errors from api calls on the specific user
+ * {: 'error message', : 'error message 2'}
+ */
+ errors?: OnyxCommon.Errors;
+
+ /** Is this action pending? */
+ pendingAction?: OnyxCommon.PendingAction;
+};
+
+export default PolicyMember;
diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts
new file mode 100644
index 000000000000..50ec77212efd
--- /dev/null
+++ b/src/types/onyx/PrivatePersonalDetails.ts
@@ -0,0 +1,18 @@
+type Address = {
+ street: string;
+ city: string;
+ state: string;
+ zip: string;
+ country: string;
+};
+
+type PrivatePersonalDetails = {
+ legalFirstName?: string;
+ legalLastName?: string;
+ dob?: string;
+
+ /** User's home address */
+ address?: Address;
+};
+
+export default PrivatePersonalDetails;
diff --git a/src/types/onyx/QueuedOnyxUpdates.ts b/src/types/onyx/QueuedOnyxUpdates.ts
new file mode 100644
index 000000000000..40daa2e09335
--- /dev/null
+++ b/src/types/onyx/QueuedOnyxUpdates.ts
@@ -0,0 +1,5 @@
+import Onyx from 'react-native-onyx';
+
+type QueuedOnyxUpdates = Array;
+
+export default QueuedOnyxUpdates;
diff --git a/src/types/onyx/ReceiptModal.ts b/src/types/onyx/ReceiptModal.ts
new file mode 100644
index 000000000000..0d52f684b4d2
--- /dev/null
+++ b/src/types/onyx/ReceiptModal.ts
@@ -0,0 +1,7 @@
+type ReceiptModal = {
+ isAttachmentInvalid: boolean;
+ attachmentInvalidReasonTitle: string;
+ attachmentInvalidReason: string;
+};
+
+export default ReceiptModal;
diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts
new file mode 100644
index 000000000000..4f2a05d7f4e1
--- /dev/null
+++ b/src/types/onyx/ReimbursementAccount.ts
@@ -0,0 +1,44 @@
+import {ValueOf} from 'type-fest';
+import * as OnyxCommon from './OnyxCommon';
+import CONST from '../../CONST';
+
+type ACHData = {
+ /** Step of the setup flow that we are on. Determines which view is presented. */
+ currentStep: ValueOf;
+
+ /** Optional subStep we would like the user to start back on */
+ subStep?: ValueOf;
+
+ /** Bank account state */
+ state?: string;
+
+ /** Bank account ID of the VBA that we are validating is required */
+ bankAccountID?: number;
+};
+
+type ReimbursementAccount = {
+ /** Whether we are loading the data via the API */
+ isLoading?: boolean;
+
+ /** A date that indicates the user has been throttled */
+ throttledDate?: string;
+
+ /** Additional data for the account in setup */
+ achData?: ACHData;
+
+ /** Disable validation button if max attempts exceeded */
+ maxAttemptsReached?: boolean;
+
+ /** Alert message to display above submit button */
+ error?: string;
+
+ /** Which field needs attention? */
+ errorFields?: OnyxCommon.ErrorFields;
+
+ /** Any additional error message to show */
+ errors?: OnyxCommon.Errors;
+
+ pendingAction?: OnyxCommon.PendingAction;
+};
+
+export default ReimbursementAccount;
diff --git a/src/types/onyx/ReimbursementAccountDraft.ts b/src/types/onyx/ReimbursementAccountDraft.ts
new file mode 100644
index 000000000000..d55c2b5b3567
--- /dev/null
+++ b/src/types/onyx/ReimbursementAccountDraft.ts
@@ -0,0 +1,30 @@
+type ReimbursementAccountDraft = {
+ accountNumber?: string;
+ routingNumber?: string;
+ acceptTerms?: boolean;
+ plaidAccountID?: string;
+ plaidMask?: string;
+ companyName?: string;
+ companyPhone?: string;
+ website?: string;
+ companyTaxID?: string;
+ incorporationType?: string;
+ incorporationDate?: string | Date;
+ incorporationState?: string;
+ hasNoConnectionToCannabis?: boolean;
+ isControllingOfficer?: boolean;
+ isOnfidoSetupComplete?: boolean;
+ ownsMoreThan25Percent?: boolean;
+ hasOtherBeneficialOwners?: boolean;
+ acceptTermsAndConditions?: boolean;
+ certifyTrueInformation?: boolean;
+ beneficialOwners?: string[];
+ isSavings?: boolean;
+ bankName?: string;
+ plaidAccessToken?: string;
+ amount1?: string;
+ amount2?: string;
+ amount3?: string;
+};
+
+export default ReimbursementAccountDraft;
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
new file mode 100644
index 000000000000..8660837ba874
--- /dev/null
+++ b/src/types/onyx/Report.ts
@@ -0,0 +1,86 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
+
+type Report = {
+ /** The specific type of chat */
+ chatType?: ValueOf;
+
+ /** Whether there is an outstanding amount in IOU */
+ hasOutstandingIOU?: boolean;
+
+ /** List of icons for report participants */
+ icons?: OnyxCommon.Icon[];
+
+ /** Are we loading more report actions? */
+ isLoadingMoreReportActions?: boolean;
+
+ /** Flag to check if the report actions data are loading */
+ isLoadingReportActions?: boolean;
+
+ /** Whether the user is not an admin of policyExpenseChat chat */
+ isOwnPolicyExpenseChat?: boolean;
+
+ /** Indicates if the report is pinned to the LHN or not */
+ isPinned?: boolean;
+
+ /** The email of the last message's actor */
+ lastActorEmail?: string;
+
+ /** The text of the last message on the report */
+ lastMessageText?: string;
+
+ /** The time of the last message on the report */
+ lastVisibleActionCreated?: string;
+
+ /** The last time the report was visited */
+ lastReadTime?: string;
+
+ /** The current user's notification preference for this report */
+ notificationPreference?: string | number;
+
+ /** The policy name to use for an archived report */
+ oldPolicyName?: string;
+
+ /** The email address of the report owner */
+ ownerEmail?: string;
+
+ /** List of primarylogins of participants of the report */
+ participants?: string[];
+
+ /** Linked policy's ID */
+ policyID?: string;
+
+ /** Name of the report */
+ reportName?: string;
+
+ /** ID of the report */
+ reportID?: string;
+
+ /** The state that the report is currently in */
+ stateNum?: ValueOf;
+
+ /** The status of the current report */
+ statusNum?: ValueOf;
+
+ /** Which user role is capable of posting messages on the report */
+ writeCapability?: ValueOf;
+
+ /** The report type */
+ type?: string;
+
+ parentReportID?: string;
+ parentReportActionID?: string;
+ isOptimisticReport?: boolean;
+ hasDraft?: boolean;
+ managerID?: number;
+ lastVisibleActionLastModified?: string;
+ displayName?: string;
+ lastMessageHtml?: string;
+ welcomeMessage?: string;
+ lastActorAccountID?: number;
+ ownerAccountID?: number;
+ participantAccountIDs?: number[];
+};
+
+export default Report;
diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts
new file mode 100644
index 000000000000..5f0072959917
--- /dev/null
+++ b/src/types/onyx/ReportAction.ts
@@ -0,0 +1,85 @@
+import OriginalMessage, {Reaction} from './OriginalMessage';
+import * as OnyxCommon from './OnyxCommon';
+
+type Message = {
+ /** The type of the action item fragment. Used to render a corresponding component */
+ type: string;
+
+ /** The text content of the fragment. */
+ text: string;
+
+ /** Used to apply additional styling. Style refers to a predetermined constant and not a class name. e.g. 'normal'
+ * or 'strong'
+ */
+ style?: string;
+
+ /** ID of a report */
+ reportID?: string;
+
+ /** ID of a policy */
+ policyID?: string;
+
+ /** The target of a link fragment e.g. '_blank' */
+ target?: string;
+
+ /** The destination of a link fragment e.g. 'https://www.expensify.com' */
+ href?: string;
+
+ /** An additional avatar url - not the main avatar url but used within a message. */
+ iconUrl?: string;
+
+ /** Fragment edited flag */
+ isEdited: boolean;
+
+ isDeletedParentAction: boolean;
+ whisperedTo: number[];
+ reactions: Reaction[];
+};
+
+type Person = {
+ type?: string;
+ style?: string;
+ text?: string;
+};
+
+type ReportActionBase = {
+ /** The ID of the reportAction. It is the string representation of the a 64-bit integer. */
+ reportActionID?: string;
+
+ actorAccountID?: number;
+
+ /** Person who created the action */
+ person?: Person[];
+
+ /** ISO-formatted datetime */
+ created?: string;
+
+ /** report action message */
+ message?: Message[];
+
+ /** Whether we have received a response back from the server */
+ isLoading?: boolean;
+
+ /** Error message that's come back from the server. */
+ error?: string;
+
+ /** accountIDs of the people to which the whisper was sent to (if any). Returns empty array if it is not a whisper */
+ whisperedToAccountIDs?: number[];
+
+ avatar?: string;
+ automatic?: boolean;
+ shouldShow?: boolean;
+ childReportID?: string;
+ childType?: string;
+ childOldestFourEmails?: string;
+ childOldestFourAccountIDs?: string;
+ childCommenterCount?: number;
+ childLastVisibleActionCreated?: string;
+ childVisibleActionCount?: number;
+
+ pendingAction?: OnyxCommon.PendingAction;
+};
+
+type ReportAction = ReportActionBase & OriginalMessage;
+
+export default ReportAction;
diff --git a/src/types/onyx/ReportActionReactions.ts b/src/types/onyx/ReportActionReactions.ts
new file mode 100644
index 000000000000..196e2707bbd2
--- /dev/null
+++ b/src/types/onyx/ReportActionReactions.ts
@@ -0,0 +1,15 @@
+type User = {
+ /** The skin tone which was used and also the timestamp of when it was added */
+ skinTones: Record;
+};
+
+type ReportActionReaction = {
+ /** The time the emoji was added */
+ createdAt: string;
+ /** All the users who have added this emoji */
+ users: Record;
+};
+
+type ReportActionReactions = Record;
+
+export default ReportActionReactions;
diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts
new file mode 100644
index 000000000000..e730dfd807fb
--- /dev/null
+++ b/src/types/onyx/Request.ts
@@ -0,0 +1,8 @@
+type Request = {
+ command?: string;
+ data?: Record;
+ type?: string;
+ shouldUseSecure?: boolean;
+};
+
+export default Request;
diff --git a/src/types/onyx/ScreenShareRequest.ts b/src/types/onyx/ScreenShareRequest.ts
new file mode 100644
index 000000000000..564ba99c22ac
--- /dev/null
+++ b/src/types/onyx/ScreenShareRequest.ts
@@ -0,0 +1,9 @@
+type ScreenShareRequest = {
+ /** Access token required to join a screen share room, generated by the backend */
+ accessToken: string;
+
+ /** Name of the screen share room to join */
+ roomName: string;
+};
+
+export default ScreenShareRequest;
diff --git a/src/types/onyx/SecurityGroup.ts b/src/types/onyx/SecurityGroup.ts
new file mode 100644
index 000000000000..b2362c1eb628
--- /dev/null
+++ b/src/types/onyx/SecurityGroup.ts
@@ -0,0 +1,5 @@
+type SecurityGroup = {
+ hasRestrictedPrimaryLogin: boolean;
+};
+
+export default SecurityGroup;
diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts
new file mode 100644
index 000000000000..75cb4f4818ad
--- /dev/null
+++ b/src/types/onyx/Session.ts
@@ -0,0 +1,17 @@
+type Session = {
+ /** The user's email for the current session */
+ email?: string;
+
+ /** Currently logged in user authToken */
+ authToken?: string;
+
+ /** Currently logged in user encrypted authToken */
+ encryptedAuthToken?: string;
+
+ /** Currently logged in user accountID */
+ accountID?: number;
+
+ autoAuthState?: string;
+};
+
+export default Session;
diff --git a/src/types/onyx/Task.ts b/src/types/onyx/Task.ts
new file mode 100644
index 000000000000..9d5c83ee4a40
--- /dev/null
+++ b/src/types/onyx/Task.ts
@@ -0,0 +1,27 @@
+import Report from './Report';
+
+type Task = {
+ /** Title of the Task */
+ title: string;
+
+ /** Description of the Task */
+ description?: string;
+
+ // TODO: Make sure this field exists in the API
+ /** Share destination of the Task */
+ shareDestination?: string;
+
+ /** The task report if it's currently being edited */
+ report?: Report;
+
+ /** Assignee of the task */
+ assignee?: string;
+
+ /** The account id of the assignee */
+ assigneeAccountID?: number;
+
+ /** Report id only when a task was created from a report */
+ parentReportID?: string;
+};
+
+export default Task;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
new file mode 100644
index 000000000000..0e0c7a423829
--- /dev/null
+++ b/src/types/onyx/Transaction.ts
@@ -0,0 +1,22 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type Comment = {
+ comment?: string;
+};
+
+type Transaction = {
+ transactionID: string;
+ amount: number;
+ currency: string;
+ reportID: string;
+ comment: Comment;
+ merchant: string;
+ created: string;
+ pendingAction: OnyxCommon.PendingAction;
+ errors: OnyxCommon.Errors;
+ modifiedAmount?: number;
+ modifiedCreated?: string;
+ modifiedCurrency?: string;
+};
+
+export default Transaction;
diff --git a/src/types/onyx/User.ts b/src/types/onyx/User.ts
new file mode 100644
index 000000000000..f770b22b2272
--- /dev/null
+++ b/src/types/onyx/User.ts
@@ -0,0 +1,30 @@
+type User = {
+ /** Whether or not the user is subscribed to news updates */
+ isSubscribedToNewsletter: boolean;
+
+ /** Whether we should use the staging version of the secure API server */
+ shouldUseStagingServer?: boolean;
+
+ /** Is the user account validated? */
+ validated: boolean;
+
+ /** Whether or not the user is on a public domain email account or not */
+ isFromPublicDomain: boolean;
+
+ /** Whether or not the user use expensify card */
+ isUsingExpensifyCard: boolean;
+
+ /** Whever Expensify Card approval flow is ongoing - checking loginList for private domains */
+ isCheckingDomain?: boolean;
+
+ /** Whether or not the user has lounge access */
+ hasLoungeAccess?: boolean;
+
+ /** error associated with adding a secondary login */
+ error?: string;
+
+ /** Whether the form is being submitted */
+ loading?: boolean;
+};
+
+export default User;
diff --git a/src/types/onyx/UserWallet.ts b/src/types/onyx/UserWallet.ts
new file mode 100644
index 000000000000..8624f16000c9
--- /dev/null
+++ b/src/types/onyx/UserWallet.ts
@@ -0,0 +1,53 @@
+import {ValueOf} from 'type-fest';
+import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
+
+type WalletLinkedAccountType = 'debitCard' | 'bankAccount';
+
+type ErrorCode = 'ssnError' | 'kbaNeeded' | 'kycFailed';
+
+type UserWallet = {
+ /** The user's available wallet balance */
+ availableBalance: number;
+
+ /** The user's current wallet balance */
+ currentBalance: number;
+
+ /** What step in the activation flow are we on? */
+ currentStep: ValueOf;
+
+ /** If we should show the FailedKYC view after the user submitted their info with a non fixable error */
+ shouldShowFailedKYC?: boolean;
+
+ /** Status of wallet - e.g. SILVER or GOLD */
+ tierName: ValueOf;
+
+ /** The user's wallet tier */
+ tier?: number;
+
+ /** Whether we should show the ActivateStep success view after the user finished the KYC flow */
+ shouldShowWalletActivationSuccess?: boolean;
+
+ /** The ID of the linked account */
+ walletLinkedAccountID: number;
+
+ /** The type of the linked account (debitCard or bankAccount) */
+ walletLinkedAccountType: WalletLinkedAccountType;
+
+ /** The user's bank account ID */
+ bankAccountID?: number;
+
+ /** The user's current wallet limit */
+ walletLimit?: number;
+
+ /** The user's current wallet limit enforcement period */
+ walletLimitEnforcementPeriod?: number;
+
+ /** Error code returned by the server */
+ errorCode?: ErrorCode;
+
+ /** An error message to display to the user */
+ errors?: OnyxCommon.Errors;
+};
+
+export default UserWallet;
diff --git a/src/types/onyx/WalletAdditionalDetails.ts b/src/types/onyx/WalletAdditionalDetails.ts
new file mode 100644
index 000000000000..e766ba4cfe50
--- /dev/null
+++ b/src/types/onyx/WalletAdditionalDetails.ts
@@ -0,0 +1,23 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type WalletAdditionalDetails = {
+ /** Questions returned by Idology */
+ questions?: {
+ prompt: string;
+ type: string;
+ answer: string[];
+ };
+
+ /** ExpectID ID number related to those questions */
+ idNumber?: string;
+
+ /** Error code to determine additional behavior */
+ errorCode?: string;
+
+ /** Which field needs attention? */
+ errorFields?: OnyxCommon.ErrorFields;
+ isLoading?: boolean;
+ errors?: OnyxCommon.Errors;
+};
+
+export default WalletAdditionalDetails;
diff --git a/src/types/onyx/WalletOnfido.ts b/src/types/onyx/WalletOnfido.ts
new file mode 100644
index 000000000000..7a65c0f710ef
--- /dev/null
+++ b/src/types/onyx/WalletOnfido.ts
@@ -0,0 +1,26 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type WalletOnfido = {
+ /** Unique identifier returned from openOnfidoFlow then re-sent to ActivateWallet with Onfido response data */
+ applicantID: string;
+
+ /** Token used to initialize the Onfido SDK token */
+ sdkToken: string;
+
+ /** Loading state to provide feedback when we are waiting for a request to finish */
+ isLoading?: boolean;
+
+ /** Error message to inform the user of any problem that might occur */
+ errors?: OnyxCommon.Errors;
+
+ /** A list of Onfido errors that the user can fix in order to attempt the Onfido flow again */
+ fixableErrors?: string[];
+
+ /** Whether the user has accepted the privacy policy of Onfido or not */
+ hasAcceptedPrivacyPolicy?: boolean;
+
+ /** If we should show the FailedKYC view after the user submitted their info with a non fixable error */
+ shouldShowFailedKYC?: boolean;
+};
+
+export default WalletOnfido;
diff --git a/src/types/onyx/WalletStatement.ts b/src/types/onyx/WalletStatement.ts
new file mode 100644
index 000000000000..d42aae32a823
--- /dev/null
+++ b/src/types/onyx/WalletStatement.ts
@@ -0,0 +1,6 @@
+type WalletStatement = {
+ /** Whether we are currently generating a PDF version of the statement */
+ isGenerating: boolean;
+};
+
+export default WalletStatement;
diff --git a/src/types/onyx/WalletTerms.ts b/src/types/onyx/WalletTerms.ts
new file mode 100644
index 000000000000..5394f126c33c
--- /dev/null
+++ b/src/types/onyx/WalletTerms.ts
@@ -0,0 +1,11 @@
+import * as OnyxCommon from './OnyxCommon';
+
+type WalletTerms = {
+ /** Any error message to show */
+ errors?: OnyxCommon.Errors;
+
+ /** 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?: string;
+};
+
+export default WalletTerms;
diff --git a/src/types/onyx/WalletTransfer.ts b/src/types/onyx/WalletTransfer.ts
new file mode 100644
index 000000000000..c5d92250f91d
--- /dev/null
+++ b/src/types/onyx/WalletTransfer.ts
@@ -0,0 +1,26 @@
+import CONST from '../../CONST';
+import * as OnyxCommon from './OnyxCommon';
+
+type WalletTransfer = {
+ /** Selected accountID for transfer */
+ selectedAccountID?: string | number;
+
+ /** Selected accountType for transfer */
+ selectedAccountType?: string;
+
+ /** Type to filter the payment Method list */
+ // TODO: Remove this after CONST.ts is migrated to TS
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents
+ filterPaymentMethodType?: typeof CONST.PAYMENT_METHODS.DEBIT_CARD | typeof CONST.PAYMENT_METHODS.BANK_ACCOUNT;
+
+ /** Whether the success screen is shown to user. */
+ shouldShowSuccess?: boolean;
+
+ /** An error message to display to the user */
+ errors?: OnyxCommon.Errors;
+
+ /** Whether or not data is loading */
+ loading?: boolean;
+};
+
+export default WalletTransfer;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
new file mode 100644
index 000000000000..e7ab360551c5
--- /dev/null
+++ b/src/types/onyx/index.ts
@@ -0,0 +1,92 @@
+import Account from './Account';
+import Request from './Request';
+import Credentials from './Credentials';
+import QueuedOnyxUpdates from './QueuedOnyxUpdates';
+import IOU from './IOU';
+import Modal from './Modal';
+import Network from './Network';
+import CustomStatusDraft from './CustomStatusDraft';
+import PersonalDetails from './PersonalDetails';
+import PrivatePersonalDetails from './PrivatePersonalDetails';
+import Task from './Task';
+import Currency from './Currency';
+import ScreenShareRequest from './ScreenShareRequest';
+import User from './User';
+import Login from './Login';
+import Session from './Session';
+import Beta from './Beta';
+import Paypal from './Paypal';
+import BlockedFromConcierge from './BlockedFromConcierge';
+import PlaidData from './PlaidData';
+import UserWallet from './UserWallet';
+import WalletOnfido from './WalletOnfido';
+import WalletAdditionalDetails from './WalletAdditionalDetails';
+import WalletTerms from './WalletTerms';
+import BankAccount from './BankAccount';
+import Card from './Card';
+import WalletStatement from './WalletStatement';
+import PersonalBankAccount from './PersonalBankAccount';
+import FrequentlyUsedEmoji from './FrequentlyUsedEmoji';
+import ReimbursementAccount from './ReimbursementAccount';
+import ReimbursementAccountDraft from './ReimbursementAccountDraft';
+import WalletTransfer from './WalletTransfer';
+import ReceiptModal from './ReceiptModal';
+import MapboxAccessToken from './MapboxAccessToken';
+
+import Download from './Download';
+import PolicyMember from './PolicyMember';
+import Policy from './Policy';
+import Report from './Report';
+import ReportAction from './ReportAction';
+import ReportActionReactions from './ReportActionReactions';
+import SecurityGroup from './SecurityGroup';
+import Transaction from './Transaction';
+
+import Form, {AddDebitCardForm} from './Form';
+
+export type {
+ Account,
+ Request,
+ Credentials,
+ QueuedOnyxUpdates,
+ IOU,
+ Modal,
+ Network,
+ CustomStatusDraft,
+ PersonalDetails,
+ PrivatePersonalDetails,
+ Task,
+ Currency,
+ ScreenShareRequest,
+ User,
+ Login,
+ Session,
+ Beta,
+ Paypal,
+ BlockedFromConcierge,
+ PlaidData,
+ UserWallet,
+ WalletOnfido,
+ WalletAdditionalDetails,
+ WalletTerms,
+ BankAccount,
+ Card,
+ WalletStatement,
+ PersonalBankAccount,
+ ReimbursementAccount,
+ ReimbursementAccountDraft,
+ FrequentlyUsedEmoji,
+ WalletTransfer,
+ ReceiptModal,
+ MapboxAccessToken,
+ Download,
+ PolicyMember,
+ Policy,
+ Report,
+ ReportAction,
+ ReportActionReactions,
+ SecurityGroup,
+ Transaction,
+ Form,
+ AddDebitCardForm,
+};
diff --git a/src/types/utils/DeepValueOf.ts b/src/types/utils/DeepValueOf.ts
new file mode 100644
index 000000000000..eb44eac7e751
--- /dev/null
+++ b/src/types/utils/DeepValueOf.ts
@@ -0,0 +1,4 @@
+// eslint-disable-next-line @typescript-eslint/ban-types
+type DeepValueOf = T extends object ? DeepValueOf : T;
+
+export default DeepValueOf;
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 93648aa8ea74..6fbbe19cec8e 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -35,12 +35,13 @@ describe('actions/IOU', () => {
it('creates new chat if needed', () => {
const amount = 10000;
const comment = 'Giv money plz';
+ const merchant = 'KFC';
let iouReportID;
let createdAction;
let iouAction;
let transactionID;
fetch.pause();
- IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', merchant, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve()
.then(
() =>
@@ -141,7 +142,7 @@ describe('actions/IOU', () => {
// The transactionID on the iou action should match the one from the transactions collection
expect(iouAction.originalMessage.IOUTransactionID).toBe(transactionID);
- expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(transaction.merchant).toBe(merchant);
resolve();
},
@@ -205,7 +206,7 @@ describe('actions/IOU', () => {
}),
)
.then(() => {
- IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve();
})
.then(
@@ -292,7 +293,7 @@ describe('actions/IOU', () => {
// The comment should be correct
expect(transaction.comment.comment).toBe(comment);
- expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(transaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
// It should be pending
expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
@@ -396,7 +397,7 @@ describe('actions/IOU', () => {
)
.then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction))
.then(() => {
- IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney(chatReport, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve();
})
.then(
@@ -476,7 +477,7 @@ describe('actions/IOU', () => {
expect(newTransaction.reportID).toBe(iouReportID);
expect(newTransaction.amount).toBe(amount);
expect(newTransaction.comment.comment).toBe(comment);
- expect(newTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(newTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
expect(newTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
// The transactionID on the iou action should match the one from the transactions collection
@@ -528,7 +529,7 @@ describe('actions/IOU', () => {
let iouAction;
let transactionID;
fetch.pause();
- IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return (
waitForPromisesToResolve()
.then(
@@ -619,7 +620,7 @@ describe('actions/IOU', () => {
expect(transaction.reportID).toBe(iouReportID);
expect(transaction.amount).toBe(amount);
expect(transaction.comment.comment).toBe(comment);
- expect(transaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(transaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
// The transactionID on the iou action should match the one from the transactions collection
@@ -1078,9 +1079,9 @@ describe('actions/IOU', () => {
expect(vitTransaction.comment.comment).toBe(comment);
expect(groupTransaction.comment.comment).toBe(comment);
- expect(carlosTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
- expect(julesTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
- expect(vitTransaction.merchant).toBe(CONST.REPORT.TYPE.IOU);
+ expect(carlosTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(julesTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(vitTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
expect(groupTransaction.merchant).toBe(
`Split bill with ${RORY_EMAIL}, ${CARLOS_EMAIL}, ${JULES_EMAIL}, and ${VIT_EMAIL} [${DateUtils.getDBTime().slice(0, 10)}]`,
);
@@ -1183,7 +1184,7 @@ describe('actions/IOU', () => {
let createIOUAction;
let payIOUAction;
let transaction;
- IOU.requestMoney({}, amount, CONST.CURRENCY.USD, RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
+ IOU.requestMoney({}, amount, CONST.CURRENCY.USD, '', '', RORY_EMAIL, RORY_ACCOUNT_ID, {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, comment);
return waitForPromisesToResolve()
.then(
() =>
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js
index 9f6ac56e052b..c06d3bc83766 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.js
@@ -252,6 +252,7 @@ describe('actions/Report', () => {
jest.advanceTimersByTime(10);
currentTime = DateUtils.getDBTime();
Report.openReport(REPORT_ID);
+ Report.readNewestAction(REPORT_ID);
return waitForPromisesToResolve();
})
.then(() => {
diff --git a/tests/actions/SessionTest.js b/tests/actions/SessionTest.js
index 506279421e7e..d8bfa144e358 100644
--- a/tests/actions/SessionTest.js
+++ b/tests/actions/SessionTest.js
@@ -8,6 +8,10 @@ import CONST from '../../src/CONST';
import PushNotification from '../../src/libs/Notification/PushNotification';
import * as App from '../../src/libs/actions/App';
+// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
+// eslint-disable-next-line no-unused-vars
+import subscribePushNotification from '../../src/libs/Notification/PushNotification/subscribePushNotification';
+
// We are mocking this method so that we can later test to see if it was called and what arguments it was called with.
// We test HttpUtils.xhr() since this means that our API command turned into a network request and isn't only queued.
HttpUtils.xhr = jest.fn();
diff --git a/tests/perf-test/SelectionList.perf-test.js b/tests/perf-test/SelectionList.perf-test.js
new file mode 100644
index 000000000000..82cec956713f
--- /dev/null
+++ b/tests/perf-test/SelectionList.perf-test.js
@@ -0,0 +1,120 @@
+import React, {useState} from 'react';
+import {measurePerformance} from 'reassure';
+import {fireEvent} from '@testing-library/react-native';
+import _ from 'underscore';
+import SelectionList from '../../src/components/SelectionList';
+import variables from '../../src/styles/variables';
+
+jest.mock('../../src/components/Icon/Expensicons');
+
+jest.mock('../../src/hooks/useLocalize', () =>
+ jest.fn(() => ({
+ translate: jest.fn(),
+ })),
+);
+
+jest.mock('../../src/components/withLocalize', () => (Component) => (props) => (
+ ''}
+ />
+));
+
+jest.mock('../../src/components/withKeyboardState', () => (Component) => (props) => (
+
+));
+
+function SelectionListWrapper(args) {
+ const [selectedIds, setSelectedIds] = useState([]);
+
+ const sections = [
+ {
+ data: Array.from({length: 1000}, (__, i) => ({
+ text: `Item ${i}`,
+ keyForList: `item-${i}`,
+ isSelected: _.contains(selectedIds, `item-${i}`),
+ })),
+ indexOffset: 0,
+ isDisabled: false,
+ },
+ ];
+
+ const onSelectRow = (item) => {
+ if (args.canSelectMultiple) {
+ if (_.contains(selectedIds, item.keyForList)) {
+ setSelectedIds(_.without(selectedIds, item.keyForList));
+ } else {
+ setSelectedIds([...selectedIds, item.keyForList]);
+ }
+ } else {
+ setSelectedIds([item.keyForList]);
+ }
+ };
+
+ return (
+
+ );
+}
+
+test('should render 1 section and a thousand items', () => {
+ measurePerformance( );
+});
+
+test('should press a list item', () => {
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 5'));
+ };
+
+ measurePerformance( , {scenario});
+});
+
+test('should render multiple selection and select 3 items', () => {
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.press(screen.getByText('Item 2'));
+ fireEvent.press(screen.getByText('Item 3'));
+ };
+
+ measurePerformance( , {scenario});
+});
+
+test('should scroll and select a few items', () => {
+ const eventData = {
+ nativeEvent: {
+ contentOffset: {
+ y: variables.optionRowHeight * 5,
+ },
+ contentSize: {
+ // Dimensions of the scrollable content
+ height: variables.optionRowHeight * 10,
+ width: 100,
+ },
+ layoutMeasurement: {
+ // Dimensions of the device
+ height: variables.optionRowHeight * 5,
+ width: 100,
+ },
+ },
+ };
+
+ const scenario = (screen) => {
+ fireEvent.press(screen.getByText('Item 1'));
+ fireEvent.scroll(screen.getByTestId('selection-list'), eventData);
+ fireEvent.press(screen.getByText('Item 7'));
+ fireEvent.press(screen.getByText('Item 15'));
+ };
+
+ measurePerformance( , {scenario});
+});
diff --git a/tests/perf-test/SelectionListRadio.perf-test.js b/tests/perf-test/SelectionListRadio.perf-test.js
deleted file mode 100644
index b0f6d7aa1d4a..000000000000
--- a/tests/perf-test/SelectionListRadio.perf-test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React, {useState} from 'react';
-import {measurePerformance} from 'reassure';
-import {fireEvent} from '@testing-library/react-native';
-import SelectionListRadio from '../../src/components/SelectionListRadio';
-
-jest.mock('../../src/components/Icon/Expensicons');
-
-function SelectionListRadioWrapper() {
- const [selectedIndex, setSelectedIndex] = useState(0);
-
- const sections = [
- {
- data: Array.from({length: 1000}, (__, i) => ({
- text: `Item ${i}`,
- keyForList: `item-${i}`,
- isSelected: selectedIndex === i,
- })),
- indexOffset: 0,
- isDisabled: false,
- },
- ];
-
- const onSelectRow = (item) => {
- const index = Number(item.keyForList.split('-')[1]);
- setSelectedIndex(index);
- };
-
- return (
-
- );
-}
-
-test('should render SelectionListRadio with 1 section and a thousand items', () => {
- measurePerformance( );
-});
-
-test('should press a list item', () => {
- const scenario = (screen) => {
- fireEvent.press(screen.getByText('Item 5'));
- };
-
- measurePerformance( , {scenario});
-});
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 938e26a1aeb9..1666ffb87400 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -28,6 +28,7 @@ import * as Localize from '../../src/libs/Localize';
jest.setTimeout(30000);
jest.mock('../../src/libs/Notification/LocalNotification');
+jest.mock('../../src/components/Icon/Expensicons');
beforeAll(() => {
// In this test, we are generically mocking the responses of all API requests by mocking fetch() and having it
@@ -437,7 +438,7 @@ describe('Unread Indicators', () => {
return waitFor(() => expect(isNewMessagesBadgeVisible()).toBe(false));
}));
- it('Removes the new line indicator when a new message is created by the current user', () =>
+ it('Keep showing the new line indicator when a new message is created by the current user', () =>
signInAndGetAppWithUnreadChat()
.then(() => {
// Verify we are on the LHN and that the chat shows as unread in the LHN
@@ -458,7 +459,7 @@ describe('Unread Indicators', () => {
.then(() => {
const newMessageLineIndicatorHintText = Localize.translateLocal('accessibilityHints.newMessageLineIndicator');
const unreadIndicator = screen.queryAllByLabelText(newMessageLineIndicatorHintText);
- expect(unreadIndicator).toHaveLength(0);
+ expect(unreadIndicator).toHaveLength(1);
}));
xit('Keeps the new line indicator when the user moves the App to the background', () =>
diff --git a/tests/unit/CurrencyUtilsTest.js b/tests/unit/CurrencyUtilsTest.js
index 937bf95044cf..5058c56bfa00 100644
--- a/tests/unit/CurrencyUtilsTest.js
+++ b/tests/unit/CurrencyUtilsTest.js
@@ -63,6 +63,7 @@ describe('CurrencyUtils', () => {
test('Currency decimals smaller than or equal 2', () => {
expect(CurrencyUtils.getCurrencyDecimals('JPY')).toBe(0);
expect(CurrencyUtils.getCurrencyDecimals('USD')).toBe(2);
+ expect(CurrencyUtils.getCurrencyDecimals('RSD')).toBe(2);
});
test('Currency decimals larger than 2 should return 2', () => {
@@ -85,36 +86,36 @@ describe('CurrencyUtils', () => {
});
});
- describe('convertToSmallestUnit', () => {
+ describe('convertToBackendAmount', () => {
test.each([
- [CONST.CURRENCY.USD, 25, 2500],
- [CONST.CURRENCY.USD, 25.25, 2525],
- [CONST.CURRENCY.USD, 25.5, 2550],
- [CONST.CURRENCY.USD, 2500, 250000],
- [CONST.CURRENCY.USD, 80.6, 8060],
- [CONST.CURRENCY.USD, 80.9, 8090],
- [CONST.CURRENCY.USD, 80.99, 8099],
- ['JPY', 25, 25],
- ['JPY', 25.25, 25],
- ['JPY', 25.5, 26],
- ['JPY', 2500, 2500],
- ['JPY', 80.6, 81],
- ['JPY', 80.9, 81],
- ['JPY', 80.99, 81],
- ])('Correctly converts %s to amount in smallest units', (currency, amount, expectedResult) => {
- expect(CurrencyUtils.convertToSmallestUnit(currency, amount)).toBe(expectedResult);
+ [25, 2500],
+ [25.25, 2525],
+ [25.5, 2550],
+ [2500, 250000],
+ [80.6, 8060],
+ [80.9, 8090],
+ [80.99, 8099],
+ [25, 2500],
+ [25.25, 2525],
+ [25.5, 2550],
+ [2500, 250000],
+ [80.6, 8060],
+ [80.9, 8090],
+ [80.99, 8099],
+ ])('Correctly converts %s to amount in cents (subunit) handled in backend', (amount, expectedResult) => {
+ expect(CurrencyUtils.convertToBackendAmount(amount)).toBe(expectedResult);
});
});
- describe('convertToWholeUnit', () => {
+ describe('convertToFrontendAmount', () => {
test.each([
- [CONST.CURRENCY.USD, 2500, 25],
- [CONST.CURRENCY.USD, 2550, 25.5],
- ['JPY', 25, 25],
- ['JPY', 2500, 2500],
- ['JPY', 25.5, 25],
- ])('Correctly converts %s to amount in whole units', (currency, amount, expectedResult) => {
- expect(CurrencyUtils.convertToWholeUnit(currency, amount)).toBe(expectedResult);
+ [2500, 25],
+ [2550, 25.5],
+ [25, 0.25],
+ [2500, 25],
+ [2500.5, 25], // The backend should never send a decimal .5 value
+ ])('Correctly converts %s to amount in units handled in frontend', (amount, expectedResult) => {
+ expect(CurrencyUtils.convertToFrontendAmount(amount)).toBe(expectedResult);
});
});
@@ -124,9 +125,13 @@ describe('CurrencyUtils', () => {
[CONST.CURRENCY.USD, 2500, '$25.00'],
[CONST.CURRENCY.USD, 150, '$1.50'],
[CONST.CURRENCY.USD, 250000, '$2,500.00'],
- ['JPY', 25, '¥25'],
- ['JPY', 2500, '¥2,500'],
- ['JPY', 25.5, '¥25'],
+ ['JPY', 2500, '¥25'],
+ ['JPY', 250000, '¥2,500'],
+ ['JPY', 2500.5, '¥25'],
+ ['RSD', 100, 'RSD\xa01.00'],
+ ['RSD', 145, 'RSD\xa01.45'],
+ ['BHD', 12345, 'BHD\xa0123.450'],
+ ['BHD', 1, 'BHD\xa00.010'],
])('Correctly displays %s', (currency, amount, expectedResult) => {
expect(CurrencyUtils.convertToDisplayString(amount, currency)).toBe(expectedResult);
});
diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js
index fe193ef150d5..d17c1c052929 100644
--- a/tests/unit/DateUtilsTest.js
+++ b/tests/unit/DateUtilsTest.js
@@ -1,5 +1,6 @@
-import moment from 'moment';
import Onyx from 'react-native-onyx';
+import {format as tzFormat} from 'date-fns-tz';
+import {addMinutes, subHours, subMinutes, subSeconds, format, setMinutes, setHours, subDays, addDays} from 'date-fns';
import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
@@ -8,50 +9,63 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
const LOCALE = CONST.LOCALES.EN;
describe('DateUtils', () => {
- let originalNow;
beforeAll(() => {
Onyx.init({
keys: ONYXKEYS,
initialKeyStates: {
[ONYXKEYS.SESSION]: {accountID: 999},
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: 'Etc/UTC'}}},
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: {999: {timezone: {selected: 'UTC'}}},
},
});
return waitForPromisesToResolve();
});
- beforeEach(() => {
- originalNow = moment.now;
- });
-
afterEach(() => {
+ jest.useRealTimers();
Onyx.clear();
- moment.now = originalNow;
});
const datetime = '2022-11-07 00:00:00';
- it('should return a moment object with the formatted datetime when calling getLocalMomentFromDatetime', () => {
- const localMoment = DateUtils.getLocalMomentFromDatetime(LOCALE, datetime, 'America/Los_Angeles');
- expect(moment.isMoment(localMoment)).toBe(true);
- expect(moment(localMoment).format()).toEqual('2022-11-06T16:00:00-08:00');
+ const timezone = 'America/Los_Angeles';
+
+ it('getZoneAbbreviation should show zone abbreviation from the datetime', () => {
+ const zoneAbbreviation = DateUtils.getZoneAbbreviation(datetime, timezone);
+ expect(zoneAbbreviation).toBe('PST');
});
- it('should return a moment object when calling getLocalMomentFromDatetime with null instead of a datetime', () => {
- const localMoment = DateUtils.getLocalMomentFromDatetime(LOCALE, null, 'America/Los_Angeles');
- expect(moment.isMoment(localMoment)).toBe(true);
+ it('formatToLongDateWithWeekday should return a long date with a weekday', () => {
+ const formattedDate = DateUtils.formatToLongDateWithWeekday(datetime);
+ expect(formattedDate).toBe('Monday, November 7, 2022');
+ });
+
+ it('formatToDayOfWeek should return a weekday', () => {
+ const weekDay = DateUtils.formatToDayOfWeek(datetime);
+ expect(weekDay).toBe('Monday');
+ });
+ it('formatToLocalTime should return a date in a local format', () => {
+ const localTime = DateUtils.formatToLocalTime(datetime);
+ expect(localTime).toBe('12:00 AM');
+ });
+
+ it('should return a date object with the formatted datetime when calling getLocalDateFromDatetime', () => {
+ const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, datetime, timezone);
+ expect(tzFormat(localDate, CONST.DATE.FNS_TIMEZONE_FORMAT_STRING, {timeZone: timezone})).toEqual('2022-11-06T16:00:00-08:00');
});
it('should return the date in calendar time when calling datetimeToCalendarTime', () => {
- const today = moment.utc().set({hour: 14, minute: 32});
+ const today = setMinutes(setHours(new Date(), 14), 32);
expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 2:32 PM');
- const yesterday = moment.utc().subtract(1, 'days').set({hour: 7, minute: 43});
+ const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1);
+ expect(DateUtils.datetimeToCalendarTime(LOCALE, tomorrow)).toBe('Tomorrow at 2:32 PM');
+
+ const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43);
expect(DateUtils.datetimeToCalendarTime(LOCALE, yesterday)).toBe('Yesterday at 7:43 AM');
- const date = moment.utc('2022-11-05').set({hour: 10, minute: 17});
+ const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17);
expect(DateUtils.datetimeToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022 at 10:17 AM');
- const todayLowercaseDate = moment.utc().set({hour: 14, minute: 32});
+ const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32);
expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 2:32 PM');
});
@@ -59,7 +73,7 @@ describe('DateUtils', () => {
Intl.DateTimeFormat = jest.fn(() => ({
resolvedOptions: () => ({timeZone: 'America/Chicago'}),
}));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'Etc/UTC', automatic: true}}}).then(() => {
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'UTC', automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
selected: 'America/Chicago',
@@ -70,40 +84,42 @@ describe('DateUtils', () => {
it('should not update timezone if automatic and selected timezone match', () => {
Intl.DateTimeFormat = jest.fn(() => ({
- resolvedOptions: () => ({timeZone: 'Etc/UTC'}),
+ resolvedOptions: () => ({timeZone: 'UTC'}),
}));
- Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'Etc/UTC', automatic: true}}}).then(() => {
+ Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {999: {timezone: {selected: 'UTC', automatic: true}}}).then(() => {
const result = DateUtils.getCurrentTimezone();
expect(result).toEqual({
- selected: 'Etc/UTC',
+ selected: 'UTC',
automatic: true,
});
});
});
it('canUpdateTimezone should return true when lastUpdatedTimezoneTime is more than 5 minutes ago', () => {
- const currentTime = moment().add(6, 'minutes');
- moment.now = jest.fn(() => currentTime);
+ // Use fake timers to control the current time
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(addMinutes(new Date(), 6));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(true);
});
it('canUpdateTimezone should return false when lastUpdatedTimezoneTime is less than 5 minutes ago', () => {
- const currentTime = moment().add(4, 'minutes');
- moment.now = jest.fn(() => currentTime);
+ // Use fake timers to control the current time
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(addMinutes(new Date(), 4));
const isUpdateTimezoneAllowed = DateUtils.canUpdateTimezone();
expect(isUpdateTimezoneAllowed).toBe(false);
});
it('should return the date in calendar time when calling datetimeToRelative', () => {
- const aFewSecondsAgo = moment().subtract(10, 'seconds');
- expect(DateUtils.datetimeToRelative(LOCALE, aFewSecondsAgo)).toBe('a few seconds ago');
+ const aFewSecondsAgo = subSeconds(new Date(), 10);
+ expect(DateUtils.datetimeToRelative(LOCALE, aFewSecondsAgo)).toBe('less than a minute ago');
- const aMinuteAgo = moment().subtract(1, 'minute');
- expect(DateUtils.datetimeToRelative(LOCALE, aMinuteAgo)).toBe('a minute ago');
+ const aMinuteAgo = subMinutes(new Date(), 1);
+ expect(DateUtils.datetimeToRelative(LOCALE, aMinuteAgo)).toBe('1 minute ago');
- const anHourAgo = moment().subtract(1, 'hour');
- expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('an hour ago');
+ const anHourAgo = subHours(new Date(), 1);
+ expect(DateUtils.datetimeToRelative(LOCALE, anHourAgo)).toBe('about 1 hour ago');
});
it('subtractMillisecondsFromDateTime should subtract milliseconds from a given date and time', () => {
@@ -111,28 +127,28 @@ describe('DateUtils', () => {
const millisecondsToSubtract = 5000; // 5 seconds
const expectedDateTime = '2023-07-18 10:29:55.000';
const result = DateUtils.subtractMillisecondsFromDateTime(initialDateTime, millisecondsToSubtract);
- expect(result.valueOf()).toBe(expectedDateTime);
+ expect(result).toBe(expectedDateTime);
});
describe('getDBTime', () => {
it('should return the date in the format expected by the database', () => {
const getDBTime = DateUtils.getDBTime();
- expect(getDBTime).toBe(moment(getDBTime).format('YYYY-MM-DD HH:mm:ss.SSS'));
+ expect(getDBTime).toBe(format(new Date(getDBTime), CONST.DATE.FNS_DB_FORMAT_STRING));
});
- it('should represent the correct moment in utc when used with a standard datetime string', () => {
+ it('should represent the correct date in utc when used with a standard datetime string', () => {
const timestamp = 'Mon Nov 21 2022 19:04:14 GMT-0800 (Pacific Standard Time)';
const getDBTime = DateUtils.getDBTime(timestamp);
expect(getDBTime).toBe('2022-11-22 03:04:14.000');
});
- it('should represent the correct moment in time when used with an ISO string', () => {
+ it('should represent the correct date in time when used with an ISO string', () => {
const timestamp = '2022-11-22T03:08:04.326Z';
const getDBTime = DateUtils.getDBTime(timestamp);
expect(getDBTime).toBe('2022-11-22 03:08:04.326');
});
- it('should represent the correct moment in time when used with a unix timestamp', () => {
+ it('should represent the correct date in time when used with a unix timestamp', () => {
const timestamp = 1669086850792;
const getDBTime = DateUtils.getDBTime(timestamp);
expect(getDBTime).toBe('2022-11-22 03:14:10.792');
diff --git a/tests/unit/IOUUtilsTest.js b/tests/unit/IOUUtilsTest.js
index 1fa7974e762d..22790ebe721f 100644
--- a/tests/unit/IOUUtilsTest.js
+++ b/tests/unit/IOUUtilsTest.js
@@ -1,39 +1,10 @@
import Onyx from 'react-native-onyx';
import * as IOUUtils from '../../src/libs/IOUUtils';
import * as ReportUtils from '../../src/libs/ReportUtils';
-import * as NumberUtils from '../../src/libs/NumberUtils';
-import CONST from '../../src/CONST';
import ONYXKEYS from '../../src/ONYXKEYS';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import currencyList from './currencyList.json';
-
-let iouReport;
-let reportActions;
-const ownerAccountID = 5;
-const managerEmail = 'manager@iou.com';
-const managerID = 10;
-
-function createIOUReportAction(type, amount, currency, isOffline = false, IOUTransactionID = NumberUtils.rand64()) {
- const moneyRequestAction = ReportUtils.buildOptimisticIOUReportAction(type, amount, currency, 'Test comment', [managerEmail], IOUTransactionID, '', iouReport.reportID);
-
- // Default is to create requests online, if `isOffline` is not specified then we need to remove the pendingAction
- if (!isOffline) {
- moneyRequestAction.pendingAction = null;
- }
-
- reportActions.push(moneyRequestAction);
- return moneyRequestAction;
-}
-
-function deleteMoneyRequest(moneyRequestAction, isOffline = false) {
- createIOUReportAction(
- CONST.IOU.REPORT_ACTION_TYPE.DELETE,
- moneyRequestAction.originalMessage.amount,
- moneyRequestAction.originalMessage.currency,
- isOffline,
- moneyRequestAction.originalMessage.IOUTransactionID,
- );
-}
+import * as TransactionUtils from '../../src/libs/TransactionUtils';
function initCurrencyList() {
Onyx.init({
@@ -47,111 +18,98 @@ function initCurrencyList() {
describe('IOUUtils', () => {
describe('isIOUReportPendingCurrencyConversion', () => {
- beforeEach(() => {
- reportActions = [];
- const chatReportID = ReportUtils.generateReportID();
- const amount = 1000;
- const currency = 'USD';
-
- iouReport = ReportUtils.buildOptimisticIOUReport(ownerAccountID, managerID, amount, chatReportID, currency);
-
- // The starting point of all tests is the IOUReport containing a single non-pending transaction in USD
- // All requests in the tests are assumed to be online, unless isOffline is specified
- createIOUReportAction('create', amount, currency);
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
});
test('Requesting money offline in a different currency will show the pending conversion message', () => {
- // Request money offline in AED
- createIOUReportAction('create', 100, 'AED', true);
-
- // We requested money offline in a different currency, we don't know the total of the iouReport until we're back online
- expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true);
+ const iouReport = ReportUtils.buildOptimisticIOUReport(1, 2, 100, 1, 'USD');
+ const usdPendingTransaction = TransactionUtils.buildOptimisticTransaction(100, 'USD', iouReport.reportID);
+ const aedPendingTransaction = TransactionUtils.buildOptimisticTransaction(100, 'AED', iouReport.reportID);
+
+ return Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, {
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${usdPendingTransaction.transactionID}`]: usdPendingTransaction,
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${aedPendingTransaction.transactionID}`]: aedPendingTransaction,
+ }).then(() => {
+ // We requested money offline in a different currency, we don't know the total of the iouReport until we're back online
+ expect(IOUUtils.isIOUReportPendingCurrencyConversion(iouReport)).toBe(true);
+ });
});
- test('IOUReport is not pending conversion when all requests made offline have been deleted', () => {
- // Create two requests offline
- const moneyRequestA = createIOUReportAction('create', 1000, 'AED', true);
- const moneyRequestB = createIOUReportAction('create', 1000, 'AED', true);
-
- // Delete both requests
- deleteMoneyRequest(moneyRequestA, true);
- deleteMoneyRequest(moneyRequestB, true);
-
- // Both requests made offline have been deleted, total won't update so no need to show a pending conversion message
- expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false);
+ test('Requesting money online in a different currency will not show the pending conversion message', () => {
+ const iouReport = ReportUtils.buildOptimisticIOUReport(2, 3, 100, 1, 'USD');
+ const usdPendingTransaction = TransactionUtils.buildOptimisticTransaction(100, 'USD', iouReport.reportID);
+ const aedPendingTransaction = TransactionUtils.buildOptimisticTransaction(100, 'AED', iouReport.reportID);
+
+ return Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, {
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${usdPendingTransaction.transactionID}`]: {
+ ...usdPendingTransaction,
+ pendingAction: null,
+ },
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${aedPendingTransaction.transactionID}`]: {
+ ...aedPendingTransaction,
+ pendingAction: null,
+ },
+ }).then(() => {
+ // We requested money online in a different currency, we know the iouReport total and there's no need to show the pending conversion message
+ expect(IOUUtils.isIOUReportPendingCurrencyConversion(iouReport)).toBe(false);
+ });
});
+ });
- test('Deleting a request made online shows the preview', () => {
- // Request money online in AED
- const moneyRequest = createIOUReportAction('create', 1000, 'AED');
-
- // Delete it offline
- deleteMoneyRequest(moneyRequest, true);
+ describe('calculateAmount', () => {
+ beforeAll(() => initCurrencyList());
- // We don't know what the total is because we need to subtract the converted amount of the offline request from the total
- expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true);
+ test('103 JPY split among 3 participants including the default user should be [35, 34, 34]', () => {
+ const participantsAccountIDs = [100, 101];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 10300, 'JPY', true)).toBe(3500);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 10300, 'JPY')).toBe(3400);
});
- test("Deleting a request made offline while there's a previous one made online will not show the pending conversion message", () => {
- // Request money online in AED
- createIOUReportAction('create', 1000, 'AED');
-
- // Another request offline
- const moneyRequestOffline = createIOUReportAction('create', 1000, 'AED', true);
-
- // Delete the request made offline
- deleteMoneyRequest(moneyRequestOffline, true);
-
- expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false);
+ test('103 USD split among 3 participants including the default user should be [34.34, 34.33, 34.33]', () => {
+ const participantsAccountIDs = [100, 101];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 10300, 'USD', true)).toBe(3434);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 10300, 'USD')).toBe(3433);
});
- test('Deleting a request made online while we have one made offline will show the pending conversion message', () => {
- // Request money online in AED
- const moneyRequestOnline = createIOUReportAction('create', 1000, 'AED');
-
- // Request money again but offline
- createIOUReportAction('create', 1000, 'AED', true);
-
- // Delete the request made online
- deleteMoneyRequest(moneyRequestOnline, true);
-
- // We don't know what the total is because we need to subtract the converted amount of the offline request from the total
- expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true);
+ test('10 AFN split among 4 participants including the default user should be [1, 3, 3, 3]', () => {
+ const participantsAccountIDs = [100, 101, 102];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 1000, 'AFN', true)).toBe(100);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 1000, 'AFN')).toBe(300);
});
- test("Deleting a request offline in the report's currency when we have requests in a different currency does not show the pending conversion message", () => {
- // Request money in the report's currency (USD)
- const onlineMoneyRequestInUSD = createIOUReportAction('create', 1000, 'USD');
-
- // Request money online in a different currency
- createIOUReportAction('create', 2000, 'AED');
-
- // Delete the USD request offline
- deleteMoneyRequest(onlineMoneyRequestInUSD, true);
-
- expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false);
+ test('10.12 USD split among 4 participants including the default user should be [2.53, 2.53, 2.53, 2.53]', () => {
+ const participantsAccountIDs = [100, 101, 102];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 1012, 'USD', true)).toBe(253);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 1012, 'USD')).toBe(253);
});
- });
- describe('calculateAmount', () => {
- beforeAll(() => initCurrencyList());
-
- test('103 JPY split among 3 participants including the default user should be [35, 34, 34]', () => {
- const participantsAccountIDs = [100, 101];
- expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 103, true)).toBe(35);
- expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 103)).toBe(34);
+ test('10.12 USD split among 3 participants including the default user should be [3.38, 3.37, 3.37]', () => {
+ const participantsAccountIDs = [100, 102];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 1012, 'USD', true)).toBe(338);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 1012, 'USD')).toBe(337);
});
- test('10 AFN split among 4 participants including the default user should be [1, 3, 3, 3]', () => {
+ test('0.02 USD split among 4 participants including the default user should be [-0.01, 0.01, 0.01, 0.01]', () => {
const participantsAccountIDs = [100, 101, 102];
- expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 10, true)).toBe(1);
- expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 10)).toBe(3);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 2, 'USD', true)).toBe(-1);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 2, 'USD')).toBe(1);
});
- test('0.02 USD split among 4 participants including the default user should be [-1, 1, 1, 1]', () => {
- const participantsAccountIDs = [100, 101, 102];
- expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 2, true)).toBe(-1);
- expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 2)).toBe(1);
+ test('1 RSD split among 3 participants including the default user should be [0.34, 0.33, 0.33]', () => {
+ // RSD is a special case that we forced to have 2 decimals
+ const participantsAccountIDs = [100, 101];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'RSD', true)).toBe(34);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'RSD')).toBe(33);
+ });
+
+ test('1 BHD split among 3 participants including the default user should be [0.34, 0.33, 0.33]', () => {
+ // BHD has 3 decimal places, but it still produces parts with only 2 decimal places because of a backend limitation
+ const participantsAccountIDs = [100, 101];
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'BHD', true)).toBe(34);
+ expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'BHD')).toBe(33);
});
});
});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 1c176bdb1ce4..663e76a9c1f1 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
import Onyx from 'react-native-onyx';
import * as OptionsListUtils from '../../src/libs/OptionsListUtils';
+import * as ReportUtils from '../../src/libs/ReportUtils';
import ONYXKEYS from '../../src/ONYXKEYS';
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve';
import CONST from '../../src/CONST';
@@ -103,6 +104,10 @@ describe('OptionsListUtils', () => {
oldPolicyName: "SHIELD's workspace",
chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
isOwnPolicyExpenseChat: true,
+
+ // This indicates that the report is archived
+ stateNum: 2,
+ statusNum: 2,
},
};
@@ -567,39 +572,57 @@ describe('OptionsListUtils', () => {
});
it('getShareDestinationsOptions()', () => {
+ // Filter current REPORTS as we do in the component, before getting share destination options
+ const filteredReports = {};
+ _.keys(REPORTS).forEach((reportKey) => {
+ if (ReportUtils.shouldDisableWriteActions(REPORTS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS[reportKey])) {
+ return;
+ }
+ filteredReports[reportKey] = REPORTS[reportKey];
+ });
+
// When we pass an empty search value
- let results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], '');
+ let results = OptionsListUtils.getShareDestinationOptions(filteredReports, PERSONAL_DETAILS, [], '');
// Then we should expect all the recent reports to show but exclude the archived rooms
expect(results.recentReports.length).toBe(_.size(REPORTS) - 1);
// When we pass a search value that doesn't match the group chat name
- results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], 'mutants');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReports, PERSONAL_DETAILS, [], 'mutants');
// Then we should expect no recent reports to show
expect(results.recentReports.length).toBe(0);
// When we pass a search value that matches the group chat name
- results = OptionsListUtils.getShareDestinationOptions(REPORTS, PERSONAL_DETAILS, [], 'Iron Man, Fantastic');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReports, PERSONAL_DETAILS, [], 'Iron Man, Fantastic');
// Then we should expect the group chat to show along with the contacts matching the search
expect(results.recentReports.length).toBe(1);
+ // Filter current REPORTS_WITH_WORKSPACE_ROOMS as we do in the component, before getting share destination options
+ const filteredReportsWithWorkspaceRooms = {};
+ _.keys(REPORTS_WITH_WORKSPACE_ROOMS).forEach((reportKey) => {
+ if (ReportUtils.shouldDisableWriteActions(REPORTS_WITH_WORKSPACE_ROOMS[reportKey]) || ReportUtils.isExpensifyOnlyParticipantInReport(REPORTS_WITH_WORKSPACE_ROOMS[reportKey])) {
+ return;
+ }
+ filteredReportsWithWorkspaceRooms[reportKey] = REPORTS_WITH_WORKSPACE_ROOMS[reportKey];
+ });
+
// When we also have a policy to return rooms in the results
- results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], '');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, PERSONAL_DETAILS, [], '');
// Then we should expect the DMS, the group chats and the workspace room to show
// We should expect all the recent reports to show, excluding the archived rooms
expect(results.recentReports.length).toBe(_.size(REPORTS_WITH_WORKSPACE_ROOMS) - 1);
// When we search for a workspace room
- results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], 'Avengers Room');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, PERSONAL_DETAILS, [], 'Avengers Room');
// Then we should expect only the workspace room to show
expect(results.recentReports.length).toBe(1);
// When we search for a workspace room that doesn't exist
- results = OptionsListUtils.getShareDestinationOptions(REPORTS_WITH_WORKSPACE_ROOMS, PERSONAL_DETAILS, [], 'Mutants Lair');
+ results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, PERSONAL_DETAILS, [], 'Mutants Lair');
// Then we should expect no results to show
expect(results.recentReports.length).toBe(0);
@@ -628,4 +651,28 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(1);
expect(results.personalDetails[0].text).toBe('Spider-Man');
});
+
+ it('formatMemberForList()', () => {
+ const formattedMembers = _.map(PERSONAL_DETAILS, (personalDetail, key) => OptionsListUtils.formatMemberForList(personalDetail, key === '1'));
+
+ // We're only formatting items inside the array, so the order should be the same as the original PERSONAL_DETAILS array
+ expect(formattedMembers[0].text).toBe('Mister Fantastic');
+ expect(formattedMembers[1].text).toBe('Iron Man');
+ expect(formattedMembers[2].text).toBe('Spider-Man');
+
+ // We should expect only the first item to be selected
+ expect(formattedMembers[0].isSelected).toBe(true);
+
+ // And all the others to be unselected
+ expect(_.every(formattedMembers.slice(1), (personalDetail) => !personalDetail.isSelected)).toBe(true);
+
+ // `isDisabled` is always false
+ expect(_.every(formattedMembers, (personalDetail) => !personalDetail.isDisabled)).toBe(true);
+
+ // `rightElement` is always null
+ expect(_.every(formattedMembers, (personalDetail) => personalDetail.rightElement === null)).toBe(true);
+
+ // The PERSONAL_DETAILS list doesn't specify `participantsList[n].avatar`, so the default one should be used
+ expect(_.every(formattedMembers, (personalDetail) => Boolean(personalDetail.avatar.source))).toBe(true);
+ });
});
diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js
index e0b50ca54cca..e97e9147c328 100644
--- a/tests/unit/ReportUtilsTest.js
+++ b/tests/unit/ReportUtilsTest.js
@@ -288,97 +288,62 @@ describe('ReportUtils', () => {
it('returns false when there is no report', () => {
expect(ReportUtils.isWaitingForIOUActionFromCurrentUser()).toBe(false);
});
- it('returns false when there is no reports collection', () => {
+ it('returns false when the matched IOU report does not have an owner accountID', () => {
const report = {
...LHNTestUtils.getFakeReport(),
- iouReportID: '1',
+ ownerAccountID: undefined,
+ hasOutstandingIOU: true,
};
expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
});
- it('returns false when the report has no iouReportID', () => {
- const report = LHNTestUtils.getFakeReport();
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}2`, {
- reportID: '2',
- }).then(() => {
- expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
- });
- });
- it('returns false when there is no matching IOU report', () => {
- const report = {
- ...LHNTestUtils.getFakeReport(),
- iouReportID: '1',
- };
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}2`, {
- reportID: '2',
- }).then(() => {
- expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
- });
- });
- it('returns false when the matched IOU report does not have an owner email', () => {
+ it('returns false when the linked iou report has an oustanding IOU', () => {
const report = {
...LHNTestUtils.getFakeReport(),
iouReportID: '1',
};
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, {
reportID: '1',
+ ownerAccountID: 99,
+ hasOutstandingIOU: true,
}).then(() => {
expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
});
});
- it('returns false when the matched IOU report does not have an owner email', () => {
+ it('returns false when the report has no oustanding IOU but is waiting for a bank account and the logged user is the report owner', () => {
const report = {
...LHNTestUtils.getFakeReport(),
- iouReportID: '1',
+ hasOutstandingIOU: false,
+ ownerAccountID: currentUserAccountID,
+ isWaitingOnBankAccount: true,
};
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, {
- reportID: '1',
- ownerAccountID: 99,
- }).then(() => {
- expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
- });
+ expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
});
- it('returns true when the report has an oustanding IOU', () => {
+ it('returns true when the report has oustanding IOU and is waiting for a bank account and the logged user is the report owner', () => {
const report = {
...LHNTestUtils.getFakeReport(),
- iouReportID: '1',
hasOutstandingIOU: true,
+ ownerAccountID: currentUserAccountID,
+ isWaitingOnBankAccount: true,
};
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, {
- reportID: '1',
- ownerAccountID: 99,
- hasOutstandingIOU: true,
- }).then(() => {
- expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(true);
- });
+ expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(true);
});
- it('returns false when the report has no oustanding IOU', () => {
+ it('returns false when the report has no oustanding IOU but is waiting for a bank account and the logged user is not the report owner', () => {
const report = {
...LHNTestUtils.getFakeReport(),
- iouReportID: '1',
hasOutstandingIOU: false,
+ ownerAccountID: 97,
+ isWaitingOnBankAccount: true,
};
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, {
- reportID: '1',
- ownerAccountID: 99,
- hasOutstandingIOU: false,
- }).then(() => {
- expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
- });
+ expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
});
- it('returns true when the report has no oustanding IOU but is waiting for a bank account', () => {
+ it('returns true when the report has oustanding IOU', () => {
const report = {
...LHNTestUtils.getFakeReport(),
- iouReportID: '1',
- hasOutstandingIOU: false,
+ ownerAccountID: 99,
+ hasOutstandingIOU: true,
+ isWaitingOnBankAccount: false,
};
- Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}1`, {
- reportID: '1',
- ownerAccountID: currentUserEmail,
- hasOutstandingIOU: false,
- isWaitingOnBankAccount: true,
- }).then(() => {
- expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(false);
- });
+ expect(ReportUtils.isWaitingForIOUActionFromCurrentUser(report)).toBe(true);
});
});
diff --git a/tests/unit/SidebarFilterTest.js b/tests/unit/SidebarFilterTest.js
index ce5f343785d9..85d409969133 100644
--- a/tests/unit/SidebarFilterTest.js
+++ b/tests/unit/SidebarFilterTest.js
@@ -13,6 +13,7 @@ jest.mock('../../src/libs/Permissions');
const ONYXKEYS = {
PERSONAL_DETAILS_LIST: 'personalDetailsList',
+ IS_LOADING_REPORT_DATA: 'isLoadingReportData',
NVP_PRIORITY_MODE: 'nvp_priorityMode',
SESSION: 'session',
BETAS: 'betas',
@@ -71,46 +72,56 @@ describe('Sidebar', () => {
);
});
- it('includes or excludes policy expense chats depending on the beta', () => {
+ it('excludes an empty chat report', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
- // Given a policy expense report
- // and the user not being in any betas
- const report = {
- ...LHNTestUtils.getFakeReport(),
- chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
- };
+ // Given a new report
+ const report = LHNTestUtils.getFakeReport(['emptychat+1@test.com', 'emptychat+2@test.com'], 0);
return (
waitForPromisesToResolve()
- // When Onyx is updated to contain that data and the sidebar re-renders
+ // When Onyx is updated to contain that report
.then(() =>
Onyx.multiSet({
- [ONYXKEYS.BETAS]: [],
- [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
}),
)
// Then no reports are rendered in the LHN
.then(() => {
- const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
- const optionRows = screen.queryAllByAccessibilityHint(hintText);
- expect(optionRows).toHaveLength(0);
+ const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
+ const displayNames = screen.queryAllByLabelText(hintText);
+ expect(displayNames).toHaveLength(0);
})
+ );
+ });
- // When the user is added to the policy expense beta and the sidebar re-renders
+ it('includes an empty chat report if it has a draft', () => {
+ LHNTestUtils.getDefaultRenderedSidebarLinks();
+
+ // Given a new report with a draft text
+ const report = {
+ ...LHNTestUtils.getFakeReport([1, 2], 0),
+ hasDraft: true,
+ };
+
+ return (
+ waitForPromisesToResolve()
+ // When Onyx is updated to contain that report
.then(() =>
Onyx.multiSet({
- [ONYXKEYS.BETAS]: [CONST.BETAS.POLICY_EXPENSE_CHAT],
+ [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
}),
)
- // Then there is one report rendered in the LHN
+ // Then the report should be rendered in the LHN since it has a draft
.then(() => {
- const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat');
- const optionRows = screen.queryAllByAccessibilityHint(hintText);
- expect(optionRows).toHaveLength(1);
+ const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
+ const displayNames = screen.queryAllByLabelText(hintText);
+ expect(displayNames).toHaveLength(1);
})
);
});
@@ -132,6 +143,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.BETAS]: [],
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -184,6 +196,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.BETAS]: [],
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -235,6 +248,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.BETAS]: [],
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
}),
@@ -272,7 +286,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
// Given there are 6 boolean variables tested in the filtering logic:
// 1. isArchived
@@ -323,6 +337,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
@@ -366,6 +381,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -436,6 +452,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport,
[`${ONYXKEYS.COLLECTION.REPORT}${pinnedReport.reportID}`]: pinnedReport,
}),
@@ -473,7 +490,7 @@ describe('Sidebar', () => {
};
LHNTestUtils.getDefaultRenderedSidebarLinks();
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -483,6 +500,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${archivedReport.reportID}`]: archivedReport,
[`${ONYXKEYS.COLLECTION.REPORT}${archivedPolicyRoomReport.reportID}`]: archivedPolicyRoomReport,
[`${ONYXKEYS.COLLECTION.REPORT}${archivedUserCreatedPolicyRoomReport.reportID}`]: archivedUserCreatedPolicyRoomReport,
@@ -535,7 +553,7 @@ describe('Sidebar', () => {
};
LHNTestUtils.getDefaultRenderedSidebarLinks();
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -545,6 +563,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${policyRoomReport.reportID}`]: policyRoomReport,
[`${ONYXKEYS.COLLECTION.REPORT}${userCreatedPolicyRoomReport.reportID}`]: userCreatedPolicyRoomReport,
}),
@@ -592,7 +611,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
// Given there are 6 boolean variables tested in the filtering logic:
// 1. isArchived
@@ -643,6 +662,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy,
@@ -684,7 +704,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -694,6 +714,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -734,7 +755,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -744,6 +765,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -782,7 +804,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -792,6 +814,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -826,7 +849,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
return (
waitForPromisesToResolve()
@@ -836,6 +859,7 @@ describe('Sidebar', () => {
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.js
index ffc619e7d578..c3942f24e626 100644
--- a/tests/unit/SidebarOrderTest.js
+++ b/tests/unit/SidebarOrderTest.js
@@ -7,6 +7,7 @@ import * as LHNTestUtils from '../utils/LHNTestUtils';
import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
import * as Localize from '../../src/libs/Localize';
+import * as Report from '../../src/libs/actions/Report';
// Be sure to include the mocked Permissions and Expensicons libraries or else the beta tests won't work
jest.mock('../../src/libs/Permissions');
@@ -14,6 +15,7 @@ jest.mock('../../src/components/Icon/Expensicons');
const ONYXKEYS = {
PERSONAL_DETAILS_LIST: 'personalDetailsList',
+ IS_LOADING_REPORT_DATA: 'isLoadingReportData',
NVP_PRIORITY_MODE: 'nvp_priorityMode',
SESSION: 'session',
BETAS: 'betas',
@@ -67,6 +69,7 @@ describe('Sidebar', () => {
.then(() =>
Onyx.multiSet({
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
}),
)
@@ -91,6 +94,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -106,15 +110,14 @@ describe('Sidebar', () => {
LHNTestUtils.getDefaultRenderedSidebarLinks();
// Given three unread reports in the recently updated order of 3, 2, 1
- const report1 = {
- ...LHNTestUtils.getFakeReport([1, 2], 3),
- };
- const report2 = {
- ...LHNTestUtils.getFakeReport([3, 4], 2),
- };
- const report3 = {
- ...LHNTestUtils.getFakeReport([5, 6], 1),
- };
+ const report1 = LHNTestUtils.getFakeReport([1, 2], 3);
+ const report2 = LHNTestUtils.getFakeReport([3, 4], 2);
+ const report3 = LHNTestUtils.getFakeReport([5, 6], 1);
+
+ // Each report has at least one ADDCOMMENT action so should be rendered in the LNH
+ Report.addComment(report1.reportID, 'Hi, this is a comment');
+ Report.addComment(report2.reportID, 'Hi, this is a comment');
+ Report.addComment(report3.reportID, 'Hi, this is a comment');
return (
waitForPromisesToResolve()
@@ -123,6 +126,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -133,6 +137,7 @@ describe('Sidebar', () => {
.then(() => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
+
expect(displayNames).toHaveLength(3);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Five, Six');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Three, Four');
@@ -151,8 +156,15 @@ describe('Sidebar', () => {
};
const report2 = LHNTestUtils.getFakeReport([3, 4], 2);
const report3 = LHNTestUtils.getFakeReport([5, 6], 1);
+
+ // Each report has at least one ADDCOMMENT action so should be rendered in the LNH
+ Report.addComment(report1.reportID, 'Hi, this is a comment');
+ Report.addComment(report2.reportID, 'Hi, this is a comment');
+ Report.addComment(report3.reportID, 'Hi, this is a comment');
+
const currentReportId = report1.reportID;
LHNTestUtils.getDefaultRenderedSidebarLinks(currentReportId);
+
return (
waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -160,6 +172,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -190,6 +203,11 @@ describe('Sidebar', () => {
const report2 = LHNTestUtils.getFakeReport([3, 4], 2);
const report3 = LHNTestUtils.getFakeReport([5, 6], 1);
+ // Each report has at least one ADDCOMMENT action so should be rendered in the LNH
+ Report.addComment(report1.reportID, 'Hi, this is a comment');
+ Report.addComment(report2.reportID, 'Hi, this is a comment');
+ Report.addComment(report3.reportID, 'Hi, this is a comment');
+
return (
waitForPromisesToResolve()
// When Onyx is updated with the data and the sidebar re-renders
@@ -197,6 +215,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -233,6 +252,12 @@ describe('Sidebar', () => {
hasDraft: true,
};
const report3 = LHNTestUtils.getFakeReport([5, 6], 1);
+
+ // Each report has at least one ADDCOMMENT action so should be rendered in the LNH
+ Report.addComment(report1.reportID, 'Hi, this is a comment');
+ Report.addComment(report2.reportID, 'Hi, this is a comment');
+ Report.addComment(report3.reportID, 'Hi, this is a comment');
+
const currentReportId = report2.reportID;
LHNTestUtils.getDefaultRenderedSidebarLinks(currentReportId);
@@ -243,6 +268,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -287,6 +313,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -323,6 +350,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -357,7 +385,7 @@ describe('Sidebar', () => {
};
const report3 = {
...LHNTestUtils.getFakeReport([5, 6], 1),
- hasOutstandingIOU: true,
+ hasOutstandingIOU: false,
// This has to be added after the IOU report is generated
iouReportID: null,
@@ -384,6 +412,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[ONYXKEYS.SESSION]: {accountID: currentlyLoggedInUserAccountID},
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
@@ -398,13 +427,12 @@ describe('Sidebar', () => {
.then(() => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
- expect(displayNames).toHaveLength(4);
+ expect(displayNames).toHaveLength(3);
expect(screen.queryAllByTestId('Pin Icon')).toHaveLength(1);
expect(screen.queryAllByTestId('Pencil Icon')).toHaveLength(1);
expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two');
expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Email Two owes $100.00');
- expect(lodashGet(displayNames, [2, 'props', 'children'])).toBe('Five, Six');
- expect(lodashGet(displayNames, [3, 'props', 'children'])).toBe('Three, Four');
+ expect(lodashGet(displayNames, [2, 'props', 'children'])).toBe('Three, Four');
})
);
});
@@ -436,6 +464,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -495,6 +524,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -538,8 +568,13 @@ describe('Sidebar', () => {
const report2 = LHNTestUtils.getFakeReport([3, 4]);
const report3 = LHNTestUtils.getFakeReport([5, 6]);
+ // Each report has at least one ADDCOMMENT action so should be rendered in the LNH
+ Report.addComment(report1.reportID, 'Hi, this is a comment');
+ Report.addComment(report2.reportID, 'Hi, this is a comment');
+ Report.addComment(report3.reportID, 'Hi, this is a comment');
+
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -549,6 +584,7 @@ describe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -585,6 +621,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -629,7 +666,7 @@ describe('Sidebar', () => {
const report3 = LHNTestUtils.getFakeReport([5, 6], 1, true);
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -639,6 +676,7 @@ describe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
@@ -661,21 +699,31 @@ describe('Sidebar', () => {
// Given three IOU reports containing the same IOU amounts
const report1 = {
...LHNTestUtils.getFakeReport([1, 2]),
- hasOutstandingIOU: true,
// This has to be added after the IOU report is generated
iouReportID: null,
};
const report2 = {
...LHNTestUtils.getFakeReport([3, 4]),
- hasOutstandingIOU: true,
// This has to be added after the IOU report is generated
iouReportID: null,
};
const report3 = {
...LHNTestUtils.getFakeReport([5, 6]),
- hasOutstandingIOU: true,
+ hasOutstandingIOU: false,
+
+ // This has to be added after the IOU report is generated
+ iouReportID: null,
+ };
+ const report4 = {
+ ...LHNTestUtils.getFakeReport([5, 6]),
+
+ // This has to be added after the IOU report is generated
+ iouReportID: null,
+ };
+ const report5 = {
+ ...LHNTestUtils.getFakeReport([5, 6]),
// This has to be added after the IOU report is generated
iouReportID: null,
@@ -694,7 +742,7 @@ describe('Sidebar', () => {
...LHNTestUtils.getFakeReport([9, 10]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
- managerID: 2,
+ managerID: 3,
hasOutstandingIOU: true,
total: 10000,
currency: 'USD',
@@ -704,7 +752,27 @@ describe('Sidebar', () => {
...LHNTestUtils.getFakeReport([11, 12]),
type: CONST.REPORT.TYPE.IOU,
ownerAccountID: 2,
- managerID: 2,
+ managerID: 4,
+ hasOutstandingIOU: true,
+ total: 100000,
+ currency: 'USD',
+ chatReportID: report3.reportID,
+ };
+ const iouReport4 = {
+ ...LHNTestUtils.getFakeReport([11, 12]),
+ type: CONST.REPORT.TYPE.IOU,
+ ownerAccountID: 2,
+ managerID: 5,
+ hasOutstandingIOU: true,
+ total: 10000,
+ currency: 'USD',
+ chatReportID: report3.reportID,
+ };
+ const iouReport5 = {
+ ...LHNTestUtils.getFakeReport([11, 12]),
+ type: CONST.REPORT.TYPE.IOU,
+ ownerAccountID: 2,
+ managerID: 6,
hasOutstandingIOU: true,
total: 10000,
currency: 'USD',
@@ -714,6 +782,8 @@ describe('Sidebar', () => {
report1.iouReportID = iouReport1.reportID;
report2.iouReportID = iouReport2.reportID;
report3.iouReportID = iouReport3.reportID;
+ report4.iouReportID = iouReport4.reportID;
+ report5.iouReportID = iouReport5.reportID;
const currentlyLoggedInUserAccountID = 13;
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
@@ -724,26 +794,31 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[ONYXKEYS.SESSION]: {accountID: currentlyLoggedInUserAccountID},
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report4.reportID}`]: report4,
+ [`${ONYXKEYS.COLLECTION.REPORT}${report5.reportID}`]: report5,
[`${ONYXKEYS.COLLECTION.REPORT}${iouReport1.reportID}`]: iouReport1,
[`${ONYXKEYS.COLLECTION.REPORT}${iouReport2.reportID}`]: iouReport2,
[`${ONYXKEYS.COLLECTION.REPORT}${iouReport3.reportID}`]: iouReport3,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport4.reportID}`]: iouReport4,
+ [`${ONYXKEYS.COLLECTION.REPORT}${iouReport5.reportID}`]: iouReport5,
}),
)
- // Then the reports are ordered alphabetically since their amounts are the same
+ // Then the reports with the same amount are ordered alphabetically
.then(() => {
const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames');
const displayNames = screen.queryAllByLabelText(hintText);
expect(displayNames).toHaveLength(5);
- expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Email Two owes $100.00');
- expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Email Two owes $100.00');
- expect(lodashGet(displayNames, [2, 'props', 'children'])).toBe('Email Two owes $100.00');
- expect(lodashGet(displayNames, [3, 'props', 'children'])).toBe('Five, Six');
- expect(lodashGet(displayNames, [4, 'props', 'children'])).toBe('One, Two');
+ expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('Email Four owes $1,000.00');
+ expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Email Five owes $100.00');
+ expect(lodashGet(displayNames, [2, 'props', 'children'])).toBe('Email Six owes $100.00');
+ expect(lodashGet(displayNames, [3, 'props', 'children'])).toBe('Email Three owes $100.00');
+ expect(lodashGet(displayNames, [4, 'props', 'children'])).toBe('Email Two owes $100.00');
})
);
});
@@ -764,6 +839,11 @@ describe('Sidebar', () => {
lastVisibleActionCreated,
};
+ // Each report has at least one ADDCOMMENT action so should be rendered in the LNH
+ Report.addComment(report1.reportID, 'Hi, this is a comment');
+ Report.addComment(report2.reportID, 'Hi, this is a comment');
+ Report.addComment(report3.reportID, 'Hi, this is a comment');
+
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -772,6 +852,7 @@ describe('Sidebar', () => {
Onyx.multiSet({
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.DEFAULT,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1,
[`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2,
[`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3,
diff --git a/tests/unit/SidebarTest.js b/tests/unit/SidebarTest.js
index 4da568ec2561..84403ce5fc11 100644
--- a/tests/unit/SidebarTest.js
+++ b/tests/unit/SidebarTest.js
@@ -13,6 +13,7 @@ jest.mock('../../src/components/Icon/Expensicons');
const ONYXKEYS = {
PERSONAL_DETAILS_LIST: 'personalDetailsList',
+ IS_LOADING_REPORT_DATA: 'isLoadingReportData',
NVP_PRIORITY_MODE: 'nvp_priorityMode',
SESSION: 'session',
BETAS: 'betas',
@@ -55,7 +56,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -65,6 +66,7 @@ describe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
}),
)
@@ -97,7 +99,7 @@ describe('Sidebar', () => {
};
// Given the user is in all betas
- const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS, CONST.BETAS.POLICY_EXPENSE_CHAT];
+ const betas = [CONST.BETAS.DEFAULT_ROOMS, CONST.BETAS.POLICY_ROOMS];
LHNTestUtils.getDefaultRenderedSidebarLinks('0');
return (
waitForPromisesToResolve()
@@ -107,6 +109,7 @@ describe('Sidebar', () => {
[ONYXKEYS.BETAS]: betas,
[ONYXKEYS.NVP_PRIORITY_MODE]: CONST.PRIORITY_MODE.GSD,
[ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails,
+ [ONYXKEYS.IS_LOADING_REPORT_DATA]: false,
[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report,
[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]: {[action.reportActionId]: action},
}),
diff --git a/tests/unit/UrlTest.js b/tests/unit/UrlTest.js
index ea6460e24ca9..90ffb9b12d5b 100644
--- a/tests/unit/UrlTest.js
+++ b/tests/unit/UrlTest.js
@@ -1,219 +1,72 @@
const Url = require('../../src/libs/Url');
describe('Url', () => {
- describe('getURLObject()', () => {
+ describe('getPathFromURL()', () => {
it('It should work correctly', () => {
- expect(Url.getURLObject('foo.com')).toEqual({
- href: 'foo.com',
- protocol: undefined,
- hostname: 'foo.com',
- path: '',
- });
- expect(Url.getURLObject('www.foo.com')).toEqual({
- href: 'www.foo.com',
- protocol: undefined,
- hostname: 'www.foo.com',
- path: '',
- });
- expect(Url.getURLObject('http://www.foo.com')).toEqual({
- href: 'http://www.foo.com',
- protocol: 'http://',
- hostname: 'www.foo.com',
- path: '',
- });
- expect(Url.getURLObject('http://foo.com/blah_blah')).toEqual({
- href: 'http://foo.com/blah_blah',
- protocol: 'http://',
- hostname: 'foo.com',
- path: '/blah_blah',
- });
- expect(Url.getURLObject('http://foo.com/blah_blah_(wikipedia)')).toEqual({
- href: 'http://foo.com/blah_blah_(wikipedia)',
- protocol: 'http://',
- hostname: 'foo.com',
- path: '/blah_blah_(wikipedia)',
- });
- expect(Url.getURLObject('http://www.example.com/wpstyle/?p=364')).toEqual({
- href: 'http://www.example.com/wpstyle/?p=364',
- protocol: 'http://',
- hostname: 'www.example.com',
- path: '/wpstyle/?p=364',
- });
- expect(Url.getURLObject('https://www.example.com/foo/?bar=baz&inga=42&quux')).toEqual({
- href: 'https://www.example.com/foo/?bar=baz&inga=42&quux',
- protocol: 'https://',
- hostname: 'www.example.com',
- path: '/foo/?bar=baz&inga=42&quux',
- });
- expect(Url.getURLObject('http://foo.com/(something)?after=parens')).toEqual({
- href: 'http://foo.com/(something)?after=parens',
- protocol: 'http://',
- hostname: 'foo.com',
- path: '/(something)?after=parens',
- });
- expect(Url.getURLObject('http://code.google.com/events/#&product=browser')).toEqual({
- href: 'http://code.google.com/events/#&product=browser',
- protocol: 'http://',
- hostname: 'code.google.com',
- path: '/events/#&product=browser',
- });
- expect(Url.getURLObject('http://foo.bar/?q=Test%20URL-encoded%20stuff')).toEqual({
- href: 'http://foo.bar/?q=Test%20URL-encoded%20stuff',
- protocol: 'http://',
- hostname: 'foo.bar',
- path: '/?q=Test%20URL-encoded%20stuff',
- });
- expect(Url.getURLObject('http://www.test.com/path?param=123#123')).toEqual({
- href: 'http://www.test.com/path?param=123#123',
- protocol: 'http://',
-
- hostname: 'www.test.com',
- path: '/path?param=123#123',
- });
- expect(Url.getURLObject('http://1337.net')).toEqual({
- href: 'http://1337.net',
- protocol: 'http://',
-
- hostname: '1337.net',
- path: '',
- });
- expect(Url.getURLObject('http://a.b-c.de/')).toEqual({
- href: 'http://a.b-c.de/',
- protocol: 'http://',
-
- hostname: 'a.b-c.de',
- path: '/',
- });
- expect(Url.getURLObject('https://sd1.sd2.docs.google.com/')).toEqual({
- href: 'https://sd1.sd2.docs.google.com/',
- protocol: 'https://',
- hostname: 'sd1.sd2.docs.google.com',
- path: '/',
- });
- expect(Url.getURLObject('https://expensify.cash/#/r/1234')).toEqual({
- href: 'https://expensify.cash/#/r/1234',
- protocol: 'https://',
- hostname: 'expensify.cash',
- path: '/#/r/1234',
- });
- expect(Url.getURLObject('https://github.com/Expensify/ReactNativeChat/pull/6.45')).toEqual({
- href: 'https://github.com/Expensify/ReactNativeChat/pull/6.45',
- protocol: 'https://',
- hostname: 'github.com',
- path: '/Expensify/ReactNativeChat/pull/6.45',
- });
- expect(Url.getURLObject('https://github.com/Expensify/Expensify/issues/143,231')).toEqual({
- href: 'https://github.com/Expensify/Expensify/issues/143,231',
- protocol: 'https://',
- hostname: 'github.com',
- path: '/Expensify/Expensify/issues/143,231',
- });
- expect(Url.getURLObject('testRareTLDs.beer')).toEqual({
- href: 'testRareTLDs.beer',
- protocol: undefined,
- hostname: 'testRareTLDs.beer',
- path: '',
- });
- expect(Url.getURLObject('test@expensify.com')).toEqual({
- href: 'test@expensify.com',
- protocol: undefined,
- hostname: 'expensify.com',
- path: '',
- });
- expect(Url.getURLObject('test.completelyFakeTLD')).toEqual({
- href: undefined,
- protocol: undefined,
- hostname: undefined,
- path: undefined,
- });
+ expect(Url.getPathFromURL('http://www.foo.com')).toEqual('');
+ expect(Url.getPathFromURL('http://foo.com/blah_blah')).toEqual('blah_blah');
+ expect(Url.getPathFromURL('http://foo.com/blah_blah_(wikipedia)')).toEqual('blah_blah_(wikipedia)');
+ expect(Url.getPathFromURL('http://www.example.com/wpstyle/?p=364')).toEqual('wpstyle/?p=364');
+ expect(Url.getPathFromURL('https://www.example.com/foo/?bar=baz&inga=42&quux')).toEqual('foo/?bar=baz&inga=42&quux');
+ expect(Url.getPathFromURL('http://foo.com/(something)?after=parens')).toEqual('(something)?after=parens');
+ expect(Url.getPathFromURL('http://code.google.com/events/#&product=browser')).toEqual('events/#&product=browser');
+ expect(Url.getPathFromURL('http://foo.bar/?q=Test%20URL-encoded%20stuff')).toEqual('?q=Test%20URL-encoded%20stuff');
+ expect(Url.getPathFromURL('http://www.test.com/path?param=123#123')).toEqual('path?param=123#123');
+ expect(Url.getPathFromURL('http://1337.net')).toEqual('');
+ expect(Url.getPathFromURL('http://a.b-c.de/')).toEqual('');
+ expect(Url.getPathFromURL('https://sd1.sd2.docs.google.com/')).toEqual('');
+ expect(Url.getPathFromURL('https://expensify.cash/#/r/1234')).toEqual('#/r/1234');
+ expect(Url.getPathFromURL('https://github.com/Expensify/ReactNativeChat/pull/6.45')).toEqual('Expensify/ReactNativeChat/pull/6.45');
+ expect(Url.getPathFromURL('https://github.com/Expensify/Expensify/issues/143,231')).toEqual('Expensify/Expensify/issues/143,231');
+ expect(Url.getPathFromURL('testRareTLDs.beer')).toEqual('');
+ expect(Url.getPathFromURL('test@expensify.com')).toEqual('');
+ expect(Url.getPathFromURL('test.completelyFakeTLD')).toEqual('');
expect(
- Url.getURLObject(
+ Url.getPathFromURL(
// eslint-disable-next-line max-len
'https://www.expensify.com/_devportal/tools/logSearch/#query=request_id:(%22Ufjjim%22)+AND+timestamp:[2021-01-08T03:48:10.389Z+TO+2021-01-08T05:48:10.389Z]&index=logs_expensify-008878)',
),
- ).toEqual({
- // eslint-disable-next-line max-len
- href: 'https://www.expensify.com/_devportal/tools/logSearch/#query=request_id:(%22Ufjjim%22)+AND+timestamp:[2021-01-08T03:48:10.389Z+TO+2021-01-08T05:48:10.389Z]&index=logs_expensify-008878)',
- protocol: 'https://',
- hostname: 'www.expensify.com',
- path: '/_devportal/tools/logSearch/#query=request_id:(%22Ufjjim%22)+AND+timestamp:[2021-01-08T03:48:10.389Z+TO+2021-01-08T05:48:10.389Z]&index=logs_expensify-008878)',
- });
- expect(Url.getURLObject('http://necolas.github.io/react-native-web/docs/?path=/docs/components-pressable--disabled ')).toEqual({
- href: 'http://necolas.github.io/react-native-web/docs/?path=/docs/components-pressable--disabled ',
- protocol: 'http://',
- hostname: 'necolas.github.io',
- path: '/react-native-web/docs/?path=/docs/components-pressable--disabled ',
- });
- expect(Url.getURLObject('https://github.com/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ')).toEqual({
- href: 'https://github.com/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ',
- protocol: 'https://',
- hostname: 'github.com',
- path: '/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ',
- });
- expect(Url.getURLObject('https://github.com/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ')).toEqual({
- href: 'https://github.com/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ',
- protocol: 'https://',
- hostname: 'github.com',
- path: '/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ',
- });
- expect(Url.getURLObject('mm..food ')).toEqual({
- href: undefined,
- protocol: undefined,
- hostname: undefined,
- path: undefined,
- });
- expect(Url.getURLObject('upwork.com/jobs/~016781e062ce860b84 ')).toEqual({
- href: 'upwork.com/jobs/~016781e062ce860b84 ',
- protocol: undefined,
- hostname: 'upwork.com',
- path: '/jobs/~016781e062ce860b84 ',
- });
+ ).toEqual('_devportal/tools/logSearch/#query=request_id:(%22Ufjjim%22)+AND+timestamp:[2021-01-08T03:48:10.389Z+TO+2021-01-08T05:48:10.389Z]&index=logs_expensify-008878)');
+ expect(Url.getPathFromURL('http://necolas.github.io/react-native-web/docs/?path=/docs/components-pressable--disabled ')).toEqual(
+ 'react-native-web/docs/?path=/docs/components-pressable--disabled',
+ );
+ expect(Url.getPathFromURL('https://github.com/Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash ')).toEqual(
+ 'Expensify/Expensify.cash/issues/123#:~:text=Please%20work/Expensify.cash',
+ );
+ expect(Url.getPathFromURL('mm..food ')).toEqual('');
+ expect(Url.getPathFromURL('https://upwork.com/jobs/~016781e062ce860b84 ')).toEqual('jobs/~016781e062ce860b84');
expect(
- Url.getURLObject(
+ Url.getPathFromURL(
// eslint-disable-next-line max-len
"https://bastion1.sjc/logs/app/kibana#/discover?_g=()&_a=(columns:!(_source),index:'2125cbe0-28a9-11e9-a79c-3de0157ed580',interval:auto,query:(language:lucene,query:''),sort:!(timestamp,desc))",
),
- ).toEqual({
- // eslint-disable-next-line max-len
- href: "https://bastion1.sjc/logs/app/kibana#/discover?_g=()&_a=(columns:!(_source),index:'2125cbe0-28a9-11e9-a79c-3de0157ed580',interval:auto,query:(language:lucene,query:''),sort:!(timestamp,desc))",
- protocol: 'https://',
-
- hostname: 'bastion1.sjc',
- // eslint-disable-next-line max-len
- path: "/logs/app/kibana#/discover?_g=()&_a=(columns:!(_source),index:'2125cbe0-28a9-11e9-a79c-3de0157ed580',interval:auto,query:(language:lucene,query:''),sort:!(timestamp,desc))",
- });
- expect(Url.getURLObject("google.com/maps/place/The+Flying'+Saucer/@42.4043314,-86.2742418,15z/data=!4m5!3m4!1s0x0:0xe28f6108670216bc!8m2!3d42.4043316!4d-86.2742121")).toEqual({
- href: "google.com/maps/place/The+Flying'+Saucer/@42.4043314,-86.2742418,15z/data=!4m5!3m4!1s0x0:0xe28f6108670216bc!8m2!3d42.4043316!4d-86.2742121",
- protocol: undefined,
- hostname: 'google.com',
- path: "/maps/place/The+Flying'+Saucer/@42.4043314,-86.2742418,15z/data=!4m5!3m4!1s0x0:0xe28f6108670216bc!8m2!3d42.4043316!4d-86.2742121",
- });
+ ).toEqual(
+ "logs/app/kibana#/discover?_g=()&_a=(columns:!(_source),index:'2125cbe0-28a9-11e9-a79c-3de0157ed580',interval:auto,query:(language:lucene,query:''),sort:!(timestamp,desc))",
+ );
+ expect(
+ Url.getPathFromURL("https://google.com/maps/place/The+Flying'+Saucer/@42.4043314,-86.2742418,15z/data=!4m5!3m4!1s0x0:0xe28f6108670216bc!8m2!3d42.4043316!4d-86.2742121"),
+ ).toEqual("maps/place/The+Flying'+Saucer/@42.4043314,-86.2742418,15z/data=!4m5!3m4!1s0x0:0xe28f6108670216bc!8m2!3d42.4043316!4d-86.2742121");
expect(
- Url.getURLObject(
+ Url.getPathFromURL(
// eslint-disable-next-line max-len
- 'google.com/maps/place/%E9%9D%92%E5%B3%B6%E9%80%A3%E7%B5%A1%E8%88%B9%E4%B9%97%E5%A0%B4/@33.7363156,132.4877213,17.78z/data=!4m5!3m4!1s0x3545615c8c65bf7f:0xb89272c1a705a33f!8m2!3d33.7366776!4d132.4878843 ',
+ 'https://google.com/maps/place/%E9%9D%92%E5%B3%B6%E9%80%A3%E7%B5%A1%E8%88%B9%E4%B9%97%E5%A0%B4/@33.7363156,132.4877213,17.78z/data=!4m5!3m4!1s0x3545615c8c65bf7f:0xb89272c1a705a33f!8m2!3d33.7366776!4d132.4878843 ',
),
- ).toEqual({
- // eslint-disable-next-line max-len
- href: 'google.com/maps/place/%E9%9D%92%E5%B3%B6%E9%80%A3%E7%B5%A1%E8%88%B9%E4%B9%97%E5%A0%B4/@33.7363156,132.4877213,17.78z/data=!4m5!3m4!1s0x3545615c8c65bf7f:0xb89272c1a705a33f!8m2!3d33.7366776!4d132.4878843 ',
- protocol: undefined,
- hostname: 'google.com',
- // eslint-disable-next-line max-len
- path: '/maps/place/%E9%9D%92%E5%B3%B6%E9%80%A3%E7%B5%A1%E8%88%B9%E4%B9%97%E5%A0%B4/@33.7363156,132.4877213,17.78z/data=!4m5!3m4!1s0x3545615c8c65bf7f:0xb89272c1a705a33f!8m2!3d33.7366776!4d132.4878843 ',
- });
+ ).toEqual(
+ 'maps/place/%E9%9D%92%E5%B3%B6%E9%80%A3%E7%B5%A1%E8%88%B9%E4%B9%97%E5%A0%B4/@33.7363156,132.4877213,17.78z/data=!4m5!3m4!1s0x3545615c8c65bf7f:0xb89272c1a705a33f!8m2!3d33.7366776!4d132.4878843',
+ );
expect(
- Url.getURLObject(
+ Url.getPathFromURL(
// eslint-disable-next-line max-len
'https://www.google.com/maps/place/Taj+Mahal+@is~"Awesome"/@27.1751496,78.0399535,17z/data=!4m12!1m6!3m5!1s0x39747121d702ff6d:0xdd2ae4803f767dde!2sTaj+Mahal!8m2!3d27.1751448!4d78.0421422!3m4!1s0x39747121d702ff6d:0xdd2ae4803f767dde!8m2!3d27.1751448!4d78.0421422',
),
- ).toEqual({
- // eslint-disable-next-line max-len
- href: 'https://www.google.com/maps/place/Taj+Mahal+@is~"Awesome"/@27.1751496,78.0399535,17z/data=!4m12!1m6!3m5!1s0x39747121d702ff6d:0xdd2ae4803f767dde!2sTaj+Mahal!8m2!3d27.1751448!4d78.0421422!3m4!1s0x39747121d702ff6d:0xdd2ae4803f767dde!8m2!3d27.1751448!4d78.0421422',
- protocol: 'https://',
- hostname: 'www.google.com',
- // eslint-disable-next-line max-len
- path: '/maps/place/Taj+Mahal+@is~"Awesome"/@27.1751496,78.0399535,17z/data=!4m12!1m6!3m5!1s0x39747121d702ff6d:0xdd2ae4803f767dde!2sTaj+Mahal!8m2!3d27.1751448!4d78.0421422!3m4!1s0x39747121d702ff6d:0xdd2ae4803f767dde!8m2!3d27.1751448!4d78.0421422',
- });
+ ).toEqual(
+ 'maps/place/Taj+Mahal+@is~%22Awesome%22/@27.1751496,78.0399535,17z/data=!4m12!1m6!3m5!1s0x39747121d702ff6d:0xdd2ae4803f767dde!2sTaj+Mahal!8m2!3d27.1751448!4d78.0421422!3m4!1s0x39747121d702ff6d:0xdd2ae4803f767dde!8m2!3d27.1751448!4d78.0421422',
+ );
+ expect(
+ Url.getPathFromURL(
+ 'https://new.expensify.com/r/443044983936732/attachment?source=https://www.expensify.com/chat-attachments/3915228701265930556/w_a758d3c8444a64f98d37205b17141388064d458e.jpg',
+ ),
+ ).toEqual('r/443044983936732/attachment?source=https://www.expensify.com/chat-attachments/3915228701265930556/w_a758d3c8444a64f98d37205b17141388064d458e.jpg');
});
});
describe('hasSameExpensifyOrigin()', () => {
@@ -224,8 +77,8 @@ describe('Url', () => {
it('It should work correctly with www in both urls', () => {
expect(Url.hasSameExpensifyOrigin('https://www.new.expensify.com/inbox/124', 'https://www.new.expensify.com/action/123')).toBe(true);
});
- it('It should work correctly without https://', () => {
- expect(Url.hasSameExpensifyOrigin('new.expensify.com/action/1234', 'new.expensify.com/action/123')).toBe(true);
+ it('It should work correctly with www in one of two urls', () => {
+ expect(Url.hasSameExpensifyOrigin('https://new.expensify.com/action/1234', 'https://www.new.expensify.com/action/123')).toBe(true);
});
it('It should work correctly with old dot', () => {
expect(Url.hasSameExpensifyOrigin('https://expensify.com/action/123', 'https://www.expensify.com/action/123')).toBe(true);
@@ -244,9 +97,6 @@ describe('Url', () => {
it('It should work correctly with www', () => {
expect(Url.hasSameExpensifyOrigin('https://expensify.com/action/1234', 'https://www.new.expensify.com/action/123')).toBe(false);
});
- it('It should work correctly with www in one of two urls', () => {
- expect(Url.hasSameExpensifyOrigin('https://new.expensify.com/action/1234', 'https://www.new.expensify.com/action/123')).toBe(false);
- });
});
});
});
diff --git a/tests/unit/currencyList.json b/tests/unit/currencyList.json
index 1242509c0813..d3659d9b1370 100644
--- a/tests/unit/currencyList.json
+++ b/tests/unit/currencyList.json
@@ -622,7 +622,6 @@
"RSD": {
"symbol": "РСД",
"name": "Serbian Dinar",
- "decimals": 0,
"ISO4217": "941"
},
"RUB": {
diff --git a/web/index.html b/web/index.html
index d207fa54b97a..ea8cce7a6918 100644
--- a/web/index.html
+++ b/web/index.html
@@ -20,6 +20,10 @@
<% } %>