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/scripts/verifyPodfile.sh b/.github/scripts/verifyPodfile.sh index 3a57a736cd70..ec2709a25786 100755 --- a/.github/scripts/verifyPodfile.sh +++ b/.github/scripts/verifyPodfile.sh @@ -13,10 +13,10 @@ declare EXIT_CODE=0 # Check Provisioning Style. If automatic signing is enabled, iOS builds will fail, so ensure we always have the proper profile specified info "Verifying that automatic signing is not enabled" if grep -q 'PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore' ios/NewExpensify.xcodeproj/project.pbxproj; then - success "Automatic signing not enabled" + success "Automatic signing not enabled" else - error "Error: Automatic provisioning style is not allowed!" - EXIT_CODE=1 + error "Error: Automatic provisioning style is not allowed!" + EXIT_CODE=1 fi PODFILE_SHA=$(openssl sha1 ios/Podfile | awk '{print $2}') @@ -26,10 +26,26 @@ echo "Podfile: $PODFILE_SHA" echo "Podfile.lock: $PODFILE_LOCK_SHA" if [[ "$PODFILE_SHA" == "$PODFILE_LOCK_SHA" ]]; then - success "Podfile checksum verified!" + success "Podfile checksum verified!" else - error "Podfile.lock checksum mismatch. Did you forget to run \`npx pod-install\`?" - EXIT_CODE=1 + error "Podfile.lock checksum mismatch. Did you forget to run \`npx pod-install\`?" + EXIT_CODE=1 +fi + +info "Ensuring correct version of cocoapods is used..." + +POD_VERSION_REGEX='([[:digit:]]+\.[[:digit:]]+)(\.[[:digit:]]+)?'; +POD_VERSION_FROM_GEMFILE="$(sed -nr "s/gem \"cocoapods\", \"~> $POD_VERSION_REGEX\"/\1/p" Gemfile)" +info "Pod version from Gemfile: $POD_VERSION_FROM_GEMFILE" + +POD_VERSION_FROM_PODFILE_LOCK="$(sed -nr "s/COCOAPODS: $POD_VERSION_REGEX/\1/p" ios/Podfile.lock)" +info "Pod version from Podfile.lock: $POD_VERSION_FROM_PODFILE_LOCK" + +if [[ "$POD_VERSION_FROM_GEMFILE" == "$POD_VERSION_FROM_PODFILE_LOCK" ]]; then + success "Cocoapods version from Podfile.lock matches cocoapods version from Gemfile" +else + error "Cocoapods version from Podfile.lock does not match cocoapods version from Gemfile. Please use \`npm run pod-install\` or \`bundle exec pod install\` instead of \`pod install\` to install pods." + EXIT_CODE=1 fi info "Comparing Podfile.lock with node packages..." 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..a98381b0f4a7 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 @@ -157,8 +176,21 @@ jobs: ruby-version: '2.7' bundler-cache: true + - name: Cache Pod dependencies + uses: actions/cache@v3 + id: pods-cache + with: + path: ios/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: ${{ runner.os }}-pods-cache- + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + - name: Install cocoapods uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' with: timeout_minutes: 10 max_attempts: 5 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/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 16fffcc2c65e..fd8118895679 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -154,8 +154,21 @@ jobs: ruby-version: '2.7' bundler-cache: true + - name: Cache Pod dependencies + uses: actions/cache@v3 + id: pods-cache + with: + path: ios/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: ${{ runner.os }}-pods-cache- + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + - name: Install cocoapods uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' with: timeout_minutes: 10 max_attempts: 5 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 62% rename from src/ROUTES.js rename to src/ROUTES.ts index b38ce25f590f..ed4fbb97a41a 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,64 @@ 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}`, + getMoneyRequestMerchantRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`, + getMoneyRequestDistanceTabRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`, + getMoneyRequestWaypointRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`, 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 +174,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 +188,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 +217,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 ( Boolean(props.formState) && !_.isEmpty(props.formState.errors), [props.formState]); + /** * @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2} * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2} */ const onValidate = useCallback( - (values) => { + (values, shouldClearServerError = true) => { const trimmedStringValues = {}; _.each(values, (inputValue, inputID) => { if (_.isString(inputValue)) { @@ -127,7 +130,9 @@ function Form(props) { } }); - FormActions.setErrors(props.formID, null); + if (shouldClearServerError) { + FormActions.setErrors(props.formID, null); + } FormActions.setErrorFields(props.formID, null); // Run any validations passed as a prop @@ -305,6 +310,12 @@ function Form(props) { // as this is already happening by the value prop. defaultValue: undefined, errorText: errors[inputID] || fieldErrorMessage, + onFocus: (event) => { + 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()) { @@ -314,7 +325,7 @@ function Form(props) { setTimeout(() => { setTouchedInput(inputID); if (props.shouldValidateOnBlur) { - onValidate(inputValues); + onValidate(inputValues, !hasServerError); } }, 200); } @@ -328,6 +339,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, @@ -353,7 +369,19 @@ function Form(props) { return childrenElements; }, - [errors, inputRefs, inputValues, onValidate, props.draftValues, props.formID, props.formState, setTouchedInput, props.shouldValidateOnBlur, props.shouldValidateOnChange], + [ + errors, + inputRefs, + inputValues, + onValidate, + props.draftValues, + props.formID, + props.formState, + setTouchedInput, + props.shouldValidateOnBlur, + props.shouldValidateOnChange, + hasServerError, + ], ); const scrollViewContent = useCallback( diff --git a/src/components/FormAlertWithSubmitButton.js b/src/components/FormAlertWithSubmitButton.js index 9c941fa9b967..33d188719d11 100644 --- a/src/components/FormAlertWithSubmitButton.js +++ b/src/components/FormAlertWithSubmitButton.js @@ -46,6 +46,10 @@ const propTypes = { /** Custom content to display in the footer after submit button */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + + /** Styles for the button */ + // eslint-disable-next-line react/forbid-prop-types + buttonStyles: PropTypes.arrayOf(PropTypes.object), }; const defaultProps = { @@ -59,10 +63,11 @@ const defaultProps = { disablePressOnEnter: false, isSubmitActionDangerous: false, footerContent: null, + buttonStyles: [], }; function FormAlertWithSubmitButton(props) { - const buttonMarginStyle = _.isEmpty(props.footerContent) ? {} : styles.mb3; + const buttonStyles = [_.isEmpty(props.footerContent) ? {} : styles.mb3, ...props.buttonStyles]; return ( ) : ( @@ -87,7 +92,7 @@ function FormAlertWithSubmitButton(props) { success pressOnEnter={!props.disablePressOnEnter} text={props.buttonText} - style={buttonMarginStyle} + style={buttonStyles} onPress={props.onSubmit} isDisabled={props.isDisabled} isLoading={props.isLoading} 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/OfflineWithFeedback.js b/src/components/OfflineWithFeedback.js index 2f99b21b6523..fb0411d24f4c 100644 --- a/src/components/OfflineWithFeedback.js +++ b/src/components/OfflineWithFeedback.js @@ -37,6 +37,9 @@ const propTypes = { /** Whether we should show the error messages */ shouldShowErrorMessages: PropTypes.bool, + /** Whether we should disable opacity */ + shouldDisableOpacity: PropTypes.bool, + /** A function to run when the X button next to the error is clicked */ onClose: PropTypes.func, @@ -63,6 +66,7 @@ const defaultProps = { shouldHideOnDelete: true, errors: null, shouldShowErrorMessages: true, + shouldDisableOpacity: false, onClose: () => {}, style: [], contentContainerStyle: [], @@ -96,7 +100,7 @@ function OfflineWithFeedback(props) { const isOfflinePendingAction = props.network.isOffline && props.pendingAction; const isUpdateOrDeleteError = hasErrors && (props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); const isAddError = hasErrors && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; - const needsOpacity = (isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError; + const needsOpacity = !props.shouldDisableOpacity && ((isOfflinePendingAction && !isUpdateOrDeleteError) || isAddError); const needsStrikeThrough = props.network.isOffline && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const hideChildren = props.shouldHideOnDelete && !props.network.isOffline && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !hasErrors; let children = props.children; diff --git a/src/components/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..f52848589663 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -68,6 +68,9 @@ import type { OOOEventSummaryPartialDayParams, ParentNavigationSummaryParams, ManagerApprovedParams, + SetTheRequestParams, + UpdatedTheRequestParams, + RemovedTheRequestParams, } from './types'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; @@ -523,6 +526,11 @@ export default { paidUsingExpensifyWithAmount: ({amount}: PaidUsingExpensifyWithAmountParams) => `paid ${amount} using Expensify`, noReimbursableExpenses: 'This report has an invalid amount', pendingConversionMessage: "Total will update when you're back online", + changedTheRequest: 'changed the request', + setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `set the ${valueName} to ${newValueToDisplay}`, + removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => `removed the ${valueName} (previously ${oldValueToDisplay})`, + updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) => + `changed the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`, threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, error: { @@ -1345,6 +1353,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..8610f41308e1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -68,6 +68,9 @@ import type { OOOEventSummaryPartialDayParams, ParentNavigationSummaryParams, ManagerApprovedParams, + SetTheRequestParams, + UpdatedTheRequestParams, + RemovedTheRequestParams, } from './types'; /* eslint-disable max-len */ @@ -524,6 +527,12 @@ export default { paidUsingExpensifyWithAmount: ({amount}: PaidUsingExpensifyWithAmountParams) => `pagó ${amount} con Expensify`, noReimbursableExpenses: 'El importe de este informe no es válido', pendingConversionMessage: 'El total se actualizará cuando estés online', + changedTheRequest: 'cambió la solicitud', + setTheRequest: ({valueName, newValueToDisplay}: SetTheRequestParams) => `estableció ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay}`, + removedTheRequest: ({valueName, oldValueToDisplay}: RemovedTheRequestParams) => + `eliminó ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} (previamente ${oldValueToDisplay})`, + updatedTheRequest: ({valueName, newValueToDisplay, oldValueToDisplay}: UpdatedTheRequestParams) => + `cambío ${valueName === 'comerciante' ? 'el' : 'la'} ${valueName} a ${newValueToDisplay} (previamente ${oldValueToDisplay})`, threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, error: { @@ -1373,6 +1382,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/languages/types.ts b/src/languages/types.ts index 50290fb5776c..059d944fd4ba 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -184,6 +184,12 @@ type OOOEventSummaryPartialDayParams = {summary: string; timePeriod: string; dat type ParentNavigationSummaryParams = {rootReportName: string; workspaceName: string}; +type SetTheRequestParams = {valueName: string; newValueToDisplay: string}; + +type RemovedTheRequestParams = {valueName: string; oldValueToDisplay: string}; + +type UpdatedTheRequestParams = {valueName: string; newValueToDisplay: string; oldValueToDisplay: string}; + export type { AddressLineParams, CharacterLimitParams, @@ -252,4 +258,7 @@ export type { OOOEventSummaryFullDayParams, OOOEventSummaryPartialDayParams, ParentNavigationSummaryParams, + SetTheRequestParams, + UpdatedTheRequestParams, + RemovedTheRequestParams, }; 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/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js index 1d4966826492..a401dea4b911 100644 --- a/src/libs/PersonalDetailsUtils.js +++ b/src/libs/PersonalDetailsUtils.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import ONYXKEYS from '../ONYXKEYS'; import * as Localize from './Localize'; import * as UserUtils from './UserUtils'; +import * as LocalePhoneNumber from './LocalePhoneNumber'; let personalDetails = []; let allPersonalDetails = {}; @@ -115,7 +116,7 @@ function getNewPersonalDetailsOnyxData(logins, accountIDs) { login, accountID, avatar: UserUtils.getDefaultAvatarURL(accountID), - displayName: login, + displayName: LocalePhoneNumber.formatPhoneNumber(login), }; /** 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..6167a04ada2f 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); } /** @@ -1478,14 +1478,15 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip function getProperSchemaForModifiedExpenseMessage(newValue, oldValue, valueName, valueInQuotes) { const newValueToDisplay = valueInQuotes ? `"${newValue}"` : newValue; const oldValueToDisplay = valueInQuotes ? `"${oldValue}"` : oldValue; + const displayValueName = valueName.toLowerCase(); if (!oldValue) { - return `set the ${valueName} to ${newValueToDisplay}`; + return Localize.translateLocal('iou.setTheRequest', {valueName: displayValueName, newValueToDisplay}); } if (!newValue) { - return `removed the ${valueName} (previously ${oldValueToDisplay})`; + return Localize.translateLocal('iou.removedTheRequest', {valueName: displayValueName, oldValueToDisplay}); } - return `changed the ${valueName} to ${newValueToDisplay} (previously ${oldValueToDisplay})`; + return Localize.translateLocal('iou.updatedTheRequest', {valueName: displayValueName, newValueToDisplay, oldValueToDisplay}); } /** @@ -1497,7 +1498,7 @@ function getProperSchemaForModifiedExpenseMessage(newValue, oldValue, valueName, function getModifiedExpenseMessage(reportAction) { const reportActionOriginalMessage = lodashGet(reportAction, 'originalMessage', {}); if (_.isEmpty(reportActionOriginalMessage)) { - return `changed the request`; + return Localize.translateLocal('iou.changedTheRequest'); } const hasModifiedAmount = @@ -1512,12 +1513,12 @@ function getModifiedExpenseMessage(reportAction) { const currency = reportActionOriginalMessage.currency; const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.amount, currency); - return getProperSchemaForModifiedExpenseMessage(amount, oldAmount, 'amount', false); + return getProperSchemaForModifiedExpenseMessage(amount, oldAmount, Localize.translateLocal('iou.amount'), false); } const hasModifiedComment = _.has(reportActionOriginalMessage, 'oldComment') && _.has(reportActionOriginalMessage, 'newComment'); if (hasModifiedComment) { - return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.newComment, reportActionOriginalMessage.oldComment, 'description', true); + return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.newComment, reportActionOriginalMessage.oldComment, Localize.translateLocal('common.description'), true); } const hasModifiedCreated = _.has(reportActionOriginalMessage, 'oldCreated') && _.has(reportActionOriginalMessage, 'created'); @@ -1525,12 +1526,12 @@ function getModifiedExpenseMessage(reportAction) { // Take only the YYYY-MM-DD value as the original date includes timestamp let formattedOldCreated = parseISO(reportActionOriginalMessage.oldCreated); formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING); - return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.created, formattedOldCreated, 'date', false); + return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.created, formattedOldCreated, Localize.translateLocal('common.date'), false); } const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant'); if (hasModifiedMerchant) { - return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, 'merchant', true); + return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.merchant, reportActionOriginalMessage.oldMerchant, Localize.translateLocal('common.merchant'), true); } } 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/Timers.js b/src/libs/Timers.ts similarity index 73% rename from src/libs/Timers.js rename to src/libs/Timers.ts index 49bc6a7350b8..21ee2a8c2914 100644 --- a/src/libs/Timers.js +++ b/src/libs/Timers.ts @@ -1,14 +1,9 @@ -import _ from 'underscore'; - -const timers = []; +const timers: NodeJS.Timer[] = []; /** * Register a timer so it can be cleaned up later. - * - * @param {Number} timerID - * @returns {Number} */ -function register(timerID) { +function register(timerID: NodeJS.Timer): NodeJS.Timer { timers.push(timerID); return timerID; } @@ -16,8 +11,8 @@ function register(timerID) { /** * Clears all timers that we have registered. Use for long running tasks that may begin once logged out. */ -function clearAll() { - _.each(timers, (timer) => { +function clearAll(): void { + timers.forEach((timer) => { // We don't know whether it's a setTimeout or a setInterval, but it doesn't really matter. If the id doesn't // exist nothing bad happens. clearTimeout(timer); 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/Trie/TrieNode.js b/src/libs/Trie/TrieNode.js deleted file mode 100644 index 27597f861620..000000000000 --- a/src/libs/Trie/TrieNode.js +++ /dev/null @@ -1,9 +0,0 @@ -class TrieNode { - constructor() { - this.children = {}; - this.isEndOfWord = false; - this.metaData = {}; - } -} - -export default TrieNode; diff --git a/src/libs/Trie/TrieNode.ts b/src/libs/Trie/TrieNode.ts new file mode 100644 index 000000000000..645ce6747cbb --- /dev/null +++ b/src/libs/Trie/TrieNode.ts @@ -0,0 +1,19 @@ +type MetaData = Record; + +class TrieNode { + children: Record>; + + metaData: Partial; + + isEndOfWord: boolean; + + constructor() { + this.children = {}; + this.metaData = {}; + this.isEndOfWord = false; + } +} + +export default TrieNode; + +export type {MetaData}; diff --git a/src/libs/Trie/index.js b/src/libs/Trie/index.ts similarity index 62% rename from src/libs/Trie/index.js rename to src/libs/Trie/index.ts index 085f62ab4ef5..c3c5aa4bbf3f 100644 --- a/src/libs/Trie/index.js +++ b/src/libs/Trie/index.ts @@ -1,19 +1,23 @@ -import _ from 'underscore'; -import TrieNode from './TrieNode'; +import TrieNode, {MetaData} from './TrieNode'; + +type Word = { + name: string; + metaData: Partial; +}; + +class Trie { + root: TrieNode; -class Trie { constructor() { this.root = new TrieNode(); } /** * Add a word to the Trie - * @param {String} word - * @param {Object} [metaData] - attach additional data to the word - * @param {TrieNode} [node] - * @param {Boolean} [allowEmptyWords] - empty word doesn't have any char, you shouldn't pass a true value for it because we are disallowing adding an empty word + * @param [metaData] - attach additional data to the word + * @param [allowEmptyWords] - empty word doesn't have any char, you shouldn't pass a true value for it because we are disallowing adding an empty word */ - add(word, metaData = {}, node = this.root, allowEmptyWords = false) { + add(word: string, metaData: Partial = {}, node = this.root, allowEmptyWords = false) { const newWord = word.toLowerCase(); const newNode = node; if (newWord.length === 0 && !allowEmptyWords) { @@ -33,10 +37,9 @@ class Trie { /** * Search for a word in the Trie. - * @param {String} word - * @returns {Object|null} – the node for the word if it's found, or null if it's not found + * @returns - the node for the word if it's found, or null if it's not found */ - search(word) { + search(word: string): TrieNode | null { let newWord = word.toLowerCase(); let node = this.root; while (newWord.length > 1) { @@ -52,10 +55,8 @@ class Trie { /** * Update a word data in the Trie. - * @param {String} word - * @param {Object} metaData */ - update(word, metaData) { + update(word: string, metaData: TMetaData) { let newWord = word.toLowerCase(); let node = this.root; while (newWord.length > 1) { @@ -70,33 +71,26 @@ class Trie { /** * Find all leaf nodes starting with a substring. - * @param {String} substr - * @param {Number} [limit] - matching words limit - * @returns {Array} + * @param [limit] - matching words limit */ - getAllMatchingWords(substr, limit = 5) { + getAllMatchingWords(substr: string, limit = 5): Array> { const newSubstr = substr.toLowerCase(); let node = this.root; let prefix = ''; - for (let i = 0; i < newSubstr.length; i++) { - prefix += newSubstr[i]; - if (!node.children[newSubstr[i]]) { + for (const char of newSubstr) { + prefix += char; + if (!node.children[char]) { return []; } - node = node.children[newSubstr[i]]; + node = node.children[char]; } return this.getChildMatching(node, prefix, limit, []); } /** * Find all leaf nodes that are descendants of a given child node. - * @param {TrieNode} node - * @param {String} prefix - * @param {Number} limit - * @param {Array} [words] - * @returns {Array} */ - getChildMatching(node, prefix, limit, words = []) { + getChildMatching(node: TrieNode, prefix: string, limit: number, words: Array> = []): Array> { const matching = words; if (matching.length >= limit) { return matching; @@ -104,9 +98,9 @@ class Trie { if (node.isEndOfWord) { matching.push({name: prefix, metaData: node.metaData}); } - const children = _.keys(node.children); - for (let i = 0; i < children.length; i++) { - this.getChildMatching(node.children[children[i]], prefix + children[i], limit, matching); + const children = Object.keys(node.children); + for (const child of children) { + this.getChildMatching(node.children[child], prefix + child, limit, matching); } return matching; } diff --git a/src/libs/UserUtils.js b/src/libs/UserUtils.js index 918c2c9bbdc6..2d5930b0dfd8 100644 --- a/src/libs/UserUtils.js +++ b/src/libs/UserUtils.js @@ -106,9 +106,8 @@ function getDefaultAvatarURL(accountID = '', isNewDot = false) { return CONST.CONCIERGE_ICON_URL; } - // The default avatar for a user is based on a simple hash of their accountID. // Note that Avatar count starts at 1 which is why 1 has to be added to the result (or else 0 would result in a broken avatar link) - const accountIDHashBucket = hashText(String(accountID), isNewDot ? CONST.DEFAULT_AVATAR_COUNT : CONST.OLD_DEFAULT_AVATAR_COUNT) + 1; + const accountIDHashBucket = (Number(accountID) % (isNewDot ? CONST.DEFAULT_AVATAR_COUNT : CONST.OLD_DEFAULT_AVATAR_COUNT)) + 1; const avatarPrefix = isNewDot ? `default-avatar` : `avatar`; return `${CONST.CLOUDFRONT_URL}/images/avatars/${avatarPrefix}_${accountIDHashBucket}.png`; 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..3cbadb8e49bf 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -460,8 +460,8 @@ function getMoneyRequestInformation( [payerAccountID]: { accountID: payerAccountID, avatar: UserUtils.getDefaultAvatarURL(payerAccountID), - displayName: participant.displayName || payerEmail, - login: participant.login, + displayName: participant.displayName || participant.login, + login: payerEmail, }, } : undefined; @@ -851,8 +851,8 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco [accountID]: { accountID, avatar: UserUtils.getDefaultAvatarURL(accountID), - displayName: participant.displayName || email, - login: participant.login, + displayName: participant.displayName || participant.login, + login: email, }, } : undefined; @@ -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 8b898a6aaaea..85552fa14a56 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -528,12 +528,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: ''}}, }); } } @@ -926,7 +926,7 @@ function deleteReportComment(reportID, reportAction) { html: '', text: '', isEdited: true, - isDeletedParentAction: ReportActionsUtils.hasCommentThread(reportAction), + isDeletedParentAction: ReportActionsUtils.isThreadParentMessage(reportAction, reportID), }, ]; const optimisticReportActions = { @@ -1298,10 +1298,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 6daa15785921..5d0cb5ab9bf6 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 = { @@ -102,6 +104,7 @@ const defaultProps = { policies: {}, accountManagerReportID: null, personalDetails: {}, + ...withCurrentReportIDDefaultProps, }; /** @@ -131,6 +134,7 @@ function ReportScreen({ viewportOffsetTop, isComposerFullSize, errors, + currentReportID, }) { const firstRenderRef = useRef(true); const flatListRef = useRef(); @@ -157,7 +161,7 @@ function ReportScreen({ const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - const isTopMostReportId = Navigation.getTopmostReportId() === getReportID(route); + const isTopMostReportId = currentReportID === getReportID(route); let headerView = ( _.size(reportActions) === 1, [reportActions]); - const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || isEmptyChat) && shouldShowComposeInput; + const parentAction = ReportActionsUtils.getParentReportAction(report); + const shouldAutoFocus = !modal.isVisible && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentAction))) && shouldShowComposeInput; const valueRef = useRef(value); valueRef.current = value; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c7517977aa27..3ad92fa5c769 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -285,6 +285,15 @@ function ReportActionCompose({ setIsFocused(true); }, []); + // resets the composer to normal size when + // the send button is pressed. + const resetFullComposerSize = useCallback(() => { + 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/ReportActionItemParentAction.js b/src/pages/home/report/ReportActionItemParentAction.js index 2af66779309e..68c5163643b5 100644 --- a/src/pages/home/report/ReportActionItemParentAction.js +++ b/src/pages/home/report/ReportActionItemParentAction.js @@ -53,6 +53,7 @@ function ReportActionItemParentAction(props) { } return ( `${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`, - }, - }), -)(SidebarLinks); +export default compose(withLocalize, withWindowDimensions)(SidebarLinks); export {basePropTypes}; diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 3eca506f7591..5d0a21038d68 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import _ from 'underscore'; import {deepEqual} from 'fast-equals'; import {withOnyx} from 'react-native-onyx'; @@ -81,6 +81,10 @@ function SidebarLinksData({isFocused, allReportActions, betas, chatReports, curr return reportIDsRef.current || []; }, [allReportActions, betas, chatReports, currentReportID, policies, priorityMode, isLoading]); + const currentReportIDRef = useRef(currentReportID); + currentReportIDRef.current = currentReportID; + const isActiveReport = useCallback((reportID) => currentReportIDRef.current === reportID, []); + return ( 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}) => ( - + ( ; - } - return ( Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)} /> -
- - - - - - - - + ) : ( + + + + + + - - - {isUSAForm ? ( + - - ) : ( + + {isUSAForm ? ( + + + + ) : ( + + )} + - )} - - - - - + + + + )}
); } diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js index 90c469c4e25d..22346a48658d 100644 --- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -38,6 +38,7 @@ const defaultProps = { function DateOfBirthPage({translate, privatePersonalDetails}) { usePrivatePersonalDetails(); + const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true); /** * @param {Object} values @@ -59,32 +60,32 @@ function DateOfBirthPage({translate, privatePersonalDetails}) { return errors; }, []); - if (lodashGet(privatePersonalDetails, 'isLoading', true)) { - return ; - } - return ( Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)} /> -
- - + {isLoadingPersonalDetails ? ( + + ) : ( +
+ + + )}
); } diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js index 031816247317..0caf20a3e128 100644 --- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -47,6 +47,7 @@ function LegalNamePage(props) { usePrivatePersonalDetails(); const legalFirstName = lodashGet(props.privatePersonalDetails, 'legalFirstName', ''); const legalLastName = lodashGet(props.privatePersonalDetails, 'legalLastName', ''); + const isLoadingPersonalDetails = lodashGet(props.privatePersonalDetails, 'isLoading', true); const validate = useCallback((values) => { const errors = {}; @@ -66,10 +67,6 @@ function LegalNamePage(props) { return errors; }, []); - if (lodashGet(props.privatePersonalDetails, 'isLoading', true)) { - return ; - } - return ( Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)} /> -
- - - - - - -
+ {isLoadingPersonalDetails ? ( + + ) : ( +
+ + + + + + +
+ )}
); } diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js index e22aeca6a3d8..e1c4f14047a2 100644 --- a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js +++ b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js @@ -60,6 +60,7 @@ function PersonalDetailsInitialPage(props) { const privateDetails = props.privatePersonalDetails || {}; const address = privateDetails.address || {}; const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim(); + const isLoadingPersonalDetails = lodashGet(props.privatePersonalDetails, 'isLoading', true); /** * Applies common formatting to each piece of an address @@ -83,42 +84,42 @@ function PersonalDetailsInitialPage(props) { return formattedAddress.trim().replace(/,$/, ''); }; - if (lodashGet(props.privatePersonalDetails, 'isLoading', true)) { - return ; - } - return ( Navigation.goBack(ROUTES.SETTINGS_PROFILE)} /> - - - - {props.translate('privatePersonalDetails.privateDataMessage')} + {isLoadingPersonalDetails ? ( + + ) : ( + + + + {props.translate('privatePersonalDetails.privateDataMessage')} + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME)} + /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH)} + titleStyle={[styles.flex1]} + /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} + /> - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME)} - /> - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH)} - titleStyle={[styles.flex1]} - /> - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} - /> - - + + )} ); } diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index ccf67844e7f6..9c9cc2d1f3c5 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -230,13 +230,14 @@ function LoginForm(props) { // We need to unmount the submit button when the component is not visible so that the Enter button // key handler gets unsubscribed props.isVisible && ( - + { diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 6db3a20a3e4a..b7a1986c06e6 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -186,7 +186,7 @@ function WorkspaceInvitePage(props) { ); const headerMessage = useMemo(() => { - 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(() => {