diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index 701a9bca70a7..561b8e61bc21 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -207,10 +207,7 @@ function fetchTag(tag) { console.log(`Running command: ${command}`); execSync(command); } catch (e) { - // This can happen if the tag was only created locally but does not exist in the remote. In this case, we'll fetch history of the staging branch instead - const command = `git fetch origin staging --no-tags --shallow-exclude=${previousPatchVersion}`; - console.log(`Running command: ${command}`); - execSync(command); + console.error(e); } } @@ -301,13 +298,14 @@ function getValidMergedPRs(commits) { * @returns {Promise>} – Pull request numbers */ function getPullRequestsMergedBetween(fromTag, toTag) { + console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); // Find which commit messages correspond to merged PR's const pullRequestNumbers = getValidMergedPRs(commitList); console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return pullRequestNumbers; + return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); }); } diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index def58d95e846..e42f97508bc5 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -170,10 +170,7 @@ function fetchTag(tag) { console.log(`Running command: ${command}`); execSync(command); } catch (e) { - // This can happen if the tag was only created locally but does not exist in the remote. In this case, we'll fetch history of the staging branch instead - const command = `git fetch origin staging --no-tags --shallow-exclude=${previousPatchVersion}`; - console.log(`Running command: ${command}`); - execSync(command); + console.error(e); } } @@ -264,13 +261,14 @@ function getValidMergedPRs(commits) { * @returns {Promise>} – Pull request numbers */ function getPullRequestsMergedBetween(fromTag, toTag) { + console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); // Find which commit messages correspond to merged PR's const pullRequestNumbers = getValidMergedPRs(commitList); console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return pullRequestNumbers; + return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); }); } diff --git a/.github/libs/GitUtils.js b/.github/libs/GitUtils.js index ba9d7fa2b38a..7bc600470dd1 100644 --- a/.github/libs/GitUtils.js +++ b/.github/libs/GitUtils.js @@ -22,10 +22,7 @@ function fetchTag(tag) { console.log(`Running command: ${command}`); execSync(command); } catch (e) { - // This can happen if the tag was only created locally but does not exist in the remote. In this case, we'll fetch history of the staging branch instead - const command = `git fetch origin staging --no-tags --shallow-exclude=${previousPatchVersion}`; - console.log(`Running command: ${command}`); - execSync(command); + console.error(e); } } @@ -116,13 +113,14 @@ function getValidMergedPRs(commits) { * @returns {Promise>} – Pull request numbers */ function getPullRequestsMergedBetween(fromTag, toTag) { + console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); // Find which commit messages correspond to merged PR's const pullRequestNumbers = getValidMergedPRs(commitList); console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return pullRequestNumbers; + return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); }); } diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e1b1696411b1..e432d9291f45 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -85,7 +85,7 @@ The GitHub workflows require a large list of secrets to deploy, notify and test 1. `LARGE_SECRET_PASSPHRASE` - decrypts secrets stored in various encrypted files stored in GitHub repository. To create updated versions of these encrypted files, refer to steps 1-4 of [this encrypted secrets help page](https://docs.github.com/en/actions/reference/encrypted-secrets#limits-for-secrets) using the `LARGE_SECRET_PASSPHRASE`. 1. `android/app/my-upload-key.keystore.gpg` 1. `android/app/android-fastlane-json-key.json.gpg` - 1. `ios/chat_expensify_adhoc.mobileprovision.gpg` + 1. `ios/expensify_chat_adhoc.mobileprovision.gpg` 1. `ios/chat_expensify_appstore.mobileprovision.gpg` 1. `ios/Certificates.p12.gpg` 1. `SLACK_WEBHOOK` - Sends Slack notifications via Slack WebHook https://expensify.slack.com/services/B01AX48D7MM diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index b78a5fac4b69..7b71f6263c88 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -119,31 +119,3 @@ jobs: uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - - # Create a new StagingDeployCash for the next release cycle. - createNewStagingDeployCash: - runs-on: ubuntu-latest - needs: [updateStaging, createNewPatchVersion] - steps: - - uses: actions/checkout@v3 - with: - ref: staging - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - # Create a local git tag so that GitUtils.getPullRequestsMergedBetween can use `git log` to generate a - # list of pull requests that were merged between this version tag and another. - # NOTE: This tag is only used locally and shouldn't be pushed to the remote. - # If it was pushed, that would trigger the staging deploy which is handled in a separate workflow (deploy.yml) - - name: Tag version - run: git tag ${{ needs.createNewPatchVersion.outputs.NEW_VERSION }} - - - name: Create new StagingDeployCash - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main - with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - NPM_VERSION: ${{ needs.createNewPatchVersion.outputs.NEW_VERSION }} - - - if: ${{ failure() }} - uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main - with: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 795271cab60a..1983e406c77b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: - uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Lint JavaScript with ESLint + - name: Lint JavaScript and Typescript with ESLint run: npm run lint env: CI: true diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 84f8373ff247..400a0d4364fe 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -28,6 +28,25 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform + deployChecklist: + name: Create or update deploy checklist + runs-on: ubuntu-latest + needs: validateActor + steps: + - uses: actions/checkout@v3 + - uses: Expensify/App/.github/actions/composite/setupNode@main + + - name: Set version + id: getVersion + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Create or update staging deploy + uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main + with: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + NPM_VERSION: ${{ steps.getVersion.outputs.VERSION }} + android: name: Build and deploy Android needs: validateActor diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index c9fb636238aa..e3977734fc50 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -98,25 +98,6 @@ jobs: # Force-update the remote staging branch git push --force origin staging - # Create a local git tag on staging so that GitUtils.getPullRequestsMergedBetween can use `git log` to generate a - # list of pull requests that were merged between this version tag and another. - # NOTE: This tag is only used locally and shouldn't be pushed to the remote. - # If it was pushed, that would trigger the staging deploy which is handled in a separate workflow (deploy.yml) - - name: Tag staging - run: git tag ${{ needs.createNewVersion.outputs.NEW_VERSION }} - - - name: Update StagingDeployCash - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main - with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - NPM_VERSION: ${{ needs.createNewVersion.outputs.NEW_VERSION }} - - - name: Find open StagingDeployCash - id: getStagingDeployCash - run: echo "STAGING_DEPLOY_CASH=$(gh issue list --label StagingDeployCash --json number --jq '.[0].number')" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - if: ${{ failure() }} uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main with: diff --git a/android/app/build.gradle b/android/app/build.gradle index a9e7a0d48b73..bd38d9ebe4ba 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001036400 - versionName "1.3.64-0" + versionCode 1001036702 + versionName "1.3.67-2" } flavorDimensions "default" diff --git a/android/app/src/development/assets/airshipconfig.properties b/android/app/src/development/assets/airshipconfig.properties index 490f74552f11..43907fcbf251 100644 --- a/android/app/src/development/assets/airshipconfig.properties +++ b/android/app/src/development/assets/airshipconfig.properties @@ -5,4 +5,4 @@ developmentLogLevel = VERBOSE # Notification Customization notificationIcon = ic_notification -notificationAccentColor = #2EAAE2 \ No newline at end of file +notificationAccentColor = #03D47C \ No newline at end of file diff --git a/android/app/src/main/assets/airshipconfig.properties b/android/app/src/main/assets/airshipconfig.properties index 194c4577de8b..e15533fdda4d 100644 --- a/android/app/src/main/assets/airshipconfig.properties +++ b/android/app/src/main/assets/airshipconfig.properties @@ -4,4 +4,4 @@ inProduction = true # Notification Customization notificationIcon = ic_notification -notificationAccentColor = #2EAAE2 \ No newline at end of file +notificationAccentColor = #03D47C \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png index 7612112d1bc5..5a36b56c4bc9 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_notification.png and b/android/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png index 89accf5424f8..502b45ac86bd 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_notification.png and b/android/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png index a01f2c5e0dc9..d03ded01cf16 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_notification.png and b/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png index 3bb969329c79..cb9b4b24e518 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png and b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png index 697922b1e689..2469d9193901 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/values-large/orientation.xml b/android/app/src/main/res/values-large/orientation.xml index c06e0147ee73..9f60d109a2fc 100644 --- a/android/app/src/main/res/values-large/orientation.xml +++ b/android/app/src/main/res/values-large/orientation.xml @@ -1,4 +1,4 @@ - false + true diff --git a/android/app/src/main/res/values-sw600dp/orientation.xml b/android/app/src/main/res/values-sw600dp/orientation.xml index c06e0147ee73..9f60d109a2fc 100644 --- a/android/app/src/main/res/values-sw600dp/orientation.xml +++ b/android/app/src/main/res/values-sw600dp/orientation.xml @@ -1,4 +1,4 @@ - false + true diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 01f145dafbc6..661c700130c7 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -274,6 +274,7 @@ Form.js will automatically provide the following props to any input with the inp - onBlur: An onBlur handler that calls validate. - onTouched: An onTouched handler that marks the input as touched. - onInputChange: An onChange handler that saves draft values and calls validate for that input (inputA). Passing an inputID as a second param allows inputA to manipulate the input value of the provided inputID (inputB). +- onFocus: An onFocus handler that marks the input as focused. ## Dynamic Form Inputs diff --git a/docs/assets/images/insights-chart.png b/docs/assets/images/insights-chart.png index 7b10c8c92d8d..4b21b8d70a09 100644 Binary files a/docs/assets/images/insights-chart.png and b/docs/assets/images/insights-chart.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 92c61cb81b2c..ecec05f1cec1 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -224,11 +224,11 @@ platform :ios do contact_phone: ENV["APPLE_CONTACT_PHONE"], demo_account_name: ENV["APPLE_DEMO_EMAIL"], demo_account_password: ENV["APPLE_DEMO_PASSWORD"], - notes: "1. Log into the Expensify app using the provided email - 2. Now, you have to log in to this gmail account on https://mail.google.com/ so you can retrieve a One-Time-Password - 3. To log in to the gmail account, use the password above (That's NOT a password for the Expensify app but for the Gmail account) - 4. At the Gmail inbox, you should have received a one-time 6 digit magic code - 5. Use that to sign in" + notes: "1. In the Expensify app, enter the email 'appletest.expensify@proton.me'. This will trigger a sign-in link to be sent to 'appletest.expensify@proton.me' + 2. Navigate to https://account.proton.me/login, log into Proton Mail using 'appletest.expensify@proton.me' as email and the password associated with 'appletest.expensify@proton.me', provided above + 3. Once logged into Proton Mail, navigate to your inbox and locate the email triggered in step 1. The email subject should be 'Your magic sign-in link for Expensify' + 4. Open the email and copy the 6-digit sign-in code provided within + 5. Return to the Expensify app and enter the copied 6-digit code in the designated login field" } ) rescue Exception => e diff --git a/ios/Certificates.p12.gpg b/ios/Certificates.p12.gpg index c4a68891f6e4..f63d6861f888 100644 Binary files a/ios/Certificates.p12.gpg and b/ios/Certificates.p12.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 81d81db8616d..00b380a7d1dc 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.64 + 1.3.67 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.64.0 + 1.3.67.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes @@ -108,6 +108,8 @@ armv7 + UIRequiresFullScreen + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -117,8 +119,6 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeLeft UIUserInterfaceStyle Dark diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 377e23436ec7..031ce55e7518 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.64 + 1.3.67 CFBundleSignature ???? CFBundleVersion - 1.3.64.0 + 1.3.67.2 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 16ed1e05dc64..2bea672171fe 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -591,7 +591,7 @@ PODS: - React-Core - react-native-pager-view (6.2.0): - React-Core - - react-native-pdf (6.6.2): + - react-native-pdf (6.7.1): - React-Core - react-native-performance (4.0.0): - React-Core @@ -1254,7 +1254,7 @@ SPEC CHECKSUMS: react-native-key-command: c2645ec01eb1fa664606c09480c05cb4220ef67b react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df - react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa + react-native-pdf: 7c0e91ada997bac8bac3bb5bea5b6b81f5a3caae react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 diff --git a/ios/chat_expensify_adhoc.mobileprovision.gpg b/ios/chat_expensify_adhoc.mobileprovision.gpg deleted file mode 100644 index 97179c8a65ac..000000000000 Binary files a/ios/chat_expensify_adhoc.mobileprovision.gpg and /dev/null differ diff --git a/ios/chat_expensify_appstore.mobileprovision.gpg b/ios/chat_expensify_appstore.mobileprovision.gpg index 39137ea24a07..246f5f0ec99e 100644 Binary files a/ios/chat_expensify_appstore.mobileprovision.gpg and b/ios/chat_expensify_appstore.mobileprovision.gpg differ diff --git a/ios/expensify_chat_adhoc.mobileprovision.gpg b/ios/expensify_chat_adhoc.mobileprovision.gpg index 1464356e423e..8160fba0cfa9 100644 Binary files a/ios/expensify_chat_adhoc.mobileprovision.gpg and b/ios/expensify_chat_adhoc.mobileprovision.gpg differ diff --git a/ios/expensify_chat_dev.mobileprovision.gpg b/ios/expensify_chat_dev.mobileprovision.gpg deleted file mode 100644 index 3b8b96b2c142..000000000000 Binary files a/ios/expensify_chat_dev.mobileprovision.gpg and /dev/null differ diff --git a/package-lock.json b/package-lock.json index ea138c99b8a2..cd763dffefbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.64-0", + "version": "1.3.67-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.64-0", + "version": "1.3.67-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -85,7 +85,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.70", + "react-native-onyx": "1.0.72", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^4.0.0", @@ -179,7 +179,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^25.4.0", + "electron": "^25.8.0", "electron-builder": "24.5.0", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -25005,9 +25005,9 @@ } }, "node_modules/electron": { - "version": "25.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-25.4.0.tgz", - "integrity": "sha512-VLTRxDhL4UvQbqM7pTNENnJo62cdAPZT92N+B7BZQ5Xfok1wuVPEewIjBot4K7U3EpLUuHn1veeLzho3ihiP+Q==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-25.8.0.tgz", + "integrity": "sha512-T3kC1a/3ntSaYMCVVfUUc9v7myPzi6J2GP0Ad/CyfWKDPp054dGyKxb2EEjKnxQQ7wfjsT1JTEdBG04x6ekVBw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -40884,9 +40884,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.70", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.70.tgz", - "integrity": "sha512-bc/u4kkcwbrN6kLxXprZbwYqApYJ7G07IKteJhRuIjXi1hMPxOznRxxqMaOTELgET9y5LezUOB2QOwfEZ59FLg==", + "version": "1.0.72", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.72.tgz", + "integrity": "sha512-roJuA92qZH2PLYSqBhSPCse+Ra2EJu4FBpVqguwJRp6oaLNHR1CtPTgU1xMh/kj2nWmdpcqKoOc3nS35asb80g==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -65715,9 +65715,9 @@ } }, "electron": { - "version": "25.4.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-25.4.0.tgz", - "integrity": "sha512-VLTRxDhL4UvQbqM7pTNENnJo62cdAPZT92N+B7BZQ5Xfok1wuVPEewIjBot4K7U3EpLUuHn1veeLzho3ihiP+Q==", + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-25.8.0.tgz", + "integrity": "sha512-T3kC1a/3ntSaYMCVVfUUc9v7myPzi6J2GP0Ad/CyfWKDPp054dGyKxb2EEjKnxQQ7wfjsT1JTEdBG04x6ekVBw==", "dev": true, "requires": { "@electron/get": "^2.0.0", @@ -76679,9 +76679,9 @@ } }, "react-native-onyx": { - "version": "1.0.70", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.70.tgz", - "integrity": "sha512-bc/u4kkcwbrN6kLxXprZbwYqApYJ7G07IKteJhRuIjXi1hMPxOznRxxqMaOTELgET9y5LezUOB2QOwfEZ59FLg==", + "version": "1.0.72", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.72.tgz", + "integrity": "sha512-roJuA92qZH2PLYSqBhSPCse+Ra2EJu4FBpVqguwJRp6oaLNHR1CtPTgU1xMh/kj2nWmdpcqKoOc3nS35asb80g==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index 1fc9d4022ee2..6666fd19cf7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.64-0", + "version": "1.3.67-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -11,7 +11,7 @@ "postinstall": "scripts/postInstall.sh", "clean": "npx react-native clean-project-auto", "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\"", + "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios --list-devices --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) (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\\\"\"", @@ -125,7 +125,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.70", + "react-native-onyx": "1.0.72", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^4.0.0", @@ -219,7 +219,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^25.4.0", + "electron": "^25.8.0", "electron-builder": "24.5.0", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", diff --git a/src/App.js b/src/App.js index c432a0b666c8..7ec82b9a4f8a 100644 --- a/src/App.js +++ b/src/App.js @@ -24,6 +24,7 @@ import {CurrentReportIDContextProvider} from './components/withCurrentReportID'; import {EnvironmentProvider} from './components/withEnvironment'; import * as Session from './libs/actions/Session'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; +import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; // 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()) { @@ -42,6 +43,7 @@ const fill = {flex: 1}; function App() { useDefaultDragAndDrop(); + OnyxUpdateManager(); return ( ; [ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report; diff --git a/src/ROUTES.js b/src/ROUTES.ts similarity index 64% rename from src/ROUTES.js rename to src/ROUTES.ts index b38ce25f590f..3ea8bd868d6a 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.ts @@ -1,10 +1,16 @@ -import lodashGet from 'lodash/get'; +import {ValueOf} from 'type-fest'; import * as Url from './libs/Url'; +import CONST from './CONST'; /** * This is a file containing constants for all of the routes we want to be able to go to */ +type ParseReportRouteParams = { + reportID: string; + isSubReportPageRoute: boolean; +}; + const REPORT = 'r'; const IOU_REQUEST = 'request/new'; const IOU_BILL = 'split/new'; @@ -20,7 +26,7 @@ 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 = '', backTo = '') => { + getBankAccountRoute: (stepToOpen = '', policyID = '', backTo = ''): string => { const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : ''; return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`; }, @@ -47,7 +53,7 @@ export default { 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}`, + getSettingsAddLoginRoute: (type: string) => `settings/addlogin/${type}`, SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', SETTINGS_PERSONAL_DETAILS, @@ -56,7 +62,7 @@ export default { SETTINGS_PERSONAL_DETAILS_ADDRESS: `${SETTINGS_PERSONAL_DETAILS}/address`, SETTINGS_CONTACT_METHODS, SETTINGS_CONTACT_METHOD_DETAILS: `${SETTINGS_CONTACT_METHODS}/:contactMethod/details`, - getEditContactMethodRoute: (contactMethod) => `${SETTINGS_CONTACT_METHODS}/${encodeURIComponent(contactMethod)}/details`, + getEditContactMethodRoute: (contactMethod: string) => `${SETTINGS_CONTACT_METHODS}/${encodeURIComponent(contactMethod)}/details`, SETTINGS_NEW_CONTACT_METHOD: `${SETTINGS_CONTACT_METHODS}/new`, SETTINGS_2FA: 'settings/security/two-factor-auth', SETTINGS_STATUS, @@ -67,14 +73,14 @@ export default { REPORT, REPORT_WITH_ID: 'r/:reportID/:reportActionID?', EDIT_REQUEST: 'r/:threadReportID/edit/:field', - getEditRequestRoute: (threadReportID, field) => `r/${threadReportID}/edit/${field}`, + getEditRequestRoute: (threadReportID: string, field: ValueOf) => `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}`, + getEditRequestCurrencyRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`, + getReportRoute: (reportID: string) => `r/${reportID}`, REPORT_WITH_ID_DETAILS_SHARE_CODE: 'r/:reportID/details/shareCode', - getReportShareCodeRoute: (reportID) => `r/${reportID}/details/shareCode`, + getReportShareCodeRoute: (reportID: string) => `r/${reportID}/details/shareCode`, REPORT_ATTACHMENTS: 'r/:reportID/attachment', - getReportAttachmentRoute: (reportID, source) => `r/${reportID}/attachment?source=${encodeURI(source)}`, + getReportAttachmentRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`, /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ CONCIERGE: 'concierge', @@ -100,64 +106,61 @@ export default { 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`, - getMoneyRequestRoute: (iouType, reportID = '') => `${iouType}/new/${reportID}`, - 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}`, + getMoneyRequestRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`, + getMoneyRequestAmountRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`, + getMoneyRequestParticipantsRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`, + getMoneyRequestConfirmationRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`, + getMoneyRequestCreatedRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`, + getMoneyRequestCurrencyRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`, + getMoneyRequestDescriptionRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`, + getMoneyRequestCategoryRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`, SPLIT_BILL_DETAILS: `r/:reportID/split/:reportActionID`, - getSplitBillDetailsRoute: (reportID, reportActionID) => `r/${reportID}/split/${reportActionID}`, - getNewTaskRoute: (reportID) => `${NEW_TASK}/${reportID}`, + getSplitBillDetailsRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`, + getNewTaskRoute: (reportID: string) => `${NEW_TASK}/${reportID}`, NEW_TASK_WITH_REPORT_ID: `${NEW_TASK}/:reportID?`, TASK_TITLE: 'r/:reportID/title', TASK_DESCRIPTION: 'r/:reportID/description', TASK_ASSIGNEE: 'r/:reportID/assignee', - getTaskReportTitleRoute: (reportID) => `r/${reportID}/title`, - getTaskReportDescriptionRoute: (reportID) => `r/${reportID}/description`, - getTaskReportAssigneeRoute: (reportID) => `r/${reportID}/assignee`, + getTaskReportTitleRoute: (reportID: string) => `r/${reportID}/title`, + getTaskReportDescriptionRoute: (reportID: string) => `r/${reportID}/description`, + getTaskReportAssigneeRoute: (reportID: string) => `r/${reportID}/assignee`, NEW_TASK_ASSIGNEE: `${NEW_TASK}/assignee`, NEW_TASK_SHARE_DESTINATION: `${NEW_TASK}/share-destination`, NEW_TASK_DETAILS: `${NEW_TASK}/details`, NEW_TASK_TITLE: `${NEW_TASK}/title`, NEW_TASK_DESCRIPTION: `${NEW_TASK}/description`, FLAG_COMMENT: `flag/:reportID/:reportActionID`, - getFlagCommentRoute: (reportID, reportActionID) => `flag/${reportID}/${reportActionID}`, + getFlagCommentRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`, SEARCH: 'search', SAVE_THE_WORLD: 'save-the-world', I_KNOW_A_TEACHER: 'save-the-world/i-know-a-teacher', INTRO_SCHOOL_PRINCIPAL: 'save-the-world/intro-school-principal', I_AM_A_TEACHER: 'save-the-world/i-am-a-teacher', DETAILS: 'details', - getDetailsRoute: (login) => `details?login=${encodeURIComponent(login)}`, + getDetailsRoute: (login: string) => `details?login=${encodeURIComponent(login)}`, PROFILE: 'a/:accountID', - getProfileRoute: (accountID, backTo = '') => { + getProfileRoute: (accountID: string | number, backTo = '') => { const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; return `a/${accountID}${backToParam}`; }, REPORT_PARTICIPANTS: 'r/:reportID/participants', - getReportParticipantsRoute: (reportID) => `r/${reportID}/participants`, + getReportParticipantsRoute: (reportID: string) => `r/${reportID}/participants`, REPORT_WITH_ID_DETAILS: 'r/:reportID/details', - getReportDetailsRoute: (reportID) => `r/${reportID}/details`, + getReportDetailsRoute: (reportID: string) => `r/${reportID}/details`, REPORT_SETTINGS: 'r/:reportID/settings', - getReportSettingsRoute: (reportID) => `r/${reportID}/settings`, + getReportSettingsRoute: (reportID: string) => `r/${reportID}/settings`, REPORT_SETTINGS_ROOM_NAME: 'r/:reportID/settings/room-name', - getReportSettingsRoomNameRoute: (reportID) => `r/${reportID}/settings/room-name`, + getReportSettingsRoomNameRoute: (reportID: string) => `r/${reportID}/settings/room-name`, REPORT_SETTINGS_NOTIFICATION_PREFERENCES: 'r/:reportID/settings/notification-preferences', - getReportSettingsNotificationPreferencesRoute: (reportID) => `r/${reportID}/settings/notification-preferences`, + getReportSettingsNotificationPreferencesRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`, REPORT_WELCOME_MESSAGE: 'r/:reportID/welcomeMessage', - getReportWelcomeMessageRoute: (reportID) => `r/${reportID}/welcomeMessage`, + getReportWelcomeMessageRoute: (reportID: string) => `r/${reportID}/welcomeMessage`, REPORT_SETTINGS_WRITE_CAPABILITY: 'r/:reportID/settings/who-can-post', - getReportSettingsWriteCapabilityRoute: (reportID) => `r/${reportID}/settings/who-can-post`, + getReportSettingsWriteCapabilityRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`, TRANSITION_BETWEEN_APPS: 'transition', VALIDATE_LOGIN: 'v/:accountID/:validateCode', GET_ASSISTANCE: 'get-assistance/:taskID', - getGetAssistanceRoute: (taskID) => `get-assistance/${taskID}`, + getGetAssistanceRoute: (taskID: string) => `get-assistance/${taskID}`, UNLINK_LOGIN: 'u/:accountID/:validateCode', APPLE_SIGN_IN: 'sign-in-with-apple', @@ -168,7 +171,7 @@ export default { // when linking users from e.com in order to share a session in this app. ENABLE_PAYMENTS: 'enable-payments', WALLET_STATEMENT_WITH_DATE: 'statements/:yearMonth', - getWalletStatementWithDateRoute: (yearMonth) => `statements/${yearMonth}`, + getWalletStatementWithDateRoute: (yearMonth: string) => `statements/${yearMonth}`, WORKSPACE_NEW: 'workspace/new', WORKSPACE_INITIAL: 'workspace/:policyID', WORKSPACE_INVITE: 'workspace/:policyID/invite', @@ -182,27 +185,23 @@ export default { WORKSPACE_TRAVEL: 'workspace/:policyID/travel', WORKSPACE_MEMBERS: 'workspace/:policyID/members', WORKSPACE_NEW_ROOM: 'workspace/new-room', - getWorkspaceInitialRoute: (policyID) => `workspace/${policyID}`, - getWorkspaceInviteRoute: (policyID) => `workspace/${policyID}/invite`, - getWorkspaceInviteMessageRoute: (policyID) => `workspace/${policyID}/invite-message`, - getWorkspaceSettingsRoute: (policyID) => `workspace/${policyID}/settings`, - getWorkspaceCardRoute: (policyID) => `workspace/${policyID}/card`, - getWorkspaceReimburseRoute: (policyID) => `workspace/${policyID}/reimburse`, - getWorkspaceRateAndUnitRoute: (policyID) => `workspace/${policyID}/rateandunit`, - getWorkspaceBillsRoute: (policyID) => `workspace/${policyID}/bills`, - getWorkspaceInvoicesRoute: (policyID) => `workspace/${policyID}/invoices`, - getWorkspaceTravelRoute: (policyID) => `workspace/${policyID}/travel`, - getWorkspaceMembersRoute: (policyID) => `workspace/${policyID}/members`, + getWorkspaceInitialRoute: (policyID: string) => `workspace/${policyID}`, + getWorkspaceInviteRoute: (policyID: string) => `workspace/${policyID}/invite`, + getWorkspaceInviteMessageRoute: (policyID: string) => `workspace/${policyID}/invite-message`, + getWorkspaceSettingsRoute: (policyID: string) => `workspace/${policyID}/settings`, + getWorkspaceCardRoute: (policyID: string) => `workspace/${policyID}/card`, + getWorkspaceReimburseRoute: (policyID: string) => `workspace/${policyID}/reimburse`, + getWorkspaceRateAndUnitRoute: (policyID: string) => `workspace/${policyID}/rateandunit`, + getWorkspaceBillsRoute: (policyID: string) => `workspace/${policyID}/bills`, + getWorkspaceInvoicesRoute: (policyID: string) => `workspace/${policyID}/invoices`, + getWorkspaceTravelRoute: (policyID: string) => `workspace/${policyID}/travel`, + getWorkspaceMembersRoute: (policyID: string) => `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} - */ - parseReportRouteParams: (route) => { + parseReportRouteParams: (route: string): ParseReportRouteParams => { let parsingRoute = route; if (parsingRoute.at(0) === '/') { // remove the first slash @@ -215,9 +214,9 @@ export default { const pathSegments = parsingRoute.split('/'); return { - reportID: lodashGet(pathSegments, 1), + reportID: pathSegments[1], isSubReportPageRoute: pathSegments.length > 2, }; }, SIGN_IN_MODAL: 'sign-in-modal', -}; +} as const; diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js index 641e65ce9d12..62eeb3030619 100644 --- a/src/components/ButtonWithDropdownMenu.js +++ b/src/components/ButtonWithDropdownMenu.js @@ -108,7 +108,6 @@ function ButtonWithDropdownMenu(props) { isLoading={props.isLoading} shouldRemoveRightBorderRadius style={[styles.flex1, styles.pr0]} - pressOnEnter large={isButtonSizeLarge} medium={!isButtonSizeLarge} innerStyles={[innerStyleDropButton]} @@ -144,7 +143,6 @@ function ButtonWithDropdownMenu(props) { isLoading={props.isLoading} text={selectedItem.text} onPress={(event) => props.onPress(event, props.options[0].value)} - pressOnEnter large={isButtonSizeLarge} medium={!isButtonSizeLarge} innerStyles={[innerStyleDropButton]} diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index cbd22cc39dfd..44075a4ec1eb 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -83,6 +83,9 @@ const propTypes = { /** Whether this is the report action compose */ isReportActionCompose: PropTypes.bool, + /** Whether the sull composer is open */ + isComposerFullSize: PropTypes.bool, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -111,6 +114,7 @@ const defaultProps = { shouldCalculateCaretPosition: false, checkComposerVisibility: () => false, isReportActionCompose: false, + isComposerFullSize: false, }; /** @@ -161,6 +165,7 @@ function Composer({ checkComposerVisibility, selection: selectionProp, isReportActionCompose, + isComposerFullSize, ...props }) { const textRef = useRef(null); @@ -413,7 +418,6 @@ function Composer({ { MapboxToken.init(); @@ -163,7 +164,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) useEffect(updateGradientVisibility, [scrollContainerHeight, scrollContentHeight]); return ( - + setScrollContainerHeight(lodashGet(event, 'nativeEvent.layout.height', 0))} @@ -176,7 +177,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) setScrollContentHeight(height); }} onScroll={updateGradientVisibility} - scrollEventThrottle={16} + scrollEventThrottle={variables.distanceScrollEventThrottle} ref={scrollViewRef} > {_.map(waypoints, (waypoint, key) => { @@ -212,7 +213,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) {shouldShowGradient && ( )} {hasRouteError && ( diff --git a/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js b/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js index d9cc806e9012..82e503456f7d 100644 --- a/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js +++ b/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js @@ -6,4 +6,7 @@ export default { /** Should this dropZone be disabled? */ isDisabled: PropTypes.bool, + + /** Indicate that users are dragging file or not */ + setIsDraggingOver: PropTypes.func, }; diff --git a/src/components/DragAndDrop/Provider/index.js b/src/components/DragAndDrop/Provider/index.js index 89b0f47a830d..6408f6dbfbfa 100644 --- a/src/components/DragAndDrop/Provider/index.js +++ b/src/components/DragAndDrop/Provider/index.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useRef, useCallback} from 'react'; +import React, {useRef, useCallback, useEffect} from 'react'; import {View} from 'react-native'; import {PortalHost} from '@gorhom/portal'; import Str from 'expensify-common/lib/str'; @@ -17,7 +17,7 @@ function shouldAcceptDrop(event) { return _.some(event.dataTransfer.types, (type) => type === 'Files'); } -function DragAndDropProvider({children, isDisabled = false}) { +function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver = () => {}}) { const dropZone = useRef(null); const dropZoneID = useRef(Str.guid('drag-n-drop')); @@ -33,6 +33,10 @@ function DragAndDropProvider({children, isDisabled = false}) { isDisabled, }); + useEffect(() => { + setIsDraggingOver(isDraggingOver); + }, [isDraggingOver, setIsDraggingOver]); + return ( { + focusedInput.current = inputID; + if (_.isFunction(child.props.onFocus)) { + child.props.onFocus(event); + } + }, onBlur: (event) => { // Only run validation when user proactively blurs the input. if (Visibility.isVisible() && Visibility.hasFocus()) { @@ -328,6 +335,11 @@ function Form(props) { }, onInputChange: (value, key) => { const inputKey = key || inputID; + + if (focusedInput.current && focusedInput.current !== inputKey) { + setTouchedInput(focusedInput.current); + } + setInputValues((prevState) => { const newState = { ...prevState, diff --git a/src/components/HeaderGap/index.desktop.js b/src/components/HeaderGap/index.desktop.js index 10974aa9f5ee..6b47f56516de 100644 --- a/src/components/HeaderGap/index.desktop.js +++ b/src/components/HeaderGap/index.desktop.js @@ -1,9 +1,22 @@ import React, {PureComponent} from 'react'; import {View} from 'react-native'; +import PropTypes from 'prop-types'; import styles from '../../styles/styles'; -export default class HeaderGap extends PureComponent { +const propTypes = { + /** Styles to apply to the HeaderGap */ + // eslint-disable-next-line react/forbid-prop-types + styles: PropTypes.arrayOf(PropTypes.object), +}; + +class HeaderGap extends PureComponent { render() { - return ; + return ; } } + +HeaderGap.propTypes = propTypes; +HeaderGap.defaultProps = { + styles: [], +}; +export default HeaderGap; diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js index 67b9a0406aef..5fabf73547ea 100644 --- a/src/components/PopoverMenu/index.js +++ b/src/components/PopoverMenu/index.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useState} from 'react'; +import React, {useRef} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent'; @@ -48,13 +48,13 @@ const defaultProps = { function PopoverMenu(props) { const {isSmallScreenWidth} = useWindowDimensions(); - const [selectedItemIndex, setSelectedItemIndex] = useState(null); + const selectedItemIndex = useRef(null); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: props.menuItems.length - 1, isActive: props.isVisible}); const selectItem = (index) => { const selectedItem = props.menuItems[index]; props.onItemSelected(selectedItem, index); - setSelectedItemIndex(index); + selectedItemIndex.current = index; }; useKeyboardShortcut( @@ -78,9 +78,9 @@ function PopoverMenu(props) { isVisible={props.isVisible} onModalHide={() => { setFocusedIndex(-1); - if (selectedItemIndex !== null) { - props.menuItems[selectedItemIndex].onSelected(); - setSelectedItemIndex(null); + if (selectedItemIndex.current !== null) { + props.menuItems[selectedItemIndex.current].onSelected(); + selectedItemIndex.current = null; } }} animationIn={props.animationIn} diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 05c3463538c6..02da03225062 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -32,7 +32,10 @@ import PressableWithFeedback from '../Pressable/PressableWithoutFeedback'; import * as ReceiptUtils from '../../libs/ReceiptUtils'; import ReportActionItemImages from './ReportActionItemImages'; import transactionPropTypes from '../transactionPropTypes'; +import * as StyleUtils from '../../styles/StyleUtils'; import colors from '../../styles/colors'; +import variables from '../../styles/variables'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; import MoneyRequestSkeletonView from '../MoneyRequestSkeletonView'; const propTypes = { @@ -135,9 +138,12 @@ const defaultProps = { }; function MoneyRequestPreview(props) { + const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + 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 || ''; @@ -265,7 +271,15 @@ function MoneyRequestPreview(props) { - {getDisplayAmountText()} + + {getDisplayAmountText()} + {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && ( Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={hasErrors && transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - subtitle={hasErrors && transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT ? translate('common.error.enterMerchant') : ''} + brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + subtitle={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} subtitleTextStyle={styles.textLabelError} /> diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js index 82082b18ce1c..e8e3aa8e8c40 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.js +++ b/src/components/ReportActionItem/ReportActionItemImages.js @@ -11,7 +11,7 @@ const propTypes = { images: PropTypes.arrayOf( PropTypes.shape({ thumbnail: PropTypes.string, - image: PropTypes.string, + image: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }), ).isRequired, diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index ebdd79f586e1..f760e5d5aeb4 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -124,7 +124,7 @@ class ScreenWrapper extends React.Component { style={styles.flex1} enabled={this.props.shouldEnablePickerAvoiding} > - + {this.props.environment === CONST.ENVIRONMENT.DEV && } {this.props.environment === CONST.ENVIRONMENT.DEV && } { diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js index 7162ca074f43..83033d9e97b7 100644 --- a/src/components/ScreenWrapper/propTypes.js +++ b/src/components/ScreenWrapper/propTypes.js @@ -36,6 +36,9 @@ const propTypes = { /** Whether to use the maxHeight (true) or use the 100% of the height (false) */ shouldEnableMaxHeight: PropTypes.bool, + /** Array of additional styles for header gap */ + headerGapStyles: PropTypes.arrayOf(PropTypes.object), + ...windowDimensionsPropTypes, ...environmentPropTypes, @@ -59,6 +62,7 @@ const defaultProps = { shouldEnablePickerAvoiding: true, shouldShowOfflineIndicator: true, offlineIndicatorStyle: [], + headerGapStyles: [], }; export {propTypes, defaultProps}; diff --git a/src/components/avatarPropTypes.js b/src/components/avatarPropTypes.js index 7e978fc74963..12ee5c622b4f 100644 --- a/src/components/avatarPropTypes.js +++ b/src/components/avatarPropTypes.js @@ -5,5 +5,5 @@ export default PropTypes.shape({ source: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), type: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_WORKSPACE]), name: PropTypes.string, - id: PropTypes.number, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }); diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js index 66ed18a1f0b7..bc0a10025ba8 100644 --- a/src/components/transactionPropTypes.js +++ b/src/components/transactionPropTypes.js @@ -68,7 +68,7 @@ export default PropTypes.shape({ /** The receipt object associated with the transaction */ receipt: PropTypes.shape({ receiptID: PropTypes.number, - source: PropTypes.string, + source: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), state: PropTypes.string, }), diff --git a/src/components/withWindowDimensions.js b/src/components/withWindowDimensions/index.js similarity index 95% rename from src/components/withWindowDimensions.js rename to src/components/withWindowDimensions/index.js index 9ec9c5d4acbd..a3836fa99e6b 100644 --- a/src/components/withWindowDimensions.js +++ b/src/components/withWindowDimensions/index.js @@ -2,9 +2,9 @@ import React, {forwardRef, createContext, useState, useEffect} from 'react'; import PropTypes from 'prop-types'; import {Dimensions} from 'react-native'; import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import variables from '../styles/variables'; -import getWindowHeightAdjustment from '../libs/getWindowHeightAdjustment'; +import getComponentDisplayName from '../../libs/getComponentDisplayName'; +import variables from '../../styles/variables'; +import getWindowHeightAdjustment from '../../libs/getWindowHeightAdjustment'; const WindowDimensionsContext = createContext(null); const windowDimensionsPropTypes = { diff --git a/src/components/withWindowDimensions/index.native.js b/src/components/withWindowDimensions/index.native.js new file mode 100644 index 000000000000..e147a20c9f4e --- /dev/null +++ b/src/components/withWindowDimensions/index.native.js @@ -0,0 +1,116 @@ +import React, {forwardRef, createContext, useState, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {Dimensions} from 'react-native'; +import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import getComponentDisplayName from '../../libs/getComponentDisplayName'; +import variables from '../../styles/variables'; +import getWindowHeightAdjustment from '../../libs/getWindowHeightAdjustment'; + +const WindowDimensionsContext = createContext(null); +const windowDimensionsPropTypes = { + // Width of the window + windowWidth: PropTypes.number.isRequired, + + // Height of the window + windowHeight: PropTypes.number.isRequired, + + // Is the window width extra narrow, like on a Fold mobile device? + isExtraSmallScreenWidth: PropTypes.bool.isRequired, + + // Is the window width narrow, like on a mobile device? + isSmallScreenWidth: PropTypes.bool.isRequired, + + // Is the window width medium sized, like on a tablet device? + isMediumScreenWidth: PropTypes.bool.isRequired, + + // Is the window width wide, like on a browser or desktop? + isLargeScreenWidth: PropTypes.bool.isRequired, +}; + +const windowDimensionsProviderPropTypes = { + /* Actual content wrapped by this component */ + children: PropTypes.node.isRequired, +}; + +function WindowDimensionsProvider(props) { + const [windowDimension, setWindowDimension] = useState(() => { + const initialDimensions = Dimensions.get('window'); + return { + windowHeight: initialDimensions.height, + windowWidth: initialDimensions.width, + }; + }); + + useEffect(() => { + const onDimensionChange = (newDimensions) => { + const {window} = newDimensions; + + setWindowDimension({ + windowHeight: window.height, + windowWidth: window.width, + }); + }; + + const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChange); + + return () => { + if (!dimensionsEventListener) { + return; + } + dimensionsEventListener.remove(); + }; + }, []); + + return ( + + {(insets) => { + const isExtraSmallScreenWidth = windowDimension.windowWidth <= variables.extraSmallMobileResponsiveWidthBreakpoint; + const isSmallScreenWidth = true; + const isMediumScreenWidth = false; + const isLargeScreenWidth = false; + return ( + + {props.children} + + ); + }} + + ); +} + +WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes; +WindowDimensionsProvider.displayName = 'WindowDimensionsProvider'; + +/** + * @param {React.Component} WrappedComponent + * @returns {React.Component} + */ +export default function withWindowDimensions(WrappedComponent) { + const WithWindowDimensions = forwardRef((props, ref) => ( + + {(windowDimensionsProps) => ( + + )} + + )); + + WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`; + return WithWindowDimensions; +} + +export {WindowDimensionsProvider, windowDimensionsPropTypes}; diff --git a/src/hooks/useWindowDimensions.js b/src/hooks/useWindowDimensions/index.js similarity index 95% rename from src/hooks/useWindowDimensions.js rename to src/hooks/useWindowDimensions/index.js index 58e6b8758927..86ff7ce85d3d 100644 --- a/src/hooks/useWindowDimensions.js +++ b/src/hooks/useWindowDimensions/index.js @@ -1,6 +1,6 @@ // eslint-disable-next-line no-restricted-imports import {useWindowDimensions} from 'react-native'; -import variables from '../styles/variables'; +import variables from '../../styles/variables'; /** * A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints. diff --git a/src/hooks/useWindowDimensions/index.native.js b/src/hooks/useWindowDimensions/index.native.js new file mode 100644 index 000000000000..358e43f1b75d --- /dev/null +++ b/src/hooks/useWindowDimensions/index.native.js @@ -0,0 +1,23 @@ +// eslint-disable-next-line no-restricted-imports +import {useWindowDimensions} from 'react-native'; +import variables from '../../styles/variables'; + +/** + * A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints. + * @returns {Object} + */ +export default function () { + const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + const isExtraSmallScreenHeight = windowHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint; + const isSmallScreenWidth = true; + const isMediumScreenWidth = false; + const isLargeScreenWidth = false; + return { + windowWidth, + windowHeight, + isExtraSmallScreenHeight, + isSmallScreenWidth, + isMediumScreenWidth, + isLargeScreenWidth, + }; +} diff --git a/src/languages/en.ts b/src/languages/en.ts index af7957e1a560..c863caae67ff 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1345,6 +1345,7 @@ export default { fastReimbursementsVBACopy: "You're all set to reimburse receipts from your bank account!", updateCustomUnitError: "Your changes couldn't be saved. The workspace was modified while you were offline, please try again.", invalidRateError: 'Please enter a valid rate', + lowRateError: 'Rate must be greater than 0', }, bills: { manageYourBills: 'Manage your bills', diff --git a/src/languages/es.ts b/src/languages/es.ts index f950733b005c..fd98e3ef51fa 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1373,6 +1373,7 @@ export default { fastReimbursementsVBACopy: '¡Todo listo para reembolsar recibos desde tu cuenta bancaria!', updateCustomUnitError: 'Los cambios no han podido ser guardados. El espacio de trabajo ha sido modificado mientras estabas desconectado. Por favor, inténtalo de nuevo.', invalidRateError: 'Por favor, introduce una tarifa válida', + lowRateError: 'La tarifa debe ser mayor que 0', }, bills: { manageYourBills: 'Gestiona tus facturas', diff --git a/src/libs/API.js b/src/libs/API.js index 9405fb8f3a51..491503f07381 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -21,7 +21,8 @@ Request.use(Middleware.RecheckConnection); // Reauthentication - Handles jsonCode 407 which indicates an expired authToken. We need to reauthenticate and get a new authToken with our stored credentials. Request.use(Middleware.Reauthentication); -// SaveResponseInOnyx - Merges either the successData or failureData into Onyx depending on if the call was successful or not +// SaveResponseInOnyx - Merges either the successData or failureData into Onyx depending on if the call was successful or not. This needs to be the LAST middleware we use, don't add any +// middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state. Request.use(Middleware.SaveResponseInOnyx); /** diff --git a/src/libs/ControlSelection/index.native.js b/src/libs/ControlSelection/index.native.ts similarity index 55% rename from src/libs/ControlSelection/index.native.js rename to src/libs/ControlSelection/index.native.ts index ea91f4bbb1da..e9a1e4e9ad5b 100644 --- a/src/libs/ControlSelection/index.native.js +++ b/src/libs/ControlSelection/index.native.ts @@ -1,11 +1,15 @@ +import ControlSelectionModule from './types'; + function block() {} function unblock() {} function blockElement() {} function unblockElement() {} -export default { +const ControlSelection: ControlSelectionModule = { block, unblock, blockElement, unblockElement, }; + +export default ControlSelection; diff --git a/src/libs/ControlSelection/index.js b/src/libs/ControlSelection/index.ts similarity index 64% rename from src/libs/ControlSelection/index.js rename to src/libs/ControlSelection/index.ts index 7269960d744a..9625b4e49787 100644 --- a/src/libs/ControlSelection/index.js +++ b/src/libs/ControlSelection/index.ts @@ -1,4 +1,5 @@ -import _ from 'underscore'; +import ControlSelectionModule from './types'; +import CustomRefObject from '../../types/utils/CustomRefObject'; /** * Block selection on the whole app @@ -18,10 +19,9 @@ function unblock() { /** * Block selection on particular element - * @param {Element} ref */ -function blockElement(ref) { - if (_.isNull(ref)) { +function blockElement(ref?: CustomRefObject | null) { + if (!ref) { return; } @@ -31,10 +31,9 @@ function blockElement(ref) { /** * Unblock selection on particular element - * @param {Element} ref */ -function unblockElement(ref) { - if (_.isNull(ref)) { +function unblockElement(ref?: CustomRefObject | null) { + if (!ref) { return; } @@ -42,9 +41,11 @@ function unblockElement(ref) { ref.onselectstart = () => true; } -export default { +const ControlSelection: ControlSelectionModule = { block, unblock, blockElement, unblockElement, }; + +export default ControlSelection; diff --git a/src/libs/ControlSelection/types.ts b/src/libs/ControlSelection/types.ts new file mode 100644 index 000000000000..5706a4981d30 --- /dev/null +++ b/src/libs/ControlSelection/types.ts @@ -0,0 +1,10 @@ +import CustomRefObject from '../../types/utils/CustomRefObject'; + +type ControlSelectionModule = { + block: () => void; + unblock: () => void; + blockElement: (ref?: CustomRefObject | null) => void; + unblockElement: (ref?: CustomRefObject | null) => void; +}; + +export default ControlSelectionModule; diff --git a/src/libs/EmojiTrie.js b/src/libs/EmojiTrie.js index c5448c340d81..b0bd0d5eec5d 100644 --- a/src/libs/EmojiTrie.js +++ b/src/libs/EmojiTrie.js @@ -18,26 +18,40 @@ function createTrie(lang = CONST.LOCALES.DEFAULT) { return; } - const name = isDefaultLocale ? item.name : _.get(langEmojis, [item.code, 'name']); - const names = isDefaultLocale ? [name] : [...new Set([name, item.name])]; - _.forEach(names, (nm) => { - const node = trie.search(nm); - if (!node) { - trie.add(nm, {code: item.code, types: item.types, name: nm, suggestions: []}); - } else { - trie.update(nm, {code: item.code, types: item.types, name: nm, suggestions: node.metaData.suggestions}); - } - }); + const englishName = item.name; + const localeName = _.get(langEmojis, [item.code, 'name'], englishName); + const node = trie.search(localeName); + if (!node) { + trie.add(localeName, {code: item.code, types: item.types, name: localeName, suggestions: []}); + } else { + trie.update(localeName, {code: item.code, types: item.types, name: localeName, suggestions: node.metaData.suggestions}); + } + + // Add keywords for both the locale language and English to enable users to search using either language. const keywords = _.get(langEmojis, [item.code, 'keywords'], []).concat(isDefaultLocale ? [] : _.get(localeEmojis, [CONST.LOCALES.DEFAULT, item.code, 'keywords'], [])); for (let j = 0; j < keywords.length; j++) { const keywordNode = trie.search(keywords[j]); if (!keywordNode) { - trie.add(keywords[j], {suggestions: [{code: item.code, types: item.types, name}]}); + trie.add(keywords[j], {suggestions: [{code: item.code, types: item.types, name: localeName}]}); } else { trie.update(keywords[j], { ...keywordNode.metaData, - suggestions: [...keywordNode.metaData.suggestions, {code: item.code, types: item.types, name}], + suggestions: [...keywordNode.metaData.suggestions, {code: item.code, types: item.types, name: localeName}], + }); + } + } + + // If current language isn't the default, prepend the English name of the emoji in the suggestions as well. + // We do this because when the user types the english name of the emoji, we want to show the emoji in the suggestions before all the others. + if (!isDefaultLocale) { + const englishNode = trie.search(englishName); + if (!englishNode) { + trie.add(englishName, {suggestions: [{code: item.code, types: item.types, name: localeName}]}); + } else { + trie.update(englishName, { + ...englishNode.metaData, + suggestions: [{code: item.code, types: item.types, name: localeName}, ...englishNode.metaData.suggestions], }); } } diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index df00418b7524..80665541e24b 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -319,7 +319,16 @@ function replaceEmojis(text, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, } for (let i = 0; i < emojiData.length; i++) { const name = emojiData[i].slice(1, -1); - const checkEmoji = trie.search(name); + let checkEmoji = trie.search(name); + // If the user has selected a language other than English, and the emoji doesn't exist in that language, + // we will check if the emoji exists in English. + if (lang !== CONST.LOCALES.DEFAULT && (!checkEmoji || !checkEmoji.metaData.code)) { + const englishTrie = emojisTrie[CONST.LOCALES.DEFAULT]; + if (englishTrie) { + const englishEmoji = englishTrie.search(name); + checkEmoji = englishEmoji; + } + } if (checkEmoji && checkEmoji.metaData.code) { let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData, preferredSkinTone); emojis.push({ diff --git a/src/libs/Middleware/SaveResponseInOnyx.js b/src/libs/Middleware/SaveResponseInOnyx.js index 28b8a93fb585..8cb66c0c10d0 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.js +++ b/src/libs/Middleware/SaveResponseInOnyx.js @@ -1,34 +1,32 @@ -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'; +// If we're executing any of these requests, we don't need to trigger our OnyxUpdates flow to update the current data even if our current value is out of +// date because all these requests are updating the app to the most current state. +const requestsToIgnoreLastUpdateID = ['OpenApp', 'ReconnectApp', 'GetMissingOnyxMessages']; + /** - * @param {Promise} response + * @param {Promise} requestResponse * @param {Object} request * @returns {Promise} */ -function SaveResponseInOnyx(response, request) { - return response.then((responseData) => { - // Make sure we have response data (i.e. response isn't a promise being passed down to us by a failed retry request and responseData undefined) - if (!responseData) { +function SaveResponseInOnyx(requestResponse, request) { + return requestResponse.then((response) => { + // Make sure we have response data (i.e. response isn't a promise being passed down to us by a failed retry request and response undefined) + if (!response) { return; } + const onyxUpdates = response.onyxData; - // 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) + // Sometimes we call requests that are successfull but they don't have any response or any success/failure data to set. Let's return early since + // we don't need to store anything here. + if (!onyxUpdates && !request.successData && !request.failureData) { + return Promise.resolve(response); + } - // 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) { @@ -39,30 +37,26 @@ function SaveResponseInOnyx(response, request) { 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)); + const responseToApply = { + type: CONST.ONYX_UPDATE_TYPES.HTTPS, + lastUpdateID: Number(response.lastUpdateID || 0), + previousUpdateID: Number(response.previousUpdateID || 0), + request, + response, + }; - // 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; + if (_.includes(requestsToIgnoreLastUpdateID, request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response.previousUpdateID || 0))) { + return OnyxUpdates.apply(responseToApply); + } - // First apply any onyx data updates that are being sent back from the API. We wait for this to complete and then - // apply successData or failureData. This ensures that we do not update any pending, loading, or other UI states contained - // in successData/failureData until after the component has received and API data. - const onyxDataUpdatePromise = responseData.onyxData ? updateHandler(responseData.onyxData) : Promise.resolve(); + // 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.saveUpdateInformation(responseToApply); - return onyxDataUpdatePromise - .then(() => { - // Handle the request's success/failure data (client-side data) - if (responseData.jsonCode === 200 && request.successData) { - return updateHandler(request.successData); - } - if (responseData.jsonCode !== 200 && request.failureData) { - return updateHandler(request.failureData); - } - return Promise.resolve(); - }) - .then(() => responseData); + // Ensure the queue is paused while the client resolves the gap in onyx updates so that updates are guaranteed to happen in a specific order. + return Promise.resolve({ + ...response, + shouldPauseQueue: true, + }); }); } diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index 42d6627d6699..00c2d536e8ba 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -72,12 +72,12 @@ function NavigationRoot(props) { }, [isSmallScreenWidth]); useEffect(() => { - if (!navigationRef.isReady()) { + if (!navigationRef.isReady() || !props.authenticated) { return; } // We need to force state rehydration so the CustomRouter can add the CentralPaneNavigator route if necessary. navigationRef.resetRoot(navigationRef.getRootState()); - }, [isSmallScreenWidth]); + }, [isSmallScreenWidth, props.authenticated]); const prevStatusBarBackgroundColor = useRef(themeColors.appBG); const statusBarBackgroundColor = useRef(themeColors.appBG); diff --git a/src/libs/Network/SequentialQueue.js b/src/libs/Network/SequentialQueue.js index f8ea396663a5..e53515fb5e87 100644 --- a/src/libs/Network/SequentialQueue.js +++ b/src/libs/Network/SequentialQueue.js @@ -21,6 +21,30 @@ let isSequentialQueueRunning = false; let currentRequest = null; let isQueuePaused = false; +/** + * 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; +} + +/** + * Gets the current Onyx queued updates, apply them and clear the queue if the queue is not paused. + */ +function flushOnyxUpdatesQueue() { + // The only situation where the queue is paused is if we found a gap between the app current data state and our server's. If that happens, + // we'll trigger async calls to make the client updated again. While we do that, we don't want to insert anything in Onyx. + if (isQueuePaused) { + return; + } + QueuedOnyxUpdates.flushQueue(); +} + /** * Process any persisted requests, when online, one at a time until the queue is empty. * @@ -44,7 +68,12 @@ function process() { // Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed. currentRequest = Request.processWithMiddleware(requestToProcess, true) - .then(() => { + .then((response) => { + // A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and + // that gap needs resolved before the queue can continue. + if (response.shouldPauseQueue) { + pause(); + } PersistedRequests.remove(requestToProcess); RequestThrottle.clear(); return process(); @@ -94,12 +123,27 @@ function flush() { isSequentialQueueRunning = false; resolveIsReadyPromise(); currentRequest = null; - Onyx.update(QueuedOnyxUpdates.getQueuedUpdates()).then(QueuedOnyxUpdates.clear); + flushOnyxUpdatesQueue(); }); }, }); } +/** + * 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; + flushOnyxUpdatesQueue(); + flush(); +} + /** * @returns {Boolean} */ @@ -149,30 +193,4 @@ function waitForIdle() { return isReadyPromise; } -/** - * 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/PusherUtils.js b/src/libs/PusherUtils.js index 9d84bd4012fe..b4615d3c7d8b 100644 --- a/src/libs/PusherUtils.js +++ b/src/libs/PusherUtils.js @@ -18,12 +18,13 @@ function subscribeToMultiEvent(eventType, callback) { /** * @param {String} eventType * @param {Mixed} data + * @returns {Promise} */ function triggerMultiEventHandler(eventType, data) { if (!multiEventCallbackMapping[eventType]) { - return; + return Promise.resolve(); } - multiEventCallbackMapping[eventType](data); + return multiEventCallbackMapping[eventType](data); } /** diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 9cbc414bf582..9bb365c0f42a 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -105,11 +105,15 @@ function isWhisperAction(action) { } /** + * Returns whether the comment is a thread parent message/the first message in a thread + * * @param {Object} reportAction + * @param {String} reportID * @returns {Boolean} */ -function hasCommentThread(reportAction) { - return lodashGet(reportAction, 'childType', '') === CONST.REPORT.TYPE.CHAT && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0; +function isThreadParentMessage(reportAction = {}, reportID) { + const {childType, childVisibleActionCount = 0, childReportID} = reportAction; + return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID); } /** @@ -628,7 +632,7 @@ export { getLastClosedReportAction, getLatestReportActionFromOnyxData, isMoneyRequestAction, - hasCommentThread, + isThreadParentMessage, getLinkedTransactionID, getMostRecentReportActionLastModified, getReportPreviewAction, diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 7390bac47dd1..53423e8deaf2 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -642,7 +642,7 @@ function isDM(report) { * @returns {Boolean} */ function hasSingleParticipant(report) { - return report.participants && report.participants.length === 1; + return report.participantAccountIDs && report.participantAccountIDs.length === 1; } /** @@ -815,7 +815,7 @@ function getRoomWelcomeMessage(report, isUserPolicyAdmin) { * @returns {Boolean} */ function chatIncludesConcierge(report) { - return report.participantAccountIDs && _.contains(report.participantAccountIDs, CONST.ACCOUNT_ID.CONCIERGE); + return !_.isEmpty(report.participantAccountIDs) && _.contains(report.participantAccountIDs, CONST.ACCOUNT_ID.CONCIERGE); } /** diff --git a/src/libs/RoomNameInputUtils.js b/src/libs/RoomNameInputUtils.ts similarity index 82% rename from src/libs/RoomNameInputUtils.js rename to src/libs/RoomNameInputUtils.ts index 15b85f9f651a..2777acee45dd 100644 --- a/src/libs/RoomNameInputUtils.js +++ b/src/libs/RoomNameInputUtils.ts @@ -2,11 +2,8 @@ import CONST from '../CONST'; /** * Replaces spaces with dashes - * - * @param {String} roomName - * @returns {String} */ -function modifyRoomName(roomName) { +function modifyRoomName(roomName: string): string { const modifiedRoomNameWithoutHash = roomName .replace(/ /g, '-') diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index b99c44abad90..16deefef3a00 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -92,6 +92,7 @@ function hasReceipt(transaction) { function areRequiredFieldsEmpty(transaction) { return ( transaction.modifiedMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || + transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || (transaction.modifiedMerchant === '' && (transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === '' || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) || (transaction.modifiedAmount === 0 && transaction.amount === 0) || diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js index 6028e0468696..90c2a9ec4f16 100644 --- a/src/libs/actions/App.js +++ b/src/libs/actions/App.js @@ -18,7 +18,6 @@ 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; @@ -208,6 +207,35 @@ function reconnectApp(updateIDFrom = 0) { }); } +/** + * Fetches data when the app will call reconnectApp without params for the last time. This is a separate function + * because it will follow patterns that are not recommended so we can be sure we're not putting the app in a unusable + * state because of race conditions between reconnectApp and other pusher updates being applied at the same time. + * @return {Promise} + */ +function finalReconnectAppAfterActivatingReliableUpdates() { + console.debug(`[OnyxUpdates] Executing last reconnect app with promise`); + return getPolicyParamsForOpenOrReconnect().then((policyParams) => { + const params = {...policyParams}; + + // When the app reconnects we do a fast "sync" of the LHN and only return chats that have new messages. We achieve this by sending the most recent reportActionID. + // we have locally. And then only update the user about chats with messages that have occurred after that reportActionID. + // + // - Look through the local report actions and reports to find the most recently modified report action or report. + // - We send this to the server so that it can compute which new chats the user needs to see and return only those as an optimization. + Timing.start(CONST.TIMING.CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION); + params.mostRecentReportActionLastModified = ReportActionsUtils.getMostRecentReportActionLastModified(); + Timing.end(CONST.TIMING.CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION, '', 500); + + // It is SUPER BAD FORM to return promises from action methods. + // DO NOT FOLLOW THIS PATTERN!!!!! + // It was absolutely necessary in order to not break the app while migrating to the new reliable updates pattern. This method will be removed + // as soon as we have everyone migrated to the reliableUpdate beta. + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects('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 @@ -231,48 +259,6 @@ function getMissingOnyxUpdates(updateIDFrom = 0, updateIDTo = 0) { ); } -// 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. @@ -484,4 +470,6 @@ export { beginDeepLinkRedirect, beginDeepLinkRedirectAfterTransition, createWorkspaceAndNavigateToIt, + getMissingOnyxUpdates, + finalReconnectAppAfterActivatingReliableUpdates, }; diff --git a/src/libs/actions/AppUpdate.js b/src/libs/actions/AppUpdate.ts similarity index 78% rename from src/libs/actions/AppUpdate.js rename to src/libs/actions/AppUpdate.ts index 502ef9762252..f0e3c1c3da20 100644 --- a/src/libs/actions/AppUpdate.js +++ b/src/libs/actions/AppUpdate.ts @@ -5,10 +5,7 @@ function triggerUpdateAvailable() { Onyx.set(ONYXKEYS.UPDATE_AVAILABLE, true); } -/** - * @param {Boolean} isBeta - */ -function setIsAppInBeta(isBeta) { +function setIsAppInBeta(isBeta: boolean) { Onyx.set(ONYXKEYS.IS_BETA, isBeta); } diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js index fc4d2ece4b52..4ba9f6ee33a0 100644 --- a/src/libs/actions/DemoActions.js +++ b/src/libs/actions/DemoActions.js @@ -29,12 +29,18 @@ function createDemoWorkspaceAndNavigate(workspaceOwnerEmail, apiCommand) { // Get report updates from Onyx response data const reportUpdate = _.find(response.onyxData, ({key}) => key === ONYXKEYS.COLLECTION.REPORT); if (!reportUpdate) { + // If there's no related onyx data, navigate the user home so they're not stuck. + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); return; } // Get the policy expense chat update const policyExpenseChatReport = _.find(reportUpdate.value, ({chatType}) => chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); if (!policyExpenseChatReport) { + // If there's no related onyx data, navigate the user home so they're not stuck. + Navigation.goBack(); + Navigation.navigate(ROUTES.HOME); return; } diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 8d1de1dc4d60..bc7adf47bd8c 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1538,7 +1538,13 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho true, ); - const optimisticReportPreviewAction = ReportUtils.updateReportPreview(iouReport, ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID)); + // In some instances, the report preview action might not be available to the payer (only whispered to the requestor) + // hence we need to make the updates to the action safely. + let optimisticReportPreviewAction = null; + const reportPreviewAction = ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); + if (reportPreviewAction) { + optimisticReportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction); + } const optimisticData = [ { @@ -1564,13 +1570,6 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho }, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, @@ -1611,7 +1610,18 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho }, }, }, - { + ]; + + // In case the report preview action is loaded locally, let's update it. + if (optimisticReportPreviewAction) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, + value: { + [optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction, + }, + }); + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, value: { @@ -1619,8 +1629,8 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho created: optimisticReportPreviewAction.created, }, }, - }, - ]; + }); + } return { params: { diff --git a/src/libs/actions/OnyxUpdateManager.js b/src/libs/actions/OnyxUpdateManager.js new file mode 100644 index 000000000000..f0051b85f302 --- /dev/null +++ b/src/libs/actions/OnyxUpdateManager.js @@ -0,0 +1,81 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; +import Log from '../Log'; +import * as SequentialQueue from '../Network/SequentialQueue'; +import * as App from './App'; +import * as OnyxUpdates from './OnyxUpdates'; + +// This file is in charge of looking at the updateIDs coming from the server and comparing them to the last updateID that the client has. +// If the client is behind the server, then we need to +// 1. Pause all sequential queue requests +// 2. Pause all Onyx updates from Pusher +// 3. Get the missing updates from the server +// 4. Apply those updates +// 5. Apply the original update that triggered this request (it could have come from either HTTPS or Pusher) +// 6. Restart the sequential queue +// 7. Restart the Onyx updates from Pusher +// This will ensure that the client is up-to-date with the server and all the updates have been applied in the correct order. +// It's important that this file is separate and not imported by OnyxUpdates.js, so that there are no circular dependencies. Onyx +// is used as a pub/sub mechanism to break out of the circular dependency. +// The circular dependency happens because this file calls API.GetMissingOnyxUpdates() which uses the SaveResponseInOnyx.js file +// (as a middleware). Therefore, SaveResponseInOnyx.js can't import and use this file directly. + +let lastUpdateIDAppliedToClient = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (val) => (lastUpdateIDAppliedToClient = val), +}); + +export default () => { + console.debug('[OnyxUpdateManager] Listening for updates from the server'); + Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER, + callback: (val) => { + if (!val) { + return; + } + + const updateParams = val; + const lastUpdateIDFromServer = val.lastUpdateID; + const previousUpdateIDFromServer = val.previousUpdateID; + + // In cases where we received a previousUpdateID and it doesn't match our lastUpdateIDAppliedToClient + // we need to perform one of the 2 possible cases: + // + // 1. This is the first time we're receiving an lastUpdateID, so we need to do a final reconnectApp before + // fully migrating to the reliable updates mode. + // 2. This client already has the reliable updates mode enabled, but it's missing some updates and it + // needs to fetch those. + // + // For both of those, we need to pause the sequential queue. This is important so that the updates are + // applied in their correct and specific order. If this queue was not paused, then there would be a lot of + // onyx data being applied while we are fetching the missing updates and that would put them all out of order. + SequentialQueue.pause(); + let canUnpauseQueuePromise; + + // The flow below is setting the promise to a reconnect app to address flow (1) explained above. + if (!lastUpdateIDAppliedToClient) { + Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); + + // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. + canUnpauseQueuePromise = App.finalReconnectAppAfterActivatingReliableUpdates(); + } else { + // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above. + console.debug(`[OnyxUpdateManager] Client is behind the server by ${previousUpdateIDFromServer - lastUpdateIDAppliedToClient} so fetching incremental updates`); + Log.info('Gap detected in update IDs from server so fetching incremental updates', true, { + lastUpdateIDFromServer, + previousUpdateIDFromServer, + lastUpdateIDAppliedToClient, + }); + canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, lastUpdateIDFromServer); + } + + canUnpauseQueuePromise.finally(() => { + OnyxUpdates.apply(updateParams).finally(() => { + console.debug('[OnyxUpdateManager] Done applying all updates'); + SequentialQueue.unpause(); + }); + }); + }, + }); +}; diff --git a/src/libs/actions/OnyxUpdates.js b/src/libs/actions/OnyxUpdates.js index e582016f0109..8e45e7dd2e66 100644 --- a/src/libs/actions/OnyxUpdates.js +++ b/src/libs/actions/OnyxUpdates.js @@ -1,22 +1,123 @@ import Onyx from 'react-native-onyx'; +import _ from 'underscore'; +import PusherUtils from '../PusherUtils'; import ONYXKEYS from '../../ONYXKEYS'; +import * as QueuedOnyxUpdates from './QueuedOnyxUpdates'; +import CONST from '../../CONST'; + +// This 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. If that +// callback were triggered it would lead to duplicate processing of server updates. +let lastUpdateIDAppliedToClient = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (val) => (lastUpdateIDAppliedToClient = val), +}); /** - * - * @param {Number} [lastUpdateID] - * @param {Number} [previousUpdateID] + * @param {Object} request + * @param {Object} response + * @returns {Promise} */ -function saveUpdateIDs(lastUpdateID = 0, previousUpdateID = 0) { - // Return early if there were no updateIDs - if (!lastUpdateID) { - return; - } +function applyHTTPSOnyxUpdates(request, response) { + console.debug('[OnyxUpdateManager] Applying https update'); + // 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; - Onyx.merge(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, { - lastUpdateIDFromServer: lastUpdateID, - previousUpdateIDFromServer: previousUpdateID, + // First apply any onyx data updates that are being sent back from the API. We wait for this to complete and then + // apply successData or failureData. This ensures that we do not update any pending, loading, or other UI states contained + // in successData/failureData until after the component has received and API data. + const onyxDataUpdatePromise = response.onyxData ? updateHandler(response.onyxData) : Promise.resolve(); + + return onyxDataUpdatePromise + .then(() => { + // Handle the request's success/failure data (client-side data) + if (response.jsonCode === 200 && request.successData) { + return updateHandler(request.successData); + } + if (response.jsonCode !== 200 && request.failureData) { + return updateHandler(request.failureData); + } + return Promise.resolve(); + }) + .then(() => { + console.debug('[OnyxUpdateManager] Done applying HTTPS update'); + return Promise.resolve(response); + }); +} + +/** + * @param {Array} updates + * @returns {Promise} + */ +function applyPusherOnyxUpdates(updates) { + console.debug('[OnyxUpdateManager] Applying pusher update'); + const pusherEventPromises = _.map(updates, (update) => PusherUtils.triggerMultiEventHandler(update.eventType, update.data)); + return Promise.all(pusherEventPromises).then(() => { + console.debug('[OnyxUpdateManager] Done applying Pusher update'); }); } +/** + * @param {Object[]} updateParams + * @param {String} updateParams.type + * @param {Number} updateParams.lastUpdateID + * @param {Object} [updateParams.request] Exists if updateParams.type === 'https' + * @param {Object} [updateParams.response] Exists if updateParams.type === 'https' + * @param {Object} [updateParams.updates] Exists if updateParams.type === 'pusher' + * @returns {Promise} + */ +function apply({lastUpdateID, type, request, response, updates}) { + console.debug(`[OnyxUpdateManager] Applying update type: ${type} with lastUpdateID: ${lastUpdateID}`, {request, response, updates}); + + if (lastUpdateID && lastUpdateID < lastUpdateIDAppliedToClient) { + console.debug('[OnyxUpdateManager] Update received was older than current state, returning without applying the updates'); + return Promise.resolve(); + } + if (lastUpdateID && lastUpdateID > lastUpdateIDAppliedToClient) { + Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, lastUpdateID); + } + if (type === CONST.ONYX_UPDATE_TYPES.HTTPS) { + return applyHTTPSOnyxUpdates(request, response); + } + if (type === CONST.ONYX_UPDATE_TYPES.PUSHER) { + return applyPusherOnyxUpdates(updates); + } +} + +/** + * @param {Object[]} updateParams + * @param {String} updateParams.type + * @param {Object} [updateParams.request] Exists if updateParams.type === 'https' + * @param {Object} [updateParams.response] Exists if updateParams.type === 'https' + * @param {Object} [updateParams.updates] Exists if updateParams.type === 'pusher' + * @param {Number} [updateParams.lastUpdateID] + * @param {Number} [updateParams.previousUpdateID] + */ +function saveUpdateInformation(updateParams) { + // Always use set() here so that the updateParams are never merged and always unique to the request that came in + Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, updateParams); +} + +/** + * This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state + * and return if an update is needed + * @param {Number} previousUpdateID The previousUpdateID contained in the response object + * @returns {Boolean} + */ +function doesClientNeedToBeUpdated(previousUpdateID = 0) { + // If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state + if (!previousUpdateID) { + return false; + } + + // If we don't have any value in lastUpdateIDAppliedToClient, this is the first time we're receiving anything, so we need to do a last reconnectApp + if (!lastUpdateIDAppliedToClient) { + return true; + } + + return lastUpdateIDAppliedToClient < previousUpdateID; +} + // eslint-disable-next-line import/prefer-default-export -export {saveUpdateIDs}; +export {saveUpdateInformation, doesClientNeedToBeUpdated, apply}; diff --git a/src/libs/actions/QueuedOnyxUpdates.js b/src/libs/actions/QueuedOnyxUpdates.js index 486108dd56cf..06f15be1340f 100644 --- a/src/libs/actions/QueuedOnyxUpdates.js +++ b/src/libs/actions/QueuedOnyxUpdates.js @@ -22,10 +22,10 @@ function clear() { } /** - * @returns {Array} + * @returns {Promise} */ -function getQueuedUpdates() { - return queuedOnyxUpdates; +function flushQueue() { + return Onyx.update(queuedOnyxUpdates).then(clear); } -export {queueOnyxUpdates, clear, getQueuedUpdates}; +export {queueOnyxUpdates, flushQueue}; diff --git a/src/libs/actions/Receipt.js b/src/libs/actions/Receipt.ts similarity index 72% rename from src/libs/actions/Receipt.js rename to src/libs/actions/Receipt.ts index fbe9c22faaa2..530db149d902 100644 --- a/src/libs/actions/Receipt.js +++ b/src/libs/actions/Receipt.ts @@ -3,12 +3,8 @@ import ONYXKEYS from '../../ONYXKEYS'; /** * Sets the upload receipt error modal content when an invalid receipt is uploaded - * - * @param {Boolean} isAttachmentInvalid - * @param {String} attachmentInvalidReasonTitle - * @param {String} attachmentInvalidReason */ -function setUploadReceiptError(isAttachmentInvalid, attachmentInvalidReasonTitle, attachmentInvalidReason) { +function setUploadReceiptError(isAttachmentInvalid: boolean, attachmentInvalidReasonTitle: string, attachmentInvalidReason: string) { Onyx.merge(ONYXKEYS.RECEIPT_MODAL, { isAttachmentInvalid, attachmentInvalidReasonTitle, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 78214e87ecd1..ec36f96df97b 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -529,12 +529,12 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p onyxData.optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportObject.parentReportID}`, - value: {[parentReportActionID]: {childReportID: reportID}}, + value: {[parentReportActionID]: {childReportID: reportID, childType: CONST.REPORT.TYPE.CHAT}}, }); onyxData.failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportObject.parentReportID}`, - value: {[parentReportActionID]: {childReportID: '0'}}, + value: {[parentReportActionID]: {childReportID: '0', childType: ''}}, }); } } @@ -1020,7 +1020,7 @@ function deleteReportComment(reportID, reportAction) { html: '', text: '', isEdited: true, - isDeletedParentAction: ReportActionsUtils.hasCommentThread(reportAction), + isDeletedParentAction: ReportActionsUtils.isThreadParentMessage(reportAction, reportID), }, ]; const optimisticReportActions = { @@ -1392,10 +1392,6 @@ function updateWriteCapabilityAndNavigate(report, newValue) { */ function navigateToConciergeChat() { if (!conciergeChatReportID) { - // In order not to delay the report life cycle, we first navigate to the unknown report - if (!Navigation.getTopmostReportId()) { - Navigation.navigate(ROUTES.REPORT); - } // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled Welcome.serverDataIsReadyPromise().then(() => { diff --git a/src/libs/actions/Session/clearCache/index.js b/src/libs/actions/Session/clearCache/index.js deleted file mode 100644 index 9ccd0193cfbd..000000000000 --- a/src/libs/actions/Session/clearCache/index.js +++ /dev/null @@ -1,5 +0,0 @@ -function clearStorage() { - return new Promise((res) => res()); -} - -export default clearStorage; diff --git a/src/libs/actions/Session/clearCache/index.native.js b/src/libs/actions/Session/clearCache/index.native.js deleted file mode 100644 index 3bd647dbf8fb..000000000000 --- a/src/libs/actions/Session/clearCache/index.native.js +++ /dev/null @@ -1,8 +0,0 @@ -import {CachesDirectoryPath, unlink} from 'react-native-fs'; - -function clearStorage() { - // `unlink` is used to delete the caches directory - return unlink(CachesDirectoryPath); -} - -export default clearStorage; diff --git a/src/libs/actions/Session/clearCache/index.native.ts b/src/libs/actions/Session/clearCache/index.native.ts new file mode 100644 index 000000000000..ce2e6beafa9f --- /dev/null +++ b/src/libs/actions/Session/clearCache/index.native.ts @@ -0,0 +1,7 @@ +import {CachesDirectoryPath, unlink} from 'react-native-fs'; +import ClearCache from './types'; + +// `unlink` is used to delete the caches directory +const clearStorage: ClearCache = () => unlink(CachesDirectoryPath); + +export default clearStorage; diff --git a/src/libs/actions/Session/clearCache/index.ts b/src/libs/actions/Session/clearCache/index.ts new file mode 100644 index 000000000000..2722d8636a75 --- /dev/null +++ b/src/libs/actions/Session/clearCache/index.ts @@ -0,0 +1,5 @@ +import ClearCache from './types'; + +const clearStorage: ClearCache = () => new Promise((res) => res()); + +export default clearStorage; diff --git a/src/libs/actions/Session/clearCache/types.ts b/src/libs/actions/Session/clearCache/types.ts new file mode 100644 index 000000000000..8c04b73e09c1 --- /dev/null +++ b/src/libs/actions/Session/clearCache/types.ts @@ -0,0 +1,3 @@ +type ClearCache = () => Promise; + +export default ClearCache; diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 6227686b3f45..d66cc243acf4 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -743,7 +743,10 @@ function getShareDestination(reportID, reports, personalDetails) { const report = lodashGet(reports, `report_${reportID}`, {}); let subtitle = ''; if (ReportUtils.isChatReport(report) && ReportUtils.isDM(report) && ReportUtils.hasSingleParticipant(report)) { - subtitle = LocalePhoneNumber.formatPhoneNumber(report.participants[0]); + const participantAccountID = lodashGet(report, 'participantAccountIDs[0]'); + const displayName = lodashGet(personalDetails, [participantAccountID, 'displayName']); + const login = lodashGet(personalDetails, [participantAccountID, 'login']); + subtitle = LocalePhoneNumber.formatPhoneNumber(login || displayName); } else { subtitle = ReportUtils.getChatRoomSubtitle(report); } diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index b77c5b278bc9..ee93c6acb1e5 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -546,8 +546,6 @@ function subscribeToUserEvents() { // Handles the mega multipleEvents from Pusher which contains an array of single events. // Each single event is passed to PusherUtils in order to trigger the callbacks for that event PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.MULTIPLE_EVENTS, currentUserAccountID, (pushJSON) => { - let 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 @@ -556,28 +554,44 @@ function subscribeToUserEvents() { // - 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; - OnyxUpdates.saveUpdateIDs(Number(pushJSON.lastUpdateID || 0), Number(pushJSON.previousUpdateID || 0)); + _.each(pushJSON, (multipleEvent) => { + PusherUtils.triggerMultiEventHandler(multipleEvent.eventType, multipleEvent.data); + }); + return; + } + + const updates = { + type: CONST.ONYX_UPDATE_TYPES.PUSHER, + lastUpdateID: Number(pushJSON.lastUpdateID || 0), + updates: pushJSON.updates, + previousUpdateID: Number(pushJSON.previousUpdateID || 0), + }; + if (!OnyxUpdates.doesClientNeedToBeUpdated(Number(pushJSON.previousUpdateID || 0))) { + OnyxUpdates.apply(updates); + return; } - _.each(updates, (multipleEvent) => { - PusherUtils.triggerMultiEventHandler(multipleEvent.eventType, multipleEvent.data); - }); + + // If we reached this point, we need to pause the queue while we prepare to fetch older OnyxUpdates. + SequentialQueue.pause(); + OnyxUpdates.saveUpdateInformation(updates); }); // Handles Onyx updates coming from Pusher through the mega multipleEvents. - PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON) => { + PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON) => SequentialQueue.getCurrentRequest().then(() => { // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher if (!currentUserAccountID) { return; } - Onyx.update(pushJSON); + const onyxUpdatePromise = Onyx.update(pushJSON); triggerNotifications(pushJSON); - }); - }); + + // Return a promise when Onyx is done updating so that the OnyxUpdatesManager can properly apply all + // the onyx updates in order + return onyxUpdatePromise; + }), + ); } /** diff --git a/src/libs/canFocusInputOnScreenFocus/index.js b/src/libs/canFocusInputOnScreenFocus/index.js deleted file mode 100644 index c930c0d944ec..000000000000 --- a/src/libs/canFocusInputOnScreenFocus/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import * as DeviceCapabilities from '../DeviceCapabilities'; - -export default () => !DeviceCapabilities.canUseTouchScreen(); diff --git a/src/libs/canFocusInputOnScreenFocus/index.native.js b/src/libs/canFocusInputOnScreenFocus/index.native.js deleted file mode 100644 index eae5767cffbc..000000000000 --- a/src/libs/canFocusInputOnScreenFocus/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export default () => false; diff --git a/src/libs/canFocusInputOnScreenFocus/index.native.ts b/src/libs/canFocusInputOnScreenFocus/index.native.ts new file mode 100644 index 000000000000..79d711c49fa6 --- /dev/null +++ b/src/libs/canFocusInputOnScreenFocus/index.native.ts @@ -0,0 +1,5 @@ +import CanFocusInputOnScreenFocus from './types'; + +const canFocusInputOnScreenFocus: CanFocusInputOnScreenFocus = () => false; + +export default canFocusInputOnScreenFocus; diff --git a/src/libs/canFocusInputOnScreenFocus/index.ts b/src/libs/canFocusInputOnScreenFocus/index.ts new file mode 100644 index 000000000000..be500074d7e3 --- /dev/null +++ b/src/libs/canFocusInputOnScreenFocus/index.ts @@ -0,0 +1,6 @@ +import * as DeviceCapabilities from '../DeviceCapabilities'; +import CanFocusInputOnScreenFocus from './types'; + +const canFocusInputOnScreenFocus: CanFocusInputOnScreenFocus = () => !DeviceCapabilities.canUseTouchScreen(); + +export default canFocusInputOnScreenFocus; diff --git a/src/libs/canFocusInputOnScreenFocus/types.ts b/src/libs/canFocusInputOnScreenFocus/types.ts new file mode 100644 index 000000000000..5a65e5e7d198 --- /dev/null +++ b/src/libs/canFocusInputOnScreenFocus/types.ts @@ -0,0 +1,3 @@ +type CanFocusInputOnScreenFocus = () => boolean; + +export default CanFocusInputOnScreenFocus; diff --git a/src/libs/checkForUpdates.js b/src/libs/checkForUpdates.js deleted file mode 100644 index fbf7ee84a8a7..000000000000 --- a/src/libs/checkForUpdates.js +++ /dev/null @@ -1,23 +0,0 @@ -const _ = require('underscore'); - -const UPDATE_INTERVAL = 1000 * 60 * 60 * 8; - -/** - * Check for updates every 8 hours and perform and platform-specific update - * - * @param {Object} platformSpecificUpdater - * @param {Function} platformSpecificUpdater.update - * @param {Function} [platformSpecificUpdater.init] - */ -function checkForUpdates(platformSpecificUpdater) { - if (_.isFunction(platformSpecificUpdater.init)) { - platformSpecificUpdater.init(); - } - - // Check for updates every hour - setInterval(() => { - platformSpecificUpdater.update(); - }, UPDATE_INTERVAL); -} - -module.exports = checkForUpdates; diff --git a/src/libs/checkForUpdates.ts b/src/libs/checkForUpdates.ts new file mode 100644 index 000000000000..51ce12335e29 --- /dev/null +++ b/src/libs/checkForUpdates.ts @@ -0,0 +1,19 @@ +const UPDATE_INTERVAL = 1000 * 60 * 60 * 8; + +type PlatformSpecificUpdater = { + update: () => void; + init?: () => void; +}; + +function checkForUpdates(platformSpecificUpdater: PlatformSpecificUpdater) { + if (typeof platformSpecificUpdater.init === 'function') { + platformSpecificUpdater.init(); + } + + // Check for updates every hour + setInterval(() => { + platformSpecificUpdater.update(); + }, UPDATE_INTERVAL); +} + +module.exports = checkForUpdates; diff --git a/src/libs/isReportMessageAttachment.js b/src/libs/isReportMessageAttachment.ts similarity index 71% rename from src/libs/isReportMessageAttachment.js rename to src/libs/isReportMessageAttachment.ts index e107df8ddfaa..3f9e9d2de201 100644 --- a/src/libs/isReportMessageAttachment.js +++ b/src/libs/isReportMessageAttachment.ts @@ -1,13 +1,18 @@ import CONST from '../CONST'; +type IsReportMessageAttachmentParams = { + text: string; + html: string; + translationKey: string; +}; + /** * Check whether a report action is Attachment or not. * Ignore messages containing [Attachment] as the main content. Attachments are actions with only text as [Attachment]. * - * @param {Object} reportActionMessage report action's message as text, html and translationKey - * @returns {Boolean} + * @param reportActionMessage report action's message as text, html and translationKey */ -export default function isReportMessageAttachment({text, html, translationKey}) { +export default function isReportMessageAttachment({text, html, translationKey}: IsReportMessageAttachmentParams): boolean { if (translationKey) { return translationKey === CONST.TRANSLATION_KEYS.ATTACHMENT; } diff --git a/src/libs/onyxSubscribe.js b/src/libs/onyxSubscribe.js deleted file mode 100644 index 600d010ed27f..000000000000 --- a/src/libs/onyxSubscribe.js +++ /dev/null @@ -1,12 +0,0 @@ -import Onyx from 'react-native-onyx'; - -/** - * Connect to onyx data. Same params as Onyx.connect(), but returns a function to unsubscribe. - * - * @param {Object} mapping Same as for Onyx.connect() - * @return {function(): void} Unsubscribe callback - */ -export default (mapping) => { - const connectionId = Onyx.connect(mapping); - return () => Onyx.disconnect(connectionId); -}; diff --git a/src/libs/onyxSubscribe.ts b/src/libs/onyxSubscribe.ts new file mode 100644 index 000000000000..469a7b810b1f --- /dev/null +++ b/src/libs/onyxSubscribe.ts @@ -0,0 +1,15 @@ +import Onyx, {ConnectOptions} from 'react-native-onyx'; +import {OnyxKey} from '../ONYXKEYS'; + +/** + * Connect to onyx data. Same params as Onyx.connect(), but returns a function to unsubscribe. + * + * @param mapping Same as for Onyx.connect() + * @return Unsubscribe callback + */ +function onyxSubscribe(mapping: ConnectOptions) { + const connectionId = Onyx.connect(mapping); + return () => Onyx.disconnect(connectionId); +} + +export default onyxSubscribe; diff --git a/src/libs/shouldRenderOffscreen/index.android.js b/src/libs/shouldRenderOffscreen/index.android.js deleted file mode 100644 index c91ffa15894d..000000000000 --- a/src/libs/shouldRenderOffscreen/index.android.js +++ /dev/null @@ -1,2 +0,0 @@ -// Rendering offscreen on Android allows it to apply opacity to stacked components correctly. -export default true; diff --git a/src/libs/shouldRenderOffscreen/index.android.ts b/src/libs/shouldRenderOffscreen/index.android.ts new file mode 100644 index 000000000000..bf2d9837086f --- /dev/null +++ b/src/libs/shouldRenderOffscreen/index.android.ts @@ -0,0 +1,6 @@ +import ShouldRenderOffscreen from './types'; + +// Rendering offscreen on Android allows it to apply opacity to stacked components correctly. +const shouldRenderOffscreen: ShouldRenderOffscreen = true; + +export default shouldRenderOffscreen; diff --git a/src/libs/shouldRenderOffscreen/index.js b/src/libs/shouldRenderOffscreen/index.js deleted file mode 100644 index 33136544dba2..000000000000 --- a/src/libs/shouldRenderOffscreen/index.js +++ /dev/null @@ -1 +0,0 @@ -export default false; diff --git a/src/libs/shouldRenderOffscreen/index.ts b/src/libs/shouldRenderOffscreen/index.ts new file mode 100644 index 000000000000..eadcc44814f9 --- /dev/null +++ b/src/libs/shouldRenderOffscreen/index.ts @@ -0,0 +1,5 @@ +import ShouldRenderOffscreen from './types'; + +const shouldRenderOffscreen: ShouldRenderOffscreen = false; + +export default shouldRenderOffscreen; diff --git a/src/libs/shouldRenderOffscreen/types.ts b/src/libs/shouldRenderOffscreen/types.ts new file mode 100644 index 000000000000..63cd98eec31b --- /dev/null +++ b/src/libs/shouldRenderOffscreen/types.ts @@ -0,0 +1,3 @@ +type ShouldRenderOffscreen = boolean; + +export default ShouldRenderOffscreen; diff --git a/src/libs/tryResolveUrlFromApiRoot.js b/src/libs/tryResolveUrlFromApiRoot.js index dc5780bb25e3..cc46f034e45b 100644 --- a/src/libs/tryResolveUrlFromApiRoot.js +++ b/src/libs/tryResolveUrlFromApiRoot.js @@ -20,6 +20,11 @@ const ORIGIN_PATTERN = new RegExp(`^(${ORIGINS_TO_REPLACE.join('|')})`); * @returns {String} */ export default function tryResolveUrlFromApiRoot(url) { + // in native, when we import an image asset, it will have a number representation which can be used in `source` of Image + // in this case we can skip the url resolving + if (typeof url === 'number') { + return url; + } const apiRoot = ApiUtils.getApiRoot({shouldUseSecure: false}); return url.replace(ORIGIN_PATTERN, apiRoot); } diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 2f5bcae08f06..e7600a2dfff6 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -37,6 +37,7 @@ import ReportScreenContext from './ReportScreenContext'; import TaskHeaderActionButton from '../../components/TaskHeaderActionButton'; import DragAndDropProvider from '../../components/DragAndDrop/Provider'; import usePrevious from '../../hooks/usePrevious'; +import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDefaultProps} from '../../components/withCurrentReportID'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -88,6 +89,7 @@ const propTypes = { ...windowDimensionsPropTypes, ...viewportOffsetTopPropTypes, + ...withCurrentReportIDPropTypes, }; const defaultProps = { @@ -103,6 +105,7 @@ const defaultProps = { policies: {}, accountManagerReportID: null, personalDetails: {}, + ...withCurrentReportIDDefaultProps, }; /** @@ -132,6 +135,7 @@ function ReportScreen({ viewportOffsetTop, isComposerFullSize, errors, + currentReportID, }) { const firstRenderRef = useRef(true); const flatListRef = useRef(); @@ -158,7 +162,7 @@ function ReportScreen({ const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const isTopMostReportId = Navigation.getTopmostReportId() === getReportID(route); + const isTopMostReportId = currentReportID === getReportID(route); let headerView = ( { + if (isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + setIsFullComposerAvailable(false); + }, [isComposerFullSize, reportID]); + // We are returning a callback here as we want to incoke the method on unmount only useEffect( () => () => { @@ -338,7 +347,7 @@ function ReportActionCompose({ reportID={reportID} report={report} reportParticipantIDs={reportParticipantIDs} - isFullComposerAvailable={isFullComposerAvailable} + isFullComposerAvailable={isFullComposerAvailable && !isCommentEmpty} isComposerFullSize={isComposerFullSize} updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse} isBlockedFromConcierge={isBlockedFromConcierge} @@ -400,6 +409,7 @@ function ReportActionCompose({ diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.js index 4f1dc5fff191..8128b5a6b39d 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.js +++ b/src/pages/home/report/ReportActionCompose/SendButton.js @@ -23,11 +23,14 @@ const propTypes = { /** Sets the isCommentEmpty flag to true */ setIsCommentEmpty: PropTypes.func.isRequired, + /** resets the composer to normal size */ + resetFullComposerSize: PropTypes.func.isRequired, + /** Submits the form */ submitForm: PropTypes.func.isRequired, }; -function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, submitForm}) { +function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, resetFullComposerSize, submitForm}) { const {translate} = useLocalize(); const Tap = Gesture.Tap() @@ -40,6 +43,7 @@ function SendButton({isDisabled: isDisabledProp, animatedRef, setIsCommentEmpty, const updates = {text: ''}; // We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state runOnJS(setIsCommentEmpty)(true); + runOnJS(resetFullComposerSize)(); updatePropsPaperWorklet(viewTag, viewName, updates); // clears native text input on the UI thread runOnJS(submitForm)(); }); diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 22ded971898f..8425f78a3a10 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -344,6 +344,7 @@ function ReportActionItem(props) { {!props.draftMessage ? ( ReportActions.clearReportActionErrors(props.report.reportID, props.action)} pendingAction={props.draftMessage ? null : props.action.pendingAction} - shouldHideOnDelete={!ReportActionsUtils.hasCommentThread(props.action)} + shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(props.action, props.report.reportID)} errors={props.action.errors} errorRowStyles={[styles.ml10, styles.mr2]} needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(props.action)} diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index 91ee8f7531da..d768fcacd5b7 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -67,6 +67,9 @@ const propTypes = { /** icon */ actorIcon: avatarPropTypes, + /** Whether the comment is a thread parent message/the first message in a thread */ + isThreadParentMessage: PropTypes.bool, + ...windowDimensionsPropTypes, /** localization props */ @@ -88,6 +91,7 @@ const defaultProps = { style: [], delegateAccountID: 0, actorIcon: {}, + isThreadParentMessage: false, }; function ReportActionItemFragment(props) { @@ -113,7 +117,7 @@ function ReportActionItemFragment(props) { // While offline we display the previous message with a strikethrough style. Once online we want to // immediately display "[Deleted message]" while the delete action is pending. - if ((!props.network.isOffline && props.hasCommentThread && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) || props.fragment.isDeletedParentAction) { + if ((!props.network.isOffline && props.isThreadParentMessage && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) || props.fragment.isDeletedParentAction) { return ${props.translate('parentReportAction.deletedMessage')}`} />; } diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 40d2d5e6d89c..bc92889158d0 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -23,6 +23,9 @@ const propTypes = { /** Whether or not the message is hidden by moderation */ isHidden: PropTypes.bool, + /** The ID of the report */ + reportID: PropTypes.string.isRequired, + /** localization props */ ...withLocalizePropTypes, }; @@ -53,7 +56,7 @@ function ReportActionItemMessage(props) { fragment={fragment} isAttachment={props.action.isAttachment} iouMessage={iouMessage} - hasCommentThread={ReportActionsUtils.hasCommentThread(props.action)} + isThreadParentMessage={ReportActionsUtils.isThreadParentMessage(props.action, props.reportID)} attachmentInfo={props.action.attachmentInfo} pendingAction={props.action.pendingAction} source={lodashGet(props.action, 'originalMessage.source')} diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index d3409afb9d5c..f0ded5e451b3 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -304,6 +304,10 @@ function arePropsEqual(oldProps, newProps) { return false; } + if (lodashGet(newProps, 'report.participantAccountIDs', 0) !== lodashGet(oldProps, 'report.participantAccountIDs', 0)) { + return false; + } + return _.isEqual(lodashGet(newProps.report, 'icons', []), lodashGet(oldProps.report, 'icons', [])); } diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 1a3f63ede6e6..a75a03f7a517 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -238,11 +238,6 @@ function FloatingActionButtonAndPopover(props) { text: props.translate('iou.requestMoney'), onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)), }, - { - icon: Expensicons.Heart, - text: props.translate('sidebarScreen.saveTheWorld'), - onSelected: () => interceptAnonymousUser(() => Navigation.navigate(ROUTES.SAVE_THE_WORLD)), - }, { icon: Expensicons.Receipt, text: props.translate('iou.splitBill'), diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index 2a2f3674cdfd..32d646702fb2 100644 --- a/src/pages/iou/MoneyRequestSelectorPage.js +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -1,6 +1,6 @@ import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; -import React from 'react'; +import React, {useState} from 'react'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import ONYXKEYS from '../../ONYXKEYS'; @@ -21,6 +21,7 @@ import OnyxTabNavigator, {TopTab} from '../../libs/Navigation/OnyxTabNavigator'; import NewRequestAmountPage from './steps/NewRequestAmountPage'; import reportPropTypes from '../reportPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; +import themeColors from '../../styles/themes/default'; const propTypes = { /** React Navigation route */ @@ -43,11 +44,13 @@ const propTypes = { }; const defaultProps = { - selectedTab: CONST.TAB.MANUAL, + selectedTab: CONST.TAB.SCAN, report: {}, }; function MoneyRequestSelectorPage(props) { + const [isDraggingOver, setIsDraggingOver] = useState(false); + const iouType = lodashGet(props.route, 'params.iouType', ''); const reportID = lodashGet(props.route, 'params.reportID', ''); const {translate} = useLocalize(); @@ -70,10 +73,22 @@ function MoneyRequestSelectorPage(props) { {({safeAreaPaddingBottomStyle}) => ( - + ( { - const searchValue = searchTerm.trim(); + const searchValue = searchTerm.trim().toLowerCase(); if (!userToInvite && CONST.EXPENSIFY_EMAILS.includes(searchValue)) { return translate('messages.errorMessageInvalidEmail'); } diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js index e72b02e18696..e551e0d6d1b9 100644 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js @@ -93,9 +93,11 @@ class WorkspaceRateAndUnitPage extends React.Component { validate(values) { const errors = {}; const decimalSeparator = this.props.toLocaleDigit('.'); - const rateValueRegex = RegExp(String.raw`^\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,3})?$`, 'i'); + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,3})?$`, 'i'); if (!rateValueRegex.test(values.rate) || values.rate === '') { errors.rate = 'workspace.reimburse.invalidRateError'; + } else if (parseFloat(values.rate) <= 0) { + errors.rate = 'workspace.reimburse.lowRateError'; } return errors; } diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index d4e4239fc7dd..8945bc0be058 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -1098,11 +1098,18 @@ function getMentionTextColor(isOurMention: boolean): string { /** * Returns padding vertical based on number of lines */ -function getComposeTextAreaPadding(numberOfLines: number): ViewStyle | CSSProperties { +function getComposeTextAreaPadding(numberOfLines: number, isComposerFullSize: boolean): ViewStyle | CSSProperties { let paddingValue = 5; - if (numberOfLines === 1) paddingValue = 9; - // In case numberOfLines = 3, there will be a Expand Icon appearing at the top left, so it has to be recalculated so that the textArea can be full height - if (numberOfLines === 3) paddingValue = 8; + // Issue #26222: If isComposerFullSize paddingValue will always be 5 to prevent padding jumps when adding multiple lines. + if (!isComposerFullSize) { + if (numberOfLines === 1) { + paddingValue = 9; + } + // In case numberOfLines = 3, there will be a Expand Icon appearing at the top left, so it has to be recalculated so that the textArea can be full height + else if (numberOfLines === 3) { + paddingValue = 8; + } + } return { paddingTop: paddingValue, paddingBottom: paddingValue, @@ -1180,6 +1187,42 @@ function getDropDownButtonHeight(buttonSize: ButtonSizeValue): ViewStyle | CSSPr }; } +/** + * Returns fitting fontSize and lineHeight values in order to prevent large amounts from being cut off on small screen widths. + */ +function getAmountFontSizeAndLineHeight(baseFontSize: number, baseLineHeight: number, isSmallScreenWidth: boolean, windowWidth: number): ViewStyle | CSSProperties { + let toSubtract = 0; + + if (isSmallScreenWidth) { + const widthDifference = variables.mobileResponsiveWidthBreakpoint - windowWidth; + switch (true) { + case widthDifference > 450: + toSubtract = 11; + break; + case widthDifference > 400: + toSubtract = 8; + break; + case widthDifference > 350: + toSubtract = 4; + break; + default: + break; + } + } + + return { + fontSize: baseFontSize - toSubtract, + lineHeight: baseLineHeight - toSubtract, + }; +} + +/** + * Get transparent color by setting alpha value 0 of the passed hex(#xxxxxx) color code + */ +function getTransparentColor(color: string) { + return `${color}00`; +} + export { getAvatarSize, getAvatarWidthStyle, @@ -1256,4 +1299,6 @@ export { getDisabledLinkStyles, getCheckboxContainerStyle, getDropDownButtonHeight, + getAmountFontSizeAndLineHeight, + getTransparentColor, }; diff --git a/src/styles/styles.js b/src/styles/styles.js index 7bb44acfb97a..1c1340600a51 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -2700,6 +2700,12 @@ const styles = { marginBottom: 0, }, + moneyRequestPreviewAmount: { + ...headlineFont, + ...whiteSpace.preWrap, + color: themeColors.heading, + }, + defaultCheckmarkWrapper: { marginLeft: 8, alignSelf: 'center', @@ -3871,7 +3877,7 @@ const styles = { distanceRequestContainer: (maxHeight) => ({ ...flex.flexShrink2, - minHeight: variables.baseMenuItemHeight * 2, + minHeight: variables.optionRowHeight * 2, maxHeight, }), diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.ts similarity index 98% rename from src/styles/utilities/spacing.js rename to src/styles/utilities/spacing.ts index 47b523d89ac2..7147b1f2b7d4 100644 --- a/src/styles/utilities/spacing.js +++ b/src/styles/utilities/spacing.ts @@ -1,3 +1,5 @@ +import {ViewStyle} from 'react-native'; + /** * Spacing utility styles with Bootstrap inspired naming. * All styles should be incremented by units of 4. @@ -506,4 +508,4 @@ export default { gap7: { gap: 28, }, -}; +} satisfies Record; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 3b6dbf47970e..eb182ab1eca0 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -151,7 +151,7 @@ export default { pressDimValue: 0.8, qrShareHorizontalPadding: 32, - baseMenuItemHeight: 64, - moneyRequestSkeletonHeight: 107, + + distanceScrollEventThrottle: 16, } as const; diff --git a/src/types/onyx/Card.ts b/src/types/onyx/Card.ts index deaf32cabd1f..1efa5906360e 100644 --- a/src/types/onyx/Card.ts +++ b/src/types/onyx/Card.ts @@ -1,9 +1,11 @@ import {ValueOf} from 'type-fest'; import CONST from '../../CONST'; +type State = 3 /* OPEN */ | 4 /* NOT_ACTIVATED */ | 5 /* STATE_DEACTIVATED */ | 6 /* CLOSED */ | 7 /* STATE_SUSPENDED */; + type Card = { cardID: number; - state: number; + state: State; bank: string; availableSpend: number; domainName: string; diff --git a/src/types/onyx/OnyxUpdatesFromServer.ts b/src/types/onyx/OnyxUpdatesFromServer.ts new file mode 100644 index 000000000000..02a96d4ce230 --- /dev/null +++ b/src/types/onyx/OnyxUpdatesFromServer.ts @@ -0,0 +1,14 @@ +import {OnyxUpdate} from 'react-native-onyx'; +import Request from './Request'; +import Response from './Response'; + +type OnyxUpdatesFromServer = { + type: 'https' | 'pusher'; + lastUpdateID: number | string; + previousUpdateID: number | string; + request?: Request; + response?: Response; + updates?: OnyxUpdate[]; +}; + +export default OnyxUpdatesFromServer; diff --git a/src/types/onyx/RecentlyUsedCategories.ts b/src/types/onyx/RecentlyUsedCategories.ts new file mode 100644 index 000000000000..d251b16f8667 --- /dev/null +++ b/src/types/onyx/RecentlyUsedCategories.ts @@ -0,0 +1,3 @@ +type RecentlyUsedCategories = string[]; + +export default RecentlyUsedCategories; diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index e730dfd807fb..1df20cfb28fe 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -1,8 +1,12 @@ +import {OnyxUpdate} from 'react-native-onyx'; + type Request = { command?: string; data?: Record; type?: string; shouldUseSecure?: boolean; + successData?: OnyxUpdate[]; + failureData?: OnyxUpdate[]; }; export default Request; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts new file mode 100644 index 000000000000..c501034e971c --- /dev/null +++ b/src/types/onyx/Response.ts @@ -0,0 +1,11 @@ +import {OnyxUpdate} from 'react-native-onyx'; + +type Response = { + previousUpdateID?: number | string; + lastUpdateID?: number | string; + jsonCode?: number; + onyxData?: OnyxUpdate[]; + requestID?: string; +}; + +export default Response; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 9e6cd603472f..4326920ab51f 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -6,6 +6,18 @@ type Comment = { comment?: string; }; +type Geometry = { + coordinates: number[][]; + type: 'LineString'; +}; + +type Route = { + distance: number; + geometry: Geometry; +}; + +type Routes = Record; + type Transaction = { transactionID: string; amount: number; @@ -25,6 +37,7 @@ type Transaction = { source?: string; state?: ValueOf; }; + routes?: Routes; }; export default Transaction; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 039448fac531..d908c0b36ce1 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -33,6 +33,7 @@ import ReimbursementAccountDraft from './ReimbursementAccountDraft'; import WalletTransfer from './WalletTransfer'; import ReceiptModal from './ReceiptModal'; import MapboxAccessToken from './MapboxAccessToken'; +import OnyxUpdatesFromServer from './OnyxUpdatesFromServer'; import Download from './Download'; import PolicyMember from './PolicyMember'; import Policy from './Policy'; @@ -43,6 +44,7 @@ import SecurityGroup from './SecurityGroup'; import Transaction from './Transaction'; import Form, {AddDebitCardForm} from './Form'; import RecentWaypoints from './RecentWaypoints'; +import RecentlyUsedCategories from './RecentlyUsedCategories'; export type { Account, @@ -90,5 +92,7 @@ export type { Transaction, Form, AddDebitCardForm, + OnyxUpdatesFromServer, RecentWaypoints, + RecentlyUsedCategories, }; diff --git a/src/types/utils/CustomRefObject.ts b/src/types/utils/CustomRefObject.ts new file mode 100644 index 000000000000..aa726d7a0f86 --- /dev/null +++ b/src/types/utils/CustomRefObject.ts @@ -0,0 +1,5 @@ +import {RefObject} from 'react'; + +type CustomRefObject = RefObject & {onselectstart: () => boolean}; + +export default CustomRefObject; diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 6fbbe19cec8e..afb06cdb6fb3 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -9,6 +9,7 @@ import DateUtils from '../../src/libs/DateUtils'; import * as NumberUtils from '../../src/libs/NumberUtils'; import * as ReportActions from '../../src/libs/actions/ReportActions'; import * as Report from '../../src/libs/actions/Report'; +import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager'; const CARLOS_EMAIL = 'cmartins@expensifail.com'; const CARLOS_ACCOUNT_ID = 1; @@ -19,6 +20,7 @@ const RORY_ACCOUNT_ID = 3; const VIT_EMAIL = 'vit@expensifail.com'; const VIT_ACCOUNT_ID = 4; +OnyxUpdateManager(); describe('actions/IOU', () => { beforeAll(() => { Onyx.init({ diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js index c06d3bc83766..978186fcf9c4 100644 --- a/tests/actions/ReportTest.js +++ b/tests/actions/ReportTest.js @@ -14,6 +14,7 @@ import * as PersistedRequests from '../../src/libs/actions/PersistedRequests'; import * as User from '../../src/libs/actions/User'; import * as ReportUtils from '../../src/libs/ReportUtils'; import DateUtils from '../../src/libs/DateUtils'; +import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager'; jest.mock('../../src/libs/actions/Report', () => { const originalModule = jest.requireActual('../../src/libs/actions/Report'); @@ -24,6 +25,7 @@ jest.mock('../../src/libs/actions/Report', () => { }; }); +OnyxUpdateManager(); describe('actions/Report', () => { beforeAll(() => { PusherHelper.setup(); diff --git a/tests/actions/SessionTest.js b/tests/actions/SessionTest.js index d8bfa144e358..59a7441679ea 100644 --- a/tests/actions/SessionTest.js +++ b/tests/actions/SessionTest.js @@ -7,6 +7,7 @@ import * as TestHelper from '../utils/TestHelper'; import CONST from '../../src/CONST'; import PushNotification from '../../src/libs/Notification/PushNotification'; import * as App from '../../src/libs/actions/App'; +import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager'; // 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 @@ -24,6 +25,7 @@ Onyx.init({ registerStorageEventListener: () => {}, }); +OnyxUpdateManager(); beforeEach(() => Onyx.clear().then(waitForPromisesToResolve)); describe('Session', () => { diff --git a/tests/unit/NetworkTest.js b/tests/unit/NetworkTest.js index c8dcda0e2af5..7d8c4f23197c 100644 --- a/tests/unit/NetworkTest.js +++ b/tests/unit/NetworkTest.js @@ -14,6 +14,7 @@ import Log from '../../src/libs/Log'; import * as MainQueue from '../../src/libs/Network/MainQueue'; import * as App from '../../src/libs/actions/App'; import NetworkConnection from '../../src/libs/NetworkConnection'; +import OnyxUpdateManager from '../../src/libs/actions/OnyxUpdateManager'; jest.mock('../../src/libs/Log'); jest.useFakeTimers(); @@ -22,6 +23,7 @@ Onyx.init({ keys: ONYXKEYS, }); +OnyxUpdateManager(); const originalXHR = HttpUtils.xhr; beforeEach(() => {