diff --git a/.github/ISSUE_TEMPLATE/DesignDoc.md b/.github/ISSUE_TEMPLATE/DesignDoc.md index 424b549a0940..2fbdcf7a65d5 100644 --- a/.github/ISSUE_TEMPLATE/DesignDoc.md +++ b/.github/ISSUE_TEMPLATE/DesignDoc.md @@ -27,6 +27,7 @@ labels: Daily, NewFeature - [ ] Confirm that the doc has the minimum necessary number of reviews before proceeding - [ ] Email `strategy@expensify.com` one last time to let them know the Design Doc is moving into the implementation phase - [ ] Implement the changes +- [ ] Add regression tests so that QA can test your feature with every deploy ([instructions](https://stackoverflowteams.com/c/expensify/questions/363)) - [ ] Send out a follow up email to `strategy@expensify.com` once everything has been implemented and do a **Project Wrap-Up** retrospective that provides: - Summary of what we accomplished with this project - What went well? diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index 701a9bca70a7..561b8e61bc21 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -207,10 +207,7 @@ function fetchTag(tag) { console.log(`Running command: ${command}`); execSync(command); } catch (e) { - // This can happen if the tag was only created locally but does not exist in the remote. In this case, we'll fetch history of the staging branch instead - const command = `git fetch origin staging --no-tags --shallow-exclude=${previousPatchVersion}`; - console.log(`Running command: ${command}`); - execSync(command); + console.error(e); } } @@ -301,13 +298,14 @@ function getValidMergedPRs(commits) { * @returns {Promise>} – Pull request numbers */ function getPullRequestsMergedBetween(fromTag, toTag) { + console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); // Find which commit messages correspond to merged PR's const pullRequestNumbers = getValidMergedPRs(commitList); console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return pullRequestNumbers; + return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); }); } diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index def58d95e846..e42f97508bc5 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -170,10 +170,7 @@ function fetchTag(tag) { console.log(`Running command: ${command}`); execSync(command); } catch (e) { - // This can happen if the tag was only created locally but does not exist in the remote. In this case, we'll fetch history of the staging branch instead - const command = `git fetch origin staging --no-tags --shallow-exclude=${previousPatchVersion}`; - console.log(`Running command: ${command}`); - execSync(command); + console.error(e); } } @@ -264,13 +261,14 @@ function getValidMergedPRs(commits) { * @returns {Promise>} – Pull request numbers */ function getPullRequestsMergedBetween(fromTag, toTag) { + console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); // Find which commit messages correspond to merged PR's const pullRequestNumbers = getValidMergedPRs(commitList); console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return pullRequestNumbers; + return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); }); } diff --git a/.github/libs/GitUtils.js b/.github/libs/GitUtils.js index ba9d7fa2b38a..7bc600470dd1 100644 --- a/.github/libs/GitUtils.js +++ b/.github/libs/GitUtils.js @@ -22,10 +22,7 @@ function fetchTag(tag) { console.log(`Running command: ${command}`); execSync(command); } catch (e) { - // This can happen if the tag was only created locally but does not exist in the remote. In this case, we'll fetch history of the staging branch instead - const command = `git fetch origin staging --no-tags --shallow-exclude=${previousPatchVersion}`; - console.log(`Running command: ${command}`); - execSync(command); + console.error(e); } } @@ -116,13 +113,14 @@ function getValidMergedPRs(commits) { * @returns {Promise>} – Pull request numbers */ function getPullRequestsMergedBetween(fromTag, toTag) { + console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); // Find which commit messages correspond to merged PR's const pullRequestNumbers = getValidMergedPRs(commitList); console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return pullRequestNumbers; + return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); }); } diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e1b1696411b1..e432d9291f45 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -85,7 +85,7 @@ The GitHub workflows require a large list of secrets to deploy, notify and test 1. `LARGE_SECRET_PASSPHRASE` - decrypts secrets stored in various encrypted files stored in GitHub repository. To create updated versions of these encrypted files, refer to steps 1-4 of [this encrypted secrets help page](https://docs.github.com/en/actions/reference/encrypted-secrets#limits-for-secrets) using the `LARGE_SECRET_PASSPHRASE`. 1. `android/app/my-upload-key.keystore.gpg` 1. `android/app/android-fastlane-json-key.json.gpg` - 1. `ios/chat_expensify_adhoc.mobileprovision.gpg` + 1. `ios/expensify_chat_adhoc.mobileprovision.gpg` 1. `ios/chat_expensify_appstore.mobileprovision.gpg` 1. `ios/Certificates.p12.gpg` 1. `SLACK_WEBHOOK` - Sends Slack notifications via Slack WebHook https://expensify.slack.com/services/B01AX48D7MM diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index b78a5fac4b69..7b71f6263c88 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -119,31 +119,3 @@ jobs: uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - - # Create a new StagingDeployCash for the next release cycle. - createNewStagingDeployCash: - runs-on: ubuntu-latest - needs: [updateStaging, createNewPatchVersion] - steps: - - uses: actions/checkout@v3 - with: - ref: staging - token: ${{ secrets.OS_BOTIFY_TOKEN }} - - # Create a local git tag so that GitUtils.getPullRequestsMergedBetween can use `git log` to generate a - # list of pull requests that were merged between this version tag and another. - # NOTE: This tag is only used locally and shouldn't be pushed to the remote. - # If it was pushed, that would trigger the staging deploy which is handled in a separate workflow (deploy.yml) - - name: Tag version - run: git tag ${{ needs.createNewPatchVersion.outputs.NEW_VERSION }} - - - name: Create new StagingDeployCash - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main - with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - NPM_VERSION: ${{ needs.createNewPatchVersion.outputs.NEW_VERSION }} - - - if: ${{ failure() }} - uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main - with: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 795271cab60a..1983e406c77b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: - uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Lint JavaScript with ESLint + - name: Lint JavaScript and Typescript with ESLint run: npm run lint env: CI: true diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 84f8373ff247..400a0d4364fe 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -28,6 +28,25 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform + deployChecklist: + name: Create or update deploy checklist + runs-on: ubuntu-latest + needs: validateActor + steps: + - uses: actions/checkout@v3 + - uses: Expensify/App/.github/actions/composite/setupNode@main + + - name: Set version + id: getVersion + run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + + - name: Create or update staging deploy + uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main + with: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + NPM_VERSION: ${{ steps.getVersion.outputs.VERSION }} + android: name: Build and deploy Android needs: validateActor diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index c9fb636238aa..e3977734fc50 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -98,25 +98,6 @@ jobs: # Force-update the remote staging branch git push --force origin staging - # Create a local git tag on staging so that GitUtils.getPullRequestsMergedBetween can use `git log` to generate a - # list of pull requests that were merged between this version tag and another. - # NOTE: This tag is only used locally and shouldn't be pushed to the remote. - # If it was pushed, that would trigger the staging deploy which is handled in a separate workflow (deploy.yml) - - name: Tag staging - run: git tag ${{ needs.createNewVersion.outputs.NEW_VERSION }} - - - name: Update StagingDeployCash - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main - with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - NPM_VERSION: ${{ needs.createNewVersion.outputs.NEW_VERSION }} - - - name: Find open StagingDeployCash - id: getStagingDeployCash - run: echo "STAGING_DEPLOY_CASH=$(gh issue list --label StagingDeployCash --json number --jq '.[0].number')" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - - if: ${{ failure() }} uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main with: diff --git a/android/app/build.gradle b/android/app/build.gradle index db6109d0f77d..7f8ab01ca837 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 1001036204 - versionName "1.3.62-4" + versionCode 1001036700 + versionName "1.3.67-0" } 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/articles/request-money/Request-and-Split-Bills.md b/docs/articles/request-money/Request-and-Split-Bills.md index a2c63cf6f8f7..bb27cd75c742 100644 --- a/docs/articles/request-money/Request-and-Split-Bills.md +++ b/docs/articles/request-money/Request-and-Split-Bills.md @@ -16,7 +16,10 @@ These two features ensure you can live in the moment and settle up afterward. # How to Request Money - Select the Green **+** button and choose **Request Money** -- Enter the amount **$** they owe and click **Next** +- Select the relevant option: + - **Manual:** Enter the merchant and amount manually. + - **Scan:** Take a photo of the receipt to have the merchant and amount auto-filled. + - **Distance:** Enter the details of your trip, plus any stops along the way, and the mileage and amount will be automatically calculated. - Search for the user or enter their email! - Enter a reason for the request (optional) - Click **Request!** 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 f684494e8563..083e2a524ef5 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.62 + 1.3.67 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.62.4 + 1.3.67.0 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 0da2355a39fd..30c8e2b034ca 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.62 + 1.3.67 CFBundleSignature ???? CFBundleVersion - 1.3.62.4 + 1.3.67.0 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 82b0af87b6dd..ac697c0c5f7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.62-4", + "version": "1.3.67-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.62-4", + "version": "1.3.67-0", "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.5.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.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-25.5.0.tgz", + "integrity": "sha512-w1DNj1LuAk0Vaas1rQ0pAkTe2gZ5YG75J27mC2m88y0G6Do5b5YoFDaF84fOGQHeQ4j8tC5LngSgWhbwmqDlrw==", "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.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-25.5.0.tgz", + "integrity": "sha512-w1DNj1LuAk0Vaas1rQ0pAkTe2gZ5YG75J27mC2m88y0G6Do5b5YoFDaF84fOGQHeQ4j8tC5LngSgWhbwmqDlrw==", "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 16991adc44f2..ce1ede2302a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.62-4", + "version": "1.3.67-0", "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.5.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/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index e761ebeed26b..1697dddba805 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -95,6 +95,9 @@ const propTypes = { /** Maximum number of characters allowed in search input */ maxInputLength: PropTypes.number, + /** The result types to return from the Google Places Autocomplete request */ + resultTypes: PropTypes.string, + /** Information about the network */ network: networkPropTypes.isRequired, @@ -123,6 +126,7 @@ const defaultProps = { }, maxInputLength: undefined, predefinedPlaces: [], + resultTypes: 'address', }; // Do not convert to class component! It's been tried before and presents more challenges than it's worth. @@ -134,10 +138,10 @@ function AddressSearch(props) { const query = useMemo( () => ({ language: props.preferredLocale, - types: 'address', + types: props.resultTypes, components: props.isLimitedToUSA ? 'country:us' : undefined, }), - [props.preferredLocale, props.isLimitedToUSA], + [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], ); const saveLocationDetails = (autocompleteData, details) => { 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 && ( @@ -266,7 +267,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken}) isDisabled={_.size(validatedWaypoints) < 2} text={translate('common.next')} /> - + ); } diff --git a/src/components/DownloadAppModal.js b/src/components/DownloadAppModal.js index ffa933708e4c..c96c6b3d28c0 100644 --- a/src/components/DownloadAppModal.js +++ b/src/components/DownloadAppModal.js @@ -26,13 +26,13 @@ const defaultProps = { }; function DownloadAppModal({isAuthenticated, showDownloadAppBanner}) { - const [shouldShowBanner, setshouldShowBanner] = useState(Browser.isMobile() && isAuthenticated && showDownloadAppBanner); + const [shouldShowBanner, setShouldShowBanner] = useState(Browser.isMobile() && isAuthenticated && showDownloadAppBanner); const {translate} = useLocalize(); const handleCloseBanner = () => { setShowDownloadAppModal(false); - setshouldShowBanner(false); + setShouldShowBanner(false); }; let link = ''; @@ -44,6 +44,8 @@ function DownloadAppModal({isAuthenticated, showDownloadAppBanner}) { } const handleOpenAppStore = () => { + setShowDownloadAppModal(false); + setShouldShowBanner(false); Link.openExternalLink(link, true); }; 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 ( = CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT) { - targetOffset = offsetAtEmojiBottom - CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT; - } else if (offsetAtEmojiTop - CONST.EMOJI_PICKER_HEADER_HEIGHT <= this.currentScrollOffset) { - // There is always a sticky header on the top, subtract the EMOJI_PICKER_HEADER_HEIGHT from offsetAtEmojiTop to get the correct scroll position. - targetOffset = offsetAtEmojiTop - CONST.EMOJI_PICKER_HEADER_HEIGHT; - } - if (targetOffset !== this.currentScrollOffset) { - // Disable pointer events so that onHover doesn't get triggered when the items move while we're scrolling - if (!this.state.arePointerEventsDisabled) { - this.setState({arePointerEventsDisabled: true}); - } - this.emojiList.scrollToOffset({offset: targetOffset, animated: false}); - } - } - /** * Filter the entire list of emojis to only emojis that have the search term in their keywords * @@ -530,6 +496,7 @@ class EmojiPickerMenu extends Component { return ( @@ -566,10 +533,11 @@ class EmojiPickerMenu extends Component { {overscrollBehaviorY: 'contain'}, // Set overflow to hidden to prevent elastic scrolling when there are not enough contents to scroll in FlatList {overflowY: this.state.filteredEmojis.length > overflowLimit ? 'auto' : 'hidden'}, + // Set scrollPaddingTop to consider sticky headers while scrolling + {scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT}, ]} extraData={[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]} stickyHeaderIndices={this.state.headerIndices} - onScroll={(e) => (this.currentScrollOffset = e.nativeEvent.contentOffset.y)} getItemLayout={this.getItemLayout} contentContainerStyle={styles.flexGrow1} ListEmptyComponent={{this.props.translate('common.noResultsFound')}} diff --git a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js index 37e90f01c707..728e56792ddb 100644 --- a/src/components/EmojiPicker/EmojiPickerMenuItem/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenuItem/index.js @@ -42,13 +42,14 @@ class EmojiPickerMenuItem extends PureComponent { super(props); this.ref = null; + this.focusAndScroll = this.focusAndScroll.bind(this); } componentDidMount() { if (!this.props.isFocused) { return; } - this.ref.focus(); + this.focusAndScroll(); } componentDidUpdate(prevProps) { @@ -58,7 +59,12 @@ class EmojiPickerMenuItem extends PureComponent { if (!this.props.isFocused) { return; } - this.ref.focus(); + this.focusAndScroll(); + } + + focusAndScroll() { + this.ref.focus({preventScroll: true}); + this.ref.scrollIntoView({block: 'nearest'}); } render() { diff --git a/src/components/Form.js b/src/components/Form.js index eb6945f6ec78..a45c6d769d57 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -108,6 +108,7 @@ function Form(props) { const formContentRef = useRef(null); const inputRefs = useRef({}); const touchedInputs = useRef({}); + const focusedInput = useRef(null); const isFirstRender = useRef(true); const {validate, onSubmit, children} = props; @@ -305,6 +306,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()) { @@ -328,6 +335,11 @@ function Form(props) { }, onInputChange: (value, key) => { const inputKey = key || inputID; + + if (focusedInput.current && focusedInput.current !== inputKey) { + setTouchedInput(focusedInput.current); + } + setInputValues((prevState) => { const newState = { ...prevState, diff --git a/src/components/HeaderGap/index.desktop.js b/src/components/HeaderGap/index.desktop.js index 10974aa9f5ee..6b47f56516de 100644 --- a/src/components/HeaderGap/index.desktop.js +++ b/src/components/HeaderGap/index.desktop.js @@ -1,9 +1,22 @@ import React, {PureComponent} from 'react'; import {View} from 'react-native'; +import PropTypes from 'prop-types'; import styles from '../../styles/styles'; -export default class HeaderGap extends PureComponent { +const propTypes = { + /** Styles to apply to the HeaderGap */ + // eslint-disable-next-line react/forbid-prop-types + styles: PropTypes.arrayOf(PropTypes.object), +}; + +class HeaderGap extends PureComponent { render() { - return ; + return ; } } + +HeaderGap.propTypes = propTypes; +HeaderGap.defaultProps = { + styles: [], +}; +export default HeaderGap; diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 21fade6eb942..4c7bd54efa18 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -12,6 +12,9 @@ import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDe import OptionRowLHN, {propTypes as basePropTypes, defaultProps as baseDefaultProps} from './OptionRowLHN'; import * as Report from '../../libs/actions/Report'; import * as UserUtils from '../../libs/UserUtils'; +import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import * as TransactionUtils from '../../libs/TransactionUtils'; + import participantPropTypes from '../participantPropTypes'; import CONST from '../../CONST'; import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; @@ -75,6 +78,7 @@ function OptionRowLHNData({ preferredLocale, comment, policies, + receiptTransactions, parentReportActions, ...propsToForward }) { @@ -88,6 +92,14 @@ function OptionRowLHNData({ const parentReportAction = parentReportActions[fullReport.parentReportActionID]; const optionItemRef = useRef(); + + const linkedTransaction = useMemo(() => { + const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(reportActions); + const lastReportAction = _.first(sortedReportActions); + return TransactionUtils.getLinkedTransaction(lastReportAction); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullReport.reportID, receiptTransactions, reportActions]); + const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! const item = SidebarUtils.getOptionData(fullReport, reportActions, personalDetails, preferredLocale, policy); @@ -98,7 +110,7 @@ function OptionRowLHNData({ return item; // Listen parentReportAction to update title of thread report when parentReportAction changed // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fullReport, reportActions, personalDetails, preferredLocale, policy, parentReportAction]); + }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction]); useEffect(() => { if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { @@ -186,6 +198,11 @@ export default React.memo( key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`, canEvict: false, }, + // Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions. + // In some scenarios, a transaction might be created after reportActions have been modified. + // This can lead to situations where `lastTransaction` doesn't update and retains the previous value. + // However, performance overhead of this is minimized by using memos inside the component. + receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION}, }), )(OptionRowLHNData), ); diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 966c393ac0d8..a7695c939907 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -291,14 +291,13 @@ function MoneyRequestConfirmationList(props) { return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)]; }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]); - const distanceMerchant = useMemo(() => DistanceRequestUtils.getDistanceMerchant(distance, unit, rate, currency, translate), [distance, unit, rate, currency, translate]); - useEffect(() => { if (!props.isDistanceRequest) { return; } + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(distance, unit, rate, currency, translate); IOU.setMoneyRequestMerchant(distanceMerchant); - }, [distanceMerchant, props.isDistanceRequest]); + }, [distance, unit, rate, currency, translate, props.isDistanceRequest]); /** * @param {Object} option @@ -467,7 +466,7 @@ function MoneyRequestConfirmationList(props) { {props.isDistanceRequest ? ( + {translate('iou.receiptStatusTitle')} diff --git a/src/components/MoneyRequestSkeletonView.js b/src/components/MoneyRequestSkeletonView.js new file mode 100644 index 000000000000..50a7b56b91e3 --- /dev/null +++ b/src/components/MoneyRequestSkeletonView.js @@ -0,0 +1,40 @@ +import React from 'react'; +import {Rect} from 'react-native-svg'; +import SkeletonViewContentLoader from 'react-content-loader/native'; +import variables from '../styles/variables'; +import themeColors from '../styles/themes/default'; +import styles from '../styles/styles'; + +function MoneyRequestSkeletonView() { + return ( + + + + + + ); +} + +MoneyRequestSkeletonView.displayName = 'MoneyRequestSkeletonView'; +export default MoneyRequestSkeletonView; 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/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.js index bb312f85be41..3b310bb6b4fa 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.js +++ b/src/components/ReportActionItem/MoneyRequestAction.js @@ -58,6 +58,9 @@ const propTypes = { network: networkPropTypes.isRequired, + /** Whether a message is a whisper */ + isWhisper: PropTypes.bool, + /** Styles to be assigned to Container */ // eslint-disable-next-line react/forbid-prop-types style: PropTypes.arrayOf(PropTypes.object), @@ -71,6 +74,7 @@ const defaultProps = { reportActions: {}, isHovered: false, style: [], + isWhisper: false, }; function MoneyRequestAction({ @@ -86,6 +90,7 @@ function MoneyRequestAction({ isHovered, network, style, + isWhisper, }) { const {translate} = useLocalize(); const isSplitBillAction = lodashGet(action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; @@ -137,6 +142,7 @@ function MoneyRequestAction({ onPreviewPressed={onMoneyRequestPreviewPressed} containerStyles={[styles.cursorPointer, isHovered ? styles.reportPreviewBoxHoverBorder : undefined, ...style]} isHovered={isHovered} + isWhisper={isWhisper} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 0c03f446939c..02da03225062 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -27,14 +27,16 @@ import * as CurrencyUtils from '../../libs/CurrencyUtils'; import * as IOUUtils from '../../libs/IOUUtils'; import * as ReportUtils from '../../libs/ReportUtils'; import * as TransactionUtils from '../../libs/TransactionUtils'; -import * as StyleUtils from '../../styles/StyleUtils'; -import getButtonState from '../../libs/getButtonState'; import refPropTypes from '../refPropTypes'; 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 = { /** The active IOUReport, used for Onyx subscription */ @@ -111,6 +113,9 @@ const propTypes = { */ shouldShowPendingConversionMessage: PropTypes.bool, + /** Whether a message is a whisper */ + isWhisper: PropTypes.bool, + ...withLocalizePropTypes, }; @@ -129,12 +134,16 @@ const defaultProps = { }, transaction: {}, shouldShowPendingConversionMessage: false, + isWhisper: false, }; 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 || ''; @@ -156,6 +165,13 @@ function MoneyRequestPreview(props) { const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(props.transaction); const isDistanceRequest = TransactionUtils.isDistanceRequest(props.transaction); + // Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan + const shouldShowMerchant = + !_.isEmpty(requestMerchant) && !props.isBillSplit && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant; + + const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction.receipt.source, props.transaction.filename || props.transaction.receiptFilename || '')] : []; + const getSettledMessage = () => { switch (lodashGet(props.action, 'originalMessage.paymentType', '')) { case CONST.IOU.PAYMENT_TYPE.PAYPAL_ME: @@ -220,88 +236,96 @@ function MoneyRequestPreview(props) { errorRowStyles={[styles.mbn1]} needsOffscreenAlphaCompositing > - + {hasReceipt && ( )} - - - - {getPreviewHeaderText()} - {Boolean(getSettledMessage()) && ( - <> - - {getSettledMessage()} - + {_.isEmpty(props.transaction) ? ( + + ) : ( + + + + {getPreviewHeaderText()} + {Boolean(getSettledMessage()) && ( + <> + + {getSettledMessage()} + + )} + + {hasFieldErrors && ( + )} - {hasFieldErrors && ( - - )} - - - - - {getDisplayAmountText()} - {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && ( - - + + + {getDisplayAmountText()} + + {ReportUtils.isSettled(props.iouReport.reportID) && !props.isBillSplit && ( + + + + )} + + {props.isBillSplit && ( + + )} - {props.isBillSplit && ( - - + {shouldShowMerchant && ( + + {requestMerchant} )} - - {!props.isBillSplit && !_.isEmpty(requestMerchant) && ( - {requestMerchant} - - )} - - - {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( - {props.translate('iou.pendingConversionMessage')} + + {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( + {props.translate('iou.pendingConversionMessage')} + )} + {shouldShowDescription && {description}} + + {props.isBillSplit && !_.isEmpty(participantAccountIDs) && ( + + {props.translate('iou.amountEach', { + amount: CurrencyUtils.convertToDisplayString( + IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency), + requestCurrency, + ), + })} + )} - {!_.isEmpty(description) && {description}} - {props.isBillSplit && !_.isEmpty(participantAccountIDs) && ( - - {props.translate('iou.amountEach', { - amount: CurrencyUtils.convertToDisplayString( - IOUUtils.calculateAmount(isPolicyExpenseChat ? 1 : participantAccountIDs.length - 1, requestAmount, requestCurrency), - requestCurrency, - ), - })} - - )} - + )} diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index b549df7fc55f..a9264812b99d 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -66,6 +66,8 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans comment: transactionDescription, merchant: transactionMerchant, } = ReportUtils.getTransactionDetails(transaction); + const isEmptyMerchant = + transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); @@ -158,8 +160,8 @@ function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, trans shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => 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/ReportActionItemImage.js b/src/components/ReportActionItem/ReportActionItemImage.js index 5f8444af0b21..070f534f4924 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.js +++ b/src/components/ReportActionItem/ReportActionItemImage.js @@ -35,47 +35,44 @@ const defaultProps = { function ReportActionItemImage({thumbnail, image, enablePreviewModal}) { const {translate} = useLocalize(); + const imageSource = tryResolveUrlFromApiRoot(image || ''); + const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail || ''); - if (thumbnail) { - const imageSource = tryResolveUrlFromApiRoot(image); - const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail); - const thumbnailComponent = ( - - ); - - if (enablePreviewModal) { - return ( - - {({report}) => ( - { - const route = ROUTES.getReportAttachmentRoute(report.reportID, imageSource); - Navigation.navigate(route); - }} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={translate('accessibilityHints.viewAttachment')} - > - {thumbnailComponent} - - )} - - ); - } - return thumbnailComponent; - } - - return ( + const receiptImageComponent = thumbnail ? ( + + ) : ( ); + + if (enablePreviewModal) { + return ( + + {({report}) => ( + { + const route = ROUTES.getReportAttachmentRoute(report.reportID, imageSource); + Navigation.navigate(route); + }} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={translate('accessibilityHints.viewAttachment')} + > + {receiptImageComponent} + + )} + + ); + } + + return receiptImageComponent; } ReportActionItemImage.propTypes = propTypes; diff --git a/src/components/ReportActionItem/ReportActionItemImages.js b/src/components/ReportActionItem/ReportActionItemImages.js index a5164fb11dea..7e6287720952 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.js +++ b/src/components/ReportActionItem/ReportActionItemImages.js @@ -54,10 +54,14 @@ function ReportActionItemImages({images, size, total, isHovered}) { {_.map(shownImages, ({thumbnail, image}, index) => { const isLastImage = index === numberOfShownImages - 1; + + // Show a border to separate multiple images. Shown to the right for each except the last. + const shouldShowBorder = shownImages.length > 1 && index < shownImages.length - 1; + const borderStyle = shouldShowBorder ? styles.reportActionItemImageBorder : {}; return ( + ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || receiptFilename || ''), + ); const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts; const previewSubtitle = hasOnlyOneReceiptRequest @@ -176,10 +181,10 @@ function ReportPreview(props) { accessibilityRole="button" accessibilityLabel={props.translate('iou.viewDetails')} > - + {hasReceipts && ( ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || ''))} + images={lastThreeReceipts} size={3} total={transactionsWithReceipts.length} isHovered={props.isHovered || isScanning} @@ -196,10 +201,6 @@ function ReportPreview(props) { fill={colors.red} /> )} - @@ -214,7 +215,7 @@ function ReportPreview(props) { )} - {hasReceipts && !isScanning && ( + {!isScanning && (numberOfRequests > 1 || hasReceipts) && ( {previewSubtitle || moneyRequestComment} 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.js b/src/languages/en.ts similarity index 88% rename from src/languages/en.js rename to src/languages/en.ts index 364029a81ece..c863caae67ff 100755 --- a/src/languages/en.js +++ b/src/languages/en.ts @@ -1,5 +1,74 @@ import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; import CONST from '../CONST'; +import type { + AddressLineParams, + CharacterLimitParams, + MaxParticipantsReachedParams, + ZipCodeExampleFormatParams, + LoggedInAsParams, + NewFaceEnterMagicCodeParams, + WelcomeEnterMagicCodeParams, + AlreadySignedInParams, + GoBackMessageParams, + LocalTimeParams, + EditActionParams, + DeleteActionParams, + DeleteConfirmationParams, + BeginningOfChatHistoryDomainRoomPartOneParams, + BeginningOfChatHistoryAdminRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartTwo, + WelcomeToRoomParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + ReportArchiveReasonsPolicyDeletedParams, + RequestCountParams, + SettleExpensifyCardParams, + SettlePaypalMeParams, + RequestAmountParams, + SplitAmountParams, + AmountEachParams, + PayerOwesAmountParams, + PayerOwesParams, + PayerPaidAmountParams, + PayerPaidParams, + PayerSettledParams, + WaitingOnBankAccountParams, + SettledAfterAddedBankAccountParams, + PaidElsewhereWithAmountParams, + PaidUsingPaypalWithAmountParams, + PaidUsingExpensifyWithAmountParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + SizeExceededParams, + ResolutionConstraintsParams, + NotAllowedExtensionParams, + EnterMagicCodeParams, + TransferParams, + InstantSummaryParams, + NotYouParams, + DateShouldBeBeforeParams, + DateShouldBeAfterParams, + IncorrectZipFormatParams, + WeSentYouMagicSignInLinkParams, + ToValidateLoginParams, + NoLongerHaveAccessParams, + OurEmailProviderParams, + ConfirmThatParams, + UntilTimeParams, + StepCounterParams, + UserIsAlreadyMemberOfWorkspaceParams, + GoToRoomParams, + WelcomeNoteParams, + RoomNameReservedErrorParams, + RenamedRoomActionParams, + RoomRenamedToParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + ParentNavigationSummaryParams, + ManagerApprovedParams, +} from './types'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; /* eslint-disable max-len */ @@ -73,7 +142,7 @@ export default { currentMonth: 'Current month', ssnLast4: 'Last 4 digits of SSN', ssnFull9: 'Full 9 digits of SSN', - addressLine: ({lineNumber}) => `Address line ${lineNumber}`, + addressLine: ({lineNumber}: AddressLineParams) => `Address line ${lineNumber}`, personalAddress: 'Personal address', companyAddress: 'Company address', noPO: 'PO boxes and mail drop addresses are not allowed', @@ -104,7 +173,7 @@ export default { acceptTerms: 'You must accept the Terms of Service to continue', phoneNumber: `Please enter a valid phone number, with the country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'This field is required.', - characterLimit: ({limit}) => `Exceeds the maximum length of ${limit} characters`, + characterLimit: ({limit}: CharacterLimitParams) => `Exceeds the maximum length of ${limit} characters`, dateInvalid: 'Please select a valid date', invalidCharacter: 'Invalid character', enterMerchant: 'Enter a merchant name', @@ -137,14 +206,14 @@ export default { youAfterPreposition: 'you', your: 'your', conciergeHelp: 'Please reach out to Concierge for help.', - maxParticipantsReached: ({count}) => `You've selected the maximum number (${count}) of participants.`, + maxParticipantsReached: ({count}: MaxParticipantsReachedParams) => `You've selected the maximum number (${count}) of participants.`, youAppearToBeOffline: 'You appear to be offline.', thisFeatureRequiresInternet: 'This feature requires an active internet connection to be used.', areYouSure: 'Are you sure?', verify: 'Verify', yesContinue: 'Yes, continue', websiteExample: 'e.g. https://www.expensify.com', - zipCodeExampleFormat: ({zipSampleFormat}) => (zipSampleFormat ? `e.g. ${zipSampleFormat}` : ''), + zipCodeExampleFormat: ({zipSampleFormat}: ZipCodeExampleFormatParams) => (zipSampleFormat ? `e.g. ${zipSampleFormat}` : ''), description: 'Description', with: 'with', shareCode: 'Share code', @@ -210,7 +279,7 @@ export default { redirectedToDesktopApp: "We've redirected you to the desktop app.", youCanAlso: 'You can also', openLinkInBrowser: 'open this link in your browser', - loggedInAs: ({email}) => `You're logged in as ${email}. Click "Open link" in the prompt to log into the desktop app with this account.`, + loggedInAs: ({email}: LoggedInAsParams) => `You're logged in as ${email}. Click "Open link" in the prompt to log into the desktop app with this account.`, doNotSeePrompt: "Can't see the prompt?", tryAgain: 'Try again', or: ', or', @@ -256,8 +325,9 @@ export default { phrase2: "Money talks. And now that chat and payments are in one place, it's also easy.", phrase3: 'Your payments get to you as fast as you can get your point across.', enterPassword: 'Please enter your password', - newFaceEnterMagicCode: ({login}) => `It's always great to see a new face around here! Please enter the magic code sent to ${login}. It should arrive within a minute or two.`, - welcomeEnterMagicCode: ({login}) => `Please enter the magic code sent to ${login}. It should arrive within a minute or two.`, + newFaceEnterMagicCode: ({login}: NewFaceEnterMagicCodeParams) => + `It's always great to see a new face around here! Please enter the magic code sent to ${login}. It should arrive within a minute or two.`, + welcomeEnterMagicCode: ({login}: WelcomeEnterMagicCodeParams) => `Please enter the magic code sent to ${login}. It should arrive within a minute or two.`, }, DownloadAppModal: { downloadTheApp: 'Download the app', @@ -271,8 +341,8 @@ export default { }, }, thirdPartySignIn: { - alreadySignedIn: ({email}) => `You are already signed in as ${email}.`, - goBackMessage: ({provider}) => `Don't want to sign in with ${provider}?`, + alreadySignedIn: ({email}: AlreadySignedInParams) => `You are already signed in as ${email}.`, + goBackMessage: ({provider}: GoBackMessageParams) => `Don't want to sign in with ${provider}?`, continueWithMyCurrentSession: 'Continue with my current session', redirectToDesktopMessage: "We'll redirect you to the desktop app once you finish signing in.", signInAgreementMessage: 'By logging in, you agree to the', @@ -297,7 +367,7 @@ export default { ], blockedFromConcierge: 'Communication is barred', fileUploadFailed: 'Upload failed. File is not supported.', - localTime: ({user, time}) => `It's ${time} for ${user}`, + localTime: ({user, time}: LocalTimeParams) => `It's ${time} for ${user}`, edited: '(edited)', emoji: 'Emoji', collapse: 'Collapse', @@ -311,9 +381,9 @@ export default { copyEmailToClipboard: 'Copy email to clipboard', markAsUnread: 'Mark as unread', markAsRead: 'Mark as read', - editAction: ({action}) => `Edit ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`, - deleteAction: ({action}) => `Delete ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`, - deleteConfirmation: ({action}) => `Are you sure you want to delete this ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}?`, + editAction: ({action}: EditActionParams) => `Edit ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`, + deleteAction: ({action}: DeleteActionParams) => `Delete ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}`, + deleteConfirmation: ({action}: DeleteConfirmationParams) => `Are you sure you want to delete this ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}?`, onlyVisible: 'Only visible to', replyInThread: 'Reply in thread', flagAsOffensive: 'Flag as offensive', @@ -325,13 +395,14 @@ export default { reportActionsView: { beginningOfArchivedRoomPartOne: 'You missed the party in ', beginningOfArchivedRoomPartTwo: ", there's nothing to see here.", - beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}) => `Collaboration with everyone at ${domainRoom} starts here! 🎉\nUse `, + beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `Collaboration with everyone at ${domainRoom} starts here! 🎉\nUse `, beginningOfChatHistoryDomainRoomPartTwo: ' to chat with colleagues, share tips, and ask questions.', - beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}) => `Collaboration among ${workspaceName} admins starts here! 🎉\nUse `, + beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `Collaboration among ${workspaceName} admins starts here! 🎉\nUse `, beginningOfChatHistoryAdminRoomPartTwo: ' to chat about topics such as workspace configurations and more.', beginningOfChatHistoryAdminOnlyPostingRoom: 'Only admins can send messages in this room.', - beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}) => `Collaboration between all ${workspaceName} members starts here! 🎉\nUse `, - beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}) => ` to chat about anything ${workspaceName} related.`, + beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => + `Collaboration between all ${workspaceName} members starts here! 🎉\nUse `, + beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` to chat about anything ${workspaceName} related.`, beginningOfChatHistoryUserRoomPartOne: 'Collaboration starts here! 🎉\nUse this space to chat about anything ', beginningOfChatHistoryUserRoomPartTwo: ' related.', beginningOfChatHistory: 'This is the beginning of your chat with ', @@ -340,7 +411,7 @@ export default { beginningOfChatHistoryPolicyExpenseChatPartThree: ' starts here! 🎉 This is the place to chat, request money and settle up.', chatWithAccountManager: 'Chat with your account manager here', sayHello: 'Say hello!', - welcomeToRoom: ({roomName}) => `Welcome to ${roomName}!`, + welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welcome to ${roomName}!`, usePlusButton: '\n\nYou can also use the + button below to request money or assign a task!', }, reportAction: { @@ -357,12 +428,14 @@ export default { }, reportArchiveReasons: { [CONST.REPORT.ARCHIVE_REASON.DEFAULT]: 'This chat room has been archived.', - [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED]: ({displayName}) => `This workspace chat is no longer active because ${displayName} closed their account.`, - [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED]: ({displayName, oldDisplayName}) => + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED]: ({displayName}: ReportArchiveReasonsClosedParams) => + `This workspace chat is no longer active because ${displayName} closed their account.`, + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED]: ({displayName, oldDisplayName}: ReportArchiveReasonsMergedParams) => `This workspace chat is no longer active because ${oldDisplayName} has merged their account with ${displayName}.`, - [CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY]: ({displayName, policyName}) => + [CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY]: ({displayName, policyName}: ReportArchiveReasonsRemovedFromPolicyParams) => `This workspace chat is no longer active because ${displayName} is no longer a member of the ${policyName} workspace.`, - [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}) => `This workspace chat is no longer active because ${policyName} is no longer an active workspace.`, + [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => + `This workspace chat is no longer active because ${policyName} is no longer an active workspace.`, }, writeCapabilityPage: { label: 'Who can post', @@ -424,33 +497,34 @@ export default { receiptScanning: 'Receipt scan in progress…', receiptStatusTitle: 'Scanning…', receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.", - requestCount: ({count, scanningReceipts = 0}) => `${count} requests${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}`, + requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} requests${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}`, deleteRequest: 'Delete request', deleteConfirmation: 'Are you sure that you want to delete this request?', settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', settledPaypalMe: 'Paid using Paypal.me', - settleExpensify: ({formattedAmount}) => `Pay ${formattedAmount} with Expensify`, + settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount} with Expensify`, payElsewhere: 'Pay elsewhere', - settlePaypalMe: ({formattedAmount}) => `Pay ${formattedAmount} with PayPal.me`, - requestAmount: ({amount}) => `request ${amount}`, - splitAmount: ({amount}) => `split ${amount}`, - amountEach: ({amount}) => `${amount} each`, - payerOwesAmount: ({payer, amount}) => `${payer} owes ${amount}`, - payerOwes: ({payer}) => `${payer} owes: `, - payerPaidAmount: ({payer, amount}) => `${payer} paid ${amount}`, - payerPaid: ({payer}) => `${payer} paid: `, - managerApproved: ({manager}) => `${manager} approved:`, - payerSettled: ({amount}) => `paid ${amount}`, - waitingOnBankAccount: ({submitterDisplayName}) => `started settling up, payment is held until ${submitterDisplayName} adds a bank account`, - settledAfterAddedBankAccount: ({submitterDisplayName, amount}) => `${submitterDisplayName} added a bank account. The ${amount} payment has been made.`, - paidElsewhereWithAmount: ({amount}) => `paid ${amount} elsewhere`, - paidUsingPaypalWithAmount: ({amount}) => `paid ${amount} using Paypal.me`, - paidUsingExpensifyWithAmount: ({amount}) => `paid ${amount} using Expensify`, + settlePaypalMe: ({formattedAmount}: SettlePaypalMeParams) => `Pay ${formattedAmount} with PayPal.me`, + requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`, + splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, + amountEach: ({amount}: AmountEachParams) => `${amount} each`, + payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} owes ${amount}`, + payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `, + payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} paid ${amount}`, + payerPaid: ({payer}: PayerPaidParams) => `${payer} paid: `, + managerApproved: ({manager}: ManagerApprovedParams) => `${manager} approved:`, + payerSettled: ({amount}: PayerSettledParams) => `paid ${amount}`, + waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up, payment is held until ${submitterDisplayName} adds a bank account`, + settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) => + `${submitterDisplayName} added a bank account. The ${amount} payment has been made.`, + paidElsewhereWithAmount: ({amount}: PaidElsewhereWithAmountParams) => `paid ${amount} elsewhere`, + paidUsingPaypalWithAmount: ({amount}: PaidUsingPaypalWithAmountParams) => `paid ${amount} using Paypal.me`, + paidUsingExpensifyWithAmount: ({amount}: PaidUsingExpensifyWithAmountParams) => `paid ${amount} using Expensify`, noReimbursableExpenses: 'This report has an invalid amount', pendingConversionMessage: "Total will update when you're back online", - threadRequestReportName: ({formattedAmount, comment}) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`, - threadSentMoneyReportName: ({formattedAmount, comment}) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, + threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`, + threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, error: { invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', @@ -478,10 +552,10 @@ export default { removePhoto: 'Remove photo', editImage: 'Edit photo', deleteWorkspaceError: 'Sorry, there was an unexpected problem deleting your workspace avatar.', - sizeExceeded: ({maxUploadSizeInMB}) => `The selected image exceeds the maximum upload size of ${maxUploadSizeInMB}MB.`, - resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}) => + sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `The selected image exceeds the maximum upload size of ${maxUploadSizeInMB}MB.`, + resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}: ResolutionConstraintsParams) => `Please upload an image larger than ${minHeightInPx}x${minWidthInPx} pixels and smaller than ${maxHeightInPx}x${maxWidthInPx} pixels.`, - notAllowedExtension: ({allowedExtensions}) => `Profile picture must be one of the following types: ${allowedExtensions.join(', ')}.`, + notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `Profile picture must be one of the following types: ${allowedExtensions.join(', ')}.`, }, profilePage: { profile: 'Profile', @@ -517,7 +591,7 @@ export default { helpTextAfterEmail: ' from multiple email addresses.', pleaseVerify: 'Please verify this contact method', getInTouch: "Whenever we need to get in touch with you, we'll use this contact method.", - enterMagicCode: ({contactMethod}) => `Please enter the magic code sent to ${contactMethod}`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Please enter the magic code sent to ${contactMethod}`, setAsDefault: 'Set as default', yourDefaultContactMethod: 'This is your current default contact method. You will not be able to delete this contact method until you set an alternative default by selecting another contact method and pressing “Set as default”.', @@ -536,6 +610,7 @@ export default { invalidContactMethod: 'Invalid contact method', }, newContactMethod: 'New contact method', + goBackContactMethods: 'Go back to contact methods', }, pronouns: { coCos: 'Co / Cos', @@ -708,9 +783,9 @@ export default { addBankAccountFailure: 'An unexpected error occurred while trying to add your bank account. Please try again.', }, transferAmountPage: { - transfer: ({amount}) => `Transfer${amount ? ` ${amount}` : ''}`, + transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`, instant: 'Instant (Debit card)', - instantSummary: ({rate, minAmount}) => `${rate}% fee (${minAmount} minimum)`, + instantSummary: ({rate, minAmount}: InstantSummaryParams) => `${rate}% fee (${minAmount} minimum)`, ach: '1-3 Business days (Bank account)', achSummary: 'No fee', whichAccount: 'Which account?', @@ -841,7 +916,7 @@ export default { }, cannotGetAccountDetails: "Couldn't retrieve account details, please try to sign in again.", loginForm: 'Login form', - notYou: ({user}) => `Not ${user}?`, + notYou: ({user}: NotYouParams) => `Not ${user}?`, }, personalDetails: { error: { @@ -857,27 +932,29 @@ export default { legalLastName: 'Legal last name', homeAddress: 'Home address', error: { - dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`, - dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`, + dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`, + dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, hasInvalidCharacter: 'Name can only include letters.', - incorrectZipFormat: ({zipFormat}) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, + incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, resendValidationForm: { linkHasBeenResent: 'Link has been re-sent', - weSentYouMagicSignInLink: ({login, loginType}) => `I've sent a magic sign-in link to ${login}. Please check your ${loginType} to sign in.`, + weSentYouMagicSignInLink: ({login, loginType}: WeSentYouMagicSignInLinkParams) => `I've sent a magic sign-in link to ${login}. Please check your ${loginType} to sign in.`, resendLink: 'Resend link', }, unlinkLoginForm: { - toValidateLogin: ({primaryLogin, secondaryLogin}) => `To validate ${secondaryLogin}, please resend the magic code from the Account Settings of ${primaryLogin}.`, - noLongerHaveAccess: ({primaryLogin}) => `If you no longer have access to ${primaryLogin}, please unlink your accounts.`, + toValidateLogin: ({primaryLogin, secondaryLogin}: ToValidateLoginParams) => + `To validate ${secondaryLogin}, please resend the magic code from the Account Settings of ${primaryLogin}.`, + noLongerHaveAccess: ({primaryLogin}: NoLongerHaveAccessParams) => `If you no longer have access to ${primaryLogin}, please unlink your accounts.`, unlink: 'Unlink', linkSent: 'Link sent!', succesfullyUnlinkedLogin: 'Secondary login successfully unlinked!', }, emailDeliveryFailurePage: { - ourEmailProvider: ({login}) => `Our email provider has temporarily suspended emails to ${login} due to delivery issues. To unblock your login, please follow these steps:`, - confirmThat: ({login}) => `Confirm that ${login} is spelled correctly and is a real, deliverable email address. `, + ourEmailProvider: (user: OurEmailProviderParams) => + `Our email provider has temporarily suspended emails to ${user.login} due to delivery issues. To unblock your login, please follow these steps:`, + confirmThat: ({login}: ConfirmThatParams) => `Confirm that ${login} is spelled correctly and is a real, deliverable email address. `, emailAliases: 'Email aliases such as "expenses@domain.com" must have access to their own email inbox for it to be a valid Expensify login.', ensureYourEmailClient: 'Ensure your email client allows expensify.com emails. ', youCanFindDirections: 'You can find directions on how to complete this step ', @@ -922,9 +999,9 @@ export default { save: 'Save', message: 'Message', untilTomorrow: 'Until tomorrow', - untilTime: ({time}) => `Until ${time}`, + untilTime: ({time}: UntilTimeParams) => `Until ${time}`, }, - stepCounter: ({step, total, text}) => { + stepCounter: ({step, total, text}: StepCounterParams) => { let result = `Step ${step}`; if (total) { @@ -1007,7 +1084,7 @@ export default { messages: { errorMessageInvalidPhone: `Please enter a valid phone number without brackets or dashes. If you're outside the US please include your country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, errorMessageInvalidEmail: 'Invalid email', - userIsAlreadyMemberOfWorkspace: ({login, workspace}) => `${login} is already a member of ${workspace}`, + userIsAlreadyMemberOfWorkspace: ({login, workspace}: UserIsAlreadyMemberOfWorkspaceParams) => `${login} is already a member of ${workspace}`, }, onfidoStep: { acceptTerms: 'By continuing with the request to activate your Expensify wallet, you confirm that you have read, understand and accept ', @@ -1212,7 +1289,7 @@ export default { unavailable: 'Unavailable workspace', memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.', notAuthorized: `You do not have access to this page. Are you trying to join the workspace? Please reach out to the owner of this workspace so they can add you as a member! Something else? Reach out to ${CONST.EMAIL.CONCIERGE}`, - goToRoom: ({roomName}) => `Go to ${roomName} room`, + goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`, }, emptyWorkspace: { title: 'Create a new workspace', @@ -1268,6 +1345,7 @@ export default { fastReimbursementsVBACopy: "You're all set to reimburse receipts from your bank account!", updateCustomUnitError: "Your changes couldn't be saved. The workspace was modified while you were offline, please try again.", invalidRateError: 'Please enter a valid rate', + lowRateError: 'Rate must be greater than 0', }, bills: { manageYourBills: 'Manage your bills', @@ -1308,7 +1386,7 @@ export default { personalMessagePrompt: 'Message', genericFailureMessage: 'An error occurred inviting the user to the workspace, please try again.', inviteNoMembersError: 'Please select at least one member to invite', - welcomeNote: ({workspaceName}) => + welcomeNote: ({workspaceName}: WelcomeNoteParams) => `You have been invited to ${workspaceName || 'a workspace'}! Download the Expensify mobile app at use.expensify.com/download to start tracking your expenses.`, }, editor: { @@ -1381,15 +1459,16 @@ export default { restrictedDescription: 'People in your workspace can find this room', privateDescription: 'People invited to this room can find it', publicDescription: 'Anyone can find this room', + // eslint-disable-next-line @typescript-eslint/naming-convention public_announceDescription: 'Anyone can find this room', createRoom: 'Create room', roomAlreadyExistsError: 'A room with this name already exists', - roomNameReservedError: ({reservedName}) => `${reservedName} is a default room on all workspaces. Please choose another name.`, + roomNameReservedError: ({reservedName}: RoomNameReservedErrorParams) => `${reservedName} is a default room on all workspaces. Please choose another name.`, roomNameInvalidError: 'Room names can only include lowercase letters, numbers and hyphens', pleaseEnterRoomName: 'Please enter a room name', pleaseSelectWorkspace: 'Please select a workspace', - renamedRoomAction: ({oldName, newName}) => ` renamed this room from ${oldName} to ${newName}`, - roomRenamedTo: ({newName}) => `Room renamed to ${newName}`, + renamedRoomAction: ({oldName, newName}: RenamedRoomActionParams) => ` renamed this room from ${oldName} to ${newName}`, + roomRenamedTo: ({newName}: RoomRenamedToParams) => `Room renamed to ${newName}`, social: 'social', selectAWorkspace: 'Select a workspace', growlMessageOnRenameError: 'Unable to rename policy room, please check your connection and try again.', @@ -1397,6 +1476,7 @@ export default { restricted: 'Restricted', private: 'Private', public: 'Public', + // eslint-disable-next-line @typescript-eslint/naming-convention public_announce: 'Public Announce', }, }, @@ -1540,8 +1620,8 @@ export default { noActivityYet: 'No activity yet', }, chronos: { - oooEventSummaryFullDay: ({summary, dayCount, date}) => `${summary} for ${dayCount} ${dayCount === 1 ? 'day' : 'days'} until ${date}`, - oooEventSummaryPartialDay: ({summary, timePeriod, date}) => `${summary} from ${timePeriod} on ${date}`, + oooEventSummaryFullDay: ({summary, dayCount, date}: OOOEventSummaryFullDayParams) => `${summary} for ${dayCount} ${dayCount === 1 ? 'day' : 'days'} until ${date}`, + oooEventSummaryPartialDay: ({summary, timePeriod, date}: OOOEventSummaryPartialDayParams) => `${summary} from ${timePeriod} on ${date}`, }, footer: { features: 'Features', @@ -1597,7 +1677,7 @@ export default { reply: 'Reply', from: 'From', in: 'In', - parentNavigationSummary: ({rootReportName, workspaceName}) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`, + parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`, }, qrCodes: { copyUrlToClipboard: 'Copy URL to clipboard', @@ -1677,4 +1757,4 @@ export default { heroBody: 'Use New Expensify for event updates, networking, social chatter, and to get paid back for your ride to or from the show!', }, }, -}; +} as const; diff --git a/src/languages/es-ES.js b/src/languages/es-ES.ts similarity index 100% rename from src/languages/es-ES.js rename to src/languages/es-ES.ts diff --git a/src/languages/es.js b/src/languages/es.ts similarity index 90% rename from src/languages/es.js rename to src/languages/es.ts index 2e7ae7dd09eb..fd98e3ef51fa 100644 --- a/src/languages/es.js +++ b/src/languages/es.ts @@ -1,5 +1,74 @@ import CONST from '../CONST'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; +import type { + AddressLineParams, + CharacterLimitParams, + MaxParticipantsReachedParams, + ZipCodeExampleFormatParams, + LoggedInAsParams, + NewFaceEnterMagicCodeParams, + WelcomeEnterMagicCodeParams, + AlreadySignedInParams, + GoBackMessageParams, + LocalTimeParams, + EditActionParams, + DeleteActionParams, + DeleteConfirmationParams, + BeginningOfChatHistoryDomainRoomPartOneParams, + BeginningOfChatHistoryAdminRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartTwo, + WelcomeToRoomParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + ReportArchiveReasonsPolicyDeletedParams, + RequestCountParams, + SettleExpensifyCardParams, + SettlePaypalMeParams, + RequestAmountParams, + SplitAmountParams, + AmountEachParams, + PayerOwesAmountParams, + PayerOwesParams, + PayerPaidAmountParams, + PayerPaidParams, + PayerSettledParams, + WaitingOnBankAccountParams, + SettledAfterAddedBankAccountParams, + PaidElsewhereWithAmountParams, + PaidUsingPaypalWithAmountParams, + PaidUsingExpensifyWithAmountParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + SizeExceededParams, + ResolutionConstraintsParams, + NotAllowedExtensionParams, + EnterMagicCodeParams, + TransferParams, + InstantSummaryParams, + NotYouParams, + DateShouldBeBeforeParams, + DateShouldBeAfterParams, + IncorrectZipFormatParams, + WeSentYouMagicSignInLinkParams, + ToValidateLoginParams, + NoLongerHaveAccessParams, + OurEmailProviderParams, + ConfirmThatParams, + UntilTimeParams, + StepCounterParams, + UserIsAlreadyMemberOfWorkspaceParams, + GoToRoomParams, + WelcomeNoteParams, + RoomNameReservedErrorParams, + RenamedRoomActionParams, + RoomRenamedToParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + ParentNavigationSummaryParams, + ManagerApprovedParams, +} from './types'; /* eslint-disable max-len */ export default { @@ -72,7 +141,7 @@ export default { currentMonth: 'Mes actual', ssnLast4: 'Últimos 4 dígitos de su SSN', ssnFull9: 'Los 9 dígitos del SSN', - addressLine: ({lineNumber}) => `Dirección línea ${lineNumber}`, + addressLine: ({lineNumber}: AddressLineParams) => `Dirección línea ${lineNumber}`, personalAddress: 'Dirección física personal', companyAddress: 'Dirección física de la empresa', noPO: 'No se aceptan apartados ni direcciones postales', @@ -103,7 +172,7 @@ export default { acceptTerms: 'Debes aceptar los Términos de Servicio para continuar', phoneNumber: `Introduce un teléfono válido, incluyendo el código del país (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Este campo es obligatorio.', - characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`, + characterLimit: ({limit}: CharacterLimitParams) => `Supera el límite de ${limit} caracteres`, dateInvalid: 'Por favor, selecciona una fecha válida', invalidCharacter: 'Carácter invalido', enterMerchant: 'Introduce un comerciante', @@ -136,14 +205,14 @@ export default { youAfterPreposition: 'ti', your: 'tu', conciergeHelp: 'Por favor, contacta con Concierge para obtener ayuda.', - maxParticipantsReached: ({count}) => `Has seleccionado el número máximo (${count}) de participantes.`, + maxParticipantsReached: ({count}: MaxParticipantsReachedParams) => `Has seleccionado el número máximo (${count}) de participantes.`, youAppearToBeOffline: 'Parece que estás desconectado.', thisFeatureRequiresInternet: 'Esta función requiere una conexión a Internet activa para ser utilizada.', areYouSure: '¿Estás seguro?', verify: 'Verifique', yesContinue: 'Sí, continuar', websiteExample: 'p. ej. https://www.expensify.com', - zipCodeExampleFormat: ({zipSampleFormat}) => (zipSampleFormat ? `p. ej. ${zipSampleFormat}` : ''), + zipCodeExampleFormat: ({zipSampleFormat}: ZipCodeExampleFormatParams) => (zipSampleFormat ? `p. ej. ${zipSampleFormat}` : ''), description: 'Descripción', with: 'con', shareCode: 'Compartir código', @@ -209,7 +278,8 @@ export default { redirectedToDesktopApp: 'Te hemos redirigido a la aplicación de escritorio.', youCanAlso: 'También puedes', openLinkInBrowser: 'abrir este enlace en tu navegador', - loggedInAs: ({email}) => `Has iniciado sesión como ${email}. Haga clic en "Abrir enlace" en el aviso para iniciar sesión en la aplicación de escritorio con esta cuenta.`, + loggedInAs: ({email}: LoggedInAsParams) => + `Has iniciado sesión como ${email}. Haga clic en "Abrir enlace" en el aviso para iniciar sesión en la aplicación de escritorio con esta cuenta.`, doNotSeePrompt: '¿No ves el aviso?', tryAgain: 'Inténtalo de nuevo', or: ', o', @@ -255,8 +325,9 @@ export default { phrase2: 'El dinero habla. Y ahora que chat y pagos están en un mismo lugar, es también fácil.', phrase3: 'Tus pagos llegan tan rápido como tus mensajes.', enterPassword: 'Por favor, introduce tu contraseña', - newFaceEnterMagicCode: ({login}) => `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}. Debería llegar en un par de minutos.`, - welcomeEnterMagicCode: ({login}) => `Por favor, introduce el código mágico enviado a ${login}. Debería llegar en un par de minutos.`, + newFaceEnterMagicCode: ({login}: NewFaceEnterMagicCodeParams) => + `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}. Debería llegar en un par de minutos.`, + welcomeEnterMagicCode: ({login}: WelcomeEnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${login}. Debería llegar en un par de minutos.`, }, DownloadAppModal: { downloadTheApp: 'Descarga la aplicación', @@ -270,8 +341,8 @@ export default { }, }, thirdPartySignIn: { - alreadySignedIn: ({email}) => `Ya has iniciado sesión con ${email}.`, - goBackMessage: ({provider}) => `No quieres iniciar sesión con ${provider}?`, + alreadySignedIn: ({email}: AlreadySignedInParams) => `Ya has iniciado sesión con ${email}.`, + goBackMessage: ({provider}: GoBackMessageParams) => `No quieres iniciar sesión con ${provider}?`, continueWithMyCurrentSession: 'Continuar con mi sesión actual', redirectToDesktopMessage: 'Lo redirigiremos a la aplicación de escritorio una vez que termine de iniciar sesión.', signInAgreementMessage: 'Al iniciar sesión, aceptas las', @@ -296,7 +367,7 @@ export default { ], blockedFromConcierge: 'Comunicación no permitida', fileUploadFailed: 'Subida fallida. El archivo no es compatible.', - localTime: ({user, time}) => `Son las ${time} para ${user}`, + localTime: ({user, time}: LocalTimeParams) => `Son las ${time} para ${user}`, edited: '(editado)', emoji: 'Emoji', collapse: 'Colapsar', @@ -310,9 +381,9 @@ export default { copyEmailToClipboard: 'Copiar email al portapapeles', markAsUnread: 'Marcar como no leído', markAsRead: 'Marcar como leído', - editAction: ({action}) => `Edit ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, - deleteAction: ({action}) => `Eliminar ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, - deleteConfirmation: ({action}) => `¿Estás seguro de que quieres eliminar este ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, + editAction: ({action}: EditActionParams) => `Edit ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, + deleteAction: ({action}: DeleteActionParams) => `Eliminar ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, + deleteConfirmation: ({action}: DeleteConfirmationParams) => `¿Estás seguro de que quieres eliminar este ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', flagAsOffensive: 'Marcar como ofensivo', @@ -324,13 +395,15 @@ export default { reportActionsView: { beginningOfArchivedRoomPartOne: 'Te perdiste la fiesta en ', beginningOfArchivedRoomPartTwo: ', no hay nada que ver aquí.', - beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}) => `Colabora aquí con todos los participantes de ${domainRoom}! 🎉\nUtiliza `, + beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `Colabora aquí con todos los participantes de ${domainRoom}! 🎉\nUtiliza `, beginningOfChatHistoryDomainRoomPartTwo: ' para chatear con compañeros, compartir consejos o hacer una pregunta.', - beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}) => `Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `, + beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => + `Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `, beginningOfChatHistoryAdminRoomPartTwo: ' para chatear sobre temas como la configuración del espacio de trabajo y mas.', beginningOfChatHistoryAdminOnlyPostingRoom: 'Solo los administradores pueden enviar mensajes en esta sala.', - beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}) => `Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `, - beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`, + beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => + `Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `, + beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`, beginningOfChatHistoryUserRoomPartOne: 'Este es el lugar para colaborar! 🎉\nUsa este espacio para chatear sobre cualquier cosa relacionada con ', beginningOfChatHistoryUserRoomPartTwo: '.', beginningOfChatHistory: 'Aquí comienzan tus conversaciones con ', @@ -339,7 +412,7 @@ export default { beginningOfChatHistoryPolicyExpenseChatPartThree: ' empieza aquí! 🎉 Este es el lugar donde chatear, pedir dinero y pagar.', chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí', sayHello: '¡Saluda!', - welcomeToRoom: ({roomName}) => `¡Bienvenido a ${roomName}!`, + welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `¡Bienvenido a ${roomName}!`, usePlusButton: '\n\n¡También puedes usar el botón + de abajo para pedir dinero o asignar una tarea!', }, reportAction: { @@ -356,12 +429,14 @@ export default { }, reportArchiveReasons: { [CONST.REPORT.ARCHIVE_REASON.DEFAULT]: 'Esta sala de chat ha sido eliminada.', - [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED]: ({displayName}) => `Este chat de espacio de trabajo esta desactivado porque ${displayName} ha cerrado su cuenta.`, - [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED]: ({displayName, oldDisplayName}) => + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED]: ({displayName}: ReportArchiveReasonsClosedParams) => + `Este chat de espacio de trabajo esta desactivado porque ${displayName} ha cerrado su cuenta.`, + [CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED]: ({displayName, oldDisplayName}: ReportArchiveReasonsMergedParams) => `Este chat de espacio de trabajo esta desactivado porque ${oldDisplayName} ha combinado su cuenta con ${displayName}.`, - [CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY]: ({displayName, policyName}) => + [CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY]: ({displayName, policyName}: ReportArchiveReasonsRemovedFromPolicyParams) => `Este chat de espacio de trabajo esta desactivado porque ${displayName} ha dejado de ser miembro del espacio de trabajo ${policyName}.`, - [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}) => `Este chat de espacio de trabajo esta desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, + [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}: ReportArchiveReasonsPolicyDeletedParams) => + `Este chat de espacio de trabajo esta desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, }, writeCapabilityPage: { label: 'Quién puede postear', @@ -423,33 +498,34 @@ export default { receiptScanning: 'Escaneo de recibo en curso…', receiptStatusTitle: 'Escaneando…', receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.', - requestCount: ({count, scanningReceipts = 0}) => `${count} solicitudes${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`, + requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} solicitudes${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`, deleteRequest: 'Eliminar pedido', deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?', settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', settledPaypalMe: 'Pagado con PayPal.me', - settleExpensify: ({formattedAmount}) => `Pagar ${formattedAmount} con Expensify`, + settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount} con Expensify`, payElsewhere: 'Pagar de otra forma', - settlePaypalMe: ({formattedAmount}) => `Pagar ${formattedAmount} con PayPal.me`, - requestAmount: ({amount}) => `solicitar ${amount}`, - splitAmount: ({amount}) => `dividir ${amount}`, - amountEach: ({amount}) => `${amount} cada uno`, - payerOwesAmount: ({payer, amount}) => `${payer} debe ${amount}`, - payerOwes: ({payer}) => `${payer} debe: `, - payerPaidAmount: ({payer, amount}) => `${payer} pagó ${amount}`, - payerPaid: ({payer}) => `${payer} pagó: `, - managerApproved: ({manager}) => `${manager} aprobó:`, - payerSettled: ({amount}) => `pagó ${amount}`, - waitingOnBankAccount: ({submitterDisplayName}) => `inicio el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`, - settledAfterAddedBankAccount: ({submitterDisplayName, amount}) => `${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`, - paidElsewhereWithAmount: ({amount}) => `pagó ${amount} de otra forma`, - paidUsingPaypalWithAmount: ({amount}) => `pagó ${amount} con PayPal.me`, - paidUsingExpensifyWithAmount: ({amount}) => `pagó ${amount} con Expensify`, + settlePaypalMe: ({formattedAmount}: SettlePaypalMeParams) => `Pagar ${formattedAmount} con PayPal.me`, + requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, + splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, + amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`, + payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} debe ${amount}`, + payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `, + payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer} pagó ${amount}`, + payerPaid: ({payer}: PayerPaidParams) => `${payer} pagó: `, + managerApproved: ({manager}: ManagerApprovedParams) => `${manager} aprobó:`, + payerSettled: ({amount}: PayerSettledParams) => `pagó ${amount}`, + waitingOnBankAccount: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inicio el pago, pero no se procesará hasta que ${submitterDisplayName} añada una cuenta bancaria`, + settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) => + `${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`, + paidElsewhereWithAmount: ({amount}: PaidElsewhereWithAmountParams) => `pagó ${amount} de otra forma`, + paidUsingPaypalWithAmount: ({amount}: PaidUsingPaypalWithAmountParams) => `pagó ${amount} con PayPal.me`, + 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', - threadRequestReportName: ({formattedAmount, comment}) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, - threadSentMoneyReportName: ({formattedAmount, comment}) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, + threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, + threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, error: { invalidSplit: 'La suma de las partes no equivale al monto total', other: 'Error inesperado, por favor inténtalo más tarde', @@ -477,10 +553,10 @@ export default { removePhoto: 'Eliminar foto', editImage: 'Editar foto', deleteWorkspaceError: 'Lo sentimos, hubo un problema eliminando el avatar de su espacio de trabajo.', - sizeExceeded: ({maxUploadSizeInMB}) => `La imagen supera el tamaño máximo de ${maxUploadSizeInMB}MB.`, - resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}) => + sizeExceeded: ({maxUploadSizeInMB}: SizeExceededParams) => `La imagen supera el tamaño máximo de ${maxUploadSizeInMB}MB.`, + resolutionConstraints: ({minHeightInPx, minWidthInPx, maxHeightInPx, maxWidthInPx}: ResolutionConstraintsParams) => `Por favor, elige una imagen más grande que ${minHeightInPx}x${minWidthInPx} píxeles y más pequeña que ${maxHeightInPx}x${maxWidthInPx} píxeles.`, - notAllowedExtension: ({allowedExtensions}) => `La foto de perfil debe ser de uno de los siguientes tipos: ${allowedExtensions.join(', ')}.`, + notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `La foto de perfil debe ser de uno de los siguientes tipos: ${allowedExtensions.join(', ')}.`, }, profilePage: { profile: 'Perfil', @@ -517,7 +593,7 @@ export default { helpTextAfterEmail: ' desde varias direcciones de correo electrónico.', pleaseVerify: 'Por favor, verifica este método de contacto', getInTouch: 'Utilizaremos este método de contacto cuando necesitemos contactarte.', - enterMagicCode: ({contactMethod}) => `Por favor, introduce el código mágico enviado a ${contactMethod}`, + enterMagicCode: ({contactMethod}: EnterMagicCodeParams) => `Por favor, introduce el código mágico enviado a ${contactMethod}`, setAsDefault: 'Establecer como predeterminado', yourDefaultContactMethod: 'Este es tu método de contacto predeterminado. No podrás eliminarlo hasta que añadas otro método de contacto y lo marques como predeterminado pulsando "Establecer como predeterminado".', @@ -536,6 +612,7 @@ export default { invalidContactMethod: 'Método de contacto no válido', }, newContactMethod: 'Nuevo método de contacto', + goBackContactMethods: 'Volver a métodos de contacto', }, pronouns: { coCos: 'Co / Cos', @@ -709,9 +786,9 @@ export default { addBankAccountFailure: 'Ocurrió un error inesperado al intentar añadir la cuenta bancaria. Inténtalo de nuevo.', }, transferAmountPage: { - transfer: ({amount}) => `Transferir${amount ? ` ${amount}` : ''}`, + transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`, instant: 'Instante', - instantSummary: ({rate, minAmount}) => `Tarifa del ${rate}% (${minAmount} mínimo)`, + instantSummary: ({rate, minAmount}: InstantSummaryParams) => `Tarifa del ${rate}% (${minAmount} mínimo)`, ach: '1-3 días laborales', achSummary: 'Sin cargo', whichAccount: '¿Qué cuenta?', @@ -843,7 +920,7 @@ export default { }, cannotGetAccountDetails: 'No se pudieron cargar los detalles de tu cuenta. Por favor, intenta iniciar sesión de nuevo.', loginForm: 'Formulario de inicio de sesión', - notYou: ({user}) => `¿No eres ${user}?`, + notYou: ({user}: NotYouParams) => `¿No eres ${user}?`, }, personalDetails: { error: { @@ -859,28 +936,30 @@ export default { legalLastName: 'Apellidos legales', homeAddress: 'Domicilio', error: { - dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`, - dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`, - incorrectZipFormat: ({zipFormat}) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, + dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`, + dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, + incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, hasInvalidCharacter: 'El nombre sólo puede incluir letras.', }, }, resendValidationForm: { linkHasBeenResent: 'El enlace se ha reenviado', - weSentYouMagicSignInLink: ({login, loginType}) => `Te he enviado un hiperenlace mágico para iniciar sesión a ${login}. Por favor, revisa tu ${loginType}`, + weSentYouMagicSignInLink: ({login, loginType}: WeSentYouMagicSignInLinkParams) => + `Te he enviado un hiperenlace mágico para iniciar sesión a ${login}. Por favor, revisa tu ${loginType}`, resendLink: 'Reenviar enlace', }, unlinkLoginForm: { - toValidateLogin: ({primaryLogin, secondaryLogin}) => `Para validar ${secondaryLogin}, reenvía el código mágico desde la Configuración de la cuenta de ${primaryLogin}.`, - noLongerHaveAccess: ({primaryLogin}) => `Si ya no tienes acceso a ${primaryLogin} por favor, desvincula las cuentas.`, + toValidateLogin: ({primaryLogin, secondaryLogin}: ToValidateLoginParams) => + `Para validar ${secondaryLogin}, reenvía el código mágico desde la Configuración de la cuenta de ${primaryLogin}.`, + noLongerHaveAccess: ({primaryLogin}: NoLongerHaveAccessParams) => `Si ya no tienes acceso a ${primaryLogin} por favor, desvincula las cuentas.`, unlink: 'Desvincular', linkSent: '¡Enlace enviado!', succesfullyUnlinkedLogin: '¡Nombre de usuario secundario desvinculado correctamente!', }, emailDeliveryFailurePage: { - ourEmailProvider: ({login}) => + ourEmailProvider: ({login}: OurEmailProviderParams) => `Nuestro proveedor de correo electrónico ha suspendido temporalmente los correos electrónicos a ${login} debido a problemas de entrega. Para desbloquear el inicio de sesión, sigue estos pasos:`, - confirmThat: ({login}) => `Confirma que ${login} está escrito correctamente y que es una dirección de correo electrónico real que puede recibir correos. `, + confirmThat: ({login}: ConfirmThatParams) => `Confirma que ${login} está escrito correctamente y que es una dirección de correo electrónico real que puede recibir correos. `, emailAliases: 'Los alias de correo electrónico como "expenses@domain.com" deben tener acceso a su propia bandeja de entrada de correo electrónico para que sea un inicio de sesión válido de Expensify.', ensureYourEmailClient: 'Asegúrese de que su cliente de correo electrónico permita correos electrónicos de expensify.com. ', @@ -926,7 +1005,7 @@ export default { save: 'Guardar', message: 'Mensaje', untilTomorrow: 'Hasta mañana', - untilTime: ({time}) => { + untilTime: ({time}: UntilTimeParams) => { // Check for HH:MM AM/PM format and starts with '01:' if (CONST.REGEX.TIME_STARTS_01.test(time)) { return `Hasta la ${time}`; @@ -943,7 +1022,7 @@ export default { return `Hasta ${time}`; }, }, - stepCounter: ({step, total, text}) => { + stepCounter: ({step, total, text}: StepCounterParams) => { let result = `Paso ${step}`; if (total) { @@ -1029,7 +1108,7 @@ export default { messages: { errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, errorMessageInvalidEmail: 'Email inválido', - userIsAlreadyMemberOfWorkspace: ({login, workspace}) => `${login} ya es miembro de ${workspace}`, + userIsAlreadyMemberOfWorkspace: ({login, workspace}: UserIsAlreadyMemberOfWorkspaceParams) => `${login} ya es miembro de ${workspace}`, }, onfidoStep: { acceptTerms: 'Al continuar con la solicitud para activar su billetera Expensify, confirma que ha leído, comprende y acepta ', @@ -1237,7 +1316,7 @@ export default { unavailable: 'Espacio de trabajo no disponible', memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.', notAuthorized: `No tienes acceso a esta página. ¿Estás tratando de unirte al espacio de trabajo? Comunícate con el propietario de este espacio de trabajo para que pueda añadirte como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`, - goToRoom: ({roomName}) => `Ir a la sala ${roomName}`, + goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`, }, emptyWorkspace: { title: 'Crear un nuevo espacio de trabajo', @@ -1294,6 +1373,7 @@ export default { fastReimbursementsVBACopy: '¡Todo listo para reembolsar recibos desde tu cuenta bancaria!', updateCustomUnitError: 'Los cambios no han podido ser guardados. El espacio de trabajo ha sido modificado mientras estabas desconectado. Por favor, inténtalo de nuevo.', invalidRateError: 'Por favor, introduce una tarifa válida', + lowRateError: 'La tarifa debe ser mayor que 0', }, bills: { manageYourBills: 'Gestiona tus facturas', @@ -1334,7 +1414,7 @@ export default { personalMessagePrompt: 'Mensaje', inviteNoMembersError: 'Por favor, selecciona al menos un miembro a invitar', genericFailureMessage: 'Se produjo un error al invitar al usuario al espacio de trabajo. Vuelva a intentarlo..', - welcomeNote: ({workspaceName}) => + welcomeNote: ({workspaceName}: WelcomeNoteParams) => `¡Has sido invitado a ${workspaceName}! Descargue la aplicación móvil Expensify en use.expensify.com/download para comenzar a rastrear sus gastos.`, }, editor: { @@ -1408,15 +1488,17 @@ export default { restrictedDescription: 'Sólo las personas en tu espacio de trabajo pueden encontrar esta sala', privateDescription: 'Sólo las personas que están invitadas a esta sala pueden encontrarla', publicDescription: 'Cualquier persona puede unirse a esta sala', + // eslint-disable-next-line @typescript-eslint/naming-convention public_announceDescription: 'Cualquier persona puede unirse a esta sala', createRoom: 'Crea una sala de chat', roomAlreadyExistsError: 'Ya existe una sala con este nombre', - roomNameReservedError: ({reservedName}) => `${reservedName} es el nombre una sala por defecto de todos los espacios de trabajo. Por favor, elige otro nombre.`, + roomNameReservedError: ({reservedName}: RoomNameReservedErrorParams) => + `${reservedName} es el nombre una sala por defecto de todos los espacios de trabajo. Por favor, elige otro nombre.`, roomNameInvalidError: 'Los nombres de las salas solo pueden contener minúsculas, números y guiones', pleaseEnterRoomName: 'Por favor, escribe el nombre de una sala', pleaseSelectWorkspace: 'Por favor, selecciona un espacio de trabajo', - renamedRoomAction: ({oldName, newName}) => ` cambió el nombre de la sala de ${oldName} a ${newName}`, - roomRenamedTo: ({newName}) => `Sala renombrada a ${newName}`, + renamedRoomAction: ({oldName, newName}: RenamedRoomActionParams) => ` cambió el nombre de la sala de ${oldName} a ${newName}`, + roomRenamedTo: ({newName}: RoomRenamedToParams) => `Sala renombrada a ${newName}`, social: 'social', selectAWorkspace: 'Seleccionar un espacio de trabajo', growlMessageOnRenameError: 'No se ha podido cambiar el nombre del espacio de trabajo, por favor, comprueba tu conexión e inténtalo de nuevo.', @@ -1424,6 +1506,7 @@ export default { restricted: 'Restringida', private: 'Privada', public: 'Público', + // eslint-disable-next-line @typescript-eslint/naming-convention public_announce: 'Anuncio Público', }, }, @@ -1567,8 +1650,8 @@ export default { noActivityYet: 'Sin actividad todavía', }, chronos: { - oooEventSummaryFullDay: ({summary, dayCount, date}) => `${summary} por ${dayCount} ${dayCount === 1 ? 'día' : 'días'} hasta el ${date}`, - oooEventSummaryPartialDay: ({summary, timePeriod, date}) => `${summary} de ${timePeriod} del ${date}`, + oooEventSummaryFullDay: ({summary, dayCount, date}: OOOEventSummaryFullDayParams) => `${summary} por ${dayCount} ${dayCount === 1 ? 'día' : 'días'} hasta el ${date}`, + oooEventSummaryPartialDay: ({summary, timePeriod, date}: OOOEventSummaryPartialDayParams) => `${summary} de ${timePeriod} del ${date}`, }, footer: { features: 'Características', @@ -2084,7 +2167,7 @@ export default { reply: 'Respuesta', from: 'De', in: 'en', - parentNavigationSummary: ({rootReportName, workspaceName}) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`, + parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`, }, qrCodes: { copyUrlToClipboard: 'Copiar URL al portapapeles', diff --git a/src/languages/translations.js b/src/languages/translations.ts similarity index 65% rename from src/languages/translations.js rename to src/languages/translations.ts index c8dd8c8ab0e0..a2d27baa26c9 100644 --- a/src/languages/translations.js +++ b/src/languages/translations.ts @@ -5,5 +5,6 @@ import esES from './es-ES'; export default { en, es, + // eslint-disable-next-line @typescript-eslint/naming-convention 'es-ES': esES, }; diff --git a/src/languages/types.ts b/src/languages/types.ts new file mode 100644 index 000000000000..50290fb5776c --- /dev/null +++ b/src/languages/types.ts @@ -0,0 +1,255 @@ +type AddressLineParams = { + lineNumber: number; +}; + +type CharacterLimitParams = { + limit: number; +}; + +type MaxParticipantsReachedParams = { + count: number; +}; + +type ZipCodeExampleFormatParams = { + zipSampleFormat: string; +}; + +type LoggedInAsParams = { + email: string; +}; + +type NewFaceEnterMagicCodeParams = { + login: string; +}; + +type WelcomeEnterMagicCodeParams = { + login: string; +}; + +type AlreadySignedInParams = { + email: string; +}; + +type GoBackMessageParams = { + provider: string; +}; + +type LocalTimeParams = { + user: string; + time: string; +}; + +type EditActionParams = { + action: NonNullable; +}; + +type DeleteActionParams = { + action: NonNullable; +}; + +type DeleteConfirmationParams = { + action: NonNullable; +}; + +type BeginningOfChatHistoryDomainRoomPartOneParams = { + domainRoom: string; +}; + +type BeginningOfChatHistoryAdminRoomPartOneParams = { + workspaceName: string; +}; + +type BeginningOfChatHistoryAnnounceRoomPartOneParams = { + workspaceName: string; +}; + +type BeginningOfChatHistoryAnnounceRoomPartTwo = { + workspaceName: string; +}; + +type WelcomeToRoomParams = { + roomName: string; +}; + +type ReportArchiveReasonsClosedParams = { + displayName: string; +}; + +type ReportArchiveReasonsMergedParams = { + displayName: string; + oldDisplayName: string; +}; + +type ReportArchiveReasonsRemovedFromPolicyParams = { + displayName: string; + policyName: string; +}; + +type ReportArchiveReasonsPolicyDeletedParams = { + policyName: string; +}; + +type RequestCountParams = { + count: number; + scanningReceipts: number; +}; + +type SettleExpensifyCardParams = { + formattedAmount: string; +}; + +type SettlePaypalMeParams = {formattedAmount: string}; + +type RequestAmountParams = {amount: number}; + +type SplitAmountParams = {amount: number}; + +type AmountEachParams = {amount: number}; + +type PayerOwesAmountParams = {payer: string; amount: number}; + +type PayerOwesParams = {payer: string}; + +type PayerPaidAmountParams = {payer: string; amount: number}; + +type ManagerApprovedParams = {manager: string}; + +type PayerPaidParams = {payer: string}; + +type PayerSettledParams = {amount: number}; + +type WaitingOnBankAccountParams = {submitterDisplayName: string}; + +type SettledAfterAddedBankAccountParams = {submitterDisplayName: string; amount: string}; + +type PaidElsewhereWithAmountParams = {amount: string}; + +type PaidUsingPaypalWithAmountParams = {amount: string}; + +type PaidUsingExpensifyWithAmountParams = {amount: string}; + +type ThreadRequestReportNameParams = {formattedAmount: string; comment: string}; + +type ThreadSentMoneyReportNameParams = {formattedAmount: string; comment: string}; + +type SizeExceededParams = {maxUploadSizeInMB: number}; + +type ResolutionConstraintsParams = {minHeightInPx: number; minWidthInPx: number; maxHeightInPx: number; maxWidthInPx: number}; + +type NotAllowedExtensionParams = {allowedExtensions: string[]}; + +type EnterMagicCodeParams = {contactMethod: string}; + +type TransferParams = {amount: string}; + +type InstantSummaryParams = {rate: number; minAmount: number}; + +type NotYouParams = {user: string}; + +type DateShouldBeBeforeParams = {dateString: string}; + +type DateShouldBeAfterParams = {dateString: string}; + +type IncorrectZipFormatParams = {zipFormat?: string}; + +type WeSentYouMagicSignInLinkParams = {login: string; loginType: string}; + +type ToValidateLoginParams = {primaryLogin: string; secondaryLogin: string}; + +type NoLongerHaveAccessParams = {primaryLogin: string}; + +type OurEmailProviderParams = {login: string}; + +type ConfirmThatParams = {login: string}; + +type UntilTimeParams = {time: string}; + +type StepCounterParams = {step: number; total?: number; text?: string}; + +type UserIsAlreadyMemberOfWorkspaceParams = {login: string; workspace: string}; + +type GoToRoomParams = {roomName: string}; + +type WelcomeNoteParams = {workspaceName: string}; + +type RoomNameReservedErrorParams = {reservedName: string}; + +type RenamedRoomActionParams = {oldName: string; newName: string}; + +type RoomRenamedToParams = {newName: string}; + +type OOOEventSummaryFullDayParams = {summary: string; dayCount: number; date: string}; + +type OOOEventSummaryPartialDayParams = {summary: string; timePeriod: string; date: string}; + +type ParentNavigationSummaryParams = {rootReportName: string; workspaceName: string}; + +export type { + AddressLineParams, + CharacterLimitParams, + MaxParticipantsReachedParams, + ZipCodeExampleFormatParams, + LoggedInAsParams, + NewFaceEnterMagicCodeParams, + WelcomeEnterMagicCodeParams, + AlreadySignedInParams, + GoBackMessageParams, + LocalTimeParams, + EditActionParams, + DeleteActionParams, + DeleteConfirmationParams, + BeginningOfChatHistoryDomainRoomPartOneParams, + BeginningOfChatHistoryAdminRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartOneParams, + BeginningOfChatHistoryAnnounceRoomPartTwo, + WelcomeToRoomParams, + ReportArchiveReasonsClosedParams, + ReportArchiveReasonsMergedParams, + ReportArchiveReasonsRemovedFromPolicyParams, + ReportArchiveReasonsPolicyDeletedParams, + RequestCountParams, + SettleExpensifyCardParams, + SettlePaypalMeParams, + RequestAmountParams, + SplitAmountParams, + AmountEachParams, + PayerOwesAmountParams, + PayerOwesParams, + PayerPaidAmountParams, + PayerPaidParams, + ManagerApprovedParams, + PayerSettledParams, + WaitingOnBankAccountParams, + SettledAfterAddedBankAccountParams, + PaidElsewhereWithAmountParams, + PaidUsingPaypalWithAmountParams, + PaidUsingExpensifyWithAmountParams, + ThreadRequestReportNameParams, + ThreadSentMoneyReportNameParams, + SizeExceededParams, + ResolutionConstraintsParams, + NotAllowedExtensionParams, + EnterMagicCodeParams, + TransferParams, + InstantSummaryParams, + NotYouParams, + DateShouldBeBeforeParams, + DateShouldBeAfterParams, + IncorrectZipFormatParams, + WeSentYouMagicSignInLinkParams, + ToValidateLoginParams, + NoLongerHaveAccessParams, + OurEmailProviderParams, + ConfirmThatParams, + UntilTimeParams, + StepCounterParams, + UserIsAlreadyMemberOfWorkspaceParams, + GoToRoomParams, + WelcomeNoteParams, + RoomNameReservedErrorParams, + RenamedRoomActionParams, + RoomRenamedToParams, + OOOEventSummaryFullDayParams, + OOOEventSummaryPartialDayParams, + ParentNavigationSummaryParams, +}; 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/MoneyRequestUtils.js b/src/libs/MoneyRequestUtils.js index 706c34ad912d..e60eae0cdfe5 100644 --- a/src/libs/MoneyRequestUtils.js +++ b/src/libs/MoneyRequestUtils.js @@ -82,4 +82,15 @@ function replaceAllDigits(text, convertFn) { .value(); } -export {stripCommaFromAmount, stripSpacesFromAmount, addLeadingZero, validateAmount, replaceAllDigits}; +/** + * Check if distance request or not + * + * @param {String} iouType - `send` | `split` | `request` + * @param {String} selectedTab - `manual` | `scan` | `distance` + * @returns {Boolean} + */ +function isDistanceRequest(iouType, selectedTab) { + return iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST && selectedTab === CONST.TAB.DISTANCE; +} + +export {stripCommaFromAmount, stripSpacesFromAmount, addLeadingZero, validateAmount, replaceAllDigits, isDistanceRequest}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 26282cebc398..e10e51b307e4 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -35,6 +35,7 @@ import styles from '../../../styles/styles'; import * as SessionUtils from '../../SessionUtils'; import NotFoundPage from '../../../pages/ErrorPage/NotFoundPage'; import getRootNavigatorScreenOptions from './getRootNavigatorScreenOptions'; +import DemoSetupPage from '../../../pages/DemoSetupPage'; let timezone; let currentAccountID; @@ -164,9 +165,9 @@ class AuthScreens extends React.Component { // Check if we should be running any demos immediately after signing in. if (lodashGet(this.props.demoInfo, 'saastr.isBeginningDemo', false)) { - Navigation.navigate(ROUTES.SAASTR); + Navigation.navigate(ROUTES.SAASTR, CONST.NAVIGATION.TYPE.FORCED_UP); } else if (lodashGet(this.props.demoInfo, 'sbe.isBeginningDemo', false)) { - Navigation.navigate(ROUTES.SBE); + Navigation.navigate(ROUTES.SBE, CONST.NAVIGATION.TYPE.FORCED_UP); } if (this.props.lastOpenedPublicRoomID) { // Re-open the last opened public room if the user logged in from a public room link @@ -282,6 +283,16 @@ class AuthScreens extends React.Component { return ConciergePage; }} /> + + - - ); 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/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 8390aa7d700b..ee3054e02f96 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -18,6 +18,10 @@ export default { [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT, [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS, + // Demo routes + [CONST.DEMO_PAGES.SAASTR]: ROUTES.SAASTR, + [CONST.DEMO_PAGES.SBE]: ROUTES.SBE, + // Sidebar [SCREENS.HOME]: { path: ROUTES.HOME, @@ -26,8 +30,6 @@ export default { [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: { screens: { [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID, - [CONST.DEMO_PAGES.SAASTR]: ROUTES.SAASTR, - [CONST.DEMO_PAGES.SBE]: ROUTES.SBE, }, }, [SCREENS.NOT_FOUND]: '*', 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/NumberFormatUtils.js b/src/libs/NumberFormatUtils.js deleted file mode 100644 index 48e4d3dadbb6..000000000000 --- a/src/libs/NumberFormatUtils.js +++ /dev/null @@ -1,9 +0,0 @@ -function format(locale, number, options) { - return new Intl.NumberFormat(locale, options).format(number); -} - -function formatToParts(locale, number, options) { - return new Intl.NumberFormat(locale, options).formatToParts(number); -} - -export {format, formatToParts}; diff --git a/src/libs/NumberFormatUtils.ts b/src/libs/NumberFormatUtils.ts new file mode 100644 index 000000000000..7c81e71f4db8 --- /dev/null +++ b/src/libs/NumberFormatUtils.ts @@ -0,0 +1,9 @@ +function format(locale: string, number: number, options?: Intl.NumberFormatOptions): string { + return new Intl.NumberFormat(locale, options).format(number); +} + +function formatToParts(locale: string, number: number, options?: Intl.NumberFormatOptions): Intl.NumberFormatPart[] { + return new Intl.NumberFormat(locale, options).formatToParts(number); +} + +export {format, formatToParts}; diff --git a/src/libs/NumberUtils.js b/src/libs/NumberUtils.js index f8d110bd0b00..ee0b8a8fa397 100644 --- a/src/libs/NumberUtils.js +++ b/src/libs/NumberUtils.js @@ -47,4 +47,18 @@ function generateHexadecimalValue(num) { .toUpperCase(); } -export {rand64, generateHexadecimalValue}; +/** + * Generates a random integer between a and b + * It's and equivalent of _.random(a, b) + * + * @param {Number} a + * @param {Number} b + * @returns {Number} random integer between a and b + */ +function generateRandomInt(a, b) { + const lower = Math.ceil(Math.min(a, b)); + const upper = Math.floor(Math.max(a, b)); + return Math.floor(lower + Math.random() * (upper - lower + 1)); +} + +export {rand64, generateHexadecimalValue, generateRandomInt}; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index a9c319865bbb..d26ad48430b0 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -394,7 +394,7 @@ function getLastMessageTextForReport(report) { if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) { lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`; } else if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(report, lastReportAction); + lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(report, lastReportAction, true); } else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) { const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastReportAction); diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index e1b51fb0f7c5..f37cd5bb5bf3 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -86,14 +86,6 @@ function canUseCustomStatus(betas) { return _.contains(betas, CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas); } -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseDistanceRequests(betas) { - return _.contains(betas, CONST.BETAS.DISTANCE_REQUESTS) || canUseAllBetas(betas); -} - /** * Link previews are temporarily disabled. * @returns {Boolean} @@ -112,6 +104,5 @@ export default { canUsePolicyRooms, canUseTasks, canUseCustomStatus, - canUseDistanceRequests, canUseLinkPreviews, }; 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 3ed10b865812..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); } /** @@ -362,6 +366,10 @@ function shouldReportActionBeVisibleAsLastAction(reportAction) { return false; } + if (!_.isEmpty(reportAction.errors)) { + return false; + } + return shouldReportActionBeVisible(reportAction, reportAction.reportActionID) && !isWhisperAction(reportAction) && !isDeletedAction(reportAction); } @@ -624,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 ff9a5ee14519..53423e8deaf2 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -124,7 +124,15 @@ function getPolicyName(report, returnEmptyIfNotFound = false, policy = undefined // Public rooms send back the policy name with the reportSummary, // since they can also be accessed by people who aren't in the workspace - return lodashGet(finalPolicy, 'name') || report.policyName || report.oldPolicyName || noPolicyFound; + const policyName = lodashGet(finalPolicy, 'name') || report.policyName || report.oldPolicyName || noPolicyFound; + + // The SBE and SAASTR policies have the user name in its name, however, we do not want to show that + if (lodashGet(finalPolicy, 'owner') === CONST.EMAIL.SBE || lodashGet(finalPolicy, 'owner') === CONST.EMAIL.SAASTR) { + const policyNameParts = policyName.split(' '); + if (!Str.isValidEmail(policyNameParts[0])) return policyName; + return policyNameParts.length > 1 ? policyNameParts.slice(1).join(' ') : policyName; + } + return policyName; } /** @@ -634,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; } /** @@ -807,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); } /** @@ -1410,9 +1418,10 @@ function getTransactionReportName(reportAction) { * * @param {Object} report * @param {Object} [reportAction={}] + * @param {Boolean} [shouldConsiderReceiptBeingScanned=false] * @returns {String} */ -function getReportPreviewMessage(report, reportAction = {}) { +function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false) { const reportActionMessage = lodashGet(reportAction, 'message[0].html', ''); if (_.isEmpty(report) || !report.reportID) { @@ -1429,6 +1438,14 @@ function getReportPreviewMessage(report, reportAction = {}) { return `approved ${formattedAmount}`; } + if (shouldConsiderReceiptBeingScanned && ReportActionsUtils.isMoneyRequestAction(reportAction)) { + const linkedTransaction = TransactionUtils.getLinkedTransaction(reportAction); + + if (!_.isEmpty(linkedTransaction) && TransactionUtils.hasReceipt(linkedTransaction) && TransactionUtils.isReceiptBeingScanned(linkedTransaction)) { + return Localize.translateLocal('iou.receiptScanning'); + } + } + if (isSettled(report.reportID)) { // A settled report preview message can come in three formats "paid ... using Paypal.me", "paid ... elsewhere" or "paid ... using Expensify" let translatePhraseKey = 'iou.paidElsewhereWithAmount'; diff --git a/src/libs/RoomNameInputUtils.js b/src/libs/RoomNameInputUtils.ts similarity index 82% rename from src/libs/RoomNameInputUtils.js rename to src/libs/RoomNameInputUtils.ts index 15b85f9f651a..2777acee45dd 100644 --- a/src/libs/RoomNameInputUtils.js +++ b/src/libs/RoomNameInputUtils.ts @@ -2,11 +2,8 @@ import CONST from '../CONST'; /** * Replaces spaces with dashes - * - * @param {String} roomName - * @returns {String} */ -function modifyRoomName(roomName) { +function modifyRoomName(roomName: string): string { const modifiedRoomNameWithoutHash = roomName .replace(/ /g, '-') diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index b31ebb0757c9..16deefef3a00 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -89,8 +89,15 @@ function hasReceipt(transaction) { * @param {Object} transaction * @returns {Boolean} */ -function areModifiedFieldsPopulated(transaction) { - return transaction.modifiedMerchant !== CONST.TRANSACTION.UNKNOWN_MERCHANT && transaction.modifiedAmount !== 0 && transaction.modifiedCreated !== ''; +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) || + (transaction.modifiedCreated === '' && transaction.created === '') + ); } /** @@ -262,7 +269,7 @@ function isReceiptBeingScanned(transaction) { * @returns {Boolean} */ function hasMissingSmartscanFields(transaction) { - return hasReceipt(transaction) && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && !areModifiedFieldsPopulated(transaction); + return hasReceipt(transaction) && !isDistanceRequest(transaction) && !isReceiptBeingScanned(transaction) && areRequiredFieldsEmpty(transaction); } /** 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 b4f04174c1ac..bc7adf47bd8c 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -21,6 +21,7 @@ import * as ErrorUtils from '../ErrorUtils'; import * as UserUtils from '../UserUtils'; import * as Report from './Report'; import * as NumberUtils from '../NumberUtils'; +import ReceiptGeneric from '../../../assets/images/receipt-generic.png'; let allReports; Onyx.connect({ @@ -394,7 +395,7 @@ function getMoneyRequestInformation( let filename; if (receipt && receipt.source) { receiptObject.source = receipt.source; - receiptObject.state = CONST.IOU.RECEIPT_STATE.SCANREADY; + receiptObject.state = receipt.state || CONST.IOU.RECEIPT_STATE.SCANREADY; filename = receipt.name; } let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( @@ -509,6 +510,10 @@ function getMoneyRequestInformation( * @param {String} merchant */ function createDistanceRequest(report, participant, comment, created, transactionID, amount, currency, merchant) { + const optimisticReceipt = { + source: ReceiptGeneric, + state: CONST.IOU.RECEIPT_STATE.OPEN, + }; const {iouReport, chatReport, transaction, iouAction, createdChatReportActionID, createdIOUReportActionID, reportPreviewAction, onyxData} = getMoneyRequestInformation( report, participant, @@ -519,7 +524,7 @@ function createDistanceRequest(report, participant, comment, created, transactio merchant, null, null, - null, + optimisticReceipt, transactionID, ); API.write( @@ -1533,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 = [ { @@ -1559,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}`, @@ -1606,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: { @@ -1614,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/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/getButtonState.js b/src/libs/getButtonState.ts similarity index 56% rename from src/libs/getButtonState.js rename to src/libs/getButtonState.ts index c4536831bbf2..af7c4db6b4ec 100644 --- a/src/libs/getButtonState.js +++ b/src/libs/getButtonState.ts @@ -1,16 +1,12 @@ +import {ValueOf} from 'type-fest'; import CONST from '../CONST'; +type GetButtonState = (isActive: boolean, isPressed: boolean, isComplete: boolean, isDisabled: boolean, isInteractive: boolean) => ValueOf; + /** * Get the string representation of a button's state. - * - * @param {Boolean} [isActive] - * @param {Boolean} [isPressed] - * @param {Boolean} [isComplete] - * @param {Boolean} [isDisabled] - * @param {Boolean} [isInteractive] - * @returns {String} */ -export default function (isActive = false, isPressed = false, isComplete = false, isDisabled = false, isInteractive = true) { +const getButtonState: GetButtonState = (isActive = false, isPressed = false, isComplete = false, isDisabled = false, isInteractive = true) => { if (!isInteractive) { return CONST.BUTTON_STATES.DEFAULT; } @@ -32,4 +28,6 @@ export default function (isActive = false, isPressed = false, isComplete = false } return CONST.BUTTON_STATES.DEFAULT; -} +}; + +export default getButtonState; diff --git a/src/libs/getIsReportFullyVisible.js b/src/libs/getIsReportFullyVisible.ts similarity index 63% rename from src/libs/getIsReportFullyVisible.js rename to src/libs/getIsReportFullyVisible.ts index a8e4100acf4d..525dd0743736 100644 --- a/src/libs/getIsReportFullyVisible.js +++ b/src/libs/getIsReportFullyVisible.ts @@ -2,11 +2,7 @@ import Visibility from './Visibility'; /** * When the app is visible and the report screen is focused we can assume that the report is fully visible. - * - * @param {Boolean} isFocused - * - * @returns {Boolean} */ -export default function getIsReportFullyVisible(isFocused) { +export default function getIsReportFullyVisible(isFocused: boolean): boolean { return Visibility.isVisible() && isFocused; } 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/requireParameters.js b/src/libs/requireParameters.js deleted file mode 100644 index aa2d5e0dc8de..000000000000 --- a/src/libs/requireParameters.js +++ /dev/null @@ -1,27 +0,0 @@ -import _ from 'underscore'; - -/** - * @throws {Error} If the "parameters" object has a null or undefined value for any of the given parameterNames - * - * @param {String[]} parameterNames Array of the required parameter names - * @param {Object} parameters A map from available parameter names to their values - * @param {String} commandName The name of the API command - */ -export default function requireParameters(parameterNames, parameters, commandName) { - parameterNames.forEach((parameterName) => { - if (_(parameters).has(parameterName) && parameters[parameterName] !== null && parameters[parameterName] !== undefined) { - return; - } - - const propertiesToRedact = ['authToken', 'password', 'partnerUserSecret', 'twoFactorAuthCode']; - const parametersCopy = _.chain(parameters) - .clone() - .mapObject((val, key) => (_.contains(propertiesToRedact, key) ? '' : val)) - .value(); - const keys = _(parametersCopy).keys().join(', ') || 'none'; - - let error = `Parameter ${parameterName} is required for "${commandName}". `; - error += `Supplied parameters: ${keys}`; - throw new Error(error); - }); -} diff --git a/src/libs/requireParameters.ts b/src/libs/requireParameters.ts new file mode 100644 index 000000000000..098a6d114430 --- /dev/null +++ b/src/libs/requireParameters.ts @@ -0,0 +1,28 @@ +/** + * @throws {Error} If the "parameters" object has a null or undefined value for any of the given parameterNames + * + * @param parameterNames Array of the required parameter names + * @param parameters A map from available parameter names to their values + * @param commandName The name of the API command + */ +export default function requireParameters(parameterNames: string[], parameters: Record, commandName: string): void { + parameterNames.forEach((parameterName) => { + if (parameterName in parameters && parameters[parameterName] !== null && parameters[parameterName] !== undefined) { + return; + } + + const propertiesToRedact = ['authToken', 'password', 'partnerUserSecret', 'twoFactorAuthCode']; + const parametersCopy = {...parameters}; + Object.keys(parametersCopy).forEach((key) => { + if (!propertiesToRedact.includes(key.toString())) return; + + parametersCopy[key] = ''; + }); + + const keys = Object.keys(parametersCopy).join(', ') || 'none'; + + let error = `Parameter ${parameterName} is required for "${commandName}". `; + error += `Supplied parameters: ${keys}`; + throw new Error(error); + }); +} diff --git a/src/libs/setSelection/index.js b/src/libs/setSelection/index.js deleted file mode 100644 index c7f24ae4a199..000000000000 --- a/src/libs/setSelection/index.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function setSelection(textInput, start, end) { - if (!textInput) { - return; - } - - textInput.setSelectionRange(start, end); -} diff --git a/src/libs/setSelection/index.native.js b/src/libs/setSelection/index.native.js deleted file mode 100644 index 02d812d84cd4..000000000000 --- a/src/libs/setSelection/index.native.js +++ /dev/null @@ -1,7 +0,0 @@ -export default function setSelection(textInput, start, end) { - if (!textInput) { - return; - } - - textInput.setSelection(start, end); -} diff --git a/src/libs/setSelection/index.native.ts b/src/libs/setSelection/index.native.ts new file mode 100644 index 000000000000..e27cd4e58bd7 --- /dev/null +++ b/src/libs/setSelection/index.native.ts @@ -0,0 +1,13 @@ +import SetSelection from './types'; + +const setSelection: SetSelection = (textInput, start, end) => { + if (!textInput) { + return; + } + + if ('setSelection' in textInput) { + textInput.setSelection(start, end); + } +}; + +export default setSelection; diff --git a/src/libs/setSelection/index.ts b/src/libs/setSelection/index.ts new file mode 100644 index 000000000000..5eee88881924 --- /dev/null +++ b/src/libs/setSelection/index.ts @@ -0,0 +1,13 @@ +import SetSelection from './types'; + +const setSelection: SetSelection = (textInput, start, end) => { + if (!textInput) { + return; + } + + if ('setSelectionRange' in textInput) { + textInput.setSelectionRange(start, end); + } +}; + +export default setSelection; diff --git a/src/libs/setSelection/types.ts b/src/libs/setSelection/types.ts new file mode 100644 index 000000000000..f2717079725f --- /dev/null +++ b/src/libs/setSelection/types.ts @@ -0,0 +1,5 @@ +import {TextInput} from 'react-native'; + +type SetSelection = (textInput: TextInput | HTMLInputElement, start: number, end: number) => void; + +export default SetSelection; 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/DemoSetupPage.js b/src/pages/DemoSetupPage.js index 53739820142b..0f7578760c16 100644 --- a/src/pages/DemoSetupPage.js +++ b/src/pages/DemoSetupPage.js @@ -22,14 +22,16 @@ const propTypes = { */ function DemoSetupPage(props) { useFocusEffect(() => { - // Depending on the route that the user hit to get here, run a specific demo flow - if (props.route.name === CONST.DEMO_PAGES.SAASTR) { - DemoActions.runSaastrDemo(); - } else if (props.route.name === CONST.DEMO_PAGES.SBE) { - DemoActions.runSbeDemo(); - } else { - Navigation.goBack(); - } + Navigation.isNavigationReady().then(() => { + // Depending on the route that the user hit to get here, run a specific demo flow + if (props.route.name === CONST.DEMO_PAGES.SAASTR) { + DemoActions.runSaastrDemo(); + } else if (props.route.name === CONST.DEMO_PAGES.SBE) { + DemoActions.runSbeDemo(); + } else { + Navigation.goBack(); + } + }); }); return ; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 6c5bc2fc8da1..3ad92fa5c769 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -4,7 +4,6 @@ import {View} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; -import {useNavigation} from '@react-navigation/native'; import {useAnimatedRef} from 'react-native-reanimated'; import styles from '../../../../styles/styles'; import ONYXKEYS from '../../../../ONYXKEYS'; @@ -28,7 +27,6 @@ import ExceededCommentLength from '../../../../components/ExceededCommentLength' import ReportDropUI from '../ReportDropUI'; import reportPropTypes from '../../../reportPropTypes'; import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; -import * as Welcome from '../../../../libs/actions/Welcome'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; @@ -110,7 +108,6 @@ function ReportActionCompose({ isCommentEmpty: isCommentEmptyProp, }) { const {translate} = useLocalize(); - const navigation = useNavigation(); const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions(); const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); @@ -288,32 +285,26 @@ function ReportActionCompose({ setIsFocused(true); }, []); - /** - * Used to show Popover menu on Workspace chat at first sign-in - * @returns {Boolean} - */ - const showPopoverMenu = useCallback(() => { - setMenuVisibility(true); - return true; - }, []); - - useEffect(() => { - // Shows Popover Menu on Workspace Chat at first sign-in - if (!disabled) { - Welcome.show({ - routes: lodashGet(navigation.getState(), 'routes', []), - showPopoverMenu, - }); + // 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]); - return () => { + // We are returning a callback here as we want to incoke the method on unmount only + useEffect( + () => () => { if (!EmojiPickerActions.isActive(report.reportID)) { return; } EmojiPickerActions.hideEmojiPicker(); - }; + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + [], + ); const reportRecipientAcountIDs = ReportUtils.getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); const reportRecipient = personalDetails[reportRecipientAcountIDs[0]]; @@ -356,7 +347,7 @@ function ReportActionCompose({ reportID={reportID} report={report} reportParticipantIDs={reportParticipantIDs} - isFullComposerAvailable={isFullComposerAvailable} + isFullComposerAvailable={isFullComposerAvailable && !isCommentEmpty} isComposerFullSize={isComposerFullSize} updateShouldShowSuggestionMenuToFalse={updateShouldShowSuggestionMenuToFalse} isBlockedFromConcierge={isBlockedFromConcierge} @@ -418,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 d5d8b38bb92c..8425f78a3a10 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -244,10 +244,11 @@ function ReportActionItem(props) { /** * Get the content of ReportActionItem * @param {Boolean} hovered whether the ReportActionItem is hovered + * @param {Boolean} isWhisper whether the report action is a whisper * @param {Boolean} hasErrors whether the report action has any errors * @returns {Object} child component(s) */ - const renderItemContent = (hovered = false, hasErrors = false) => { + const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false) => { let children; const originalMessage = lodashGet(props.action, 'originalMessage', {}); @@ -273,6 +274,7 @@ function ReportActionItem(props) { contextMenuAnchor={popoverAnchorRef} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} style={props.displayAsGroup ? [] : [styles.mt2]} + isWhisper={isWhisper} /> ); } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { @@ -286,6 +288,7 @@ function ReportActionItem(props) { isHovered={hovered} contextMenuAnchor={popoverAnchorRef} checkIfContextMenuActive={toggleContextMenuFromActiveReportAction} + isWhisper={isWhisper} /> ); } else if ( @@ -341,6 +344,7 @@ function ReportActionItem(props) { {!props.draftMessage ? ( { - const content = renderItemContent(hovered || isContextMenuActive, hasErrors); + const content = renderItemContent(hovered || isContextMenuActive, isWhisper, hasErrors); if (props.draftMessage) { return {content}; @@ -573,7 +577,7 @@ function ReportActionItem(props) { 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/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 155f261693b5..bfbce8aed336 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -140,18 +140,21 @@ function ReportActionItemSingle(props) { ] : props.action.person; + const reportID = props.report && props.report.reportID; + const iouReportID = props.iouReport && props.iouReport.reportID; + const showActorDetails = useCallback(() => { if (isWorkspaceActor) { - showWorkspaceDetails(props.report.reportID); + showWorkspaceDetails(reportID); } else { // Show participants page IOU report preview if (displayAllActors) { - Navigation.navigate(ROUTES.getReportParticipantsRoute(props.iouReport.reportID)); + Navigation.navigate(ROUTES.getReportParticipantsRoute(iouReportID)); return; } showUserDetails(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID); } - }, [isWorkspaceActor, props.report.reportID, actorAccountID, props.action.delegateAccountID, props.iouReport, displayAllActors]); + }, [isWorkspaceActor, reportID, actorAccountID, props.action.delegateAccountID, iouReportID, displayAllActors]); const shouldDisableDetailPage = useMemo( () => !isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID), diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index da475e61f749..a694c4996438 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -275,6 +275,10 @@ function arePropsEqual(oldProps, newProps) { return false; } + if (lodashGet(newProps, 'report.participantAccountIDs', 0) !== lodashGet(oldProps, 'report.participantAccountIDs', 0)) { + return false; + } + return _.isEqual(lodashGet(newProps.report, 'icons', []), lodashGet(oldProps.report, 'icons', [])); } diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 0057e16aed7c..c91509e62aba 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -3,7 +3,6 @@ import React from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; import styles from '../../../styles/styles'; import * as StyleUtils from '../../../styles/StyleUtils'; import ONYXKEYS from '../../../ONYXKEYS'; @@ -30,7 +29,6 @@ import * as Session from '../../../libs/actions/Session'; import KeyboardShortcut from '../../../libs/KeyboardShortcut'; import onyxSubscribe from '../../../libs/onyxSubscribe'; import * as ReportActionContextMenu from '../report/ContextMenu/ReportActionContextMenu'; -import withCurrentReportID from '../../../components/withCurrentReportID'; import SignInOrAvatarWithOptionalStatus from './SignInOrAvatarWithOptionalStatus'; const basePropTypes = { @@ -53,22 +51,13 @@ const propTypes = { priorityMode: PropTypes.oneOf(_.values(CONST.PRIORITY_MODE)), - /** The top most report id */ - currentReportID: PropTypes.string, - - /* Onyx Props */ - report: PropTypes.shape({ - /** reportID (only present when there is a matching report) */ - reportID: PropTypes.string, - }), + isActiveReport: PropTypes.func.isRequired, ...withLocalizePropTypes, }; const defaultProps = { priorityMode: CONST.PRIORITY_MODE.DEFAULT, - currentReportID: '', - report: {}, }; class SidebarLinks extends React.PureComponent { @@ -142,10 +131,14 @@ class SidebarLinks extends React.PureComponent { */ showReportPage(option) { // Prevent opening Report page when clicking LHN row quickly after clicking FAB icon - // or when clicking the active LHN row + // or when clicking the active LHN row on large screens // or when continuously clicking different LHNs, only apply to small screen // since getTopmostReportId always returns on other devices - if (this.props.isCreateMenuOpen || this.props.currentReportID === option.reportID || (this.props.isSmallScreenWidth && Navigation.getTopmostReportId())) { + if ( + this.props.isCreateMenuOpen || + (!this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID)) || + (this.props.isSmallScreenWidth && Navigation.getTopmostReportId()) + ) { return; } Navigation.navigate(ROUTES.getReportRoute(option.reportID)); @@ -201,14 +194,5 @@ class SidebarLinks extends React.PureComponent { SidebarLinks.propTypes = propTypes; SidebarLinks.defaultProps = defaultProps; -export default compose( - withLocalize, - withWindowDimensions, - withCurrentReportID, - withOnyx({ - report: { - key: ({currentReportID}) => `${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 0e8553b00dd0..a75a03f7a517 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -67,6 +67,16 @@ const propTypes = { /** Forwarded ref to FloatingActionButtonAndPopover */ innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** Information about any currently running demos */ + demoInfo: PropTypes.shape({ + saastr: PropTypes.shape({ + isBeginningDemo: PropTypes.bool, + }), + sbe: PropTypes.shape({ + isBeginningDemo: PropTypes.bool, + }), + }), }; const defaultProps = { onHideCreateMenu: () => {}, @@ -76,6 +86,7 @@ const defaultProps = { isLoading: false, innerRef: null, shouldShowDownloadAppBanner: true, + demoInfo: {}, }; /** @@ -162,8 +173,11 @@ function FloatingActionButtonAndPopover(props) { if (props.shouldShowDownloadAppBanner && Browser.isMobile()) { return; } + if (lodashGet(props.demoInfo, 'saastr.isBeginningDemo', false) || lodashGet(props.demoInfo, 'sbe.isBeginningDemo', false)) { + return; + } Welcome.show({routes, showCreateMenu}); - }, [props.shouldShowDownloadAppBanner, props.navigation, showCreateMenu]); + }, [props.shouldShowDownloadAppBanner, props.navigation, showCreateMenu, props.demoInfo]); useEffect(() => { if (!didScreenBecomeInactive()) { @@ -224,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'), @@ -299,6 +308,9 @@ export default compose( shouldShowDownloadAppBanner: { key: ONYXKEYS.SHOW_DOWNLOAD_APP_BANNER, }, + demoInfo: { + key: ONYXKEYS.DEMO_INFO, + }, }), )( forwardRef((props, ref) => ( diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js index 0fe9e7460d86..c6f2e0d40922 100644 --- a/src/pages/iou/MoneyRequestDatePage.js +++ b/src/pages/iou/MoneyRequestDatePage.js @@ -11,6 +11,7 @@ import styles from '../../styles/styles'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; import * as IOU from '../../libs/actions/IOU'; +import * as MoneyRequestUtils from '../../libs/MoneyRequestUtils'; import NewDatePicker from '../../components/NewDatePicker'; import useLocalize from '../../hooks/useLocalize'; import CONST from '../../CONST'; @@ -51,7 +52,7 @@ function MoneyRequestDatePage({iou, route, selectedTab}) { const {translate} = useLocalize(); const iouType = lodashGet(route, 'params.iouType', ''); const reportID = lodashGet(route, 'params.reportID', ''); - const isDistanceRequest = selectedTab === CONST.TAB.DISTANCE; + const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); useEffect(() => { const moneyRequestId = `${iouType}${reportID}`; diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js index 382f29c3c8e4..72e1270f1fcb 100644 --- a/src/pages/iou/MoneyRequestDescriptionPage.js +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -14,6 +14,7 @@ import styles from '../../styles/styles'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; import * as IOU from '../../libs/actions/IOU'; +import * as MoneyRequestUtils from '../../libs/MoneyRequestUtils'; import CONST from '../../CONST'; import useLocalize from '../../hooks/useLocalize'; import focusAndUpdateMultilineInputRange from '../../libs/focusAndUpdateMultilineInputRange'; @@ -55,7 +56,7 @@ function MoneyRequestDescriptionPage({iou, route, selectedTab}) { const inputRef = useRef(null); const iouType = lodashGet(route, 'params.iouType', ''); const reportID = lodashGet(route, 'params.reportID', ''); - const isDistanceRequest = selectedTab === CONST.TAB.DISTANCE; + const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); useEffect(() => { const moneyRequestId = `${iouType}${reportID}`; diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index 3863b43aa073..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'; @@ -17,11 +17,11 @@ import ReceiptSelector from './ReceiptSelector'; import * as IOU from '../../libs/actions/IOU'; import DistanceRequestPage from './DistanceRequestPage'; import DragAndDropProvider from '../../components/DragAndDrop/Provider'; -import usePermissions from '../../hooks/usePermissions'; 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 */ @@ -44,15 +44,16 @@ 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(); - const {canUseDistanceRequests} = usePermissions(); const title = { [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST]: translate('iou.requestMoney'), @@ -61,7 +62,7 @@ function MoneyRequestSelectorPage(props) { }; const isFromGlobalCreate = !reportID; const isExpenseRequest = ReportUtils.isPolicyExpenseChat(props.report); - const shouldDisplayDistanceRequest = canUseDistanceRequests && (isExpenseRequest || isFromGlobalCreate); + const shouldDisplayDistanceRequest = isExpenseRequest || isFromGlobalCreate; const resetMoneyRequestInfo = () => { const moneyRequestID = `${iouType}${reportID}`; @@ -72,10 +73,22 @@ function MoneyRequestSelectorPage(props) { {({safeAreaPaddingBottomStyle}) => ( - + ( { const subscription = AppState.addEventListener('change', (nextAppState) => { if (appState.current.match(/inactive|background/) && nextAppState === 'active') { - Camera.getCameraPermissionStatus().then((permissionStatus) => { + CameraPermission.getCameraPermissionStatus().then((permissionStatus) => { setPermissions(permissionStatus); }); } @@ -134,12 +137,15 @@ function ReceiptSelector(props) { }; const askForPermissions = () => { - if (permissions === 'not-determined') { - Camera.requestCameraPermission().then((permissionStatus) => { + // There's no way we can check for the BLOCKED status without requesting the permission first + // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 + if (permissions === RESULTS.BLOCKED || isAndroidBlockedPermissionRef.current) { + Linking.openSettings(); + } else if (permissions === RESULTS.DENIED) { + CameraPermission.requestCameraPermission().then((permissionStatus) => { setPermissions(permissionStatus); + isAndroidBlockedPermissionRef.current = permissionStatus === RESULTS.BLOCKED; }); - } else { - Linking.openSettings(); } }; @@ -198,13 +204,13 @@ function ReceiptSelector(props) { }); }, [flash, iouType, props.iou, props.report, reportID, translate]); - Camera.getCameraPermissionStatus().then((permissionStatus) => { + CameraPermission.getCameraPermissionStatus().then((permissionStatus) => { setPermissions(permissionStatus); }); return ( - {permissions !== CONST.RECEIPT.PERMISSION_AUTHORIZED && ( + {permissions !== RESULTS.GRANTED && ( )} - {permissions === CONST.RECEIPT.PERMISSION_AUTHORIZED && device == null && ( + {permissions === RESULTS.GRANTED && device == null && ( )} - {permissions === CONST.RECEIPT.PERMISSION_AUTHORIZED && device != null && ( + {permissions === RESULTS.GRANTED && device != null && ( diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 15cb7632714e..178179f31745 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -14,6 +14,7 @@ import * as IOU from '../../../libs/actions/IOU'; import compose from '../../../libs/compose'; import * as ReportUtils from '../../../libs/ReportUtils'; import * as OptionsListUtils from '../../../libs/OptionsListUtils'; +import * as MoneyRequestUtils from '../../../libs/MoneyRequestUtils'; import withLocalize from '../../../components/withLocalize'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; import ONYXKEYS from '../../../ONYXKEYS'; @@ -59,9 +60,9 @@ const defaultProps = { function MoneyRequestConfirmPage(props) { const {windowHeight} = useWindowDimensions(); - const isDistanceRequest = props.selectedTab === CONST.TAB.DISTANCE; const prevMoneyRequestId = useRef(props.iou.id); const iouType = useRef(lodashGet(props.route, 'params.iouType', '')); + const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType.current, props.selectedTab); const reportID = useRef(lodashGet(props.route, 'params.reportID', '')); const participants = useMemo( () => @@ -259,7 +260,7 @@ function MoneyRequestConfirmPage(props) { * VirtualizedList cannot be directly nested within ScrollViews of the same orientation. * To work around this, we wrap the MoneyRequestConfirmationList component with a horizontal ScrollView. */} - + { Navigation.navigate(ROUTES.getMoneyRequestConfirmationRoute(iouType.current, reportID.current)); diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index fbe8fd92b09c..32a9134cb101 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -15,6 +15,7 @@ import * as IOU from '../../../libs/actions/IOU'; import useLocalize from '../../../hooks/useLocalize'; import MoneyRequestAmountForm from './MoneyRequestAmountForm'; import * as IOUUtils from '../../../libs/IOUUtils'; +import * as MoneyRequestUtils from '../../../libs/MoneyRequestUtils'; import FullPageNotFoundView from '../../../components/BlockingViews/FullPageNotFoundView'; import styles from '../../../styles/styles'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; @@ -64,7 +65,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { const reportID = lodashGet(route, 'params.reportID', ''); const isEditing = lodashGet(route, 'path', '').includes('amount'); const currentCurrency = lodashGet(route, 'params.currency', ''); - const isDistanceRequestTab = selectedTab === CONST.TAB.DISTANCE; + const isDistanceRequestTab = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); const currency = currentCurrency || iou.currency; diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index 7b2cf85ef141..b7a4118bc272 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -22,10 +22,10 @@ import * as User from '../../../../libs/actions/User'; import CONST from '../../../../CONST'; import * as ErrorUtils from '../../../../libs/ErrorUtils'; import themeColors from '../../../../styles/themes/default'; -import NotFoundPage from '../../../ErrorPage/NotFoundPage'; import ValidateCodeForm from './ValidateCodeForm'; import ROUTES from '../../../../ROUTES'; import FullscreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator'; +import FullPageNotFoundView from '../../../../components/BlockingViews/FullPageNotFoundView'; const propTypes = { /* Onyx Props */ @@ -108,7 +108,11 @@ class ContactMethodDetailsPage extends Component { } componentDidMount() { - User.resetContactMethodValidateCodeSentState(this.getContactMethod()); + const contactMethod = this.getContactMethod(); + const loginData = this.props.loginList[contactMethod]; + if (loginData) { + User.resetContactMethodValidateCodeSentState(contactMethod); + } } componentDidUpdate(prevProps) { @@ -211,7 +215,16 @@ class ContactMethodDetailsPage extends Component { const loginData = this.props.loginList[contactMethod]; if (!contactMethod || !loginData) { - return ; + return ( + + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS)} + /> + + ); } const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; diff --git a/src/pages/settings/Wallet/WalletPage/BaseWalletPage.js b/src/pages/settings/Wallet/WalletPage/BaseWalletPage.js index 72e31ec72152..1393892b6ef0 100644 --- a/src/pages/settings/Wallet/WalletPage/BaseWalletPage.js +++ b/src/pages/settings/Wallet/WalletPage/BaseWalletPage.js @@ -49,7 +49,6 @@ function BaseWalletPage(props) { selectedPaymentMethodType: null, }); const addPaymentMethodAnchorRef = useRef(null); - const deletePaymentMethodAnchorRef = useRef(null); const paymentMethodButtonRef = useRef(null); const [anchorPosition, setAnchorPosition] = useState({ anchorPositionHorizontal: 0, @@ -102,19 +101,15 @@ function BaseWalletPage(props) { }, [paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethod.fundID, paymentMethod.selectedPaymentMethodType]); const resetSelectedPaymentMethodData = useCallback(() => { - // The below state values are used by payment method modals and we reset them while closing the modals. - // We should only reset the values when the modal animation is completed and so using InteractionManager.runAfterInteractions which fires after all animaitons are complete - InteractionManager.runAfterInteractions(() => { - // Reset to same values as in the constructor - setPaymentMethod({ - isSelectedPaymentMethodDefault: false, - selectedPaymentMethod: {}, - formattedSelectedPaymentMethod: { - title: '', - }, - methodID: null, - selectedPaymentMethodType: null, - }); + // Reset to same values as in the constructor + setPaymentMethod({ + isSelectedPaymentMethodDefault: false, + selectedPaymentMethod: {}, + formattedSelectedPaymentMethod: { + title: '', + }, + methodID: null, + selectedPaymentMethodType: null, }); }, [setPaymentMethod]); @@ -133,6 +128,11 @@ function BaseWalletPage(props) { return; } + if (shouldShowDefaultDeleteMenu) { + setShouldShowDefaultDeleteMenu(false); + return; + } + paymentMethodButtonRef.current = nativeEvent.currentTarget; // The delete/default menu @@ -212,18 +212,12 @@ function BaseWalletPage(props) { * Hide the default / delete modal * @param {boolean} shouldClearSelectedData - Clear selected payment method data if true */ - const hideDefaultDeleteMenu = useCallback( - (shouldClearSelectedData = true) => { - setShouldShowDefaultDeleteMenu(false); - InteractionManager.runAfterInteractions(() => { - setShowConfirmDeleteContent(false); - if (shouldClearSelectedData) { - resetSelectedPaymentMethodData(); - } - }); - }, - [setShouldShowDefaultDeleteMenu, setShowConfirmDeleteContent, resetSelectedPaymentMethodData], - ); + const hideDefaultDeleteMenu = useCallback(() => { + setShouldShowDefaultDeleteMenu(false); + InteractionManager.runAfterInteractions(() => { + setShowConfirmDeleteContent(false); + }); + }, [setShouldShowDefaultDeleteMenu, setShowConfirmDeleteContent]); const makeDefaultPaymentMethod = useCallback(() => { const paymentCardList = props.fundList || {}; @@ -237,7 +231,6 @@ function BaseWalletPage(props) { } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { PaymentMethods.makeDefaultPaymentMethod(null, paymentMethod.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod); } - resetSelectedPaymentMethodData(); }, [ paymentMethod.methodID, paymentMethod.selectedPaymentMethod.bankAccountID, @@ -245,7 +238,6 @@ function BaseWalletPage(props) { paymentMethod.selectedPaymentMethodType, props.bankAccountList, props.fundList, - resetSelectedPaymentMethodData, ]); const deletePaymentMethod = useCallback(() => { @@ -256,8 +248,7 @@ function BaseWalletPage(props) { } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { PaymentMethods.deletePaymentCard(paymentMethod.selectedPaymentMethod.fundID); } - resetSelectedPaymentMethodData(); - }, [paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethod.fundID, paymentMethod.selectedPaymentMethodType, resetSelectedPaymentMethodData]); + }, [paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethod.fundID, paymentMethod.selectedPaymentMethodType]); const navigateToTransferBalancePage = () => { Navigation.navigate(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE); @@ -424,7 +415,8 @@ function BaseWalletPage(props) { right: anchorPosition.anchorPositionRight, }} withoutOverlay - anchorRef={deletePaymentMethodAnchorRef} + anchorRef={paymentMethodButtonRef} + onModalHide={resetSelectedPaymentMethodData} > {!showConfirmDeleteContent ? ( @@ -440,8 +432,8 @@ function BaseWalletPage(props) { {shouldShowMakeDefaultButton && (