diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8d0dc882bb59..c463fc559bd7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,17 +5,22 @@ ### Fixed Issues -$ GH_LINK +$ GH_LINK +PROPOSAL: GH_LINK_ISSUE(COMMENT) + ### Tests + +- [ ] Verify that no errors appear in the JS console + ### PR Review Checklist -#### Contributor (PR Author) Checklist +#### PR Author Checklist - [ ] I linked the correct issue in the `### Fixed Issues` section above - [ ] I wrote clear testing steps that cover the changes made in this PR - [ ] I added steps for local testing in the `Tests` section @@ -67,7 +86,6 @@ This is a checklist for PR authors & reviewers. Please make sure to complete all - [ ] If a new component is created I verified that: - [ ] A similar component doesn't exist in the codebase - [ ] All props are defined accurately and each prop has a `/** comment above it */` - - [ ] Any functional components have the `displayName` property - [ ] The file is named correctly - [ ] The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone - [ ] The only data being stored in the state is data necessary for rendering and nothing else @@ -85,7 +103,7 @@ This is a checklist for PR authors & reviewers. Please make sure to complete all

PR Reviewer Checklist

-The Contributor+ will copy/paste it into a new comment and complete it after the author checklist is completed +The reviewer will copy/paste it into a new comment and complete it after the author checklist is completed
- [ ] I have verified the author checklist is complete (all boxes are checked off). @@ -96,6 +114,7 @@ The Contributor+ will copy/paste it into a new comment and complete it after the - [ ] I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct) - [ ] I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline) - [ ] I checked that screenshots or videos are included for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms) +- [ ] I included screenshots or videos for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms) - [ ] I verified tests pass on **all platforms** & I tested again on: - [ ] iOS / native - [ ] Android / native @@ -120,7 +139,6 @@ The Contributor+ will copy/paste it into a new comment and complete it after the - [ ] If a new component is created I verified that: - [ ] A similar component doesn't exist in the codebase - [ ] All props are defined accurately and each prop has a `/** comment above it */` - - [ ] Any functional components have the `displayName` property - [ ] The file is named correctly - [ ] The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone - [ ] The only data being stored in the state is data necessary for rendering and nothing else @@ -137,20 +155,6 @@ The Contributor+ will copy/paste it into a new comment and complete it after the
-### QA Steps - - -- [ ] Verify that no errors appear in the JS console - ### Screenshots diff --git a/.github/actions/javascript/contributorChecklist/contributorChecklist.js b/.github/actions/javascript/contributorChecklist/contributorChecklist.js index 552e1c374071..e9e740623d49 100644 --- a/.github/actions/javascript/contributorChecklist/contributorChecklist.js +++ b/.github/actions/javascript/contributorChecklist/contributorChecklist.js @@ -4,7 +4,7 @@ const _ = require('underscore'); const GitHubUtils = require('../../../libs/GithubUtils'); /* eslint-disable max-len */ -const completedContributorChecklist = `- [x] I linked the correct issue in the \`### Fixed Issues\` section above +const completedAuthorChecklist = `- [x] I linked the correct issue in the \`### Fixed Issues\` section above - [x] I wrote clear testing steps that cover the changes made in this PR - [x] I added steps for local testing in the \`Tests\` section - [x] I added steps for Staging and/or Production testing in the \`QA steps\` section @@ -35,7 +35,6 @@ const completedContributorChecklist = `- [x] I linked the correct issue in the \ - [x] If a new component is created I verified that: - [x] A similar component doesn't exist in the codebase - [x] All props are defined accurately and each prop has a \`/** comment above it */\` - - [x] Any functional components have the \`displayName\` property - [x] The file is named correctly - [x] The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone - [x] The only data being stored in the state is data necessary for rendering and nothing else @@ -50,7 +49,7 @@ const completedContributorChecklist = `- [x] I linked the correct issue in the \ - [x] If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected. - [x] I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.`; -const completedContributorPlusChecklist = `- [x] I have verified the author checklist is complete (all boxes are checked off). +const completedReviewerChecklist = `- [x] I have verified the author checklist is complete (all boxes are checked off). - [x] I verified the correct issue is linked in the \`### Fixed Issues\` section above - [x] I verified testing steps are clear and they cover the changes made in this PR - [x] I verified the steps for local testing are in the \`Tests\` section @@ -58,6 +57,7 @@ const completedContributorPlusChecklist = `- [x] I have verified the author chec - [x] I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct) - [x] I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline) - [x] I checked that screenshots or videos are included for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms) +- [x] I included screenshots or videos for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms) - [x] I verified tests pass on **all platforms** & I tested again on: - [x] iOS / native - [x] Android / native @@ -82,7 +82,6 @@ const completedContributorPlusChecklist = `- [x] I have verified the author chec - [x] If a new component is created I verified that: - [x] A similar component doesn't exist in the codebase - [x] All props are defined accurately and each prop has a \`/** comment above it */\` - - [x] Any functional components have the \`displayName\` property - [x] The file is named correctly - [x] The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone - [x] The only data being stored in the state is data necessary for rendering and nothing else @@ -97,8 +96,8 @@ const completedContributorPlusChecklist = `- [x] I have verified the author chec - [x] If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected. - [x] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.`; -// True if we are validating a contributor checklist, otherwise we are validating a contributor+ checklist -const verifyingContributorChecklist = core.getInput('CHECKLIST', {required: true}) === 'contributor'; +// True if we are validating an author checklist, otherwise we are validating a reviewer checklist +const verifyingAuthorChecklist = core.getInput('CHECKLIST', {required: true}) === 'contributor'; const issue = github.context.payload.issue ? github.context.payload.issue.number : github.context.payload.pull_request.number; const combinedData = []; @@ -135,34 +134,34 @@ getPullRequestBody() .then(() => getAllComments()) .then(comments => combinedData.push(...comments)) .then(() => { - let contributorChecklistComplete = false; - let contributorPlusChecklistComplete = false; + let authorChecklistComplete = false; + let reviewerChecklistComplete = false; // Once we've gathered all the data, loop through each comment and look to see if it contains a completed checklist for (let i = 0; i < combinedData.length; i++) { const whitespace = /([\n\r])/gm; const comment = combinedData[i].replace(whitespace, ''); - if (comment.includes(completedContributorChecklist.replace(whitespace, ''))) { - contributorChecklistComplete = true; + if (comment.includes(completedAuthorChecklist.replace(whitespace, ''))) { + authorChecklistComplete = true; } - if (comment.includes(completedContributorPlusChecklist.replace(whitespace, ''))) { - contributorPlusChecklistComplete = true; + if (comment.includes(completedReviewerChecklist.replace(whitespace, ''))) { + reviewerChecklistComplete = true; } } - if (verifyingContributorChecklist && !contributorChecklistComplete) { + if (verifyingAuthorChecklist && !authorChecklistComplete) { console.log('Make sure you are using the most up to date checklist found here: https://raw.githubusercontent.com/Expensify/App/main/.github/PULL_REQUEST_TEMPLATE.md'); - core.setFailed('Contributor checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); + core.setFailed('PR Author Checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); return; } - if (!verifyingContributorChecklist && !contributorPlusChecklistComplete) { + if (!verifyingAuthorChecklist && !reviewerChecklistComplete) { console.log('Make sure you are using the most up to date checklist found here: https://raw.githubusercontent.com/Expensify/App/main/.github/PULL_REQUEST_TEMPLATE.md'); - core.setFailed('Contributor+ checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); + core.setFailed('PR Reviewer Checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); return; } - console.log(`${verifyingContributorChecklist ? 'Contributor' : 'Contributor+'} checklist is complete 🎉`); + console.log(`${verifyingAuthorChecklist ? 'PR Author' : 'PR Reviewer'} checklist is complete 🎉`); }); diff --git a/.github/actions/javascript/contributorChecklist/index.js b/.github/actions/javascript/contributorChecklist/index.js index 28a984ad8987..225704c5d109 100644 --- a/.github/actions/javascript/contributorChecklist/index.js +++ b/.github/actions/javascript/contributorChecklist/index.js @@ -14,7 +14,7 @@ const _ = __nccwpck_require__(3571); const GitHubUtils = __nccwpck_require__(7999); /* eslint-disable max-len */ -const completedContributorChecklist = `- [x] I linked the correct issue in the \`### Fixed Issues\` section above +const completedAuthorChecklist = `- [x] I linked the correct issue in the \`### Fixed Issues\` section above - [x] I wrote clear testing steps that cover the changes made in this PR - [x] I added steps for local testing in the \`Tests\` section - [x] I added steps for Staging and/or Production testing in the \`QA steps\` section @@ -45,7 +45,6 @@ const completedContributorChecklist = `- [x] I linked the correct issue in the \ - [x] If a new component is created I verified that: - [x] A similar component doesn't exist in the codebase - [x] All props are defined accurately and each prop has a \`/** comment above it */\` - - [x] Any functional components have the \`displayName\` property - [x] The file is named correctly - [x] The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone - [x] The only data being stored in the state is data necessary for rendering and nothing else @@ -60,7 +59,7 @@ const completedContributorChecklist = `- [x] I linked the correct issue in the \ - [x] If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected. - [x] I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.`; -const completedContributorPlusChecklist = `- [x] I have verified the author checklist is complete (all boxes are checked off). +const completedReviewerChecklist = `- [x] I have verified the author checklist is complete (all boxes are checked off). - [x] I verified the correct issue is linked in the \`### Fixed Issues\` section above - [x] I verified testing steps are clear and they cover the changes made in this PR - [x] I verified the steps for local testing are in the \`Tests\` section @@ -68,6 +67,7 @@ const completedContributorPlusChecklist = `- [x] I have verified the author chec - [x] I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct) - [x] I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline) - [x] I checked that screenshots or videos are included for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms) +- [x] I included screenshots or videos for tests on [all platforms](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#make-sure-you-can-test-on-all-platforms) - [x] I verified tests pass on **all platforms** & I tested again on: - [x] iOS / native - [x] Android / native @@ -92,7 +92,6 @@ const completedContributorPlusChecklist = `- [x] I have verified the author chec - [x] If a new component is created I verified that: - [x] A similar component doesn't exist in the codebase - [x] All props are defined accurately and each prop has a \`/** comment above it */\` - - [x] Any functional components have the \`displayName\` property - [x] The file is named correctly - [x] The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone - [x] The only data being stored in the state is data necessary for rendering and nothing else @@ -107,8 +106,8 @@ const completedContributorPlusChecklist = `- [x] I have verified the author chec - [x] If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected. - [x] I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.`; -// True if we are validating a contributor checklist, otherwise we are validating a contributor+ checklist -const verifyingContributorChecklist = core.getInput('CHECKLIST', {required: true}) === 'contributor'; +// True if we are validating an author checklist, otherwise we are validating a reviewer checklist +const verifyingAuthorChecklist = core.getInput('CHECKLIST', {required: true}) === 'contributor'; const issue = github.context.payload.issue ? github.context.payload.issue.number : github.context.payload.pull_request.number; const combinedData = []; @@ -145,36 +144,36 @@ getPullRequestBody() .then(() => getAllComments()) .then(comments => combinedData.push(...comments)) .then(() => { - let contributorChecklistComplete = false; - let contributorPlusChecklistComplete = false; + let authorChecklistComplete = false; + let reviewerChecklistComplete = false; // Once we've gathered all the data, loop through each comment and look to see if it contains a completed checklist for (let i = 0; i < combinedData.length; i++) { const whitespace = /([\n\r])/gm; const comment = combinedData[i].replace(whitespace, ''); - if (comment.includes(completedContributorChecklist.replace(whitespace, ''))) { - contributorChecklistComplete = true; + if (comment.includes(completedAuthorChecklist.replace(whitespace, ''))) { + authorChecklistComplete = true; } - if (comment.includes(completedContributorPlusChecklist.replace(whitespace, ''))) { - contributorPlusChecklistComplete = true; + if (comment.includes(completedReviewerChecklist.replace(whitespace, ''))) { + reviewerChecklistComplete = true; } } - if (verifyingContributorChecklist && !contributorChecklistComplete) { + if (verifyingAuthorChecklist && !authorChecklistComplete) { console.log('Make sure you are using the most up to date checklist found here: https://raw.githubusercontent.com/Expensify/App/main/.github/PULL_REQUEST_TEMPLATE.md'); - core.setFailed('Contributor checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); + core.setFailed('PR Author Checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); return; } - if (!verifyingContributorChecklist && !contributorPlusChecklistComplete) { + if (!verifyingAuthorChecklist && !reviewerChecklistComplete) { console.log('Make sure you are using the most up to date checklist found here: https://raw.githubusercontent.com/Expensify/App/main/.github/PULL_REQUEST_TEMPLATE.md'); - core.setFailed('Contributor+ checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); + core.setFailed('PR Reviewer Checklist is not completely filled out. Please check every box to verify you\'ve thought about the item.'); return; } - console.log(`${verifyingContributorChecklist ? 'Contributor' : 'Contributor+'} checklist is complete 🎉`); + console.log(`${verifyingAuthorChecklist ? 'PR Author' : 'PR Reviewer'} checklist is complete 🎉`); }); diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 347f30961fb7..8943669c2ba8 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -4,7 +4,7 @@ on: issue_comment: types: [created] pull_request_target: - types: [opened, closed, synchronize] + types: [opened, synchronize] jobs: CLA: diff --git a/.github/workflows/contributorChecklists.yml b/.github/workflows/contributorChecklists.yml index 338ec6ba1e55..632fd5355d92 100644 --- a/.github/workflows/contributorChecklists.yml +++ b/.github/workflows/contributorChecklists.yml @@ -1,4 +1,4 @@ -name: Contributor Checklist +name: PR Author Checklist on: pull_request diff --git a/.github/workflows/contributorPlusChecklists.yml b/.github/workflows/contributorPlusChecklists.yml index 76dda02da067..46dc8ae5a733 100644 --- a/.github/workflows/contributorPlusChecklists.yml +++ b/.github/workflows/contributorPlusChecklists.yml @@ -1,4 +1,4 @@ -name: Contributor+ Checklist +name: PR Reviewer Checklist on: pull_request_review @@ -11,4 +11,4 @@ jobs: uses: Expensify/App/.github/actions/javascript/contributorChecklist@main with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CHECKLIST: 'contributorPlus' + CHECKLIST: 'reviewer' diff --git a/.github/workflows/e2ePerformanceRegressionTests.yml b/.github/workflows/e2ePerformanceRegressionTests.yml new file mode 100644 index 000000000000..6b3b0edf537c --- /dev/null +++ b/.github/workflows/e2ePerformanceRegressionTests.yml @@ -0,0 +1,79 @@ +name: Run e2e performance regression tests + +on: + pull_request: + types: [labeled] + +jobs: + e2e-tests: + if: ${{ github.event.label.name == 'e2e' }} + name: "Run e2e performance regression tests" + # Although the tests will run on an android emulator, using macOS as its more performant + runs-on: macos-11 + steps: + - uses: Expensify/App/.github/actions/composite/setupNode@main + + - uses: ruby/setup-ruby@08245253a76fa4d1e459b7809579c62bd9eb718a + with: + ruby-version: '2.7' + bundler-cache: true + + # Improve emulator startup time, see https://github.com/marketplace/actions/android-emulator-runner + - name: Gradle cache + uses: gradle/gradle-build-action@v2 + + - name: AVD cache + uses: actions/cache@v3 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-28 + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 28 + ram-size: 3072M + heap-size: 512M + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + +# Note: if the android build fails the logs can be incomplete. It can help to run the build once manually to get a full log + - name: Preheat build system + env: + JAVA_HOME: ${{ env.JAVA_HOME_11_X64 }} + run: | + npm run android-build-e2e + + - name: Start emulator and run tests + id: tests + uses: reactivecircus/android-emulator-runner@v2 + env: + JAVA_HOME: ${{ env.JAVA_HOME_11_X64 }} + INTERACTION_TIMEOUT: 120000 # 2 minutes + # when logging progresses only refresh the _log_ every 30 seconds + LOGGER_PROGRESS_REFRESH_RATE: 30000 + # TODO: remove this once implementation done. + baseline: dev/ci-e2e-tests + with: + api-level: 28 + ram-size: 3072M + heap-size: 512M + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: npm run test:e2e + + - name: If tests failed, upload logs and video + if: ${{ failure() && steps.tests.conclusion == 'failure' }} + uses: actions/upload-artifact@v3 + with: + name: test-failure-logs + path: e2e/.results + retention-days: 5 + diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 7a81ddc52c9d..20608c66a0b6 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -210,15 +210,8 @@ jobs: steps: - uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Setup python - run: sudo apt-get install python3-setuptools - - name: Setup Cloudflare CLI - run: | - # Pip 21 doesn't support python 3.5, so use the version before it - sudo python3 -m pip install --upgrade pip==20.3.4 - pip3 install wheel # need wheel before cloudflare, this is the only way to ensure order. - pip3 install cloudflare + run: pip3 install cloudflare - name: Configure AWS Credentials # Version: 1.5.5 @@ -290,7 +283,7 @@ jobs: channel: '#announce', attachments: [{ color: 'good', - text: `🎉️ Successfully deployed ${process.env.AS_REPO} v${{ env.VERSION }} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} 🎉️`, + text: `🎉️ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} 🎉️`, }] } env: @@ -306,7 +299,7 @@ jobs: channel: '#deployer', attachments: [{ color: 'good', - text: `🎉️ Successfully deployed ${process.env.AS_REPO} v${{ env.VERSION }} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} 🎉️`, + text: `🎉️ Successfully deployed ${process.env.AS_REPO} to ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'production' || 'staging' }} 🎉️`, }] } env: @@ -323,7 +316,7 @@ jobs: channel: '#expensify-open-source', attachments: [{ color: 'good', - text: `🎉️ Successfully deployed ${process.env.AS_REPO} v${{ env.VERSION }} to production 🎉️`, + text: `🎉️ Successfully deployed ${process.env.AS_REPO} to production 🎉️`, }] } env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 16d090bbd433..90e6e06e75d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,10 +6,38 @@ on: types: [opened, synchronize] branches-ignore: [staging, production] +env: + # Number of parallel jobs for jest tests + CHUNKS: 3 jobs: + config: + runs-on: ubuntu-latest + name: Define matrix parameters + outputs: + MATRIX: ${{ steps.set-matrix.outputs.MATRIX }} + JEST_CHUNKS: ${{ steps.set-matrix.outputs.JEST_CHUNKS }} + steps: + - name: Set Matrix + id: set-matrix + uses: actions/github-script@v6 + with: + # Generate matrix array i.e. [0, 1, 2, ...., CHUNKS - 1] for test job + script: | + core.setOutput('MATRIX', Array.from({ length: Number(process.env.CHUNKS) }, (v, i) => i + 1)); + core.setOutput('JEST_CHUNKS', Number(process.env.CHUNKS) - 1); + test: + needs: config if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: ubuntu-latest + name: test (job ${{ fromJSON(matrix.chunk) }}) + env: + CI: true + strategy: + fail-fast: false + matrix: + chunk: ${{fromJson(needs.config.outputs.MATRIX)}} + steps: - uses: Expensify/App/.github/actions/composite/setupNode@main @@ -23,10 +51,19 @@ jobs: exit 1 fi - - name: Jest Unit Tests - run: npm run test - env: - CI: true + - name: Cache Jest cache + id: cache-jest-cache + uses: actions/cache@v1 + with: + path: .jest-cache + key: ${{ runner.os }}-jest + + - name: All Unit Tests + if: ${{ fromJSON(matrix.chunk) < fromJSON(env.CHUNKS) }} + # Split the jest based test files in multiple chunks/groups and then execute them in parallel in different jobs/runners. + run: npx jest --listTests --json | jq -cM '[_nwise(length / ${{ fromJSON(needs.config.outputs.JEST_CHUNKS) }} | ceil)]' | jq '[[]] + .' | jq '.[${{ fromJSON(matrix.chunk) }}] | .[] | @text' | xargs npm test - name: Pull Request Tests + # Pull request related tests will be run in separate runner in parallel. + if: ${{ fromJSON(matrix.chunk) == fromJSON(env.CHUNKS) }} run: tests/unit/getPullRequestsMergedBetweenTest.sh diff --git a/.gitignore b/.gitignore index 4b971429f2bf..ba331269cf99 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,8 @@ storybook-static # Jest coverage report /coverage.data /coverage/ + +.jest-cache + +# E2E test reports +e2e/.results/ diff --git a/README.md b/README.md index 20d4921fc261..d63b6b064532 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ variables referenced here get updated since your local `.env` file is ignored. see [PERFORMANCE.md](contributingGuides/PERFORMANCE.md#performance-metrics-opt-in-on-local-release-builds) for more information - `ONYX_METRICS` (optional) - Set this to `true` to capture even more performance metrics and see them in Flipper see [React-Native-Onyx#benchmarks](https://github.com/Expensify/react-native-onyx#benchmarks) for more information +- `E2E_TESTING` (optional) - This needs to be set to `true` when running the e2e tests for performance regression testing. + This happens usually automatically, read [this](e2e/README.md) for more information ---- diff --git a/__mocks__/react-freeze.js b/__mocks__/react-freeze.js new file mode 100644 index 000000000000..3af82e686d12 --- /dev/null +++ b/__mocks__/react-freeze.js @@ -0,0 +1,6 @@ +const Freeze = props => props.children; + +export { + // eslint-disable-next-line import/prefer-default-export + Freeze, +}; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1cdaa76a4443..bee6495f2362 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -156,8 +156,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001021000 - versionName "1.2.10-0" + versionCode 1001021704 + versionName "1.2.17-4" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { @@ -252,6 +252,12 @@ android { minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" } + // We need a custom build type, so we can allow http clear text traffic in a release build: + e2eRelease { + initWith release + matchingFallbacks = ['release'] + signingConfig signingConfigs.debug + } } // applicationVariants are e.g. debug, release diff --git a/android/app/src/e2eRelease/AndroidManifest.xml b/android/app/src/e2eRelease/AndroidManifest.xml new file mode 100644 index 000000000000..201d730f5211 --- /dev/null +++ b/android/app/src/e2eRelease/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/assets/images/product-illustrations/receipts-search--yellow.svg b/assets/images/product-illustrations/receipts-search--yellow.svg new file mode 100644 index 000000000000..9db0cc47c236 --- /dev/null +++ b/assets/images/product-illustrations/receipts-search--yellow.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkMetroBundlerPort.js b/config/checkMetroBundlerPort.js deleted file mode 100644 index a6474c891706..000000000000 --- a/config/checkMetroBundlerPort.js +++ /dev/null @@ -1,20 +0,0 @@ -const {isPackagerRunning} = require('@react-native-community/cli-tools'); - -/** - * Function isPackagerRunning indicates whether or not the packager is running. It returns a promise that - * returns one of these possible values: - * - `running`: the packager is running - * - `not_running`: the packager nor any process is running on the expected port. - * - `unrecognized`: one other process is running on the port we expect the packager to be running. - */ -isPackagerRunning().then((result) => { - if (result !== 'unrecognized') { - return; - } - - console.error( - 'The port 8081 is currently in use.', - 'You can run `lsof -i :8081` to see which program is using it.\n', - ); - process.exit(1); -}); diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index dbb9df872315..75d0229a9505 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -1,5 +1,7 @@ const path = require('path'); -const {IgnorePlugin, DefinePlugin, ProvidePlugin} = require('webpack'); +const { + IgnorePlugin, DefinePlugin, ProvidePlugin, EnvironmentPlugin, +} = require('webpack'); const {CleanWebpackPlugin} = require('clean-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); @@ -18,6 +20,7 @@ const includeModules = [ 'react-native-gesture-handler', 'react-native-flipper', 'react-native-google-places-autocomplete', + '@react-navigation/drawer', ].join('|'); /** @@ -30,9 +33,10 @@ const includeModules = [ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ mode: 'production', devtool: 'source-map', - entry: { - app: './index.js', - }, + entry: [ + 'babel-polyfill', + './index.js', + ], output: { filename: '[name]-[contenthash].bundle.js', path: path.resolve(__dirname, '../../dist'), @@ -66,12 +70,14 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'node_modules/pdfjs-dist/cmaps/', to: 'cmaps/'}, ], }), + new EnvironmentPlugin({JEST_WORKER_ID: null}), new IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/, }), ...(platform === 'web' ? [new CustomVersionFilePlugin()] : []), new DefinePlugin({ + ...(platform === 'desktop' ? {} : {process: {env: {}}}), __REACT_WEB_CONFIG__: JSON.stringify( dotenv.config({path: envFile}).parsed, ), diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index fac1cd75b06f..8e10fa3002e9 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -47,7 +47,7 @@ This is the most common scenario for contributors. The Expensify team posts new #### Proposing a job that Expensify hasn't posted -It’s possible that you found a bug or enhancement that we haven’t posted to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job, and (optionally) a solution. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the improvement. +It’s possible that you found a bug or enhancement that we haven’t posted to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job, and (optionally) a solution. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the improvement. If the bug is fixed by a PR that is not associated with your bug report, the contributor is not eligible for compensation unless they can find the PR that fixed the bug then show their bug report preceded the one associated with the PR. - Note: If you get assigned the job you proposed **and** you complete the job, this $250 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. - Note about proposed improvements: Expensify has the right not to pay the $250 reward if the suggested improvement is already planned. Currently, Expensify plans to implement all features of the old Expensify app in New Expensify. diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 2f4c217ad422..b1dcf2dd675b 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -203,11 +203,15 @@ function onSubmit(values) { The following prop is available to form inputs: - inputID: An unique identifier for the input. +- shouldSaveDraft: Saves a draft of the input value. +- defaultValue: The initial value of the input. +- value: The value to show for the input. +- onValueChange: A callback that is called when the input's value changes. Form.js will automatically provide the following props to any input with the inputID prop. - ref: A React ref that must be attached to the input. -- defaultValue: The input default value. +- value: The input value. - errorText: The translated error text that is returned by validate for that specific input. - onBlur: An onBlur handler that calls validate. - 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). diff --git a/contributingGuides/OFFLINE_UX.md b/contributingGuides/OFFLINE_UX.md index ac40a4346350..a678a0b5b042 100644 --- a/contributingGuides/OFFLINE_UX.md +++ b/contributingGuides/OFFLINE_UX.md @@ -145,9 +145,5 @@ If you're making a new feature, think about whether any data would need to be re 5. Can the server response be anticipated? - Answer NO if there is data coming back from the server that we can't know (example: a list of bank accounts from Plaid, input validation that the server must perform). Answer YES if we can know what the response from the server would be. -6. Is there validation done on the server that can't be done on the front end? - - If there is some validation happening on the server that needs to happen before the feature can work, then we answer YES to this question. Remember, this is referring to validation that cannot happen on the front end (e.g. reusing an existing password when resetting a password). For example, if we want to set up a bank account then our answer to this question is YES (because we can’t suggest to the user that their request succeeded when really it hasn’t been sent yet–their card wouldn’t work!) - - This question can be tricky, so if you're unsure, please ask a question in the #expensify-open-source slack room and tag @contributor-management-engineering. - -7. Does the user need to know if the action was successful? +6. Does the user need to know if the action was successful? - Think back to the pinning example from above: the user doesn’t need to know that their pinned report's NVP has been updated. To them the impact of clicking the pin button is that their chat is at the top of the LHN. It makes no difference to them if the server has been updated or not, so the answer would be NO. Now let’s consider sending a payment request to another user. In this example, the user needs to know if their request was actually sent, so our answer is YES. diff --git a/contributingGuides/OfflineUX_Patterns_Flowchart.png b/contributingGuides/OfflineUX_Patterns_Flowchart.png index 037f5cecd740..813474dedb0d 100644 Binary files a/contributingGuides/OfflineUX_Patterns_Flowchart.png and b/contributingGuides/OfflineUX_Patterns_Flowchart.png differ diff --git a/desktop/ELECTRON_EVENTS.js b/desktop/ELECTRON_EVENTS.js index b12910b79e99..34643447d7da 100644 --- a/desktop/ELECTRON_EVENTS.js +++ b/desktop/ELECTRON_EVENTS.js @@ -5,6 +5,8 @@ const ELECTRON_EVENTS = { SHOW_KEYBOARD_SHORTCUTS_MODAL: 'show-keyboard-shortcuts-modal', START_UPDATE: 'start-update', UPDATE_DOWNLOADED: 'update-downloaded', + FOCUS: 'focus', + BLUR: 'blur', }; module.exports = ELECTRON_EVENTS; diff --git a/desktop/contextBridge.js b/desktop/contextBridge.js index d95f5244b1f9..c49aa5d9e9fb 100644 --- a/desktop/contextBridge.js +++ b/desktop/contextBridge.js @@ -15,8 +15,12 @@ const WHITELIST_CHANNELS_RENDERER_TO_MAIN = [ const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ ELECTRON_EVENTS.SHOW_KEYBOARD_SHORTCUTS_MODAL, ELECTRON_EVENTS.UPDATE_DOWNLOADED, + ELECTRON_EVENTS.FOCUS, + ELECTRON_EVENTS.BLUR, ]; +const getErrorMessage = channel => `Electron context bridge cannot be used with channel '${channel}'`; + /** * The following methods will be available in the renderer process under `window.electron`. */ @@ -33,7 +37,7 @@ contextBridge.exposeInMainWorld('electron', { */ send: (channel, data) => { if (!_.contains(WHITELIST_CHANNELS_RENDERER_TO_MAIN, channel)) { - throw new Error(`Attempt to send data across electron context bridge with invalid channel ${channel}`); + throw new Error(getErrorMessage(channel)); } ipcRenderer.send(channel, data); @@ -48,7 +52,7 @@ contextBridge.exposeInMainWorld('electron', { */ sendSync: (channel, data) => { if (!_.contains(WHITELIST_CHANNELS_RENDERER_TO_MAIN, channel)) { - throw new Error(`Attempt to send data across electron context bridge with invalid channel ${channel}`); + throw new Error(getErrorMessage(channel)); } return ipcRenderer.sendSync(channel, data); @@ -62,10 +66,24 @@ contextBridge.exposeInMainWorld('electron', { */ on: (channel, func) => { if (!_.contains(WHITELIST_CHANNELS_MAIN_TO_RENDERER, channel)) { - throw new Error(`Attempt to send data across electron context bridge with invalid channel ${channel}`); + throw new Error(getErrorMessage(channel)); } // Deliberately strip event as it includes `sender` ipcRenderer.on(channel, (event, ...args) => func(...args)); }, + + /** + * Remove listeners for a single channel from the main process and sent to the renderer process. + * + * @param {String} channel + * @param {Function} func + */ + removeAllListeners: (channel) => { + if (!_.contains(WHITELIST_CHANNELS_MAIN_TO_RENDERER, channel)) { + throw new Error(getErrorMessage(channel)); + } + + ipcRenderer.removeAllListeners(channel); + }, }); diff --git a/desktop/main.js b/desktop/main.js index f7bd55411f4d..5336b472e0c3 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -278,6 +278,13 @@ const mainWindow = (() => { } }); + browserWindow.on(ELECTRON_EVENTS.FOCUS, () => { + browserWindow.webContents.send(ELECTRON_EVENTS.FOCUS); + }); + browserWindow.on(ELECTRON_EVENTS.BLUR, () => { + browserWindow.webContents.send(ELECTRON_EVENTS.BLUR); + }); + app.on('before-quit', () => quitting = true); app.on('activate', () => { if (expectedUpdateVersion && app.getVersion() !== expectedUpdateVersion) { diff --git a/docs/_includes/floating-concierge-button.html b/docs/_includes/floating-concierge-button.html index 05fc44c9fca6..ed183058388f 100644 --- a/docs/_includes/floating-concierge-button.html +++ b/docs/_includes/floating-concierge-button.html @@ -1,3 +1,5 @@ +{% include CONST.html %} + Chat with concierge diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 9b1a7c647a2d..b062f7b5ca71 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -1,8 +1,6 @@ -{% include CONST.html %} - Expensify Help diff --git a/e2e/.env.e2e b/e2e/.env.e2e new file mode 100644 index 000000000000..7c2afd5a820b --- /dev/null +++ b/e2e/.env.e2e @@ -0,0 +1,2 @@ +E2E_TESTING=true +CAPTURE_METRICS=true diff --git a/e2e/ADDING_TESTS.md b/e2e/ADDING_TESTS.md new file mode 100644 index 000000000000..43deeaf2f0b1 --- /dev/null +++ b/e2e/ADDING_TESTS.md @@ -0,0 +1,96 @@ +# Add E2E Tests + +Tests are executed on device, inside the app code. + +The tests are located in `src/libs/E2E/tests`. + +You have to register your test in the `e2e/config`, see the following diff: + +### e2e/config.js + +First, set the name for you test. + +```diff + const OUTPUT_DIR = 'e2e/.results'; + // add your test name here … + const TEST_NAMES = { + AppStartTime: 'App start time', ++ AnotherTest: 'Another test', + }; +``` + +Then you provide a configuration for you test. At least, you need to +set a name property for the config. You can, however, add any other values +that you might need to pass to the test running inside the app: + +```diff + module.exports = { + // … + TESTS_CONFIG: { ++ [TEST_NAMES.AnotherTest]: { ++ name: TEST_NAMES.AnotherTest, ++ ++ // ... any additional config you might need ++ }, + }, +``` + +### Create the actual test + +We created a new test file in `src/libs/E2E/tests/`. Typically, the +tests ends on `.e2e.js`, so we can distinguish it from the other tests. + +Inside this test, we write logic that gets executed in the app. You can basically do +anything here, like connecting to onyx, calling APIs, navigating. + +There are some common actions that are common among different test cases: + +- `src/libs/E2E/actions/e2eLogin.js` - Log a user into the app. + +The test will be called once the app is ready, which mean you can immediately start. +Your test is expected to default export its test function. + +An example test, which test the time it takes to navigate to a screen might looks like this: + +```js +// new file in src/libs/E2E/tests/anotherTest.e2e.js + +import Navigation from "src/libs/Navigation/Navigation"; +import Performance from "src/libs/Performance"; +import E2EClient from "./client.js"; + +const test = () => { + const firstReportIDInList = // ... some logic to get a report + + performance.markStart("navigateToReport"); + Navigation.navigate(ROUTES.getReportRoute(firstReportIDInList)); + + // markEnd will be called in the Screen's implementation + performance.subscribeToMeasurements("navigateToReport", (measurement) => { + // ... do something with the measurements + E2EClient.submitTestResults({ + name: "Navigate to report", + duration: measurement.duration, + }).then(E2EClient.submitTestDone) + }); + +}; + +export default test; +``` + +### Last step: register the test in the e2e react native entry + +In `src/lib/E2E/reactNativeLaunchingTest.js` you have to add your newly created +test file: + +```diff + // import your test here, define its name and config first in e2e/config.js + const tests = { + [E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default, ++ [E2EConfig.TEST_NAMES.AnotherTest]: require('./tests/anotherTest.e2e').default, + }; +``` + +Done! When you now start the test runner, your new test will be executed as well. + diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000000..72cc4fead9a1 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,121 @@ +# E2E performance regression tests + +This directory contains the scripts and configuration files for running the +performance regression tests. These tests are called E2E tests because they +run the app on a real device (physical or emulated). + +![Example of a e2e test run](https://raw.githubusercontent.com/hannojg/expensify-app/5f945c25e2a0650753f47f3f541b984f4d114f6d/e2e/example.gif) + +To run the e2e tests: + +1. Connect an android device. The tests are currently designed to run only on android. It can be + a physical device or an emulator. + +2. Make sure Fastlane was initialized by running `bundle install` + +3. Run the tests with `npm run test:e2e`. + +Ideally you want to run these tests on your branch before you want to merge your new feature to `main`. + +## Performance regression testing + +The output of the tests is a set of performance metrics (see video above). +The test will tell you if the performance significantly worsened for any test case. + +For this to work you need a baseline you test against. The baseline is set by default +to the `main` branch. + +The test suite will run each test-case twice, once on the baseline, and then on the branch +you are currently on. + +It will run the tests of a test case multiple time to average out the results. + +## Adding tests + +To add a test checkout the [designed guide](./ADDING_TESTS.md). + +## Structure + +For the test suite, no additional tooling was used. It is made of the following +components: + +- The tests themselves : + - The tests are located in `src/libs/E2E/tests` + - As opposed to other test frameworks, the tests are _inside the app_, and execute logic using app code (e.g. `navigationRef.navigate('Signin')`) + - For the tests there is a custom entry for react native, located in `src/libs/E2E/reactNativeLaunchingTest.js` + +- The test runner: + - Orchestrates the test suite. + - Runs the app with the tests on a device + - Responsible for gathering and comparing results + - Located in `e2e/testRunner.js`. + +- Test server: + - A nodeJS application that starts an HTTP server. + - Receives test results from the app. + - Located in `e2e/server`. + +- Client: + - Client-side code (app) for communication with the test server. + - Located in `src/libs/E2E/client.js`. + + +## How a test gets executed + +There exists a custom android entry point for the app, which is used for the e2e tests. +The entry file used is `src/libs/E2E/reactNativeEntry.js`, and here we can add our test case. + +The test case should only execute its test once. The _test runner_ is responsible for running the +test multiple time to average out the results. + +Any results of the test (which is usually a duration, like "Time it took to reopen chat", or "TTI") should be +submitted to the test server using the client: + +```js +import E2EClient from './client'; + +// ... run you test logic +const someDurationWeCollected = // ... + +E2EClient.submitTestResults({ + name: 'My test name', + duration: someDurationWeCollected, +}); +``` + +Submitting test results doesn't automatically finish the test. This enables you do submit multiple test results +from one test (e.g. measuring multiple things at the same time). + +To finish a test call `E2EClient.submitTestDone()`. + + +## Android specifics + +The tests are designed to run on android (although adding support for iOS should be easy to add). +To test under realistic conditions during the tests a release build is used. + +However, to be able to call our local HTTP test server, we need to allow +[cleartext http traffic](https://developer.android.com/training/articles/security-config#CleartextTrafficPermitted). +Therefore, a customized release build type is needed, which is called `e2eRelease`. This build type has clear +text traffic enabled but works otherwise just like a release build. + +In addition to that, another entry file will be used (instead of `index.js`). The entry file used is +`src/libs/E2E/reactNativeEntry.js`. By using a custom entry file we avoid bundling any e2e testing code +into the actual release app. + +For the app to detect that it is currently running e2e tests, an environment variable called `E2E_TESTING=true` must +be set. There is a custom environment file in `e2e/.env.e2e` that contains the env setup needed. The build automatically +picks this file for configuration. + +It can be useful to debug the app while running the e2e tests (to catch errors durign development of a test). +You can simply add the `debuggable true` property to the `e2eRelease` buildType config in `android/app/build.gradle`. +Then rebuild the app. You can now monitor the app's logs using `logcat` (`adb logcat | grep "ReactNativeJS"`). + +## Test the accuracy of the test suite + +If you run the tests on the same branch, the result should ideally be a difference of 0%. However, as the tests +get executed on an emulator, there will be some variance. This variance is mitigated by using statistical tools +such as [z-test](https://en.wikipedia.org/wiki/Z-test). However, when running on the same branch, the results +should be below 5% of change. +You might want to tweak the values in `e2e/config.js` to adjust those values. + diff --git a/e2e/compare/compare.js b/e2e/compare/compare.js new file mode 100644 index 000000000000..2cb29c572ece --- /dev/null +++ b/e2e/compare/compare.js @@ -0,0 +1,156 @@ +const fs = require('fs/promises'); +const fsSync = require('fs'); +const _ = require('underscore'); +const {OUTPUT_DIR} = require('../config'); +const { + computeProbability, + computeZ, +} = require('./math'); +const printToConsole = require('./output/console'); +const writeToMarkdown = require('./output/markdown'); + +/* + * base implementation from: https://github.com/callstack/reassure/blob/main/packages/reassure-compare/src/compare.ts + * This module reads from the baseline and compare files and compares the results. + * It has a few different output formats: + * - console: prints the results to the console + * - markdown: Writes the results in markdown format to a file + */ + +/** + * Probability threshold for considering given difference significant. + */ +const PROBABILITY_CONSIDERED_SIGNIFICANCE = 0.02; + +/** + * Duration threshold (in ms) for treating given difference as significant. + * + * This is additional filter, in addition to probability threshold above. + * Too small duration difference might be result of measurement grain of 1 ms. + */ +const DURATION_DIFF_THRESHOLD_SIGNIFICANCE = 50; + +const loadFile = path => fs.readFile(path, 'utf8') + .then((data) => { + const entries = JSON.parse(data); + + const result = {}; + entries.forEach((entry) => { + result[entry.name] = entry; + }); + + return result; + }); + +/** + * + * @param {string} name + * @param {Object} compare + * @param {Object} baseline + * @returns {Object} + */ +function buildCompareEntry(name, compare, baseline) { + const diff = compare.mean - baseline.mean; + const relativeDurationDiff = diff / baseline.mean; + + const z = computeZ(baseline.mean, baseline.stdev, compare.mean, compare.runs); + const prob = computeProbability(z); + + const isDurationDiffOfSignificance = prob < PROBABILITY_CONSIDERED_SIGNIFICANCE && Math.abs(diff) >= DURATION_DIFF_THRESHOLD_SIGNIFICANCE; + + return { + name, + baseline, + current: compare, + diff, + relativeDurationDiff, + isDurationDiffOfSignificance, + }; +} + +/** + * Compare results between baseline and current entries and categorize. + * + * @param {Object} compareEntries + * @param {Object} baselineEntries + * @returns {Object} + */ +function compareResults(compareEntries, baselineEntries) { + // Unique test scenario names + const names = [...new Set([..._.keys(compareEntries), ..._.keys(baselineEntries || {})])]; + + const compared = []; + const added = []; + const removed = []; + + names.forEach((name) => { + const current = compareEntries[name]; + const baseline = baselineEntries[name]; + + if (baseline && current) { + compared.push(buildCompareEntry(name, current, baseline)); + } else if (current) { + added.push({ + name, + current, + }); + } else if (baseline) { + removed.push({ + name, + baseline, + }); + } + }); + + const significance = _.chain(compared) + .filter(item => item.isDurationDiffOfSignificance) + .sort((a, b) => b.diff - a.diff) + .value(); + const meaningless = _.chain(compared) + .filter(item => !item.isDurationDiffOfSignificance) + .sort((a, b) => b.diff - a.diff) + .value(); + + added.sort((a, b) => b.current.mean - a.current.mean); + removed.sort((a, b) => b.baseline.mean - a.baseline.mean); + + return { + significance, + meaningless, + added, + removed, + }; +} + +module.exports = ( + baselineFile = `${OUTPUT_DIR}/baseline.json`, + compareFile = `${OUTPUT_DIR}/compare.json`, + outputFormat = 'all', +) => { + const hasBaselineFile = fsSync.existsSync(baselineFile); + if (!hasBaselineFile) { + throw new Error( + `Baseline results files "${baselineFile}" does not exists.`, + ); + } + return loadFile(baselineFile) + .then((baseline) => { + const hasCompareFile = fsSync.existsSync(compareFile); + if (!hasCompareFile) { + throw new Error( + `Compare results files "${compareFile}" does not exists.`, + ); + } + return loadFile(compareFile) + .then((compare) => { + const outputData = compareResults(compare, baseline); + + if (outputFormat === 'console' || outputFormat === 'all') { + printToConsole(outputData); + } + if (outputFormat === 'markdown' || outputFormat === 'all') { + return writeToMarkdown(`${OUTPUT_DIR}/output.md`, outputData); + } + }); + }); +}; diff --git a/e2e/compare/math.js b/e2e/compare/math.js new file mode 100644 index 000000000000..56a911846092 --- /dev/null +++ b/e2e/compare/math.js @@ -0,0 +1,71 @@ +/* + * Base implementation from: https://github.com/callstack/reassure/blob/main/packages/reassure-compare/src/compare.ts + */ + +/** + * Calculate z-score for given baseline and current performance results. + * + * Based on :: https://github.com/v8/v8/blob/master/test/benchmarks/csuite/compare-baseline.py + * + * @param {Number} baselineMean + * @param {Number} baselineStdev + * @param {Number} currentMean + * @param {Number} runs + * @returns {Number} + */ +const computeZ = (baselineMean, baselineStdev, currentMean, runs) => { + if (baselineStdev === 0) { return 1000; } + + return Math.abs((currentMean - baselineMean) / (baselineStdev / Math.sqrt(runs))); +}; + +/** + * Compute statistical hypothesis probability based on z-score. + * + * Based on :: https://github.com/v8/v8/blob/master/test/benchmarks/csuite/compare-baseline.py + * + * @param {Number} z + * @returns {Number} + */ +const computeProbability = (z) => { + // p 0.005: two sided < 0.01 + if (z > 2.575_829) { return 0; } + + // p 0.010 + if (z > 2.326_348) { return 0.01; } + + // p 0.015 + if (z > 2.170_091) { return 0.02; } + + // p 0.020 + if (z > 2.053_749) { return 0.03; } + + // p 0.025: two sided < 0.05 + if (z > 1.959_964) { return 0.04; } + + // p 0.030 + if (z > 1.880_793) { return 0.05; } + + // p 0.035 + if (z > 1.811_91) { return 0.06; } + + // p 0.040 + if (z > 1.750_686) { return 0.07; } + + // p 0.045 + if (z > 1.695_397) { return 0.08; } + + // p 0.050: two sided < 0.10 + if (z > 1.644_853) { return 0.09; } + + // p 0.100: two sided < 0.20 + if (z > 1.281_551) { return 0.1; } + + // two sided p >= 0.20 + return 0.2; +}; + +module.exports = { + computeZ, + computeProbability, +}; diff --git a/e2e/compare/output/console.js b/e2e/compare/output/console.js new file mode 100644 index 000000000000..cfcd7019bbdd --- /dev/null +++ b/e2e/compare/output/console.js @@ -0,0 +1,23 @@ +const {formatDurationDiffChange} = require('./format'); + +const printRegularLine = (entry) => { + console.debug(` - ${entry.name}: ${formatDurationDiffChange(entry)}`); +}; + +/** + * Prints the result simply to console. + * @param {Object} data + */ +module.exports = (data) => { + // No need to log errors or warnings as these were be logged on the fly + console.debug(''); + console.debug('❇️ Performance comparison results:'); + + console.debug('\n➡️ Significant changes to duration'); + data.significance.forEach(printRegularLine); + + console.debug('\n➡️ Meaningless changes to duration'); + data.meaningless.forEach(printRegularLine); + + console.debug(''); +}; diff --git a/e2e/compare/output/format.js b/e2e/compare/output/format.js new file mode 100644 index 000000000000..e4668b38fa52 --- /dev/null +++ b/e2e/compare/output/format.js @@ -0,0 +1,75 @@ +/** + * Utility for formatting text for result outputs. + * from: https://github.com/callstack/reassure/blob/main/packages/reassure-compare/src/utils/format.ts + */ + +const formatPercent = (value) => { + const valueAsPercent = value * 100; + return `${valueAsPercent.toFixed(1)}%`; +}; + +const formatPercentChange = (value) => { + const absValue = Math.abs(value); + + // Round to zero + if (absValue < 0.005) { return '±0.0%'; } + + return `${value >= 0 ? '+' : '-'}${formatPercent(absValue)}`; +}; + +const formatDuration = duration => `${duration.toFixed(1)} ms`; + +const formatDurationChange = (value) => { + if (value > 0) { + return `+${formatDuration(value)}`; + } + if (value < 0) { + return `${formatDuration(value)}`; + } + return '0 ms'; +}; + +const formatChange = (value) => { + if (value > 0) { return `+${value}`; } + if (value < 0) { return `${value}`; } + return '0'; +}; + +const getDurationSymbols = (entry) => { + if (!entry.isDurationDiffOfSignificance) { + if (entry.relativeDurationDiff > 0.15) { return '🔴'; } + if (entry.relativeDurationDiff < -0.15) { return '🟢'; } + return ''; + } + + if (entry.relativeDurationDiff > 0.33) { return '🔴🔴'; } + if (entry.relativeDurationDiff > 0.05) { return '🔴'; } + if (entry.relativeDurationDiff < -0.33) { return '🟢🟢'; } + if (entry.relativeDurationDiff < -0.05) { return ' 🟢'; } + + return ''; +}; + +const formatDurationDiffChange = (entry) => { + const {baseline, current} = entry; + + let output = `${formatDuration(baseline.mean)} → ${formatDuration(current.mean)}`; + + if (baseline.mean !== current.mean) { + output += ` (${formatDurationChange(entry.diff)}, ${formatPercentChange(entry.relativeDurationDiff)})`; + } + + output += ` ${getDurationSymbols(entry)}`; + + return output; +}; + +module.exports = { + formatPercent, + formatPercentChange, + formatDuration, + formatDurationChange, + formatChange, + getDurationSymbols, + formatDurationDiffChange, +}; diff --git a/e2e/compare/output/markdown.js b/e2e/compare/output/markdown.js new file mode 100644 index 000000000000..5f685550112d --- /dev/null +++ b/e2e/compare/output/markdown.js @@ -0,0 +1,105 @@ +// From: https://raw.githubusercontent.com/callstack/reassure/main/packages/reassure-compare/src/output/markdown.ts + +const fs = require('fs/promises'); +const path = require('path'); +const _ = require('underscore'); +const markdownTable = require('./markdownTable'); +const { + formatDuration, + formatPercent, + formatDurationDiffChange, +} = require('./format'); +const Logger = require('../../utils/logger'); + +const tableHeader = ['Name', 'Duration']; + +const collapsibleSection = (title, content) => `
\n${title}\n\n${content}\n
\n\n`; + +const buildDurationDetails = (title, entry) => { + const relativeStdev = entry.stdev / entry.mean; + + return _.filter([ + `**${title}**`, + `Mean: ${formatDuration(entry.mean)}`, + `Stdev: ${formatDuration(entry.stdev)} (${formatPercent(relativeStdev)})`, + entry.entries ? `Runs: ${entry.entries.join(' ')}` : '', + ], Boolean).join('
'); +}; + +const buildDurationDetailsEntry = entry => _.filter([ + 'baseline' in entry ? buildDurationDetails('Baseline', entry.baseline) : '', + 'current' in entry ? buildDurationDetails('Current', entry.current) : '', +], Boolean).join('

'); + +const formatEntryDuration = (entry) => { + if ('baseline' in entry && 'current' in entry) { return formatDurationDiffChange(entry); } + if ('baseline' in entry) { return formatDuration(entry.baseline.mean); } + if ('current' in entry) { return formatDuration(entry.current.mean); } + return ''; +}; + +const buildDetailsTable = (entries) => { + if (!entries.length) { return ''; } + + const rows = _.map(entries, entry => [entry.name, buildDurationDetailsEntry(entry)]); + const content = markdownTable([tableHeader, ...rows]); + + return collapsibleSection('Show details', content); +}; + +const buildSummaryTable = (entries, collapse = false) => { + if (!entries.length) { return '_There are no entries_'; } + + const rows = _.map(entries, entry => [entry.name, formatEntryDuration(entry)]); + const content = markdownTable([tableHeader, ...rows]); + + return collapse ? collapsibleSection('Show entries', content) : content; +}; + +const buildMarkdown = (data) => { + let result = '# Performance Comparison Report'; + + if (data.errors && data.errors.length) { + result += '\n\n### Errors\n'; + data.errors.forEach((message) => { + result += ` 1. 🛑 ${message}\n`; + }); + } + + if (data.warnings && data.warnings.length) { + result += '\n\n### Warnings\n'; + data.warnings.forEach((message) => { + result += ` 1. 🟡 ${message}\n`; + }); + } + + result += '\n\n### Significant Changes To Duration'; + result += `\n${buildSummaryTable(data.significance)}`; + result += `\n${buildDetailsTable(data.significance)}`; + result += '\n\n### Meaningless Changes To Duration'; + result += `\n${buildSummaryTable(data.meaningless, true)}`; + result += `\n${buildDetailsTable(data.meaningless)}`; + result += '\n'; + + return result; +}; + +const writeToFile = (filePath, content) => fs.writeFile(filePath, content).then(() => { + Logger.info(`✅ Written output markdown output file ${filePath}`); + Logger.info(`🔗 ${path.resolve(filePath)}\n`); +}).catch((error) => { + Logger.info(`❌ Could not write markdown output file ${filePath}`); + Logger.info(`🔗 ${path.resolve(filePath)}`); + console.error(error); + throw error; +}); + +const writeToMarkdown = (filePath, data) => { + const markdown = buildMarkdown(data); + return writeToFile(filePath, markdown).catch((error) => { + console.error(error); + throw error; + }); +}; + +module.exports = writeToMarkdown; diff --git a/e2e/compare/output/markdownTable.js b/e2e/compare/output/markdownTable.js new file mode 100644 index 000000000000..c6b8fd681184 --- /dev/null +++ b/e2e/compare/output/markdownTable.js @@ -0,0 +1,385 @@ +/* eslint-disable */ +// copied from https://raw.githubusercontent.com/wooorm/markdown-table/main/index.js, turned into cmjs + +/** + * @typedef Options + * Configuration (optional). + * @property {string|null|Array} [align] + * One style for all columns, or styles for their respective columns. + * Each style is either `'l'` (left), `'r'` (right), or `'c'` (center). + * Other values are treated as `''`, which doesn’t place the colon in the + * alignment row but does align left. + * *Only the lowercased first character is used, so `Right` is fine.* + * @property {boolean} [padding=true] + * Whether to add a space of padding between delimiters and cells. + * + * When `true`, there is padding: + * + * ```markdown + * | Alpha | B | + * | ----- | ----- | + * | C | Delta | + * ``` + * + * When `false`, there is no padding: + * + * ```markdown + * |Alpha|B | + * |-----|-----| + * |C |Delta| + * ``` + * @property {boolean} [delimiterStart=true] + * Whether to begin each row with the delimiter. + * + * > 👉 **Note**: please don’t use this: it could create fragile structures + * > that aren’t understandable to some markdown parsers. + * + * When `true`, there are starting delimiters: + * + * ```markdown + * | Alpha | B | + * | ----- | ----- | + * | C | Delta | + * ``` + * + * When `false`, there are no starting delimiters: + * + * ```markdown + * Alpha | B | + * ----- | ----- | + * C | Delta | + * ``` + * @property {boolean} [delimiterEnd=true] + * Whether to end each row with the delimiter. + * + * > 👉 **Note**: please don’t use this: it could create fragile structures + * > that aren’t understandable to some markdown parsers. + * + * When `true`, there are ending delimiters: + * + * ```markdown + * | Alpha | B | + * | ----- | ----- | + * | C | Delta | + * ``` + * + * When `false`, there are no ending delimiters: + * + * ```markdown + * | Alpha | B + * | ----- | ----- + * | C | Delta + * ``` + * @property {boolean} [alignDelimiters=true] + * Whether to align the delimiters. + * By default, they are aligned: + * + * ```markdown + * | Alpha | B | + * | ----- | ----- | + * | C | Delta | + * ``` + * + * Pass `false` to make them staggered: + * + * ```markdown + * | Alpha | B | + * | - | - | + * | C | Delta | + * ``` + * @property {(value: string) => number} [stringLength] + * Function to detect the length of table cell content. + * This is used when aligning the delimiters (`|`) between table cells. + * Full-width characters and emoji mess up delimiter alignment when viewing + * the markdown source. + * To fix this, you can pass this function, which receives the cell content + * and returns its “visible” size. + * Note that what is and isn’t visible depends on where the text is displayed. + * + * Without such a function, the following: + * + * ```js + * markdownTable([ + * ['Alpha', 'Bravo'], + * ['中文', 'Charlie'], + * ['👩‍❤️‍👩', 'Delta'] + * ]) + * ``` + * + * Yields: + * + * ```markdown + * | Alpha | Bravo | + * | - | - | + * | 中文 | Charlie | + * | 👩‍❤️‍👩 | Delta | + * ``` + * + * With [`string-width`](https://github.com/sindresorhus/string-width): + * + * ```js + * import stringWidth from 'string-width' + * + * markdownTable( + * [ + * ['Alpha', 'Bravo'], + * ['中文', 'Charlie'], + * ['👩‍❤️‍👩', 'Delta'] + * ], + * {stringLength: stringWidth} + * ) + * ``` + * + * Yields: + * + * ```markdown + * | Alpha | Bravo | + * | ----- | ------- | + * | 中文 | Charlie | + * | 👩‍❤️‍👩 | Delta | + * ``` + */ + +/** + * @typedef {Options} MarkdownTableOptions + * @todo + * Remove next major. + */ + +/** + * Generate a markdown ([GFM](https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables)) table.. + * + * @param {Array>} table + * Table data (matrix of strings). + * @param {Options} [options] + * Configuration (optional). + * @returns {string} + */ +function markdownTable(table, options = {}) { + const align = (options.align || []).concat() + const stringLength = options.stringLength || defaultStringLength + /** @type {Array} Character codes as symbols for alignment per column. */ + const alignments = [] + /** @type {Array>} Cells per row. */ + const cellMatrix = [] + /** @type {Array>} Sizes of each cell per row. */ + const sizeMatrix = [] + /** @type {Array} */ + const longestCellByColumn = [] + let mostCellsPerRow = 0 + let rowIndex = -1 + + // This is a superfluous loop if we don’t align delimiters, but otherwise we’d + // do superfluous work when aligning, so optimize for aligning. + while (++rowIndex < table.length) { + /** @type {Array} */ + const row = [] + /** @type {Array} */ + const sizes = [] + let columnIndex = -1 + + if (table[rowIndex].length > mostCellsPerRow) { + mostCellsPerRow = table[rowIndex].length + } + + while (++columnIndex < table[rowIndex].length) { + const cell = serialize(table[rowIndex][columnIndex]) + + if (options.alignDelimiters !== false) { + const size = stringLength(cell) + sizes[columnIndex] = size + + if ( + longestCellByColumn[columnIndex] === undefined || + size > longestCellByColumn[columnIndex] + ) { + longestCellByColumn[columnIndex] = size + } + } + + row.push(cell) + } + + cellMatrix[rowIndex] = row + sizeMatrix[rowIndex] = sizes + } + + // Figure out which alignments to use. + let columnIndex = -1 + + if (typeof align === 'object' && 'length' in align) { + while (++columnIndex < mostCellsPerRow) { + alignments[columnIndex] = toAlignment(align[columnIndex]) + } + } else { + const code = toAlignment(align) + + while (++columnIndex < mostCellsPerRow) { + alignments[columnIndex] = code + } + } + + // Inject the alignment row. + columnIndex = -1 + /** @type {Array} */ + const row = [] + /** @type {Array} */ + const sizes = [] + + while (++columnIndex < mostCellsPerRow) { + const code = alignments[columnIndex] + let before = '' + let after = '' + + if (code === 99 /* `c` */) { + before = ':' + after = ':' + } else if (code === 108 /* `l` */) { + before = ':' + } else if (code === 114 /* `r` */) { + after = ':' + } + + // There *must* be at least one hyphen-minus in each alignment cell. + let size = + options.alignDelimiters === false + ? 1 + : Math.max( + 1, + longestCellByColumn[columnIndex] - before.length - after.length + ) + + const cell = before + '-'.repeat(size) + after + + if (options.alignDelimiters !== false) { + size = before.length + size + after.length + + if (size > longestCellByColumn[columnIndex]) { + longestCellByColumn[columnIndex] = size + } + + sizes[columnIndex] = size + } + + row[columnIndex] = cell + } + + // Inject the alignment row. + cellMatrix.splice(1, 0, row) + sizeMatrix.splice(1, 0, sizes) + + rowIndex = -1 + /** @type {Array} */ + const lines = [] + + while (++rowIndex < cellMatrix.length) { + const row = cellMatrix[rowIndex] + const sizes = sizeMatrix[rowIndex] + columnIndex = -1 + /** @type {Array} */ + const line = [] + + while (++columnIndex < mostCellsPerRow) { + const cell = row[columnIndex] || '' + let before = '' + let after = '' + + if (options.alignDelimiters !== false) { + const size = + longestCellByColumn[columnIndex] - (sizes[columnIndex] || 0) + const code = alignments[columnIndex] + + if (code === 114 /* `r` */) { + before = ' '.repeat(size) + } else if (code === 99 /* `c` */) { + if (size % 2) { + before = ' '.repeat(size / 2 + 0.5) + after = ' '.repeat(size / 2 - 0.5) + } else { + before = ' '.repeat(size / 2) + after = before + } + } else { + after = ' '.repeat(size) + } + } + + if (options.delimiterStart !== false && !columnIndex) { + line.push('|') + } + + if ( + options.padding !== false && + // Don’t add the opening space if we’re not aligning and the cell is + // empty: there will be a closing space. + !(options.alignDelimiters === false && cell === '') && + (options.delimiterStart !== false || columnIndex) + ) { + line.push(' ') + } + + if (options.alignDelimiters !== false) { + line.push(before) + } + + line.push(cell) + + if (options.alignDelimiters !== false) { + line.push(after) + } + + if (options.padding !== false) { + line.push(' ') + } + + if ( + options.delimiterEnd !== false || + columnIndex !== mostCellsPerRow - 1 + ) { + line.push('|') + } + } + + lines.push( + options.delimiterEnd === false + ? line.join('').replace(/ +$/, '') + : line.join('') + ) + } + + return lines.join('\n') +} + +/** + * @param {string|null|undefined} [value] + * @returns {string} + */ +function serialize(value) { + return value === null || value === undefined ? '' : String(value) +} + +/** + * @param {string} value + * @returns {number} + */ +function defaultStringLength(value) { + return value.length +} + +/** + * @param {string|null|undefined} value + * @returns {number} + */ +function toAlignment(value) { + const code = typeof value === 'string' ? value.codePointAt(0) : 0 + + return code === 67 /* `C` */ || code === 99 /* `c` */ + ? 99 /* `c` */ + : code === 76 /* `L` */ || code === 108 /* `l` */ + ? 108 /* `l` */ + : code === 82 /* `R` */ || code === 114 /* `r` */ + ? 114 /* `r` */ + : 0 +} + +module.exports = markdownTable; diff --git a/e2e/config.js b/e2e/config.js new file mode 100644 index 000000000000..6e8b8793dcd3 --- /dev/null +++ b/e2e/config.js @@ -0,0 +1,56 @@ +const OUTPUT_DIR = 'e2e/.results'; + +/** + * @typedef TestConfig + * @property {string} name + */ + +// add your test name here … +const TEST_NAMES = { + AppStartTime: 'App start time', +}; + +module.exports = { + APP_PACKAGE: 'com.expensify.chat', + + // The port of the testing server that communicates with the app + SERVER_PORT: 3000, + + // The amount of times a test should be executed for average performance metrics + RUNS: 30, + + DEFAULT_BASELINE_BRANCH: 'main', + + // The amount of outliers to remove from a dataset before calculating the average + DROP_WORST: 8, + + // The amount of runs that should happen without counting test results + WARM_UP_RUNS: 3, + + OUTPUT_DIR, + + // The file to write intermediate results to + OUTPUT_FILE_CURRENT: `${OUTPUT_DIR}/current.json`, + + // The file we write logs to + LOG_FILE: `${OUTPUT_DIR}/debug.log`, + + // The time in milliseconds after which an operation fails due to timeout + INTERACTION_TIMEOUT: 30_000, + + TEST_NAMES, + + /** + * Add your test configurations here. At least, + * you need to add a name for your test. + * + * @type {Object.} + */ + TESTS_CONFIG: { + [TEST_NAMES.AppStartTime]: { + name: TEST_NAMES.AppStartTime, + + // ... any additional config you might need + }, + }, +}; diff --git a/e2e/measure/math.js b/e2e/measure/math.js new file mode 100644 index 000000000000..b6acc763255e --- /dev/null +++ b/e2e/measure/math.js @@ -0,0 +1,35 @@ +const _ = require('underscore'); +const {DROP_WORST} = require('../config'); + +// Simple outlier removal, where we remove at the head and tail entries +const filterOutliers = (data) => { + // Copy the values, rather than operating on references to existing values + const values = [...data].sort(); + const removePerSide = Math.ceil(DROP_WORST / 2); + values.splice(0, removePerSide); + values.splice(values.length - removePerSide); + return values; +}; +const mean = arr => _.reduce(arr, (a, b) => a + b, 0) / arr.length; + +const std = (arr) => { + const avg = mean(arr); + return Math.sqrt(_.reduce(_.map(arr, i => (i - avg) ** 2), (a, b) => a + b) / arr.length); +}; + +const getStats = (entries) => { + const cleanedEntries = filterOutliers(entries); + const meanDuration = mean(cleanedEntries); + const stdevDuration = std(cleanedEntries); + + return { + mean: meanDuration, + stdev: stdevDuration, + runs: cleanedEntries.length, + entries: cleanedEntries, + }; +}; + +module.exports = { + getStats, +}; diff --git a/e2e/measure/writeTestStats.js b/e2e/measure/writeTestStats.js new file mode 100644 index 000000000000..c4074dbdd08f --- /dev/null +++ b/e2e/measure/writeTestStats.js @@ -0,0 +1,33 @@ +const fs = require('fs'); + +const {OUTPUT_FILE_CURRENT} = require('../config'); + +/** + * Writes the results of `getStats` to the {@link OUTPUT_FILE_CURRENT} file. + * + * @param {Object} stats + * @param {string} stats.name - The name for the test, used in outputs. + * @param {number} stats.mean - The average time for the test to run. + * @param {number} stats.stdev - The standard deviation of the test. + * @param {number} stats.entries - The data points + * @param {number} stats.runs - The number of times the test was run. + * @param {string} [path] - The path to write to. Defaults to {@link OUTPUT_FILE_CURRENT}. + */ +module.exports = (stats, path = OUTPUT_FILE_CURRENT) => { + if (!stats.name || stats.mean == null || stats.stdev == null || !stats.entries || !stats.runs) { + throw new Error(`Invalid stats object:\n${JSON.stringify(stats, null, 2)}\n\n`); + } + + if (!fs.existsSync(path)) { + fs.writeFileSync(path, '[]'); + } + + try { + const content = JSON.parse(fs.readFileSync(path, 'utf8')); + const line = `${JSON.stringify(content.concat([stats]))}\n`; + fs.writeFileSync(path, line); + } catch (error) { + console.error(`Error writing ${path}`, error); + throw error; + } +}; diff --git a/e2e/server/index.js b/e2e/server/index.js new file mode 100644 index 000000000000..d220a95cdce2 --- /dev/null +++ b/e2e/server/index.js @@ -0,0 +1,144 @@ +const {createServer} = require('http'); +const Routes = require('./routes'); +const Logger = require('../utils/logger'); +const {SERVER_PORT} = require('../config'); + +const PORT = process.env.PORT || SERVER_PORT; + +// Gets the request data as a string +const getReqData = (req) => { + let data = ''; + req.on('data', (chunk) => { + data += chunk; + }); + + return new Promise((resolve) => { + req.on('end', () => { + resolve(data); + }); + }); +}; + +// Expects a POST request with JSON data. Returns parsed JSON data. +const getPostJSONRequestData = (req, res) => { + if (req.method !== 'POST') { + res.statusCode = 400; + res.end('Unsupported method'); + return; + } + + return getReqData(req).then((data) => { + try { + return JSON.parse(data); + } catch (e) { + Logger.info('❌ Failed to parse request data', data); + res.statusCode = 400; + res.end('Invalid JSON'); + } + }); +}; + +const createListenerState = () => { + const listeners = []; + const addListener = (listener) => { + listeners.push(listener); + return () => { + const index = listeners.indexOf(listener); + if (index !== -1) { + listeners.splice(index, 1); + } + }; + }; + + return [listeners, addListener]; +}; + +/** + * The test result object that a client might submit to the server. + * @typedef TestResult + * @property {string} name + * @property {number} duration Milliseconds + * @property {string} [error] Optional, if set indicates that the test run failed and has no valid results. + */ + +/** + * @callback listener + * @param {TestResult} testResult + */ + +// eslint-disable-next-line valid-jsdoc +/** + * Creates a new http server. + * The server just has two endpoints: + * + * - POST: /test_results, expects a {@link TestResult} as JSON body. + * Send test results while a test runs. + * - GET: /test_done, expected to be called when test run signals it's done + * + * It returns an instance to which you can add listeners for the test results, and test done events. + */ +const createServerInstance = () => { + const [testStartedListeners, addTestStartedListener] = createListenerState(); + const [testResultListeners, addTestResultListener] = createListenerState(); + const [testDoneListeners, addTestDoneListener] = createListenerState(); + + let activeTestConfig; + + /** + * @param {TestConfig} testConfig + */ + const setTestConfig = (testConfig) => { + activeTestConfig = testConfig; + }; + + const server = createServer((req, res) => { + res.statusCode = 200; + switch (req.url) { + case Routes.testConfig: { + testStartedListeners.forEach(listener => listener(activeTestConfig)); + if (activeTestConfig == null) { + throw new Error('No test config set'); + } + return res.end(JSON.stringify(activeTestConfig)); + } + + case Routes.testResults: { + getPostJSONRequestData(req, res).then((data) => { + if (data == null) { + // The getPostJSONRequestData function already handled the response + return; + } + + testResultListeners.forEach((listener) => { + listener(data); + }); + + res.end('ok'); + }); + break; + } + + case Routes.testDone: { + testDoneListeners.forEach((listener) => { + listener(); + }); + return res.end('ok'); + } + + default: + res.statusCode = 404; + res.end('Page not found!'); + } + }); + + return { + setTestConfig, + addTestStartedListener, + addTestResultListener, + addTestDoneListener, + start: () => new Promise(resolve => server.listen(PORT, resolve)), + stop: () => new Promise(resolve => server.close(resolve)), + }; +}; + +module.exports = createServerInstance; diff --git a/e2e/server/routes.js b/e2e/server/routes.js new file mode 100644 index 000000000000..5aac2fef4dc2 --- /dev/null +++ b/e2e/server/routes.js @@ -0,0 +1,10 @@ +module.exports = { + // The app calls this endpoint to know which test to run + testConfig: '/test_config', + + // When running a test the app reports the results to this endpoint + testResults: '/test_results', + + // When the app is done running a test it calls this endpoint + testDone: '/test_done', +}; diff --git a/e2e/testRunner.js b/e2e/testRunner.js new file mode 100644 index 000000000000..6884cda5831d --- /dev/null +++ b/e2e/testRunner.js @@ -0,0 +1,195 @@ +/** + * The test runner takes care of running the e2e tests. + * It will run the tests twice. Once on the branch that + * we want to base the results on (e.g. main), and then + * again on another branch we want to compare against the + * base (e.g. a new feature branch). + */ + +/* eslint-disable @lwc/lwc/no-async-await,no-restricted-syntax,no-await-in-loop */ +const fs = require('fs'); +const _ = require('underscore'); +const { + DEFAULT_BASELINE_BRANCH, + OUTPUT_DIR, + LOG_FILE, + RUNS, + WARM_UP_RUNS, + TESTS_CONFIG, +} = require('./config'); +const compare = require('./compare/compare'); +const Logger = require('./utils/logger'); +const execAsync = require('./utils/execAsync'); +const killApp = require('./utils/killApp'); +const launchApp = require('./utils/launchApp'); +const createServerInstance = require('./server'); +const installApp = require('./utils/installApp'); +const reversePort = require('./utils/androidReversePort'); +const math = require('./measure/math'); +const writeTestStats = require('./measure/writeTestStats'); +const withFailTimeout = require('./utils/withFailTimeout'); +const startRecordingVideo = require('./utils/startRecordingVideo'); + +const args = process.argv.slice(2); + +const baselineBranch = process.env.baseline || DEFAULT_BASELINE_BRANCH; + +// Clear all files from previous jobs +try { + fs.rmSync(OUTPUT_DIR, {recursive: true, force: true}); + fs.mkdirSync(OUTPUT_DIR); +} catch (error) { + // Do nothing + console.error(error); +} + +const restartApp = async () => { + Logger.log('Killing app …'); + await killApp('android'); + Logger.log('Launching app …'); + await launchApp('android'); +}; + +const runTestsOnBranch = async (branch, baselineOrCompare) => { + // Switch branch and install dependencies + const progress = Logger.progressInfo(`Preparing ${baselineOrCompare} tests on branch '${branch}'`); + await execAsync(`git switch ${branch}`); + + if (!args.includes('--skipInstallDeps')) { + progress.updateText(`Preparing ${baselineOrCompare} tests on branch '${branch}' - npm install`); + await execAsync('npm i'); + } + + // Build app + if (!args.includes('--skipBuild')) { + progress.updateText(`Preparing ${baselineOrCompare} tests on branch '${branch}' - building app`); + await execAsync('npm run android-build-e2e'); + } + progress.done(); + + // Install app and reverse ports + let progressLog = Logger.progressInfo('Installing app'); + await installApp('android'); + Logger.log('Reversing port (for connecting to testing server) …'); + await reversePort(); + progressLog.done(); + + // Start the HTTP server + const server = createServerInstance(); + await server.start(); + + // Create a dict in which we will store the run durations for all tests + const durationsByTestName = {}; + + // Collect results while tests are being executed + server.addTestResultListener((testResult) => { + if (testResult.error != null) { + throw new Error(`Test '${testResult.name}' failed with error: ${testResult.error}`); + } + if (testResult.duration < 0) { + return; + } + + Logger.log(`[LISTENER] Test '${testResult.name}' took ${testResult.duration}ms`); + durationsByTestName[testResult.name] = (durationsByTestName[testResult.name] || []).concat(testResult.duration); + }); + + // Run the tests + const numOfTests = _.values(TESTS_CONFIG).length; + for (let testIndex = 0; testIndex < numOfTests; testIndex++) { + const config = _.values(TESTS_CONFIG)[testIndex]; + server.setTestConfig(config); + + const warmupLogs = Logger.progressInfo(`Running test '${config.name}'`); + for (let warmUpRuns = 0; warmUpRuns < WARM_UP_RUNS; warmUpRuns++) { + const progressText = `(${testIndex + 1}/${numOfTests}) Warmup for test '${config.name}' (iteration ${warmUpRuns + 1}/${WARM_UP_RUNS})`; + warmupLogs.updateText(progressText); + + await restartApp(); + + await withFailTimeout(new Promise((resolve) => { + const cleanup = server.addTestDoneListener(() => { + Logger.log(`Warmup ${warmUpRuns + 1} done!`); + cleanup(); + resolve(); + }); + }), progressText); + } + warmupLogs.done(); + + // We run each test multiple time to average out the results + const testLog = Logger.progressInfo(''); + for (let i = 0; i < RUNS; i++) { + const progressText = `(${testIndex + 1}/${numOfTests}) Running test '${config.name}' (iteration ${i + 1}/${RUNS})`; + testLog.updateText(progressText); + + const stopVideoRecording = startRecordingVideo(); + + await restartApp(); + + // Wait for a test to finish by waiting on its done call to the http server + try { + await withFailTimeout(new Promise((resolve) => { + const cleanup = server.addTestDoneListener(() => { + Logger.log(`Test iteration ${i + 1} done!`); + cleanup(); + resolve(); + }); + }), progressText); + await stopVideoRecording(false); + } catch (e) { + // When we fail due to a timeout it's interesting to take a screenshot of the emulator to see whats going on + await stopVideoRecording(true); + testLog.done(); + throw e; // Rethrow to abort execution + } + } + testLog.done(); + } + + // Calculate statistics and write them to our work file + progressLog = Logger.progressInfo('Calculating statics and writing results'); + const outputFileName = `${OUTPUT_DIR}/${baselineOrCompare}.json`; + for (const testName of _.keys(durationsByTestName)) { + const stats = math.getStats(durationsByTestName[testName]); + await writeTestStats( + { + name: testName, + ...stats, + }, + outputFileName, + ); + } + progressLog.done(); + + await server.stop(); +}; + +const runTests = async () => { + Logger.info('Running e2e tests'); + + try { + // Run tests on baseline branch + await runTestsOnBranch(baselineBranch, 'baseline'); + + // Run tests on current branch + await runTestsOnBranch('-', 'compare'); + + await compare(); + + process.exit(0); + } catch (e) { + Logger.info('\n\nE2E test suite failed due to error:', e, '\nPrinting full logs:\n\n'); + + // Write logcat, meminfo, emulator info to file as well: + require('node:child_process').execSync(`adb logcat -d > ${OUTPUT_DIR}/logcat.txt`); + require('node:child_process').execSync(`adb shell "cat /proc/meminfo" > ${OUTPUT_DIR}/meminfo.txt`); + require('node:child_process').execSync(`cat ~/.android/avd/${process.env.AVD_NAME || 'test'}.avd/config.ini > ${OUTPUT_DIR}/emulator-config.ini`); + require('node:child_process').execSync(`adb shell "getprop" > ${OUTPUT_DIR}/emulator-properties.txt`); + + require('node:child_process').execSync(`cat ${LOG_FILE}`); + process.exit(1); + } +}; + +runTests(); diff --git a/e2e/utils/androidReversePort.js b/e2e/utils/androidReversePort.js new file mode 100644 index 000000000000..b644ca1538dd --- /dev/null +++ b/e2e/utils/androidReversePort.js @@ -0,0 +1,6 @@ +const {SERVER_PORT} = require('../config'); +const execAsync = require('./execAsync'); + +module.exports = function () { + return execAsync(`adb reverse tcp:${SERVER_PORT} tcp:${SERVER_PORT}`); +}; diff --git a/e2e/utils/execAsync.js b/e2e/utils/execAsync.js new file mode 100644 index 000000000000..501f72ca9fb3 --- /dev/null +++ b/e2e/utils/execAsync.js @@ -0,0 +1,37 @@ +const {exec} = require('node:child_process'); +const Logger = require('./logger'); + +/** + * Executes a command none-blocking by wrapping it in a promise. + * In addition to the promise it returns an abort function. + * @param {string} command + * @returns {Promise} + */ +module.exports = (command) => { + let process; + const promise = new Promise((resolve, reject) => { + Logger.log('Output of command:', command); + process = exec(command, { + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 10, // Increase max buffer to 10MB, to avoid errors + }, (error, stdout) => { + if (error) { + if (error && error.killed) { + resolve(); + } else { + Logger.log(`failed with error: ${error}`); + reject(error); + } + } else { + Logger.log(stdout); + resolve(stdout); + } + }); + }); + + promise.abort = () => { + process.kill('SIGINT'); + }; + + return promise; +}; diff --git a/e2e/utils/installApp.js b/e2e/utils/installApp.js new file mode 100644 index 000000000000..79d3bb1638e2 --- /dev/null +++ b/e2e/utils/installApp.js @@ -0,0 +1,23 @@ +const {APP_PACKAGE} = require('../config'); +const execAsync = require('./execAsync'); +const Logger = require('./logger'); + +const APP_PATH_FROM_ROOT = 'android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk'; + +/** + * Installs the app on the currently connected device for the given platform. + * It removes the app first if it already exists, so it's a clean installation. + * @param {string} platform + * @returns {Promise} + */ +module.exports = function (platform = 'android') { + if (platform !== 'android') { + throw new Error(`installApp() missing implementation for platform: ${platform}`); + } + + // Uninstall first, then install + return execAsync(`adb uninstall ${APP_PACKAGE}`).catch((e) => { + // Ignore errors + Logger.warn('Failed to uninstall app:', e); + }).finally(() => execAsync(`adb install ${APP_PATH_FROM_ROOT}`)); +}; diff --git a/e2e/utils/killApp.js b/e2e/utils/killApp.js new file mode 100644 index 000000000000..9761ee7fc66e --- /dev/null +++ b/e2e/utils/killApp.js @@ -0,0 +1,11 @@ +const {APP_PACKAGE} = require('../config'); +const execAsync = require('./execAsync'); + +module.exports = function (platform = 'android') { + if (platform !== 'android') { + throw new Error(`killApp() missing implementation for platform: ${platform}`); + } + + // Use adb to kill the app + return execAsync(`adb shell am force-stop ${APP_PACKAGE}`); +}; diff --git a/e2e/utils/launchApp.js b/e2e/utils/launchApp.js new file mode 100644 index 000000000000..dce17c7fbb3b --- /dev/null +++ b/e2e/utils/launchApp.js @@ -0,0 +1,11 @@ +const {APP_PACKAGE} = require('../config'); +const execAsync = require('./execAsync'); + +module.exports = function (platform = 'android') { + if (platform !== 'android') { + throw new Error(`launchApp() missing implementation for platform: ${platform}`); + } + + // Use adb to start the app + return execAsync(`adb shell monkey -p ${APP_PACKAGE} -c android.intent.category.LAUNCHER 1`); +}; diff --git a/e2e/utils/logger.js b/e2e/utils/logger.js new file mode 100644 index 000000000000..cbe76bbaf300 --- /dev/null +++ b/e2e/utils/logger.js @@ -0,0 +1,80 @@ +const fs = require('fs'); +const {LOG_FILE} = require('../config'); + +let isVerbose = false; +const setLogLevelVerbose = (value) => { + isVerbose = value; +}; + +// On CI systems when using .progressInfo, the current line won't reset but a new line gets added +// Which can flood the logs. You can increase this rate to mitigate this effect. +const LOGGER_PROGRESS_REFRESH_RATE = process.env.LOGGER_PROGRESS_REFRESH_RATE || 250; +const COLOR_DIM = '\x1b[2m'; +const COLOR_RESET = '\x1b[0m'; +const COLOR_YELLOW = '\x1b[33m'; + +const log = (...args) => { + if (isVerbose) { + console.debug(...args); + } + + // Write to log file + if (!fs.existsSync(LOG_FILE)) { + fs.writeFileSync(LOG_FILE, ''); + } + const time = new Date(); + const timeStr = `${time.getHours()}:${time.getMinutes()}:${time.getSeconds()} ${time.getMilliseconds()}`; + fs.appendFileSync(LOG_FILE, `[${timeStr}] ${args.join(' ')}\n`); +}; + +const progressInfo = (textParam) => { + let text = textParam || ''; + const getTexts = () => [`🕛 ${text}`, `🕔 ${text}`, `🕗 ${text}`, `🕙 ${text}`]; + log(textParam); + + const startTime = Date.now(); + let i = 0; + const timer = setInterval(() => { + process.stdout.write(`\r${getTexts()[i++]}`); + // eslint-disable-next-line no-bitwise + i &= 3; + }, Number(LOGGER_PROGRESS_REFRESH_RATE)); + + const getTimeText = () => { + const timeInSeconds = Math.round((Date.now() - startTime) / 1000).toFixed(0); + return `(${COLOR_DIM}took: ${timeInSeconds}s${COLOR_RESET})`; + }; + return { + updateText: (newText) => { + text = newText; + log(newText); + }, + done: () => { + clearInterval(timer); + process.stdout.write(`\r✅ ${text} ${getTimeText()}\n`); + }, + error: () => { + clearInterval(timer); + process.stdout.write(`\r❌ ${text} ${getTimeText()}\n`); + }, + }; +}; + +const info = (...args) => { + console.debug('> ', ...args); + log(...args); +}; + +const warn = (...args) => { + const lines = [`\n${COLOR_YELLOW}⚠️`, ...args, `${COLOR_RESET}\n`]; + console.debug(...lines); + log(...lines); +}; + +module.exports = { + log, + info, + warn, + progressInfo, + setLogLevelVerbose, +}; diff --git a/e2e/utils/startRecordingVideo.js b/e2e/utils/startRecordingVideo.js new file mode 100644 index 000000000000..005c9c123774 --- /dev/null +++ b/e2e/utils/startRecordingVideo.js @@ -0,0 +1,39 @@ +const execAsync = require('./execAsync'); +const {OUTPUT_DIR} = require('../config'); +const Logger = require('../utils/logger'); + +module.exports = () => { + // The emulator on CI launches with no-window option. + // Taking screenshots results in blank shots. + // Recording a video however includes the graphic content. + const cmd = 'adb shell screenrecord /sdcard/video.mp4'; + let recordingFailed = false; + const recording = execAsync(cmd); + recording.catch((error) => { + // Don't abort on errors + Logger.warn('Error while recording video', error); + recordingFailed = true; + }); + + return (save = false) => { + if (recordingFailed) { return; } + + recording.abort(); + return new Promise((resolve) => { + if (!save) { + resolve(); + return; + } + setTimeout(() => { + if (save) { + const getVideo = () => execAsync('adb pull /sdcard/video.mp4'); + const moveVideo = () => execAsync(`mv video.mp4 ${OUTPUT_DIR}/video.mp4`); + const cleanupVideo = () => execAsync('adb shell rm /sdcard/video.mp4'); + getVideo().then(moveVideo).then(cleanupVideo).then(resolve); + } else { + resolve(); + } + }, 1000); // Give the device some time to finish writing the file + }); + }; +}; diff --git a/e2e/utils/withFailTimeout.js b/e2e/utils/withFailTimeout.js new file mode 100644 index 000000000000..3f4971431e43 --- /dev/null +++ b/e2e/utils/withFailTimeout.js @@ -0,0 +1,28 @@ +const {INTERACTION_TIMEOUT} = require('../config'); + +const TIMEOUT = process.env.INTERACTION_TIMEOUT || INTERACTION_TIMEOUT; + +const withFailTimeout = (promise, name) => new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject( + new Error( + `[${name}] Interaction timed out after ${(TIMEOUT / 1000).toFixed( + 0, + )}s`, + ), + ); + }, Number(TIMEOUT)); + + promise + .then((value) => { + resolve(value); + }) + .catch((e) => { + reject(e); + }) + .finally(() => { + clearTimeout(timeoutId); + }); +}); + +module.exports = withFailTimeout; diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dcf54d46f828..74060ccb501c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -14,6 +14,17 @@ skip_docs opt_out_usage platform :android do + desc "Generate a new local APK for e2e testing" + lane :build_e2e do + ENV["ENVFILE"]="e2e/.env.e2e" + ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.js" + + gradle( + project_dir: './android', + task: ':app:assembleE2eRelease', + ) + end + desc "Generate a new local APK" lane :build do ENV["ENVFILE"]=".env.production" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index d442f5fc21cd..173ddbe45ef6 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.10 + 1.2.17 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.2.10.0 + 1.2.17.4 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 319b0779092c..225c28d74308 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.10 + 1.2.17 CFBundleSignature ???? CFBundleVersion - 1.2.10.0 + 1.2.17.4 diff --git a/ios/Podfile b/ios/Podfile index d72cb4dce9e4..f42d309b7a9e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -10,6 +10,7 @@ target 'NewExpensify' do pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy" pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways" pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse" + pod 'Permission-Camera', :path => "#{permissions_path}/Camera" config = use_native_modules! diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4475434729bc..c54a14feeaf9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -222,6 +222,8 @@ PODS: - Onfido (= 26.0.1) - React - OpenSSL-Universal (1.1.1100) + - Permission-Camera (3.6.1): + - RNPermissions - Permission-LocationAccuracy (3.6.1): - RNPermissions - Permission-LocationAlways (3.6.1): @@ -485,8 +487,12 @@ PODS: - React-Core - react-native-render-html (6.3.1): - React-Core - - react-native-safe-area-context (3.4.1): + - react-native-safe-area-context (4.4.1): + - RCT-Folly + - RCTRequired + - RCTTypeSafety - React-Core + - ReactCommon/turbomodule/core - react-native-webview (11.23.0): - React-Core - React-perflogger (0.70.4) @@ -615,7 +621,7 @@ PODS: - React-RCTText - ReactCommon/turbomodule/core - Yoga - - RNScreens (3.15.0): + - RNScreens (3.17.0): - React-Core - React-RCTImage - RNSVG (12.4.4): @@ -665,6 +671,7 @@ DEPENDENCIES: - libevent (~> 2.1.12) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" - OpenSSL-Universal (= 1.1.1100) + - Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`) - Permission-LocationAccuracy (from `../node_modules/react-native-permissions/ios/LocationAccuracy`) - Permission-LocationAlways (from `../node_modules/react-native-permissions/ios/LocationAlways`) - Permission-LocationWhenInUse (from `../node_modules/react-native-permissions/ios/LocationWhenInUse`) @@ -782,6 +789,8 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/sdks/hermes/hermes-engine.podspec" onfido-react-native-sdk: :path: "../node_modules/@onfido/react-native-sdk" + Permission-Camera: + :path: "../node_modules/react-native-permissions/ios/Camera" Permission-LocationAccuracy: :path: "../node_modules/react-native-permissions/ios/LocationAccuracy" Permission-LocationAlways: @@ -911,7 +920,7 @@ SPEC CHECKSUMS: Airship: 4657c3d5118441240e04674d9445cbd6e363c956 boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 - DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 + DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662 FBLazyVector: 8a28262f61fbe40c04ce8677b8d835d97c18f1b3 FBReactNativeSpec: b475991eb2d8da6a4ec32d09a8df31b0247fa87d Firebase: 629510f1a9ddb235f3a7c5c8ceb23ba887f0f814 @@ -933,7 +942,7 @@ SPEC CHECKSUMS: Flipper-RSocket: d9d9ade67cbecf6ac10730304bf5607266dd2541 FlipperKit: cbdee19bdd4e7f05472a66ce290f1b729ba3cb86 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b + glog: 5337263514dd6f09803962437687240c5dc39aa4 GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91 GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1 @@ -944,6 +953,7 @@ SPEC CHECKSUMS: Onfido: 91935f0dcee9a6647fdb386fa87cb4af9d9a3c70 onfido-react-native-sdk: bc72114ac430a2636cd2f02972ddf5b8b3b397c6 OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c + Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6 Permission-LocationAccuracy: 76df17de5c6b8bc2eee34e61ee92cdd7a864c73d Permission-LocationAlways: 8d99b025c9f73c696e0cdb367e42525f2e9a26f2 Permission-LocationWhenInUse: 3ba99e45c852763f730eabecec2870c2382b7bd4 @@ -977,7 +987,7 @@ SPEC CHECKSUMS: react-native-progress-bar-android: be43138ab7da30d51fc038bafa98e9ed594d0c40 react-native-progress-view: 4d3bbe6a099ba027b1fedb1548c2c87f74249b70 react-native-render-html: 96c979fe7452a0a41559685d2f83b12b93edac8c - react-native-safe-area-context: 9e40fb181dac02619414ba1294d6c2a807056ab9 + react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a react-native-webview: e771bc375f789ebfa02a26939a57dbc6fa897336 React-perflogger: 5e41b01b35d97cc1b0ea177181eb33b5c77623b6 React-RCTActionSheet: 48949f30b24200c82f3dd27847513be34e06a3ae @@ -1004,7 +1014,7 @@ SPEC CHECKSUMS: RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: 60e291d42c77752a0f6d6f358387bdf225a87c6e - RNScreens: 4a1af06327774490d97342c00aee0c2bafb497b7 + RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9 RNSVG: ecd661f380a07ba690c9c5929c475a44f432d674 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d @@ -1013,6 +1023,6 @@ SPEC CHECKSUMS: Yoga: 1f02ef4ce4469aefc36167138441b27d988282b1 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: 643092977a9e257478456ec4f261baefad505023 +PODFILE CHECKSUM: 5745ec909229219e7469609dba23118be023f564 COCOAPODS: 1.11.3 diff --git a/package-lock.json b/package-lock.json index 589a57e2f85b..3dc583b76a9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.2.10-0", + "version": "1.2.17-4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.2.10-0", + "version": "1.2.17-4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -30,14 +30,15 @@ "@react-native-firebase/crashlytics": "^12.3.0", "@react-native-firebase/perf": "^12.3.0", "@react-native-picker/picker": "^2.4.3", - "@react-navigation/drawer": "6.3.0", - "@react-navigation/native": "6.0.11", - "@react-navigation/stack": "6.2.2", + "@react-navigation/drawer": "6.5.0", + "@react-navigation/native": "6.0.13", + "@react-navigation/stack": "6.3.1", "babel-plugin-transform-remove-console": "^6.9.4", + "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", "dotenv": "^8.2.0", - "expensify-common": "git+https://github.com/Expensify/expensify-common.git#a9e791c1190052e934c20efdcd7cd4429b4680cb", + "expensify-common": "git+https://github.com/Expensify/expensify-common.git#03a86215c0ae2bc4e7978e1ca9717fbc7fdea11b", "fbjs": "^3.0.2", "file-loader": "^6.0.0", "html-entities": "^1.3.1", @@ -67,7 +68,7 @@ "react-native-image-picker": "^4.8.5", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.15", + "react-native-onyx": "1.0.17", "react-native-pdf": "^6.6.2", "react-native-performance": "^2.0.0", "react-native-permissions": "^3.0.1", @@ -75,9 +76,9 @@ "react-native-plaid-link-sdk": "^7.2.0", "react-native-reanimated": "2.10.0", "react-native-render-html": "6.3.1", - "react-native-safe-area-context": "^3.1.4", - "react-native-screens": "^3.10.1", - "react-native-svg": "^12.1.0", + "react-native-safe-area-context": "4.4.1", + "react-native-screens": "3.17.0", + "react-native-svg": "^12.4.4", "react-native-webview": "^11.17.2", "react-pdf": "5.7.2", "react-plaid-link": "3.3.2", @@ -92,6 +93,7 @@ "@actions/github": "5.0.3", "@babel/core": "^7.11.1", "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/preset-env": "^7.11.0", "@babel/preset-flow": "^7.12.13", "@babel/preset-react": "^7.10.4", @@ -100,6 +102,7 @@ "@octokit/plugin-paginate-rest": "3.1.0", "@octokit/plugin-throttling": "4.1.0", "@react-native-community/eslint-config": "3.0.0", + "@react-navigation/devtools": "^6.0.10", "@storybook/addon-a11y": "^6.5.9", "@storybook/addon-essentials": "^6.5.9", "@storybook/addon-react-native-web": "0.0.19--canary.37.cb55428.0", @@ -126,7 +129,7 @@ "copy-webpack-plugin": "^6.4.1", "css-loader": "^5.2.4", "diff-so-fancy": "^1.3.0", - "electron": "^17.4.11", + "electron": "^21.0.0", "electron-builder": "23.5.0", "electron-notarize": "^1.2.1", "eslint": "^7.6.0", @@ -6560,27 +6563,42 @@ "integrity": "sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ==" }, "node_modules/@react-navigation/core": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.2.2.tgz", - "integrity": "sha512-gEJ1gRqt1EIqRrnJIpSQ0wWJRue9maAQNKYrlQ0a/LSKErF3g6w+sD2wW4Bbb1yj88pGhKeuI4wdB9MVK766Pg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.4.0.tgz", + "integrity": "sha512-tpc0Ak/DiHfU3LlYaRmIY7vI4sM/Ru0xCet6runLUh9aABf4wiLgxyFJ5BtoWq6xFF8ymYEA/KWtDhetQ24YiA==", "dependencies": { - "@react-navigation/routers": "^6.1.1", + "@react-navigation/routers": "^6.1.3", "escape-string-regexp": "^4.0.0", "nanoid": "^3.1.23", "query-string": "^7.0.0", - "react-is": "^16.13.0" + "react-is": "^16.13.0", + "use-latest-callback": "^0.1.5" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@react-navigation/devtools": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-6.0.10.tgz", + "integrity": "sha512-TF2hkHBL/UxgYIorZJkFeliyJ7faa+WZJgCJ+q8B6xZuwloCxQQDb3E2yQqAf7OZOsRlTFLppCnhp1PLJRTzPw==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5", + "nanoid": "^3.1.23", + "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "react": "*" } }, "node_modules/@react-navigation/drawer": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-6.3.0.tgz", - "integrity": "sha512-rbIpJCMeRVler6JI8eiHdvFEXbF8j8ii4cD42HeN9DqjpEJRfuz134ObM3O6Qd7h0k9U69exshbAUQ+7QaWesA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-6.5.0.tgz", + "integrity": "sha512-ma3qPjAfbwF07xd1w1gaWdcvYWmT4F+Z098q2J7XGbHw8yTGQYiNTnD1NMKerXwxM24vui2tMuFHA54F1rIvHQ==", "dependencies": { - "@react-navigation/elements": "^1.3.1", - "color": "^3.1.3", + "@react-navigation/elements": "^1.3.6", + "color": "^4.2.3", "warn-once": "^0.1.0" }, "peerDependencies": { @@ -6605,11 +6623,11 @@ } }, "node_modules/@react-navigation/native": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.11.tgz", - "integrity": "sha512-z0YTB7Czdb9SNjxfzcFNB3Vym0qmUcxpiYGOOXX8PH0s+xlIs/w+2RVp6YAvAC48A30o7MMCYqy5OeR6lrtWHg==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.13.tgz", + "integrity": "sha512-CwaJcAGbhv3p3ECablxBkw8QBCGDWXqVRwQ4QbelajNW623m3sNTC9dOF6kjp8au6Rg9B5e0KmeuY0xWbPk79A==", "dependencies": { - "@react-navigation/core": "^6.2.2", + "@react-navigation/core": "^6.4.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.1.23" @@ -6620,19 +6638,19 @@ } }, "node_modules/@react-navigation/routers": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.1.tgz", - "integrity": "sha512-mWWj2yh4na/OBaE7bWrft4kdAtxnG8MlV6ph3Bi6tHqgcnxENX+dnQY6y0qg/6E7cmMlaJg5nAC5y4Enr5ir8A==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.3.tgz", + "integrity": "sha512-idJotMEzHc3haWsCh7EvnnZMKxvaS4YF/x2UyFBkNFiEFUaEo/1ioQU6qqmVLspdEv4bI/dLm97hQo7qD8Yl7Q==", "dependencies": { "nanoid": "^3.1.23" } }, "node_modules/@react-navigation/stack": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.2.2.tgz", - "integrity": "sha512-P9ZfmluOXNmbs7YdG1UWS1fAh87Yse9aX8TgqOz4FlHEm5q7g5eaM35QgWByt+wif3UiqE40D8wXpqRQvMgPWg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.3.1.tgz", + "integrity": "sha512-WkURDiSip8QpB+cuEbp5GfDPDGxER7w7ooJVgG3J2nJNnYuKxsZR7qnlqWL2vjQW81NzKQpT7xrCADy+mfvIiQ==", "dependencies": { - "@react-navigation/elements": "^1.3.4", + "@react-navigation/elements": "^1.3.6", "color": "^4.2.3", "warn-once": "^0.1.0" }, @@ -6645,34 +6663,6 @@ "react-native-screens": ">= 3.0.0" } }, - "node_modules/@react-navigation/stack/node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/@react-navigation/stack/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@react-navigation/stack/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/@sentry/browser": { "version": "7.11.1", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.11.1.tgz", @@ -14732,6 +14722,16 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, + "node_modules/@types/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "4.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz", @@ -16291,6 +16291,18 @@ "url": "https://tidelift.com/funding/github/npm/autoprefixer" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axe-core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz", @@ -17089,6 +17101,28 @@ "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", "dev": true }, + "node_modules/babel-polyfill": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", + "integrity": "sha512-F2rZGQnAdaHWQ8YAoeRbukc7HS9QgdgeyJ0rQDd485v9opwuPvjpPFcOOT/WmkKTdgy9ESgSPXDcTNpzrGr6iQ==", + "dependencies": { + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "regenerator-runtime": "^0.10.5" + } + }, + "node_modules/babel-polyfill/node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true + }, + "node_modules/babel-polyfill/node_modules/regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha512-02YopEIhAgiBHWeoTiA8aitHDt8z6w+rQqNuIftlM+ZtvSl/brTouaU7DW6GO/cHtvxJvS4Hwv2ibKdxIRi24w==" + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -17169,7 +17203,6 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "dev": true, "dependencies": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" @@ -17180,14 +17213,12 @@ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "dev": true, "hasInstallScript": true }, "node_modules/babel-runtime/node_modules/regenerator-runtime": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "dev": true + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" }, "node_modules/babel-template": { "version": "6.26.0", @@ -18980,12 +19011,15 @@ } }, "node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" } }, "node_modules/color-convert": { @@ -19019,6 +19053,22 @@ "color-support": "bin.js" } }, + "node_modules/color/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -20489,6 +20539,32 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, + "node_modules/deep-equal": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz", + "integrity": "sha512-nPiRgmbAtm1a3JsnLCf6/SLfXcjyN5v8L1TXzdCmHrXJ4hx+gW/w1YCcn7z8gJtSiDArZCgYtbao3QqLm/N1Sw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "es-get-iterator": "^1.1.1", + "get-intrinsic": "^1.0.1", + "is-arguments": "^1.0.4", + "is-date-object": "^1.0.2", + "is-regex": "^1.1.1", + "isarray": "^2.0.5", + "object-is": "^1.1.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "regexp.prototype.flags": "^1.3.0", + "side-channel": "^1.0.3", + "which-boxed-primitive": "^1.0.1", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -21180,21 +21256,21 @@ } }, "node_modules/electron": { - "version": "17.4.11", - "resolved": "https://registry.npmjs.org/electron/-/electron-17.4.11.tgz", - "integrity": "sha512-mdSWM2iY/Bh5bKzd5drYS3mf8JWyR9P9UXZA2uLEZ+1fhgLEVkY9qu501QHoMsKlNwgn96EreQC+dfdQ75VTcA==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-21.1.1.tgz", + "integrity": "sha512-EM2hvRJtiS3n54yx25Z0Qv54t3LGG+WjUHf1AOl+PKjQj+fmXnjIgVeIF9pM21kP1BTcyjrgvN6Sff0A45OB6A==", "dev": true, "hasInstallScript": true, "dependencies": { - "@electron/get": "^1.13.0", - "@types/node": "^14.6.2", - "extract-zip": "^1.0.3" + "@electron/get": "^1.14.1", + "@types/node": "^16.11.26", + "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" }, "engines": { - "node": ">= 8.6" + "node": ">= 10.17.0" } }, "node_modules/electron-builder": { @@ -21531,9 +21607,9 @@ "integrity": "sha512-uWa+i2Vz1odvE+zWXOe23rW9UPLh/5X7ESUVdK8wmNg+T6FfOZbhyZEK1GuC8JqaAZ4VBFUYaTYHFPrAX6y5bA==" }, "node_modules/electron/node_modules/@types/node": { - "version": "14.18.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.24.tgz", - "integrity": "sha512-aJdn8XErcSrfr7k8ZDDfU6/2OgjZcB2Fu9d+ESK8D7Oa5mtsv8Fa8GpcwTA0v60kuZBaalKPzuzun4Ov1YWO/w==", + "version": "16.11.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.60.tgz", + "integrity": "sha512-kYIYa1D1L+HDv5M5RXQeEu1o0FKA6yedZIoyugm/MBPROkLpX4L7HRxMrPVyo8bnvjpW/wDlqFNGzXNMb7AdRw==", "dev": true }, "node_modules/element-resize-detector": { @@ -23872,8 +23948,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#a9e791c1190052e934c20efdcd7cd4429b4680cb", - "integrity": "sha512-5CmN+8u0OA/faTtOLDcswp92aRiJgJjIxCWeyp7J1hitFGRFs+T/1xk+/3FGvz8mzSnPc66ETf4e3kzTMWem2Q==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#03a86215c0ae2bc4e7978e1ca9717fbc7fdea11b", + "integrity": "sha512-LWMhx7czsv3l6oJ7ILfYHFbllmAcPVzDTZHpXFtzLJh2lP4Wuukc0gB7MQX2EwkZ+VV2QrZj4gPkRMbr95DOKg==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -24138,47 +24214,40 @@ } }, "node_modules/extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, "dependencies": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", + "debug": "^4.1.1", + "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "bin": { "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" } }, - "node_modules/extract-zip/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/extract-zip/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "dependencies": { - "minimist": "^1.2.6" + "pump": "^3.0.0" }, - "bin": { - "mkdirp": "bin/cmd.js" + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/extract-zip/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, "node_modules/extsprintf": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", @@ -24715,6 +24784,15 @@ } } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -27073,6 +27151,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.9.tgz", + "integrity": "sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "es-abstract": "^1.20.0", + "for-each": "^0.3.3", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -27095,6 +27192,15 @@ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" }, + "node_modules/is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -27106,6 +27212,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -33482,6 +33601,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -35515,9 +35650,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.15.tgz", - "integrity": "sha512-uIJped+agmOppnCoDcs/w3qFertkLhLHyhmEEBXp0OhzNKuCs01wg7ccYFZxOVv+CtzFbtkLVB1VW3Ty/zYogA==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.17.tgz", + "integrity": "sha512-ls2GjURfpBcGnIkwVrg2uuLnTBwd0vrEiUvbMo+GF3k81GAp2flCkVTM7ciAbo155Izk50dm0uXHYq1PIjwTxw==", "dependencies": { "ascii-table": "0.0.9", "lodash": "^4.17.21", @@ -35650,18 +35785,18 @@ "integrity": "sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==" }, "node_modules/react-native-safe-area-context": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-3.4.1.tgz", - "integrity": "sha512-xfpVd0CiZR7oBhuwJ2HcZMehg5bjha1Ohu1XHpcT+9ykula0TgovH2BNU0R5Krzf/jBR1LMjR6VabxdlUjqxcA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.4.1.tgz", + "integrity": "sha512-N9XTjiuD73ZpVlejHrUWIFZc+6Z14co1K/p1IFMkImU7+avD69F3y+lhkqA2hN/+vljdZrBSiOwXPkuo43nFQA==", "peerDependencies": { "react": "*", "react-native": "*" } }, "node_modules/react-native-screens": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.15.0.tgz", - "integrity": "sha512-ezC5TibsUYyqPuuHpZoM3iEl6bRzCVBMJeGaFkn7xznuOt1VwkZVub0BvafIEYR/+AQC/RjxzMSQPs1qal0+wA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.17.0.tgz", + "integrity": "sha512-OZCQU7+3neHNaM19jBkYRjL50kXz7p7MUgWQTCcdRoshcCiolf8aXs4eRVQKGK6m1RmoB8UL0//m5R9KoR+41w==", "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -41211,6 +41346,11 @@ "node": ">=0.10.0" } }, + "node_modules/use-latest-callback": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.1.5.tgz", + "integrity": "sha512-HtHatS2U4/h32NlkhupDsPlrbiD27gSH5swBdtXbCAlc6pfOFzaj0FehW/FO12rx8j2Vy4/lJScCiJyM01E+bQ==" + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -42517,11 +42657,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "dependencies": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" }, + "node_modules/which-typed-array": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.8.tgz", + "integrity": "sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "es-abstract": "^1.20.0", + "for-each": "^0.3.3", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -42680,9 +42855,9 @@ } }, "node_modules/ws": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.8.1.tgz", - "integrity": "sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz", + "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==", "dev": true, "engines": { "node": ">=10.0.0" @@ -47785,24 +47960,36 @@ "integrity": "sha512-K0aGNn1TjalKj+65D7ycc1//H9roAQ51GJVk5ZJQFb2teECGmzd86bYDC0aYdbRf7gtovescq4Zt6FR0tgXiHQ==" }, "@react-navigation/core": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.2.2.tgz", - "integrity": "sha512-gEJ1gRqt1EIqRrnJIpSQ0wWJRue9maAQNKYrlQ0a/LSKErF3g6w+sD2wW4Bbb1yj88pGhKeuI4wdB9MVK766Pg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.4.0.tgz", + "integrity": "sha512-tpc0Ak/DiHfU3LlYaRmIY7vI4sM/Ru0xCet6runLUh9aABf4wiLgxyFJ5BtoWq6xFF8ymYEA/KWtDhetQ24YiA==", "requires": { - "@react-navigation/routers": "^6.1.1", + "@react-navigation/routers": "^6.1.3", "escape-string-regexp": "^4.0.0", "nanoid": "^3.1.23", "query-string": "^7.0.0", - "react-is": "^16.13.0" + "react-is": "^16.13.0", + "use-latest-callback": "^0.1.5" + } + }, + "@react-navigation/devtools": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@react-navigation/devtools/-/devtools-6.0.10.tgz", + "integrity": "sha512-TF2hkHBL/UxgYIorZJkFeliyJ7faa+WZJgCJ+q8B6xZuwloCxQQDb3E2yQqAf7OZOsRlTFLppCnhp1PLJRTzPw==", + "dev": true, + "requires": { + "deep-equal": "^2.0.5", + "nanoid": "^3.1.23", + "stacktrace-parser": "^0.1.10" } }, "@react-navigation/drawer": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-6.3.0.tgz", - "integrity": "sha512-rbIpJCMeRVler6JI8eiHdvFEXbF8j8ii4cD42HeN9DqjpEJRfuz134ObM3O6Qd7h0k9U69exshbAUQ+7QaWesA==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-6.5.0.tgz", + "integrity": "sha512-ma3qPjAfbwF07xd1w1gaWdcvYWmT4F+Z098q2J7XGbHw8yTGQYiNTnD1NMKerXwxM24vui2tMuFHA54F1rIvHQ==", "requires": { - "@react-navigation/elements": "^1.3.1", - "color": "^3.1.3", + "@react-navigation/elements": "^1.3.6", + "color": "^4.2.3", "warn-once": "^0.1.0" } }, @@ -47813,56 +48000,32 @@ "requires": {} }, "@react-navigation/native": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.11.tgz", - "integrity": "sha512-z0YTB7Czdb9SNjxfzcFNB3Vym0qmUcxpiYGOOXX8PH0s+xlIs/w+2RVp6YAvAC48A30o7MMCYqy5OeR6lrtWHg==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.13.tgz", + "integrity": "sha512-CwaJcAGbhv3p3ECablxBkw8QBCGDWXqVRwQ4QbelajNW623m3sNTC9dOF6kjp8au6Rg9B5e0KmeuY0xWbPk79A==", "requires": { - "@react-navigation/core": "^6.2.2", + "@react-navigation/core": "^6.4.0", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.1.23" } }, "@react-navigation/routers": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.1.tgz", - "integrity": "sha512-mWWj2yh4na/OBaE7bWrft4kdAtxnG8MlV6ph3Bi6tHqgcnxENX+dnQY6y0qg/6E7cmMlaJg5nAC5y4Enr5ir8A==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.3.tgz", + "integrity": "sha512-idJotMEzHc3haWsCh7EvnnZMKxvaS4YF/x2UyFBkNFiEFUaEo/1ioQU6qqmVLspdEv4bI/dLm97hQo7qD8Yl7Q==", "requires": { "nanoid": "^3.1.23" } }, "@react-navigation/stack": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.2.2.tgz", - "integrity": "sha512-P9ZfmluOXNmbs7YdG1UWS1fAh87Yse9aX8TgqOz4FlHEm5q7g5eaM35QgWByt+wif3UiqE40D8wXpqRQvMgPWg==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.3.1.tgz", + "integrity": "sha512-WkURDiSip8QpB+cuEbp5GfDPDGxER7w7ooJVgG3J2nJNnYuKxsZR7qnlqWL2vjQW81NzKQpT7xrCADy+mfvIiQ==", "requires": { - "@react-navigation/elements": "^1.3.4", + "@react-navigation/elements": "^1.3.6", "color": "^4.2.3", "warn-once": "^0.1.0" - }, - "dependencies": { - "color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "requires": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - } } }, "@sentry/browser": { @@ -53974,6 +54137,16 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, + "@types/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==", + "dev": true, + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@typescript-eslint/eslint-plugin": { "version": "4.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz", @@ -55184,6 +55357,12 @@ "postcss-value-parser": "^4.1.0" } }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true + }, "axe-core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz", @@ -55817,6 +55996,28 @@ "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", "dev": true }, + "babel-polyfill": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz", + "integrity": "sha512-F2rZGQnAdaHWQ8YAoeRbukc7HS9QgdgeyJ0rQDd485v9opwuPvjpPFcOOT/WmkKTdgy9ESgSPXDcTNpzrGr6iQ==", + "requires": { + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "regenerator-runtime": "^0.10.5" + }, + "dependencies": { + "core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==" + }, + "regenerator-runtime": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz", + "integrity": "sha512-02YopEIhAgiBHWeoTiA8aitHDt8z6w+rQqNuIftlM+ZtvSl/brTouaU7DW6GO/cHtvxJvS4Hwv2ibKdxIRi24w==" + } + } + }, "babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -55885,7 +56086,6 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "dev": true, "requires": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" @@ -55894,14 +56094,12 @@ "core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "dev": true + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==" }, "regenerator-runtime": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "dev": true + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" } } }, @@ -57312,12 +57510,27 @@ } }, "color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", "requires": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "dependencies": { + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + } } }, "color-convert": { @@ -58496,6 +58709,29 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, + "deep-equal": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.0.5.tgz", + "integrity": "sha512-nPiRgmbAtm1a3JsnLCf6/SLfXcjyN5v8L1TXzdCmHrXJ4hx+gW/w1YCcn7z8gJtSiDArZCgYtbao3QqLm/N1Sw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "es-get-iterator": "^1.1.1", + "get-intrinsic": "^1.0.1", + "is-arguments": "^1.0.4", + "is-date-object": "^1.0.2", + "is-regex": "^1.1.1", + "isarray": "^2.0.5", + "object-is": "^1.1.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "regexp.prototype.flags": "^1.3.0", + "side-channel": "^1.0.3", + "which-boxed-primitive": "^1.0.1", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.2" + } + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -59052,20 +59288,20 @@ } }, "electron": { - "version": "17.4.11", - "resolved": "https://registry.npmjs.org/electron/-/electron-17.4.11.tgz", - "integrity": "sha512-mdSWM2iY/Bh5bKzd5drYS3mf8JWyR9P9UXZA2uLEZ+1fhgLEVkY9qu501QHoMsKlNwgn96EreQC+dfdQ75VTcA==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-21.1.1.tgz", + "integrity": "sha512-EM2hvRJtiS3n54yx25Z0Qv54t3LGG+WjUHf1AOl+PKjQj+fmXnjIgVeIF9pM21kP1BTcyjrgvN6Sff0A45OB6A==", "dev": true, "requires": { - "@electron/get": "^1.13.0", - "@types/node": "^14.6.2", - "extract-zip": "^1.0.3" + "@electron/get": "^1.14.1", + "@types/node": "^16.11.26", + "extract-zip": "^2.0.1" }, "dependencies": { "@types/node": { - "version": "14.18.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.24.tgz", - "integrity": "sha512-aJdn8XErcSrfr7k8ZDDfU6/2OgjZcB2Fu9d+ESK8D7Oa5mtsv8Fa8GpcwTA0v60kuZBaalKPzuzun4Ov1YWO/w==", + "version": "16.11.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.60.tgz", + "integrity": "sha512-kYIYa1D1L+HDv5M5RXQeEu1o0FKA6yedZIoyugm/MBPROkLpX4L7HRxMrPVyo8bnvjpW/wDlqFNGzXNMb7AdRw==", "dev": true } } @@ -61094,9 +61330,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#a9e791c1190052e934c20efdcd7cd4429b4680cb", - "integrity": "sha512-5CmN+8u0OA/faTtOLDcswp92aRiJgJjIxCWeyp7J1hitFGRFs+T/1xk+/3FGvz8mzSnPc66ETf4e3kzTMWem2Q==", - "from": "expensify-common@git+https://github.com/Expensify/expensify-common.git#a9e791c1190052e934c20efdcd7cd4429b4680cb", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#03a86215c0ae2bc4e7978e1ca9717fbc7fdea11b", + "integrity": "sha512-LWMhx7czsv3l6oJ7ILfYHFbllmAcPVzDTZHpXFtzLJh2lP4Wuukc0gB7MQX2EwkZ+VV2QrZj4gPkRMbr95DOKg==", + "from": "expensify-common@git+https://github.com/Expensify/expensify-common.git#03a86215c0ae2bc4e7978e1ca9717fbc7fdea11b", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -61315,40 +61551,25 @@ } }, "extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, "requires": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "requires": { - "minimist": "^1.2.6" + "pump": "^3.0.0" } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true } } }, @@ -61775,6 +61996,15 @@ "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", "dev": true }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -63483,6 +63713,19 @@ "has-symbols": "^1.0.2" } }, + "is-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.9.tgz", + "integrity": "sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "es-abstract": "^1.20.0", + "for-each": "^0.3.3", + "has-tostringtag": "^1.0.0" + } + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -63499,6 +63742,12 @@ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==" }, + "is-weakmap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", + "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true + }, "is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -63507,6 +63756,16 @@ "call-bind": "^1.0.2" } }, + "is-weakset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", + "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, "is-whitespace-character": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz", @@ -68481,6 +68740,16 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -70130,9 +70399,9 @@ } }, "react-native-onyx": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.15.tgz", - "integrity": "sha512-uIJped+agmOppnCoDcs/w3qFertkLhLHyhmEEBXp0OhzNKuCs01wg7ccYFZxOVv+CtzFbtkLVB1VW3Ty/zYogA==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.17.tgz", + "integrity": "sha512-ls2GjURfpBcGnIkwVrg2uuLnTBwd0vrEiUvbMo+GF3k81GAp2flCkVTM7ciAbo155Izk50dm0uXHYq1PIjwTxw==", "requires": { "ascii-table": "0.0.9", "lodash": "^4.17.21", @@ -70219,15 +70488,15 @@ } }, "react-native-safe-area-context": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-3.4.1.tgz", - "integrity": "sha512-xfpVd0CiZR7oBhuwJ2HcZMehg5bjha1Ohu1XHpcT+9ykula0TgovH2BNU0R5Krzf/jBR1LMjR6VabxdlUjqxcA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.4.1.tgz", + "integrity": "sha512-N9XTjiuD73ZpVlejHrUWIFZc+6Z14co1K/p1IFMkImU7+avD69F3y+lhkqA2hN/+vljdZrBSiOwXPkuo43nFQA==", "requires": {} }, "react-native-screens": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.15.0.tgz", - "integrity": "sha512-ezC5TibsUYyqPuuHpZoM3iEl6bRzCVBMJeGaFkn7xznuOt1VwkZVub0BvafIEYR/+AQC/RjxzMSQPs1qal0+wA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.17.0.tgz", + "integrity": "sha512-OZCQU7+3neHNaM19jBkYRjL50kXz7p7MUgWQTCcdRoshcCiolf8aXs4eRVQKGK6m1RmoB8UL0//m5R9KoR+41w==", "requires": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -74475,6 +74744,11 @@ "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==" }, + "use-latest-callback": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.1.5.tgz", + "integrity": "sha512-HtHatS2U4/h32NlkhupDsPlrbiD27gSH5swBdtXbCAlc6pfOFzaj0FehW/FO12rx8j2Vy4/lJScCiJyM01E+bQ==" + }, "use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -75480,11 +75754,37 @@ "is-symbol": "^1.0.3" } }, + "which-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", + "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, + "requires": { + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-weakmap": "^2.0.1", + "is-weakset": "^2.0.1" + } + }, "which-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==" }, + "which-typed-array": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.8.tgz", + "integrity": "sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "es-abstract": "^1.20.0", + "for-each": "^0.3.3", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.9" + } + }, "wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -75617,9 +75917,9 @@ } }, "ws": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.8.1.tgz", - "integrity": "sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz", + "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index 72db7454f40d..c9eabc0f0567 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.2.10-0", + "version": "1.2.17-4", "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.", @@ -9,10 +9,10 @@ "scripts": { "postinstall": "cd desktop && npm install", "clean": "npx react-native clean-project-auto", - "android": "npm run check-metro-bundler-port && scripts/set-pusher-suffix.sh && npx react-native run-android", - "ios": "npm run check-metro-bundler-port && scripts/set-pusher-suffix.sh && npx react-native run-ios", - "ipad": "npm run check-metro-bundler-port && npx react-native run-ios --simulator=\"iPad Pro (12.9-inch) (4th generation)\"", - "ipad-sm": "npm run check-metro-bundler-port && npx react-native run-ios --simulator=\"iPad Pro (9.7-inch)\"", + "android": "scripts/set-pusher-suffix.sh && npx react-native run-android --port=8083", + "ios": "scripts/set-pusher-suffix.sh && npx react-native run-ios --port=8082", + "ipad": "npx react-native run-ios --port=8082 --simulator=\"iPad Pro (12.9-inch) (4th generation)\"", + "ipad-sm": "npx react-native run-ios --port=8082 --simulator=\"iPad Pro (9.7-inch)\"", "start": "npx react-native start", "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", "web-proxy": "node web/proxy.js", @@ -24,6 +24,7 @@ "desktop-build-staging": "scripts/build-desktop.sh staging", "ios-build": "fastlane ios build", "android-build": "fastlane android build", + "android-build-e2e": "bundle exec fastlane android build_e2e", "test": "jest", "lint": "eslint . --max-warnings=0", "print-version": "echo $npm_package_version", @@ -32,9 +33,9 @@ "gh-actions-build": "./.github/scripts/buildActions.sh", "gh-actions-validate": "./.github/scripts/validateActionsAndWorkflows.sh", "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", - "check-metro-bundler-port": "node config/checkMetroBundlerPort.js", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", - "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map" + "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", + "test:e2e": "node ./e2e/testRunner.js" }, "dependencies": { "@expensify/react-native-web": "0.18.9", @@ -57,14 +58,15 @@ "@react-native-firebase/crashlytics": "^12.3.0", "@react-native-firebase/perf": "^12.3.0", "@react-native-picker/picker": "^2.4.3", - "@react-navigation/drawer": "6.3.0", - "@react-navigation/native": "6.0.11", - "@react-navigation/stack": "6.2.2", + "@react-navigation/drawer": "6.5.0", + "@react-navigation/native": "6.0.13", + "@react-navigation/stack": "6.3.1", "babel-plugin-transform-remove-console": "^6.9.4", + "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", "dotenv": "^8.2.0", - "expensify-common": "git+https://github.com/Expensify/expensify-common.git#a9e791c1190052e934c20efdcd7cd4429b4680cb", + "expensify-common": "git+https://github.com/Expensify/expensify-common.git#03a86215c0ae2bc4e7978e1ca9717fbc7fdea11b", "fbjs": "^3.0.2", "file-loader": "^6.0.0", "html-entities": "^1.3.1", @@ -94,7 +96,7 @@ "react-native-image-picker": "^4.8.5", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.15", + "react-native-onyx": "1.0.17", "react-native-pdf": "^6.6.2", "react-native-performance": "^2.0.0", "react-native-permissions": "^3.0.1", @@ -102,9 +104,9 @@ "react-native-plaid-link-sdk": "^7.2.0", "react-native-reanimated": "2.10.0", "react-native-render-html": "6.3.1", - "react-native-safe-area-context": "^3.1.4", - "react-native-screens": "^3.10.1", - "react-native-svg": "^12.1.0", + "react-native-safe-area-context": "4.4.1", + "react-native-screens": "3.17.0", + "react-native-svg": "^12.4.4", "react-native-webview": "^11.17.2", "react-pdf": "5.7.2", "react-plaid-link": "3.3.2", @@ -119,6 +121,7 @@ "@actions/github": "5.0.3", "@babel/core": "^7.11.1", "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/preset-env": "^7.11.0", "@babel/preset-flow": "^7.12.13", "@babel/preset-react": "^7.10.4", @@ -127,6 +130,7 @@ "@octokit/plugin-paginate-rest": "3.1.0", "@octokit/plugin-throttling": "4.1.0", "@react-native-community/eslint-config": "3.0.0", + "@react-navigation/devtools": "^6.0.10", "@storybook/addon-a11y": "^6.5.9", "@storybook/addon-essentials": "^6.5.9", "@storybook/addon-react-native-web": "0.0.19--canary.37.cb55428.0", @@ -153,7 +157,7 @@ "copy-webpack-plugin": "^6.4.1", "css-loader": "^5.2.4", "diff-so-fancy": "^1.3.0", - "electron": "^17.4.11", + "electron": "^21.0.0", "electron-builder": "23.5.0", "electron-notarize": "^1.2.1", "eslint": "^7.6.0", @@ -217,7 +221,8 @@ ], "setupFilesAfterEnv": [ "@testing-library/jest-native/extend-expect" - ] + ], + "cacheDirectory": "/.jest-cache" }, "prettier": { "bracketSpacing": false, diff --git a/src/App.js b/src/App.js index 2f25e4122d42..ef53f1209a7e 100644 --- a/src/App.js +++ b/src/App.js @@ -13,6 +13,7 @@ import HTMLEngineProvider from './components/HTMLEngineProvider'; import ComposeProviders from './components/ComposeProviders'; import SafeArea from './components/SafeArea'; import * as Environment from './libs/Environment/Environment'; +import {WindowDimensionsProvider} from './components/withWindowDimensions'; // 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()) { @@ -37,6 +38,7 @@ const App = () => ( SafeArea, LocaleContextProvider, HTMLEngineProvider, + WindowDimensionsProvider, ]} > diff --git a/src/CONFIG.js b/src/CONFIG.js index 4c03fa63c48e..33832ad490c8 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.js @@ -71,4 +71,5 @@ export default { CAPTURE_METRICS: lodashGet(Config, 'CAPTURE_METRICS', 'false') === 'true', ONYX_METRICS: lodashGet(Config, 'ONYX_METRICS', 'false') === 'true', DEV_PORT: process.env.PORT || 8080, + E2E_TESTING: lodashGet(Config, 'E2E_TESTING', 'false') === 'true', }; diff --git a/src/CONST.js b/src/CONST.js index 76e6151a37f8..e43686b0f445 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -11,6 +11,7 @@ const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; const CONST = { ANDROID_PACKAGE_NAME, ANIMATED_TRANSITION: 300, + ANIMATED_TRANSITION_FROM_VALUE: 100, API_ATTACHMENT_VALIDATIONS: { // Same as the PHP layer allows @@ -88,6 +89,10 @@ const CONST = { }, REGEX: { US_ACCOUNT_NUMBER: /^[0-9]{4,17}$/, + + // If the account number length is from 4 to 13 digits, we show the last 4 digits and hide the rest with X + // If the length is longer than 13 digits, we show the first 6 and last 4 digits, hiding the rest with X + MASKED_US_ACCOUNT_NUMBER: /^[X]{0,9}[0-9]{4}$|^[0-9]{6}[X]{4,7}[0-9]{4}$/, SWIFT_BIC: /^[A-Za-z0-9]{8,11}$/, }, VERIFICATION_MAX_ATTEMPTS: 7, @@ -414,6 +419,7 @@ const CONST = { }, DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, + DEFAULT_CLOSE_ACCOUNT_DATA: {error: '', success: '', isLoading: false}, APP_STATE: { ACTIVE: 'active', BACKGROUND: 'background', @@ -669,6 +675,7 @@ const CONST = { FREE: 'free', PERSONAL: 'personal', CORPORATE: 'corporate', + TEAM: 'team', }, ROLE: { ADMIN: 'admin', @@ -803,6 +810,7 @@ const CONST = { }, BRICK_ROAD_INDICATOR_STATUS: { ERROR: 'error', + INFO: 'info', }, REPORT_DETAILS_MENU_ITEM: { MEMBERS: 'member', @@ -841,6 +849,8 @@ const CONST = { WRITE: 'write', MAKE_REQUEST_WITH_SIDE_EFFECTS: 'makeRequestWithSideEffects', }, + + TFA_CODE_LENGTH: 6, }; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 6ea72a9ba582..93224dfd9321 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -22,6 +22,7 @@ import compose from './libs/compose'; import withLocalize, {withLocalizePropTypes} from './components/withLocalize'; import * as User from './libs/actions/User'; import NetworkConnection from './libs/NetworkConnection'; +import Navigation from './libs/Navigation/Navigation'; Onyx.registerLogger(({level, message}) => { if (level === 'alert') { @@ -141,6 +142,9 @@ class Expensify extends PureComponent { setNavigationReady() { this.setState({isNavigationReady: true}); + + // Navigate to any pending routes now that the NavigationContainer is ready + Navigation.setIsNavigationReady(); } /** diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 713dfa3df45a..0b1f45006fc2 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -24,9 +24,6 @@ export default { // Stores current date CURRENT_DATE: 'currentDate', - // Currently viewed reportID - CURRENTLY_VIEWED_REPORTID: 'currentlyViewedReportID', - // Credentials to authenticate the user CREDENTIALS: 'credentials', @@ -161,9 +158,6 @@ export default { // Is policy data loading? IS_LOADING_POLICY_DATA: 'isLoadingPolicyData', - // Are we loading the create policy room command - IS_LOADING_CREATE_POLICY_ROOM: 'isLoadingCratePolicyRoom', - // Is Keyboard shortcuts modal open? IS_SHORTCUTS_MODAL_OPEN: 'isShortcutsModalOpen', @@ -180,5 +174,9 @@ export default { FORMS: { ADD_DEBIT_CARD_FORM: 'addDebitCardForm', REQUEST_CALL_FORM: 'requestCallForm', + REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', }, + + // Whether we should show the compose input or not + SHOULD_SHOW_COMPOSE_INPUT: 'shouldShowComposeInput', }; diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 96fa3dee7559..3aa31b8e7d31 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -26,6 +26,9 @@ const propTypes = { /** Contains plaid data */ plaidData: plaidDataPropTypes, + /** Selected account ID from the Picker associated with the end of the Plaid flow */ + selectedPlaidAccountID: PropTypes.string, + /** Plaid SDK token to use to initialize the widget */ plaidLinkToken: PropTypes.string, @@ -61,6 +64,7 @@ const defaultProps = { isLoading: false, error: '', }, + selectedPlaidAccountID: '', plaidLinkToken: '', onExitPlaid: () => {}, onSelect: () => {}, @@ -75,7 +79,6 @@ class AddPlaidBankAccount extends React.Component { constructor(props) { super(props); - this.selectAccount = this.selectAccount.bind(this); this.getPlaidLinkToken = this.getPlaidLinkToken.bind(this); } @@ -88,15 +91,6 @@ class AddPlaidBankAccount extends React.Component { BankAccounts.openPlaidBankLogin(this.props.allowDebit, this.props.bankAccountID); } - /** - * Get list of bank accounts - * - * @returns {Object[]} - */ - getPlaidBankAccounts() { - return lodashGet(this.props.plaidData, 'bankAccounts', []); - } - /** * @returns {String} */ @@ -110,25 +104,13 @@ class AddPlaidBankAccount extends React.Component { } } - /** - * Triggered when user selects a Plaid bank account. - * @param {String} plaidAccountID - */ - selectAccount(plaidAccountID) { - const selectedPlaidBankAccount = _.findWhere(this.getPlaidBankAccounts(), {plaidAccountID}); - selectedPlaidBankAccount.bankName = this.props.plaidData.bankName; - selectedPlaidBankAccount.plaidAccessToken = this.props.plaidData.plaidAccessToken; - this.props.onSelect({selectedPlaidBankAccount}); - } - render() { - const plaidBankAccounts = this.getPlaidBankAccounts(); + const plaidBankAccounts = lodashGet(this.props.plaidData, 'bankAccounts', []); const token = this.getPlaidLinkToken(); const options = _.map(plaidBankAccounts, account => ({ - value: account.plaidAccountID, label: `${account.addressName} ${account.mask}`, + value: account.plaidAccountID, + label: `${account.addressName} ${account.mask}`, })); - const institutionName = lodashGet(this.props, 'plaidData.institution.name', ''); - const selectedPlaidBankAccount = lodashGet(this.props, 'plaidData.selectedPlaidBankAccount', {}); const {icon, iconSize} = getBankIcon(); // Plaid Link view @@ -180,18 +162,18 @@ class AddPlaidBankAccount extends React.Component { height={iconSize} width={iconSize} /> - {institutionName} + {this.props.plaidData.bankName} diff --git a/src/components/AnimatedStep.js b/src/components/AnimatedStep.js index b876f9c26e87..0cd7261eb501 100644 --- a/src/components/AnimatedStep.js +++ b/src/components/AnimatedStep.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import * as Animatable from 'react-native-animatable'; import CONST from '../CONST'; +import styles from '../styles/styles'; const propTypes = { /** Children to wrap in AnimatedStep. */ @@ -25,9 +26,9 @@ const AnimatedStep = (props) => { let animationStyle; if (direction === 'in') { - animationStyle = 'slideInRight'; + animationStyle = styles.makeSlideInTranslation('translateX', CONST.ANIMATED_TRANSITION_FROM_VALUE); } else if (direction === 'out') { - animationStyle = 'slideInLeft'; + animationStyle = styles.makeSlideInTranslation('translateX', -CONST.ANIMATED_TRANSITION_FROM_VALUE); } return animationStyle; } diff --git a/src/components/AttachmentPicker/launchCamera.ios.js b/src/components/AttachmentPicker/launchCamera.ios.js new file mode 100644 index 000000000000..7ac3708c5dd3 --- /dev/null +++ b/src/components/AttachmentPicker/launchCamera.ios.js @@ -0,0 +1,32 @@ +import {PERMISSIONS, request, RESULTS} from 'react-native-permissions'; +import {launchCamera} from 'react-native-image-picker'; + +/** + * Launching the camera for iOS involves checking for permissions + * And only then starting the camera + * If the user deny permission the callback will be called with an error response + * in the same format as the error returned by react-native-image-picker + * @param {CameraOptions} options + * @param {function} callback - callback called with the result + */ +export default function launchCameraIOS(options, callback) { + // Checks current camera permissions and prompts the user in case they aren't granted + request(PERMISSIONS.IOS.CAMERA) + .then((permission) => { + if (permission !== RESULTS.GRANTED) { + const error = new Error('User did not grant permissions'); + error.errorCode = 'permission'; + throw error; + } + + launchCamera(options, callback); + }) + .catch((error) => { + /* Intercept the permission error as well as any other errors and call the callback + * follow the same pattern expected for image picker results */ + callback({ + errorMessage: error.message, + errorCode: error.errorCode || 'others', + }); + }); +} diff --git a/src/components/BigNumberPad.js b/src/components/BigNumberPad.js index 867b946f25bc..56dc37d5a440 100644 --- a/src/components/BigNumberPad.js +++ b/src/components/BigNumberPad.js @@ -78,7 +78,6 @@ class BigNumberPad extends React.Component { ControlSelection.unblock(); this.props.longPressHandlerStateChanged(false); }} - textSelectable={false} /> ); })} diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.js index 8a4447bb0cdc..960428027e14 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.js +++ b/src/components/BlockingViews/FullPageNotFoundView.js @@ -18,10 +18,30 @@ const propTypes = { /** If true, child components are replaced with a blocking "not found" view */ shouldShow: PropTypes.bool, + + /** The key in the translations file to use for the title */ + titleKey: PropTypes.string, + + /** The key in the translations file to use for the subtitle */ + subtitleKey: PropTypes.string, + + /** Whether we should show a back icon */ + shouldShowBackButton: PropTypes.bool, + + /** Whether we should show a close button */ + shouldShowCloseButton: PropTypes.bool, + + /** Method to trigger when pressing the back button of the header */ + onBackButtonPress: PropTypes.func, }; const defaultProps = { shouldShow: false, + titleKey: 'notFound.notHere', + subtitleKey: 'notFound.pageNotFound', + shouldShowBackButton: true, + shouldShowCloseButton: true, + onBackButtonPress: () => Navigation.dismissModal(), }; // eslint-disable-next-line rulesdir/no-negated-variables @@ -30,15 +50,16 @@ const FullPageNotFoundView = (props) => { return ( <> Navigation.dismissModal()} + shouldShowBackButton={props.shouldShowBackButton} + shouldShowCloseButton={props.shouldShowCloseButton} + onBackButtonPress={props.onBackButtonPress} onCloseButtonPress={() => Navigation.dismissModal()} /> - + diff --git a/src/components/Button.js b/src/components/Button.js index e71652d52382..e50d13abcbb0 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -106,9 +106,6 @@ const propTypes = { /** Id to use for this button */ nativeID: PropTypes.string, - - /** Whether text in Button should selectable */ - textSelectable: PropTypes.bool, }; const defaultProps = { @@ -139,7 +136,6 @@ const defaultProps = { shouldRemoveLeftBorderRadius: false, shouldEnableHapticFeedback: false, nativeID: '', - textSelectable: true, }; class Button extends Component { @@ -183,7 +179,7 @@ class Button extends Component { const textComponent = ( 0) { + if (embeddedImages.length > 0 && embeddedImages[0].src) { fetch(embeddedImages[0].src) .then((response) => { if (!response.ok) { throw Error(response.statusText); } @@ -339,6 +342,18 @@ class Composer extends React.Component { event.stopPropagation(); } + /** + * We want to call updateNumberOfLines only when the parent doesn't provide value in props + * as updateNumberOfLines is already being called when value changes in componentDidUpdate + */ + shouldCallUpdateNumberOfLines() { + if (!_.isEmpty(this.props.value)) { + return; + } + + this.updateNumberOfLines(); + } + /** * Check the current scrollHeight of the textarea (minus any padding) and * divide by line height to get the total number of rows for the textarea. @@ -373,9 +388,7 @@ class Composer extends React.Component { placeholderTextColor={themeColors.placeholderText} ref={el => this.textInput = el} selection={this.state.selection} - onChange={() => { - this.updateNumberOfLines(); - }} + onChange={this.shouldCallUpdateNumberOfLines} onSelectionChange={this.onSelectionChange} numberOfLines={this.state.numberOfLines} style={propStyles} diff --git a/src/components/CopyTextToClipboard.js b/src/components/CopyTextToClipboard.js index 974e40db2435..594e696361b0 100644 --- a/src/components/CopyTextToClipboard.js +++ b/src/components/CopyTextToClipboard.js @@ -59,7 +59,7 @@ class CopyTextToClipboard extends React.Component { suppressHighlighting > {this.props.text} - + { } return ( - + {`${props.commentLength}/${CONST.MAX_COMMENT_LENGTH}`} ); diff --git a/src/components/Form.js b/src/components/Form.js index f42101700862..fca7277e1920 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import compose from '../libs/compose'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import * as FormActions from '../libs/actions/FormActions'; +import * as ErrorUtils from '../libs/ErrorUtils'; import styles from '../styles/styles'; import FormAlertWithSubmitButton from './FormAlertWithSubmitButton'; @@ -16,6 +17,9 @@ const propTypes = { /** Text to be displayed in the submit button */ submitButtonText: PropTypes.string.isRequired, + /** Controls the submit button's visibility */ + isSubmitButtonVisible: PropTypes.bool, + /** Callback to validate the form */ validate: PropTypes.func.isRequired, @@ -32,23 +36,28 @@ const propTypes = { /** Controls the loading state of the form */ isLoading: PropTypes.bool, - /** Server side error message */ - error: PropTypes.string, + /** Server side errors keyed by microtime */ + errors: PropTypes.objectOf(PropTypes.string), }), /** Contains draft values for each input in the form */ // eslint-disable-next-line react/forbid-prop-types draftValues: PropTypes.object, + /** Should the button be enabled when offline */ + enabledWhenOffline: PropTypes.bool, + ...withLocalizePropTypes, }; const defaultProps = { + isSubmitButtonVisible: true, formState: { isLoading: false, - error: '', + errors: null, }, draftValues: {}, + enabledWhenOffline: false, }; class Form extends React.Component { @@ -57,10 +66,10 @@ class Form extends React.Component { this.state = { errors: {}, + inputValues: {}, }; this.inputRefs = {}; - this.inputValues = {}; this.touchedInputs = {}; this.setTouchedInput = this.setTouchedInput.bind(this); @@ -75,6 +84,11 @@ class Form extends React.Component { this.touchedInputs[inputID] = true; } + getErrorMessage() { + const latestErrorMessage = ErrorUtils.getLatestErrorMessage(this.props.formState); + return this.props.formState.error || (typeof latestErrorMessage === 'string' ? latestErrorMessage : ''); + } + submit() { // Return early if the form is already submitting to avoid duplicate submission if (this.props.formState.isLoading) { @@ -87,12 +101,12 @@ class Form extends React.Component { )); // Validate form and return early if any errors are found - if (!_.isEmpty(this.validate(this.inputValues))) { + if (!_.isEmpty(this.validate(this.state.inputValues))) { return; } // Call submit handler - this.props.onSubmit(this.inputValues); + this.props.onSubmit(this.state.inputValues); } /** @@ -100,7 +114,7 @@ class Form extends React.Component { * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2} */ validate(values) { - FormActions.setErrorMessage(this.props.formID, ''); + FormActions.setErrors(this.props.formID, null); const validationErrors = this.props.validate(values); if (!_.isObject(validationErrors)) { @@ -145,30 +159,38 @@ class Form extends React.Component { const defaultValue = this.props.draftValues[inputID] || child.props.defaultValue; // We want to initialize the input value if it's undefined - if (_.isUndefined(this.inputValues[inputID])) { - this.inputValues[inputID] = defaultValue; + if (_.isUndefined(this.state.inputValues[inputID])) { + this.state.inputValues[inputID] = defaultValue; + } + + if (!_.isUndefined(child.props.value)) { + this.state.inputValues[inputID] = child.props.value; } return React.cloneElement(child, { ref: node => this.inputRefs[inputID] = node, - defaultValue, + value: this.state.inputValues[inputID], errorText: this.state.errors[inputID] || '', onBlur: () => { this.setTouchedInput(inputID); - this.validate(this.inputValues); + this.validate(this.state.inputValues); }, onInputChange: (value, key) => { const inputKey = key || inputID; - this.inputValues[inputKey] = value; - const inputRef = this.inputRefs[inputKey]; + this.setState(prevState => ({ + inputValues: { + ...prevState.inputValues, + [inputKey]: value, + }, + }), () => this.validate(this.state.inputValues)); - if (key && inputRef && _.isFunction(inputRef.setNativeProps)) { - inputRef.setNativeProps({value}); - } if (child.props.shouldSaveDraft) { FormActions.setDraftValues(this.props.formID, {[inputKey]: value}); } - this.validate(this.inputValues); + + if (child.props.onValueChange) { + child.props.onValueChange(value); + } }, }); }); @@ -184,17 +206,20 @@ class Form extends React.Component { > {this.childrenWrapperWithProps(this.props.children)} + {this.props.isSubmitButtonVisible && ( 0 || Boolean(this.props.formState.error)} + isAlertVisible={_.size(this.state.errors) > 0 || Boolean(this.getErrorMessage())} isLoading={this.props.formState.isLoading} - message={this.props.formState.error} + message={this.getErrorMessage()} onSubmit={this.submit} onFixTheErrorsLinkPressed={() => { this.inputRefs[_.first(_.keys(this.state.errors))].focus(); }} containerStyles={[styles.mh0, styles.mt5]} + enabledWhenOffline={this.props.enabledWhenOffline} /> + )} @@ -212,7 +237,7 @@ export default compose( key: props => props.formID, }, draftValues: { - key: props => `${props.formID}DraftValues`, + key: props => `${props.formID}Draft`, }, }), )(Form); diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js index 4ce82e163db4..8425657b66fa 100755 --- a/src/components/IOUConfirmationList.js +++ b/src/components/IOUConfirmationList.js @@ -350,6 +350,7 @@ class IOUConfirmationList extends Component { hideAdditionalOptionStates forceTextUnreadStyle autoFocus + shouldDelayFocus shouldTextInputAppearBelowOptions optionHoveredStyle={canModifyParticipants ? styles.hoveredComponentBG : {}} footerContent={shouldShowSettlementButton diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js index f7466159a964..273f5854a3d7 100644 --- a/src/components/Icon/Illustrations.js +++ b/src/components/Icon/Illustrations.js @@ -11,6 +11,7 @@ import JewelBoxPink from '../../../assets/images/product-illustrations/jewel-box import JewelBoxYellow from '../../../assets/images/product-illustrations/jewel-box--yellow.svg'; import MoneyEnvelopeBlue from '../../../assets/images/product-illustrations/money-envelope--blue.svg'; import MoneyMousePink from '../../../assets/images/product-illustrations/money-mouse--pink.svg'; +import ReceiptsSearchYellow from '../../../assets/images/product-illustrations/receipts-search--yellow.svg'; import ReceiptYellow from '../../../assets/images/product-illustrations/receipt--yellow.svg'; import RocketOrange from '../../../assets/images/product-illustrations/rocket--orange.svg'; import TadaYellow from '../../../assets/images/product-illustrations/tada--yellow.svg'; @@ -31,6 +32,7 @@ export { JewelBoxYellow, MoneyEnvelopeBlue, MoneyMousePink, + ReceiptsSearchYellow, ReceiptYellow, RocketOrange, TadaYellow, diff --git a/src/components/KeyboardShortcutsModal.js b/src/components/KeyboardShortcutsModal.js index 7437d187d233..0cd8b2a385f3 100644 --- a/src/components/KeyboardShortcutsModal.js +++ b/src/components/KeyboardShortcutsModal.js @@ -56,7 +56,6 @@ class KeyboardShortcutsModal extends React.Component { { ? props.hoverStyle.backgroundColor : themeColors.sidebar; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const isMultipleParticipant = lodashGet(optionItem, 'participantsList.length', 0) > 1; - // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((optionItem.participantsList || []).slice(0, 10), isMultipleParticipant); - const avatarTooltips = !optionItem.isChatRoom && !optionItem.isArchivedRoom ? _.pluck(displayNamesWithTooltips, 'tooltip') : undefined; + const avatarTooltips = !optionItem.isChatRoom && !optionItem.isArchivedRoom ? _.pluck(optionItem.displayNamesWithTooltips, 'tooltip') : undefined; return ( @@ -163,7 +158,7 @@ const OptionRowLHN = (props) => { { const titleTextStyle = StyleUtils.combineStyles([ styles.popoverMenuText, styles.ml3, + (props.shouldShowBasicTitle ? undefined : styles.textStrong), (props.interactive && props.disabled ? styles.disabledText : undefined), ], props.style); - const descriptionTextStyle = StyleUtils.combineStyles([styles.textLabelSupporting, styles.ml3, styles.mt1, styles.breakAll], props.style); + const descriptionTextStyle = StyleUtils.combineStyles([styles.textLabelSupporting, styles.ml3, styles.breakAll], props.style); return ( { > {({hovered, pressed}) => ( <> - + {(props.icon && props.iconType === CONST.ICON_TYPE_ICON) && ( { /> )} - - - {props.title} - - {Boolean(props.description) && ( + + {Boolean(props.description) && props.shouldShowDescriptionOnTop && ( + + {props.description} + + )} + {Boolean(props.title) && ( + + {props.title} + + )} + {Boolean(props.description) && !props.shouldShowDescriptionOnTop && ( { )} {Boolean(props.shouldShowRightIcon) && ( - + ( + +); + +MenuItemWithTopDescription.propTypes = propTypes; +MenuItemWithTopDescription.displayName = 'MenuItemWithTopDescription'; + +export default MenuItemWithTopDescription; diff --git a/src/components/OnyxProvider.js b/src/components/OnyxProvider.js index 008039d86722..17868890d672 100644 --- a/src/components/OnyxProvider.js +++ b/src/components/OnyxProvider.js @@ -11,6 +11,7 @@ const [withPersonalDetails, PersonalDetailsProvider] = createOnyxContext(ONYXKEY const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); +const [withBetas, BetasProvider] = createOnyxContext(ONYXKEYS.BETAS); const propTypes = { /** Rendered child component */ @@ -25,6 +26,7 @@ const OnyxProvider = props => ( ReportActionsDraftsProvider, CurrentDateProvider, BlockedFromConciergeProvider, + BetasProvider, ]} > {props.children} @@ -42,4 +44,5 @@ export { withReportActionsDrafts, withCurrentDate, withBlockedFromConcierge, + withBetas, }; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 690e21b6b073..7fce40647c3f 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {View, findNodeHandle} from 'react-native'; +import {View} from 'react-native'; import Button from '../Button'; import FixedFooter from '../FixedFooter'; import OptionsList from '../OptionsList'; @@ -14,6 +14,7 @@ import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; import KeyboardShortcut from '../../libs/KeyboardShortcut'; import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; import {propTypes as optionsSelectorPropTypes, defaultProps as optionsSelectorDefaultProps} from './optionsSelectorPropTypes'; +import setSelection from '../../libs/setSelection'; const propTypes = { /** Whether we should wait before focusing the TextInput, useful when using transitions on Android */ @@ -88,7 +89,7 @@ class BaseOptionsSelector extends Component { } if (this.props.shouldDelayFocus) { - setTimeout(() => this.textInput.focus(), CONST.ANIMATED_TRANSITION); + this.focusTimeout = setTimeout(() => this.textInput.focus(), CONST.ANIMATED_TRANSITION); } else { this.textInput.focus(); } @@ -121,6 +122,10 @@ class BaseOptionsSelector extends Component { } componentWillUnmount() { + if (this.focusTimeout) { + clearTimeout(this.focusTimeout); + } + if (this.unsubscribeEnter) { this.unsubscribeEnter(); } @@ -202,8 +207,8 @@ class BaseOptionsSelector extends Component { selectRow(option, ref) { if (this.props.shouldFocusOnSelectRow) { // Input is permanently focused on native platforms, so we always highlight the text inside of it - this.textInput.setNativeProps({selection: {start: 0, end: this.props.value.length}}); - if (this.relatedTarget && ref === findNodeHandle(this.relatedTarget)) { + setSelection(this.textInput, 0, this.props.value.length); + if (this.relatedTarget && ref === this.relatedTarget) { this.textInput.focus(); } this.relatedTarget = null; @@ -232,12 +237,7 @@ class BaseOptionsSelector extends Component { ref={el => this.textInput = el} value={this.props.value} label={this.props.textInputLabel} - onChangeText={(text) => { - if (this.props.shouldFocusOnSelectRow) { - this.textInput.setNativeProps({selection: null}); - } - this.props.onChangeText(text); - }} + onChangeText={this.props.onChangeText} placeholder={this.props.placeholderText || this.props.translate('optionsSelector.nameEmailOrPhoneNumber')} onBlur={(e) => { if (!this.props.shouldFocusOnSelectRow) { diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js index c2eec6034861..33cf05d6d10e 100644 --- a/src/components/PDFView/index.native.js +++ b/src/components/PDFView/index.native.js @@ -133,7 +133,7 @@ class PDFView extends Component { } + renderActivityIndicator={() => } source={{uri: this.props.sourceURL}} style={pdfStyles} onError={this.initiatePasswordChallenge} diff --git a/src/components/Picker/BasePicker/index.js b/src/components/Picker/BasePicker/index.js index eb3021ab54a9..a34b523f383d 100644 --- a/src/components/Picker/BasePicker/index.js +++ b/src/components/Picker/BasePicker/index.js @@ -10,26 +10,7 @@ class BasePicker extends React.Component { constructor(props) { super(props); - this.pickerValue = this.props.defaultValue; - - this.updateSelectedValueAndExecuteOnChange = this.updateSelectedValueAndExecuteOnChange.bind(this); this.executeOnCloseAndOnBlur = this.executeOnCloseAndOnBlur.bind(this); - this.setNativeProps = this.setNativeProps.bind(this); - } - - /** - * This method mimicks RN's setNativeProps method. It's exposed to Picker's ref and can be used by other components - * to directly manipulate Picker's value when Picker is used as an uncontrolled input. - * - * @param {*} value - */ - setNativeProps({value}) { - this.pickerValue = value; - } - - updateSelectedValueAndExecuteOnChange(value) { - this.props.onInputChange(value); - this.pickerValue = value; } executeOnCloseAndOnBlur() { @@ -42,12 +23,12 @@ class BasePicker extends React.Component { const hasError = !_.isEmpty(this.props.errorText); return ( this.props.icon(this.props.size)} disabled={this.props.disabled} fixAndroidTouchableBug @@ -57,15 +38,11 @@ class BasePicker extends React.Component { onFocus: this.props.onOpen, onBlur: this.executeOnCloseAndOnBlur, }} - ref={(node) => { - if (!node || !_.isFunction(this.props.innerRef)) { + ref={(el) => { + if (!_.isFunction(this.props.innerRef)) { return; } - - this.props.innerRef(node); - - // eslint-disable-next-line no-param-reassign - node.setNativeProps = this.setNativeProps; + this.props.innerRef(el); }} /> ); diff --git a/src/components/Picker/index.js b/src/components/Picker/index.js index 81e80bea6541..4dc676966827 100644 --- a/src/components/Picker/index.js +++ b/src/components/Picker/index.js @@ -29,6 +29,9 @@ const propTypes = { /** Saves a draft of the input value when used in a form */ shouldSaveDraft: PropTypes.bool, + + /** A callback method that is called when the value changes and it receives the selected value as an argument */ + onInputChange: PropTypes.func.isRequired, }; const defaultProps = { @@ -47,6 +50,23 @@ class Picker extends PureComponent { this.state = { isOpen: false, }; + + this.onInputChange = this.onInputChange.bind(this); + } + + /** + * Forms use inputID to set values. But Picker passes an index as the second parameter to onInputChange + * We are overriding this behavior to make Picker work with Form + * @param {String} value + * @param {Number} index + */ + onInputChange(value, index) { + if (this.props.inputID) { + this.props.onInputChange(value); + return; + } + + this.props.onInputChange(value, index); } render() { @@ -72,6 +92,7 @@ class Picker extends PureComponent { value={this.props.value} // eslint-disable-next-line react/jsx-props-no-spreading {...pickerProps} + onInputChange={this.onInputChange} /> diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index 8c122600f162..c472bb3ae0a4 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -9,6 +9,7 @@ import HeaderWithCloseButton from './HeaderWithCloseButton'; import Navigation from '../libs/Navigation/Navigation'; import ScreenWrapper from './ScreenWrapper'; import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; +import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; const propTypes = { /** Whether the user is submitting verifications data */ @@ -23,23 +24,25 @@ const ReimbursementAccountLoadingIndicator = props => ( title={props.translate('reimbursementAccountLoadingAnimation.oneMoment')} onCloseButtonPress={Navigation.dismissModal} /> - {props.isSubmittingVerificationsData ? ( - - - - - {props.translate('reimbursementAccountLoadingAnimation.explanationLine')} - + + {props.isSubmittingVerificationsData ? ( + + + + + {props.translate('reimbursementAccountLoadingAnimation.explanationLine')} + + - - ) : ( - - )} + ) : ( + + )} + ); diff --git a/src/components/ReportActionItem/IOUAction.js b/src/components/ReportActionItem/IOUAction.js index 1baa2305b378..83730269a909 100644 --- a/src/components/ReportActionItem/IOUAction.js +++ b/src/components/ReportActionItem/IOUAction.js @@ -14,7 +14,7 @@ const propTypes = { action: PropTypes.shape(reportActionPropTypes).isRequired, /** The associated chatReport */ - chatReportID: PropTypes.number.isRequired, + chatReportID: PropTypes.string.isRequired, /** Is this IOUACTION the most recent? */ isMostRecentIOUReportAction: PropTypes.bool.isRequired, @@ -47,7 +47,7 @@ const IOUAction = (props) => { {((props.isMostRecentIOUReportAction && Boolean(props.action.originalMessage.IOUReportID)) || (props.action.originalMessage.type === 'pay')) && ( { return ( - + {displayName} {props.translate('newRoomPage.renamedRoomAction', {oldName, newName})} diff --git a/src/components/ReportActionsSkeletonView/index.js b/src/components/ReportActionsSkeletonView/index.js index 6d04a2a5001e..1062fd665450 100644 --- a/src/components/ReportActionsSkeletonView/index.js +++ b/src/components/ReportActionsSkeletonView/index.js @@ -10,7 +10,7 @@ const propTypes = { const ReportActionsSkeletonView = (props) => { // Determines the number of content items based on container height - const possibleVisibleContentItems = Math.floor(props.containerHeight / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT); + const possibleVisibleContentItems = Math.ceil(props.containerHeight / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT); const skeletonViewLines = []; for (let index = 0; index < possibleVisibleContentItems; index++) { const iconIndex = (index + 1) % 4; diff --git a/src/components/ReportHeaderSkeletonView.js b/src/components/ReportHeaderSkeletonView.js new file mode 100644 index 000000000000..85f2b955e65d --- /dev/null +++ b/src/components/ReportHeaderSkeletonView.js @@ -0,0 +1,38 @@ +import React from 'react'; +import {Pressable, View} from 'react-native'; +import {Rect, Circle} from 'react-native-svg'; +import SkeletonViewContentLoader from 'react-content-loader/native'; +import styles from '../styles/styles'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; +import variables from '../styles/variables'; + +const propTypes = { + ...windowDimensionsPropTypes, +}; + +const ReportHeaderSkeletonView = props => ( + + + {}} + style={[styles.LHNToggle]} + > + + + + + + + + + +); + +ReportHeaderSkeletonView.propTypes = propTypes; +ReportHeaderSkeletonView.displayName = 'ReportHeaderSkeletonView'; +export default withWindowDimensions(ReportHeaderSkeletonView); diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index c9c174fe80ad..0cb559128768 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -107,7 +107,7 @@ const ReportWelcomeText = (props) => { {_.map(displayNamesWithTooltips, ({ displayName, pronouns, tooltip, }, index) => ( - + Navigation.navigate(ROUTES.getDetailsRoute(participants[index]))}> {displayName} diff --git a/src/components/RoomHeaderAvatars.js b/src/components/RoomHeaderAvatars.js index e70ed68a9fc4..4b96b45af8cc 100644 --- a/src/components/RoomHeaderAvatars.js +++ b/src/components/RoomHeaderAvatars.js @@ -65,7 +65,7 @@ const RoomHeaderAvatars = (props) => { {_.map(iconsToDisplay, (val, index) => ( - + {index === CONST.REPORT.MAX_PREVIEW_AVATARS - 1 && props.icons.length - CONST.REPORT.MAX_PREVIEW_AVATARS !== 0 && ( diff --git a/src/components/StatePicker.js b/src/components/StatePicker.js index a76fd7ba0197..ceff688954a8 100644 --- a/src/components/StatePicker.js +++ b/src/components/StatePicker.js @@ -32,16 +32,12 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, - /** The default value of the state picker */ - defaultValue: PropTypes.string, - ...withLocalizePropTypes, }; const defaultProps = { label: '', value: undefined, - defaultValue: undefined, errorText: '', shouldSaveDraft: false, inputID: undefined, @@ -56,7 +52,6 @@ const StatePicker = forwardRef((props, ref) => ( items={STATES} onInputChange={props.onInputChange} value={props.value} - defaultValue={props.defaultValue} label={props.label || props.translate('common.state')} errorText={props.errorText} onBlur={props.onBlur} diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index eec708d91113..2f970bbb12e4 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -263,6 +263,7 @@ class BaseTextInput extends Component { }} // eslint-disable-next-line {...inputProps} + autoCorrect={this.props.secureTextEntry ? false : this.props.autoCorrect} placeholder={placeholder} placeholderTextColor={themeColors.placeholderText} underlineColorAndroid="transparent" @@ -291,6 +292,7 @@ class BaseTextInput extends Component { e.preventDefault()} > {}, - }; - - class WithWindowDimensions extends Component { - constructor(props) { - super(props); - - // Using debounce here as a temporary fix for a bug in react-native - // https://github.com/facebook/react-native/issues/29290 - // When the app is sent to background on iPads, onDimensionChange callback is called with - // swapped window dimensions before it was called with correct dimensions within miliseconds, then - // drawer is being positioned incorrectly due to animation issues in react-navigation. - // Adding debounce here slows down window dimension changes to let - // react-navigation to complete the positioning of elements properly. - this.onDimensionChange = _.debounce(this.onDimensionChange.bind(this), 100); - - const initialDimensions = Dimensions.get('window'); - const isSmallScreenWidth = initialDimensions.width <= variables.mobileResponsiveWidthBreakpoint; - const isMediumScreenWidth = initialDimensions.width > variables.mobileResponsiveWidthBreakpoint - && initialDimensions.width <= variables.tabletResponsiveWidthBreakpoint; - - this.dimensionsEventListener = null; - - this.state = { - windowHeight: initialDimensions.height, - windowWidth: initialDimensions.width, - isSmallScreenWidth, - isMediumScreenWidth, - }; - } +const windowDimensionsProviderPropTypes = { + /* Actual content wrapped by this component */ + children: PropTypes.node.isRequired, +}; - componentDidMount() { - this.dimensionsEventListener = Dimensions.addEventListener('change', this.onDimensionChange); - } +class WindowDimensionsProvider extends React.Component { + constructor(props) { + super(props); - componentWillUnmount() { - if (!this.dimensionsEventListener) { - return; - } - this.dimensionsEventListener.remove(); - } + this.onDimensionChange = this.onDimensionChange.bind(this); - /** - * Stores the application window's width and height in a component state variable. - * Called each time the application's window dimensions or screen dimensions change. - * @link https://reactnative.dev/docs/dimensions - * @param {Object} newDimensions Dimension object containing updated window and screen dimensions - */ - onDimensionChange(newDimensions) { - const {window} = newDimensions; - const isSmallScreenWidth = window.width <= variables.mobileResponsiveWidthBreakpoint; - const isMediumScreenWidth = !isSmallScreenWidth && window.width <= variables.tabletResponsiveWidthBreakpoint; - this.setState({ - windowHeight: window.height, - windowWidth: window.width, - isSmallScreenWidth, - isMediumScreenWidth, - }); - } + const initialDimensions = Dimensions.get('window'); + const isSmallScreenWidth = initialDimensions.width <= variables.mobileResponsiveWidthBreakpoint; + const isMediumScreenWidth = initialDimensions.width > variables.mobileResponsiveWidthBreakpoint + && initialDimensions.width <= variables.tabletResponsiveWidthBreakpoint; + + this.dimensionsEventListener = null; + + this.state = { + windowHeight: initialDimensions.height, + windowWidth: initialDimensions.width, + isSmallScreenWidth, + isMediumScreenWidth, + }; + } + + componentDidMount() { + this.dimensionsEventListener = Dimensions.addEventListener('change', this.onDimensionChange); + } - render() { - // eslint-disable-next-line react/destructuring-assignment - const {forwardedRef, ...rest} = this.props; - return ( - - ); + componentWillUnmount() { + if (!this.dimensionsEventListener) { + return; } + this.dimensionsEventListener.remove(); } - WithWindowDimensions.propTypes = propTypes; - WithWindowDimensions.defaultProps = defaultProps; - WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`; - return React.forwardRef((props, ref) => ( - // eslint-disable-next-line react/jsx-props-no-spreading - + /** + * Stores the application window's width and height in a component state variable. + * Called each time the application's window dimensions or screen dimensions change. + * @link https://reactnative.dev/docs/dimensions + * @param {Object} newDimensions Dimension object containing updated window and screen dimensions + */ + onDimensionChange(newDimensions) { + const {window} = newDimensions; + const isSmallScreenWidth = window.width <= variables.mobileResponsiveWidthBreakpoint; + const isMediumScreenWidth = !isSmallScreenWidth && window.width <= variables.tabletResponsiveWidthBreakpoint; + this.setState({ + windowHeight: window.height, + windowWidth: window.width, + isSmallScreenWidth, + isMediumScreenWidth, + }); + } + + render() { + return ( + + {this.props.children} + + ); + } +} + +WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes; + +/** + * @param {React.Component} WrappedComponent + * @returns {React.Component} + */ +export default function withWindowDimensions(WrappedComponent) { + const WithWindowDimensions = forwardRef((props, ref) => ( + + {windowDimensionsProps => ( + // eslint-disable-next-line react/jsx-props-no-spreading + + )} + )); + + WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`; + return WithWindowDimensions; } export { + WindowDimensionsProvider, windowDimensionsPropTypes, }; diff --git a/src/languages/en.js b/src/languages/en.js index 1b7e384a92dd..844f1b090c81 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -525,6 +525,7 @@ export default { iouReportNotFound: 'The payment details you are looking for cannot be found.', notHere: "Hmm... it's not here", pageNotFound: 'That page is nowhere to be found.', + noAccess: 'You don\'t have access to this chat', }, setPasswordPage: { enterPassword: 'Enter a password', @@ -718,7 +719,8 @@ export default { headerTitle: 'Enable payments', activatedTitle: 'Wallet activated!', activatedMessage: 'Congrats, your wallet is set up and ready to make payments.', - checkBackLater: 'We\'re still reviewing your information. Please check back later.', + checkBackLaterTitle: 'Just a minute...', + checkBackLaterMessage: 'We\'re still reviewing your information. Please check back later.', continueToPayment: 'Continue to payment', continueToTransfer: 'Continue to transfer', }, @@ -822,6 +824,7 @@ export default { error: { genericAdd: 'There was a problem adding this workspace member.', cannotRemove: 'You cannot remove yourself or the workspace owner.', + genericRemove: 'There was a problem removing that workspace member.', }, }, card: { @@ -989,7 +992,6 @@ export default { renamedRoomAction: ({oldName, newName}) => ` renamed this room from ${oldName} to ${newName}`, social: 'social', selectAWorkspace: 'Select a workspace', - growlMessageOnError: 'Unable to create policy room, please check your connection and try again.', growlMessageOnRenameError: 'Unable to rename policy room, please check your connection and try again.', visibilityOptions: { restricted: 'Restricted', diff --git a/src/languages/es.js b/src/languages/es.js index 9fee9c421a5a..66d8ceb364c1 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -525,6 +525,7 @@ export default { iouReportNotFound: 'Los detalles del pago que estás buscando no se pudieron encontrar.', notHere: 'Hmm… no está aquí', pageNotFound: 'La página que buscas no existe.', + noAccess: 'No tienes acceso a este chat', }, setPasswordPage: { enterPassword: 'Escribe una contraseña', @@ -720,7 +721,8 @@ export default { headerTitle: 'Habilitar pagos', activatedTitle: '¡Billetera activada!', activatedMessage: 'Felicidades, tu Billetera está configurada y lista para hacer pagos.', - checkBackLater: 'Todavía estamos revisando tu información. Por favor, vuelva más tarde.', + checkBackLaterTitle: 'Un momento...', + checkBackLaterMessage: 'Todavía estamos revisando tu información. Por favor, vuelva más tarde.', continueToPayment: 'Continuar al pago', continueToTransfer: 'Continuar a la transferencia', }, @@ -824,6 +826,7 @@ export default { error: { genericAdd: 'Ha ocurrido un problema al agregar el miembro al espacio de trabajo', cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo.', + genericRemove: 'Ha ocurrido un problema al eliminar al miembro del espacio de trabajo.', }, }, card: { @@ -991,7 +994,6 @@ export default { renamedRoomAction: ({oldName, newName}) => ` cambió el nombre de la sala de ${oldName} a ${newName}`, social: 'social', selectAWorkspace: 'Seleccionar un espacio de trabajo', - growlMessageOnError: 'No se pudo crear el espacio de trabajo, por favor comprueba tu conexión e inténtalo de nuevo.', growlMessageOnRenameError: 'No se pudo cambiar el nomdre del espacio de trabajo, por favor comprueba tu conexión e inténtalo de nuevo.', visibilityOptions: { restricted: 'Restringida', diff --git a/src/libs/ActiveClientManager/index.js b/src/libs/ActiveClientManager/index.js index 7f0d4bf0cd91..8eca45209044 100644 --- a/src/libs/ActiveClientManager/index.js +++ b/src/libs/ActiveClientManager/index.js @@ -1,3 +1,9 @@ +/** + * When you have many tabs in one browser, the data of Onyx is shared between all of them. Since we persist write requests in Onyx, we need to ensure that + * only one tab is processing those saved requests or we would be duplicating data (or creating errors). + * This file ensures exactly that by tracking all the clientIDs connected, storing the most recent one last and it considers that last clientID the "leader". + */ + import _ from 'underscore'; import Onyx from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; @@ -6,38 +12,51 @@ import * as ActiveClients from '../actions/ActiveClients'; const clientID = Str.guid(); const maxClients = 20; - -let activeClients; - -let resolveIsReadyPromise; -const isReadyPromise = new Promise((resolve) => { - resolveIsReadyPromise = resolve; +let activeClients = []; +let resolveSavedSelfPromise; +const savedSelfPromise = new Promise((resolve) => { + resolveSavedSelfPromise = resolve; }); /** + * Determines when the client is ready. We need to wait both till we saved our ID in onyx AND the init method was called * @returns {Promise} */ function isReady() { - return isReadyPromise; + return savedSelfPromise; } Onyx.connect({ key: ONYXKEYS.ACTIVE_CLIENTS, callback: (val) => { - activeClients = !val ? [] : val; - if (activeClients.length >= maxClients) { + if (!val) { + return; + } + + activeClients = val; + + // Remove from the beginning of the list any clients that are past the limit, to avoid having thousands of them + let removed = false; + while (activeClients.length >= maxClients) { activeClients.shift(); + removed = true; + } + + // Save the clients back to onyx, if they changed + if (removed) { ActiveClients.setActiveClients(activeClients); } }, }); /** - * Add our client ID to the list of active IDs + * Add our client ID to the list of active IDs. + * We want to ensure we have no duplicates and that the activeClient gets added at the end of the array (see isClientTheLeader) */ function init() { - ActiveClients.addClient(clientID) - .then(resolveIsReadyPromise); + activeClients = _.without(activeClients, clientID); + activeClients.push(clientID); + ActiveClients.setActiveClients(activeClients).then(resolveSavedSelfPromise); } /** diff --git a/src/libs/Authentication.js b/src/libs/Authentication.js index 85d1a4a2c573..cd32c958eb25 100644 --- a/src/libs/Authentication.js +++ b/src/libs/Authentication.js @@ -3,7 +3,6 @@ import * as Network from './Network'; import * as NetworkStore from './Network/NetworkStore'; import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens'; import CONFIG from '../CONFIG'; -// eslint-disable-next-line import/no-cycle import redirectToSignIn from './actions/SignInRedirect'; import CONST from '../CONST'; import Log from './Log'; diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js index 13aef6919e59..3582e672306d 100644 --- a/src/libs/DateUtils.js +++ b/src/libs/DateUtils.js @@ -9,8 +9,6 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import * as Localize from './Localize'; -// eslint-disable-next-line import/no-cycle -import * as PersonalDetails from './actions/PersonalDetails'; import * as CurrentDate from './actions/CurrentDate'; let currentUserEmail; @@ -140,14 +138,6 @@ function getCurrentTimezone() { return timezone; } -/* - * Updates user's timezone, if their timezone is set to automatic and - * is different from current timezone - */ -function updateTimezone() { - PersonalDetails.setPersonalDetails({timezone: getCurrentTimezone()}); -} - // Used to throttle updates to the timezone when necessary let lastUpdatedTimezoneTime = moment(); @@ -178,7 +168,6 @@ const DateUtils = { timestampToRelative, timestampToDateTime, startCurrentDateUpdater, - updateTimezone, getLocalMomentFromTimestamp, getCurrentTimezone, canUpdateTimezone, diff --git a/src/libs/E2E/actions/e2eLogin.js b/src/libs/E2E/actions/e2eLogin.js new file mode 100644 index 000000000000..67743611cb25 --- /dev/null +++ b/src/libs/E2E/actions/e2eLogin.js @@ -0,0 +1,52 @@ +/* eslint-disable rulesdir/prefer-onyx-connect-in-libs */ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as Session from '../../actions/Session'; + +/** + * Command for e2e test to automatically sign in a user. + * If the user is already logged in the function will simply + * resolve. + * + * @param {String} email + * @param {String} password + * @return {Promise} Resolved true when the user was actually signed in. Returns false if the user was already logged in. + */ +export default function (email, password) { + const waitForBeginSignInToFinish = () => new Promise((resolve) => { + const id = Onyx.connect({ + key: ONYXKEYS.CREDENTIALS, + callback: (credentials) => { + // beginSignUp writes to credentials.login once the API call is complete + if (!credentials.login) { return; } + + resolve(); + Onyx.disconnect(id); + }, + }); + }); + + let neededLogin = false; + + // Subscribe to auth token, to check if we are authenticated + return new Promise((resolve) => { + const connectionId = Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (session) => { + if (session.authToken == null || session.authToken.length === 0) { + neededLogin = true; + + // authenticate with a predefined user + Session.beginSignIn(email); + waitForBeginSignInToFinish().then(() => { + Session.signIn(password); + }); + } else { + // signal that auth was completed + resolve(neededLogin); + Onyx.disconnect(connectionId); + } + }, + }); + }); +} diff --git a/src/libs/E2E/client.js b/src/libs/E2E/client.js new file mode 100644 index 000000000000..1d61bfb348a8 --- /dev/null +++ b/src/libs/E2E/client.js @@ -0,0 +1,45 @@ +import Routes from '../../../e2e/server/routes'; +import Config from '../../../e2e/config'; + +const SERVER_ADDRESS = `http://localhost:${Config.SERVER_PORT}`; + +/** + * Submits a test result to the server. + * Note: a test can have multiple test results. + * + * @param {TestResult} testResult + * @returns {Promise} + */ +const submitTestResults = testResult => fetch(`${SERVER_ADDRESS}${Routes.testResults}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(testResult), +}).then((res) => { + if (res.statusCode === 200) { + console.debug(`[E2E] Test result '${testResult.name}' submitted successfully`); + return; + } + const errorMsg = `Test result submission failed with status code ${res.statusCode}`; + res.json().then((responseText) => { + throw new Error(`${errorMsg}: ${responseText}`); + }).catch(() => { + throw new Error(errorMsg); + }); +}); + +const submitTestDone = () => fetch(`${SERVER_ADDRESS}${Routes.testDone}`); + +/** + * @returns {Promise} + */ +const getTestConfig = () => fetch(`${SERVER_ADDRESS}${Routes.testConfig}`) + .then(res => res.json()) + .then(config => config); + +export default { + submitTestResults, + submitTestDone, + getTestConfig, +}; diff --git a/src/libs/E2E/isE2ETestSession.js b/src/libs/E2E/isE2ETestSession.js new file mode 100644 index 000000000000..eae5767cffbc --- /dev/null +++ b/src/libs/E2E/isE2ETestSession.js @@ -0,0 +1 @@ +export default () => false; diff --git a/src/libs/E2E/isE2ETestSession.native.js b/src/libs/E2E/isE2ETestSession.native.js new file mode 100644 index 000000000000..214e8241c5dc --- /dev/null +++ b/src/libs/E2E/isE2ETestSession.native.js @@ -0,0 +1,3 @@ +import CONFIG from '../../CONFIG'; + +export default () => CONFIG.E2E_TESTING; diff --git a/src/libs/E2E/reactNativeLaunchingTest.js b/src/libs/E2E/reactNativeLaunchingTest.js new file mode 100644 index 000000000000..392ed7c207b1 --- /dev/null +++ b/src/libs/E2E/reactNativeLaunchingTest.js @@ -0,0 +1,55 @@ +/* eslint-disable import/newline-after-import,import/first */ +/** + * We are using a separate entry point for the E2E tests. + * By doing this, we avoid bundling any E2E testing code + * into the actual release app. + */ + +import Performance from '../Performance'; + +// start the usual app +Performance.markStart('regularAppStart'); +import '../../../index'; +Performance.markEnd('regularAppStart'); + +import E2EConfig from '../../../e2e/config'; +import E2EClient from './client'; + +console.debug('=========================='); +console.debug('==== Running e2e test ===='); +console.debug('=========================='); + +// import your test here, define its name and config first in e2e/config.js +const tests = { + [E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default, +}; + +// Once we receive the TII measurement we know that the app is initialized and ready to be used: +const appReady = new Promise((resolve) => { + Performance.subscribeToMeasurements((entry) => { + if (entry.name !== 'TTI') { + return; + } + + resolve(); + }); +}); + +E2EClient.getTestConfig().then((config) => { + const test = tests[config.name]; + if (!test) { + // instead of throwing, report the error to the server, which is better for DX + return E2EClient.submitTestResults({ + name: config.name, + error: `Test '${config.name}' not found`, + }); + } + console.debug(`[E2E] Configured for test ${config.name}. Waiting for app to become ready`); + + appReady.then(() => { + console.debug('[E2E] App is ready, running test…'); + Performance.measureFailSafe('appStartedToReady', 'regularAppStart'); + test(); + }); +}); + diff --git a/src/libs/E2E/tests/appStartTimeTest.e2e.js b/src/libs/E2E/tests/appStartTimeTest.e2e.js new file mode 100644 index 000000000000..d66d4577ce32 --- /dev/null +++ b/src/libs/E2E/tests/appStartTimeTest.e2e.js @@ -0,0 +1,39 @@ +import _ from 'underscore'; +import E2ELogin from '../actions/e2eLogin'; +import Performance from '../../Performance'; +import E2EClient from '../client'; + +const test = () => { + const email = 'applausetester+perf2@applause.expensifail.com'; + const password = 'Password123'; + + console.debug('[E2E] App is ready, logging in…'); + + // check for login (if already logged in the action will simply resolve) + E2ELogin(email, password).then((neededLogin) => { + if (neededLogin) { + // we don't want to submit the first login to the results + return E2EClient.submitTestDone(); + } + + console.debug('[E2E] Logged in, getting metrics and submitting them…'); + + // collect performance metrics and submit + const metrics = Performance.getPerformanceMetrics(); + + // underscore promises in sequence without for-loop + Promise.all( + _.map(metrics, metric => E2EClient.submitTestResults({ + name: metric.name, + duration: metric.duration, + })), + ).then(() => { + console.debug('[E2E] Done, exiting…'); + E2EClient.submitTestDone(); + }).catch((err) => { + console.debug('[E2E] Error while submitting test results:', err); + }); + }); +}; + +export default test; diff --git a/src/libs/Middleware/Reauthentication.js b/src/libs/Middleware/Reauthentication.js index d209a867a54e..bf1bbad1a1d2 100644 --- a/src/libs/Middleware/Reauthentication.js +++ b/src/libs/Middleware/Reauthentication.js @@ -2,7 +2,6 @@ import lodashGet from 'lodash/get'; import CONST from '../../CONST'; import * as NetworkStore from '../Network/NetworkStore'; import * as MainQueue from '../Network/MainQueue'; -// eslint-disable-next-line import/no-cycle import * as Authentication from '../Authentication'; import * as PersistedRequests from '../actions/PersistedRequests'; import * as Request from '../Request'; diff --git a/src/libs/Middleware/index.js b/src/libs/Middleware/index.js index 62c5d6c1aaf9..4e270b009c1d 100644 --- a/src/libs/Middleware/index.js +++ b/src/libs/Middleware/index.js @@ -1,5 +1,4 @@ import Logging from './Logging'; -// eslint-disable-next-line import/no-cycle import Reauthentication from './Reauthentication'; import RecheckConnection from './RecheckConnection'; import Retry from './Retry'; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index a402d67761b1..0b9b62c25916 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -20,7 +20,6 @@ import KeyboardShortcut from '../../KeyboardShortcut'; import Navigation from '../Navigation'; import * as User from '../../actions/User'; import * as Modal from '../../actions/Modal'; -import * as Policy from '../../actions/Policy'; import modalCardStyleInterpolator from './modalCardStyleInterpolator'; import createCustomModalStackNavigator from './createCustomModalStackNavigator'; @@ -100,7 +99,6 @@ class AuthScreens extends React.Component { authEndpoint: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=AuthenticatePusher`, }).then(() => { User.subscribeToUserEvents(); - Policy.subscribeToPolicyEvents(); }); // Listen for report changes and fetch some data we need on initialization diff --git a/src/libs/Navigation/AppNavigator/BaseDrawerNavigator.js b/src/libs/Navigation/AppNavigator/BaseDrawerNavigator.js index 96664751686b..26537c58fd1b 100644 --- a/src/libs/Navigation/AppNavigator/BaseDrawerNavigator.js +++ b/src/libs/Navigation/AppNavigator/BaseDrawerNavigator.js @@ -49,6 +49,12 @@ class BaseDrawerNavigator extends Component { }; } + componentDidMount() { + // We need to resolve the isDrawerReady promise so that any pending drawer actions, like direct navigation from OldDot to + // a NewDot report, can happen. + Navigation.setIsDrawerReady(); + } + componentDidUpdate(prevProps) { if (prevProps.isSmallScreenWidth === this.props.isSmallScreenWidth) { return; diff --git a/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js b/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js index 5df2edc2178d..f08cd7959e2c 100644 --- a/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js +++ b/src/libs/Navigation/AppNavigator/MainDrawerNavigator.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {Component} from 'react'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; @@ -54,31 +54,55 @@ const getInitialReportScreenParams = (reports, ignoreDefaultRooms, policies) => return {reportID: String(reportID)}; }; -const MainDrawerNavigator = (props) => { - const initialParams = getInitialReportScreenParams(props.reports, !Permissions.canUseDefaultRooms(props.betas), props.policies); +class MainDrawerNavigator extends Component { + constructor(props) { + super(props); + this.initialParams = getInitialReportScreenParams(props.reports, !Permissions.canUseDefaultRooms(props.betas), props.policies); + } + + shouldComponentUpdate(nextProps) { + const initialNextParams = getInitialReportScreenParams(nextProps.reports, !Permissions.canUseDefaultRooms(nextProps.betas), nextProps.policies); + if (this.initialParams.reportID === initialNextParams.reportID) { + return false; + } - // Wait until reports are fetched and there is a reportID in initialParams - if (!initialParams.reportID) { - return ; + this.initialParams = initialNextParams; + return true; } - // After the app initializes and reports are available the home navigation is mounted - // This way routing information is updated (if needed) based on the initial report ID resolved. - // This is usually needed after login/create account and re-launches - return ( - } - screens={[ - { - name: SCREENS.REPORT, - component: ReportScreen, - initialParams, - }, - ]} - isMainScreen - /> - ); -}; + render() { + // Wait until reports are fetched and there is a reportID in initialParams + if (!this.initialParams.reportID) { + return ; + } + + // After the app initializes and reports are available the home navigation is mounted + // This way routing information is updated (if needed) based on the initial report ID resolved. + // This is usually needed after login/create account and re-launches + return ( + { + // This state belongs to the drawer so it should always have the ReportScreen as it's initial (and only) route + const reportIDFromRoute = lodashGet(state, ['routes', 0, 'params', 'reportID']); + return ( + + ); + }} + screens={[ + { + name: SCREENS.REPORT, + component: ReportScreen, + initialParams: this.initialParams, + }, + ]} + isMainScreen + /> + ); + } +} MainDrawerNavigator.propTypes = propTypes; MainDrawerNavigator.defaultProps = defaultProps; @@ -95,4 +119,3 @@ export default withOnyx({ key: ONYXKEYS.COLLECTION.POLICY, }, })(MainDrawerNavigator); -export {getInitialReportScreenParams}; diff --git a/src/libs/Navigation/DeprecatedCustomActions.js b/src/libs/Navigation/DeprecatedCustomActions.js index eb10d9c2f827..6c52f4ad5630 100644 --- a/src/libs/Navigation/DeprecatedCustomActions.js +++ b/src/libs/Navigation/DeprecatedCustomActions.js @@ -5,6 +5,7 @@ import { import lodashGet from 'lodash/get'; import linkingConfig from './linkingConfig'; import navigationRef from './navigationRef'; +import SCREENS from '../../SCREENS'; /** * @returns {Object} @@ -121,12 +122,22 @@ function pushDrawerRoute(route) { }); } + const routes = [{ + name: newScreenName, + params: newScreenParams, + }]; + + // Keep the same key so the ReportScreen does not completely re-mount + if (newScreenName === SCREENS.REPORT) { + const prevReportRoute = getRouteFromState(getActiveState()); + if (prevReportRoute.key) { + routes[0].key = prevReportRoute.key; + } + } + return CommonActions.reset({ ...state, - routes: [{ - name: newScreenName, - params: newScreenParams, - }], + routes, history, }); }; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 30f6b4ca8399..e937e501597a 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import lodashGet from 'lodash/get'; import {Keyboard} from 'react-native'; import {DrawerActions, getPathFromState, StackActions} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; @@ -15,7 +16,14 @@ const navigationIsReadyPromise = new Promise((resolve) => { resolveNavigationIsReadyPromise = resolve; }); +let resolveDrawerIsReadyPromise; +const drawerIsReadyPromise = new Promise((resolve) => { + resolveDrawerIsReadyPromise = resolve; +}); + let isLoggedIn = false; +let pendingRoute = null; + Onyx.connect({ key: ONYXKEYS.SESSION, callback: val => isLoggedIn = Boolean(val && val.authToken), @@ -124,17 +132,22 @@ function isDrawerRoute(route) { */ function navigate(route = ROUTES.HOME) { if (!canNavigate('navigate', {route})) { + // Store intended route if the navigator is not yet available, + // we will try again after the NavigationContainer is ready + Log.hmmm(`[Navigation] Container not yet ready, storing route as pending: ${route}`); + pendingRoute = route; return; } if (route === ROUTES.HOME) { - if (isLoggedIn) { + if (isLoggedIn && pendingRoute === null) { openDrawer(); return; } // If we're navigating to the signIn page while logged out, pop whatever screen is on top // since it's guaranteed that the sign in page will be underneath (since it's the initial route). + // Also, if we're coming from a link to validate login (pendingRoute is not null), we want to pop the loading screen. navigationRef.current.dispatch(StackActions.pop()); return; } @@ -177,6 +190,19 @@ function getActiveRoute() { : ''; } +/** + * @returns {String} + */ +function getReportIDFromRoute() { + if (!navigationRef.current) { + return ''; + } + + const drawerState = lodashGet(navigationRef.current.getState(), ['routes', 0, 'state']); + const reportRoute = lodashGet(drawerState, ['routes', 0]); + return lodashGet(reportRoute, ['params', 'reportID'], ''); +} + /** * Check whether the passed route is currently Active or not. * @@ -191,6 +217,19 @@ function isActiveRoute(routePath) { return getActiveRoute().substring(1) === routePath; } +/** + * Navigate to the route that we originally intended to go to + * but the NavigationContainer was not ready when navigate() was called + */ +function goToPendingRoute() { + if (pendingRoute === null) { + return; + } + Log.hmmm(`[Navigation] Container now ready, going to pending route: ${pendingRoute}`); + navigate(pendingRoute); + pendingRoute = null; +} + /** * @returns {Promise} */ @@ -199,9 +238,21 @@ function isNavigationReady() { } function setIsNavigationReady() { + goToPendingRoute(); resolveNavigationIsReadyPromise(); } +/** + * @returns {Promise} + */ +function isDrawerReady() { + return drawerIsReadyPromise; +} + +function setIsDrawerReady() { + resolveDrawerIsReadyPromise(); +} + export default { canNavigate, navigate, @@ -214,6 +265,10 @@ export default { setDidTapNotification, isNavigationReady, setIsNavigationReady, + getReportIDFromRoute, + isDrawerReady, + setIsDrawerReady, + isDrawerRoute, }; export { diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index 1618fea6d3c4..775d89f31b92 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -1,6 +1,7 @@ -import React, {Component} from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import {NavigationContainer, DefaultTheme, getPathFromState} from '@react-navigation/native'; +import {useFlipper} from '@react-navigation/devtools'; import Navigation, {navigationRef} from './Navigation'; import linkingConfig from './linkingConfig'; import AppNavigator from './AppNavigator'; @@ -27,52 +28,52 @@ const propTypes = { onReady: PropTypes.func.isRequired, }; -class NavigationRoot extends Component { - /** - * Intercept navigation state changes and log it - * @param {NavigationState} state - */ - parseAndLogRoute(state) { - if (!state) { - return; - } - - const currentPath = getPathFromState(state, linkingConfig.config); +/** + * Intercept navigation state changes and log it + * @param {NavigationState} state + */ +function parseAndLogRoute(state) { + if (!state) { + return; + } - // Don't log the route transitions from OldDot because they contain authTokens - if (currentPath.includes('/transition')) { - Log.info('Navigating from transition link from OldDot using short lived authToken'); - } else { - Log.info('Navigating to route', false, {path: currentPath}); - } + const currentPath = getPathFromState(state, linkingConfig.config); - UnreadIndicatorUpdater.throttledUpdatePageTitleAndUnreadCount(); - Navigation.setIsNavigationReady(); + // Don't log the route transitions from OldDot because they contain authTokens + if (currentPath.includes('/transition')) { + Log.info('Navigating from transition link from OldDot using short lived authToken'); + } else { + Log.info('Navigating to route', false, {path: currentPath}); } - render() { - return ( - - )} - onStateChange={this.parseAndLogRoute} - onReady={this.props.onReady} - theme={navigationTheme} - ref={navigationRef} - linking={linkingConfig} - documentTitle={{ - enabled: false, - }} - > - - - ); - } + UnreadIndicatorUpdater.throttledUpdatePageTitleAndUnreadCount(); + Navigation.setIsNavigationReady(); } +const NavigationRoot = (props) => { + useFlipper(navigationRef); + return ( + + )} + onStateChange={parseAndLogRoute} + onReady={props.onReady} + theme={navigationTheme} + ref={navigationRef} + linking={linkingConfig} + documentTitle={{ + enabled: false, + }} + > + + + ); +}; + +NavigationRoot.displayName = 'NavigationRoot'; NavigationRoot.propTypes = propTypes; export default NavigationRoot; diff --git a/src/libs/NetworkConnection.js b/src/libs/NetworkConnection.js index cd4d0b925ecc..972c3dcd5179 100644 --- a/src/libs/NetworkConnection.js +++ b/src/libs/NetworkConnection.js @@ -6,10 +6,6 @@ import * as NetworkActions from './actions/Network'; import CONFIG from '../CONFIG'; import CONST from '../CONST'; -// NetInfo.addEventListener() returns a function used to unsubscribe the -// listener so we must create a reference to it and call it in stopListeningForReconnect() -let unsubscribeFromNetInfo; -let unsubscribeFromAppState; let isOffline = false; let hasPendingNetworkCheck = false; @@ -67,7 +63,7 @@ function subscribeToNetInfo() { // Subscribe to the state change event via NetInfo so we can update // whether a user has internet connectivity or not. - unsubscribeFromNetInfo = NetInfo.addEventListener((state) => { + NetInfo.addEventListener((state) => { Log.info('[NetworkConnection] NetInfo state change', false, state); setOfflineStatus(state.isInternetReachable === false); }); @@ -76,26 +72,11 @@ function subscribeToNetInfo() { function listenForReconnect() { Log.info('[NetworkConnection] listenForReconnect called'); - unsubscribeFromAppState = AppStateMonitor.addBecameActiveListener(() => { + AppStateMonitor.addBecameActiveListener(() => { triggerReconnectionCallbacks('app became active'); }); } -/** - * Tear down the event listeners when we are finished with them. - */ -function stopListeningForReconnect() { - Log.info('[NetworkConnection] stopListeningForReconnect called'); - if (unsubscribeFromNetInfo) { - unsubscribeFromNetInfo(); - unsubscribeFromNetInfo = undefined; - } - if (unsubscribeFromAppState) { - unsubscribeFromAppState(); - unsubscribeFromAppState = undefined; - } -} - /** * Register callback to fire when we reconnect * @@ -126,7 +107,6 @@ function recheckNetworkConnection() { export default { setOfflineStatus, listenForReconnect, - stopListeningForReconnect, onReconnect, triggerReconnectionCallbacks, recheckNetworkConnection, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 2c35502f760e..32065fd99f68 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -9,6 +9,7 @@ import * as ReportUtils from './ReportUtils'; import * as Localize from './Localize'; import Permissions from './Permissions'; import * as CollectionUtils from './CollectionUtils'; +import Navigation from './Navigation/Navigation'; /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can @@ -99,6 +100,9 @@ function addSMSDomainIfPhoneNumber(login) { */ function getPersonalDetailsForLogins(logins, personalDetails) { const personalDetailsForLogins = {}; + if (!personalDetails) { + return personalDetailsForLogins; + } _.each(logins, (login) => { let personalDetail = personalDetails[login]; if (!personalDetail) { @@ -186,13 +190,16 @@ function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolic } } if (report) { - Array.prototype.push.apply(searchTerms, reportName.split('')); - Array.prototype.push.apply(searchTerms, reportName.split(',')); + Array.prototype.push.apply(searchTerms, reportName.split(/[,\s]/)); if (isChatRoomOrPolicyExpenseChat) { const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(report, policies); - Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split('')); - Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(',')); + + // When running tests, chatRoomSubtitle can be undefined due to the Localize() stuff being mocked in the tests. + // It's OK to ignore this and just add a null check in here to keep code from crashing. + if (chatRoomSubtitle) { + Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(/[,\s]/)); + } } else { searchTerms = searchTerms.concat(report.participants); } @@ -275,6 +282,8 @@ function createOption(logins, personalDetails, report, reportActions = {}, { let hasMultipleParticipants = personalDetailList.length > 1; let subtitle; + result.participantsList = personalDetailList; + if (report) { result.isChatRoom = ReportUtils.isChatRoom(report); result.isDefaultRoom = ReportUtils.isDefaultRoom(report); @@ -347,10 +356,9 @@ function createOption(logins, personalDetails, report, reportActions = {}, { const reportName = ReportUtils.getReportName(report, personalDetailMap, policies); result.text = reportName; - result.subtitle = subtitle; - result.participantsList = personalDetailList; - result.icons = ReportUtils.getIcons(report, personalDetails, policies, personalDetail.avatar); result.searchText = getSearchText(report, reportName, personalDetailList, result.isChatRoom || result.isPolicyExpenseChat); + result.icons = ReportUtils.getIcons(report, personalDetails, policies, personalDetail.avatar); + result.subtitle = subtitle; return result; } @@ -413,125 +421,65 @@ function isCurrentUser(userDetails) { * * @param {Object} reports * @param {Object} personalDetails - * @param {String} activeReportID * @param {Object} options * @returns {Object} * @private */ -function getOptions(reports, personalDetails, activeReportID, { +function getOptions(reports, personalDetails, { reportActions = {}, betas = [], selectedOptions = [], maxRecentReportsToShow = 0, excludeLogins = [], - excludeChatRooms = false, includeMultipleParticipantReports = false, includePersonalDetails = false, includeRecentReports = false, - prioritizePinnedReports = false, prioritizeDefaultRoomsInSearch = false, // When sortByReportTypeInSearch flag is true, recentReports will include the personalDetails options as well. sortByReportTypeInSearch = false, - sortByLastMessageTimestamp = true, searchValue = '', showChatPreviewLine = false, - showReportsWithNoComments = false, - hideReadReports = false, - sortByAlphaAsc = false, sortPersonalDetailsByAlphaAsc = true, forcePolicyNamePreview = false, - prioritizeIOUDebts = false, - prioritizeReportsWithDraftComments = false, }) { let recentReportOptions = []; - const pinnedReportOptions = []; let personalDetailsOptions = []; - const iouDebtReportOptions = []; - const draftReportOptions = []; - const reportMapForLogins = {}; - let sortProperty = sortByLastMessageTimestamp - ? ['lastMessageTimestamp'] - : ['lastVisitedTimestamp']; - if (sortByAlphaAsc) { - sortProperty = ['reportName']; - } - const sortDirection = [sortByAlphaAsc ? 'asc' : 'desc']; - let orderedReports = lodashOrderBy(reports, sortProperty, sortDirection); + // Filter out all the reports that shouldn't be displayed + const filteredReports = _.filter(reports, report => ReportUtils.shouldReportBeInOptionList( + report, + Navigation.getReportIDFromRoute(), + false, + currentUserLogin, + iouReports, + betas, + policies, + )); - // Move the archived Rooms to the last - orderedReports = _.sortBy(orderedReports, report => ReportUtils.isArchivedRoom(report)); + // Sorting the reports works like this: + // - Order everything by the last message timestamp (descending) + // - All archived reports should remain at the bottom + const orderedReports = _.sortBy(filteredReports, (report) => { + if (ReportUtils.isArchivedRoom(report)) { + return -Infinity; + } + + return report.lastMessageTimestamp; + }); + orderedReports.reverse(); const allReportOptions = []; _.each(orderedReports, (report) => { if (!report) { return; } + const isChatRoom = ReportUtils.isChatRoom(report); - const isDefaultRoom = ReportUtils.isDefaultRoom(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const logins = report.participants || []; - // Report data can sometimes be incomplete. If we have no logins or reportID then we will skip this entry. - const shouldFilterNoParticipants = _.isEmpty(logins) && !isChatRoom && !isDefaultRoom && !isPolicyExpenseChat; - if (!report.reportID || shouldFilterNoParticipants) { - return; - } - - const hasDraftComment = report.hasDraft || false; - const iouReport = report.iouReportID && iouReports[`${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`]; - const iouReportOwner = report.hasOutstandingIOU && iouReport - ? iouReport.ownerEmail - : ''; - - const reportContainsIOUDebt = iouReportOwner && iouReportOwner !== currentUserLogin; - const hasAddWorkspaceRoomError = report.errorFields && !_.isEmpty(report.errorFields.addWorkspaceRoom); - const shouldFilterReportIfEmpty = !showReportsWithNoComments && report.lastMessageTimestamp === 0 - - // We make exceptions for defaultRooms and policyExpenseChats so we can immediately - // highlight them in the LHN when they are created and have no messsages yet. We do - // not give archived rooms this exception since they do not need to be higlihted. - && !(!ReportUtils.isArchivedRoom(report) && (isDefaultRoom || isPolicyExpenseChat)) - - // Also make an exception for workspace rooms that failed to be added - && !hasAddWorkspaceRoomError; - - const shouldFilterReportIfRead = hideReadReports && !ReportUtils.isUnread(report); - const shouldFilterReport = shouldFilterReportIfEmpty || shouldFilterReportIfRead; - - if (report.reportID !== activeReportID - && (!report.isPinned || isDefaultRoom) - && !hasDraftComment - && shouldFilterReport - && !reportContainsIOUDebt) { - return; - } - - if (isChatRoom && excludeChatRooms) { - return; - } - - // We create policy rooms for all policies, however we don't show them unless - // - It's a free plan workspace - // - The report includes guides participants (@team.expensify.com) for 1:1 Assigned - if (!Permissions.canUseDefaultRooms(betas) - && ReportUtils.isDefaultRoom(report) - && ReportUtils.getPolicyType(report, policies) !== CONST.POLICY.TYPE.FREE - && !ReportUtils.hasExpensifyGuidesEmails(logins) - ) { - return; - } - - if (ReportUtils.isUserCreatedPolicyRoom(report) && !Permissions.canUsePolicyRooms(betas)) { - return; - } - - if (isPolicyExpenseChat && !Permissions.canUsePolicyExpenseChat(betas)) { - return; - } - // Save the report in the map if this is a single participant so we can associate the reportID with the // personal detail option later. Individuals should not be associated with single participant // policyExpenseChats or chatRooms since those are not people. @@ -558,7 +506,7 @@ function getOptions(reports, personalDetails, activeReportID, { if (sortPersonalDetailsByAlphaAsc) { // PersonalDetails should be ordered Alphabetically by default - https://github.com/Expensify/App/issues/8220#issuecomment-1104009435 - allPersonalDetailsOptions = lodashOrderBy(allPersonalDetailsOptions, [personalDetail => personalDetail.text.toLowerCase()], 'asc'); + allPersonalDetailsOptions = lodashOrderBy(allPersonalDetailsOptions, [personalDetail => personalDetail.text && personalDetail.text.toLowerCase()], 'asc'); } // Always exclude already selected options and the currently logged in user @@ -594,19 +542,7 @@ function getOptions(reports, personalDetails, activeReportID, { continue; } - // If the report is pinned and we are using the option to display pinned reports on top then we need to - // collect the pinned reports so we can sort them alphabetically once they are collected. We want to skip - // default archived rooms. - if (prioritizePinnedReports && reportOption.isPinned - && !(reportOption.isArchivedRoom && reportOption.isDefaultRoom)) { - pinnedReportOptions.push(reportOption); - } else if (prioritizeIOUDebts && reportOption.hasOutstandingIOU && !reportOption.isIOUReportOwner) { - iouDebtReportOptions.push(reportOption); - } else if (prioritizeReportsWithDraftComments && reportOption.hasDraftComment) { - draftReportOptions.push(reportOption); - } else { - recentReportOptions.push(reportOption); - } + recentReportOptions.push(reportOption); // Add this login to the exclude list so it won't appear when we process the personal details if (reportOption.login) { @@ -615,26 +551,6 @@ function getOptions(reports, personalDetails, activeReportID, { } } - // If we are prioritizing reports with draft comments, add them before the normal recent report options - // and sort them by report name. - if (prioritizeReportsWithDraftComments) { - const sortedDraftReports = lodashOrderBy(draftReportOptions, ['text'], ['asc']); - recentReportOptions = sortedDraftReports.concat(recentReportOptions); - } - - // If we are prioritizing IOUs the user owes, add them before the normal recent report options and reports - // with draft comments. - if (prioritizeIOUDebts) { - const sortedIOUReports = lodashOrderBy(iouDebtReportOptions, ['iouReportAmount'], ['desc']); - recentReportOptions = sortedIOUReports.concat(recentReportOptions); - } - - // If we are prioritizing our pinned reports then shift them to the front and sort them by report name - if (prioritizePinnedReports) { - const sortedPinnedReports = lodashOrderBy(pinnedReportOptions, ['text'], ['asc']); - recentReportOptions = sortedPinnedReports.concat(recentReportOptions); - } - // If we are prioritizing default rooms in search, do it only once we started something if (prioritizeDefaultRoomsInSearch && searchValue !== '') { const reportsSplitByDefaultChatRoom = _.partition(recentReportOptions, option => option.isChatRoom); @@ -722,7 +638,7 @@ function getSearchOptions( searchValue = '', betas, ) { - return getOptions(reports, personalDetails, 0, { + return getOptions(reports, personalDetails, { betas, searchValue: searchValue.trim(), includeRecentReports: true, @@ -735,7 +651,6 @@ function getSearchOptions( showReportsWithNoComments: true, includePersonalDetails: true, forcePolicyNamePreview: true, - prioritizeIOUDebts: false, }); } @@ -790,7 +705,7 @@ function getNewChatOptions( selectedOptions = [], excludeLogins = [], ) { - return getOptions(reports, personalDetails, 0, { + return getOptions(reports, personalDetails, { betas, searchValue: searchValue.trim(), selectedOptions, @@ -817,7 +732,7 @@ function getMemberInviteOptions( searchValue = '', excludeLogins = [], ) { - return getOptions([], personalDetails, 0, { + return getOptions([], personalDetails, { betas, searchValue: searchValue.trim(), excludeDefaultRooms: true, @@ -827,41 +742,6 @@ function getMemberInviteOptions( }); } -/** - * Build the options for the Sidebar a.k.a. LHN - * - * @param {Object} reports - * @param {Object} personalDetails - * @param {Number} activeReportID - * @param {String} priorityMode - * @param {Array} betas - * @param {Object} reportActions - * @returns {Object} - */ -function getSidebarOptions(reports, personalDetails, activeReportID, priorityMode, betas, reportActions) { - let sideBarOptions = { - prioritizeIOUDebts: true, - prioritizeReportsWithDraftComments: true, - }; - if (priorityMode === CONST.PRIORITY_MODE.GSD) { - sideBarOptions = { - hideReadReports: true, - sortByAlphaAsc: true, - }; - } - - return getOptions(reports, personalDetails, activeReportID, { - betas, - includeRecentReports: true, - includeMultipleParticipantReports: true, - maxRecentReportsToShow: 0, // Unlimited - showChatPreviewLine: true, - prioritizePinnedReports: true, - ...sideBarOptions, - reportActions, - }); -} - /** * Helper method that returns the text to be used for the header's message and title (if any) * @@ -916,7 +796,6 @@ export { getSearchOptions, getNewChatOptions, getMemberInviteOptions, - getSidebarOptions, getHeaderMessage, getPersonalDetailsForLogins, getCurrencyListForSections, diff --git a/src/libs/Performance.js b/src/libs/Performance.js index 39aab5bdbe57..94fa8eb6eaa0 100644 --- a/src/libs/Performance.js +++ b/src/libs/Performance.js @@ -6,6 +6,7 @@ import {Alert, InteractionManager} from 'react-native'; import * as Metrics from './Metrics'; import getComponentDisplayName from './getComponentDisplayName'; import CONST from '../CONST'; +import isE2ETestSession from './E2E/isE2ETestSession'; /** @type {import('react-native-performance').Performance} */ let rnPerformance; @@ -38,14 +39,49 @@ const Performance = { // When performance monitoring is disabled the implementations are blank diffObject, setupPerformanceObserver: () => {}, + getPerformanceMetrics: () => [], printPerformanceMetrics: () => {}, markStart: () => {}, markEnd: () => {}, + measureFailSafe: () => {}, + measureTTI: () => {}, traceRender: () => {}, withRenderTrace: () => Component => Component, + subscribeToMeasurements: () => {}, }; if (Metrics.canCapturePerformanceMetrics()) { + const perfModule = require('react-native-performance'); + perfModule.setResourceLoggingEnabled(true); + rnPerformance = perfModule.default; + + Performance.measureFailSafe = (measureName, startOrMeasureOptions, endMark) => { + try { + rnPerformance.measure(measureName, startOrMeasureOptions, endMark); + } catch (error) { + // Sometimes there might be no start mark recorded and the measure will fail with an error + console.debug(error.message); + } + }; + + /** + * Measures the TTI time. To be called when the app is considered to be interactive. + * @param {String} [endMark] Optional end mark name + */ + Performance.measureTTI = (endMark) => { + // Make sure TTI is captured when the app is really usable + InteractionManager.runAfterInteractions(() => { + requestAnimationFrame(() => { + Performance.measureFailSafe('TTI', 'nativeLaunchStart', endMark); + + // we don't want the alert to show on a e2e test sessio + if (!isE2ETestSession()) { + Performance.printPerformanceMetrics(); + } + }); + }); + }; + /** * Sets up an observer to capture events recorded in the native layer before the app fully initializes. */ @@ -53,31 +89,18 @@ if (Metrics.canCapturePerformanceMetrics()) { const performanceReported = require('react-native-performance-flipper-reporter'); performanceReported.setupDefaultFlipperReporter(); - const perfModule = require('react-native-performance'); - perfModule.setResourceLoggingEnabled(true); - rnPerformance = perfModule.default; - - const measureFailSafe = (measureName, startOrMeasureOptions, endMark) => { - try { - rnPerformance.measure(measureName, startOrMeasureOptions, endMark); - } catch (error) { - // Sometimes there might be no start mark recorded and the measure will fail with an error - console.debug(error.message); - } - }; - // Monitor some native marks that we want to put on the timeline new perfModule.PerformanceObserver((list, observer) => { list.getEntries() .forEach((entry) => { if (entry.name === 'nativeLaunchEnd') { - measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd'); + Performance.measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd'); } if (entry.name === 'downloadEnd') { - measureFailSafe('jsBundleDownload', 'downloadStart', 'downloadEnd'); + Performance.measureFailSafe('jsBundleDownload', 'downloadStart', 'downloadEnd'); } if (entry.name === 'runJsBundleEnd') { - measureFailSafe('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd'); + Performance.measureFailSafe('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd'); } // We don't need to keep the observer past this point @@ -95,42 +118,47 @@ if (Metrics.canCapturePerformanceMetrics()) { const end = mark.name; const name = end.replace(/_end$/, ''); const start = `${name}_start`; - measureFailSafe(name, start, end); + Performance.measureFailSafe(name, start, end); } // Capture any custom measures or metrics below if (mark.name === `${CONST.TIMING.SIDEBAR_LOADED}_end`) { - // Make sure TTI is captured when the app is really usable - InteractionManager.runAfterInteractions(() => { - requestAnimationFrame(() => { - measureFailSafe('TTI', 'nativeLaunchStart', mark.name); - Performance.printPerformanceMetrics(); - }); - }); + Performance.measureTTI(mark.name); } }); }).observe({type: 'mark', buffered: true}); }; + Performance.getPerformanceMetrics = () => _.chain([ + ...rnPerformance.getEntriesByName('nativeLaunch'), + ...rnPerformance.getEntriesByName('runJsBundle'), + ...rnPerformance.getEntriesByName('jsBundleDownload'), + ...rnPerformance.getEntriesByName('TTI'), + ...rnPerformance.getEntriesByName('regularAppStart'), + ...rnPerformance.getEntriesByName('appStartedToReady'), + ]) + .filter(entry => entry.duration > 0) + .value(); + /** * Outputs performance stats. We alert these so that they are easy to access in release builds. */ Performance.printPerformanceMetrics = () => { - const stats = _.chain([ - ...rnPerformance.getEntriesByName('nativeLaunch'), - ...rnPerformance.getEntriesByName('runJsBundle'), - ...rnPerformance.getEntriesByName('jsBundleDownload'), - ...rnPerformance.getEntriesByName('TTI'), - ]) - .filter(entry => entry.duration > 0) - .map(entry => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`) - .value(); + const stats = Performance.getPerformanceMetrics(); + const statsAsText = _.map(stats, entry => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`) + .join('\n'); if (stats.length > 0) { - Alert.alert('Performance', stats.join('\n')); + Alert.alert('Performance', statsAsText); } }; + Performance.subscribeToMeasurements = (callback) => { + new perfModule.PerformanceObserver((list) => { + list.getEntriesByType('measure').forEach(callback); + }).observe({type: 'measure', buffered: true}); + }; + /** * Add a start mark to the performance entries * @param {string} name diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 210c992960fc..94bf06fc2d1f 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -9,11 +9,12 @@ import CONST from '../CONST'; import * as Localize from './Localize'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Expensicons from '../components/Icon/Expensicons'; -import md5 from './md5'; +import hashCode from './hashCode'; import Navigation from './Navigation/Navigation'; import ROUTES from '../ROUTES'; import * as NumberUtils from './NumberUtils'; import * as NumberFormatUtils from './NumberFormatUtils'; +import Permissions from './Permissions'; let sessionEmail; Onyx.connect({ @@ -243,17 +244,12 @@ function findLastAccessedReport(reports, ignoreDefaultRooms, policies) { /** * Whether the provided report is an archived room * @param {Object} report - * @param {String} report.chatType * @param {Number} report.stateNum * @param {Number} report.statusNum * @returns {Boolean} */ function isArchivedRoom(report) { - if (!isChatRoom(report) && !isPolicyExpenseChat(report)) { - return false; - } - - return report.statusNum === CONST.REPORT.STATUS.CLOSED && report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED; + return lodashGet(report, ['statusNum']) === CONST.REPORT.STATUS.CLOSED && lodashGet(report, ['stateNum']) === CONST.REPORT.STATE_NUM.SUBMITTED; } /** @@ -265,11 +261,18 @@ function isArchivedRoom(report) { * @returns {String} */ function getPolicyName(report, policies) { - const policyName = ( - policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] - && policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`].name - ) || ''; - return policyName || report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable'); + if (_.isEmpty(policies)) { + return Localize.translateLocal('workspace.common.unavailable'); + } + + const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + if (!policy) { + return Localize.translateLocal('workspace.common.unavailable'); + } + + return policy.name + || report.oldPolicyName + || Localize.translateLocal('workspace.common.unavailable'); } /** @@ -393,8 +396,8 @@ function formatReportLastMessageText(lastMessageText) { */ function getDefaultAvatar(login = '') { // There are 8 possible default avatars, so we choose which one this user has based - // on a simple hash of their login (which is converted from HEX to INT) - const loginHashBucket = (parseInt(md5(login.toLowerCase()).substring(0, 4), 16) % 8) + 1; + // on a simple hash of their login + const loginHashBucket = (Math.abs(hashCode(login.toLowerCase())) % 8) + 1; return `${CONST.CLOUDFRONT_URL}/images/avatars/avatar_${loginHashBucket}.png`; } @@ -564,10 +567,10 @@ function navigateToDetailsPage(report) { * In a test of 500M reports (28 years of reports at our current max rate) we got 20-40 collisions meaning that * this is more than random enough for our needs. * - * @returns {Number} + * @returns {String} */ function generateReportID() { - return (Math.floor(Math.random() * (2 ** 21)) * (2 ** 32)) + Math.floor(Math.random() * (2 ** 32)); + return ((Math.floor(Math.random() * (2 ** 21)) * (2 ** 32)) + Math.floor(Math.random() * (2 ** 32))).toString(); } /** @@ -851,6 +854,110 @@ function isUnread(report) { return lastReadSequenceNumber < maxSequenceNumber; } +/** + * Determines if a report has an outstanding IOU that doesn't belong to the currently logged in user + * + * @param {Object} report + * @param {String} report.iouReportID + * @param {String} currentUserLogin + * @param {Object} iouReports + * @returns {boolean} + */ + +function hasOutstandingIOU(report, currentUserLogin, iouReports) { + if (!report || !report.iouReportID || _.isUndefined(report.hasOutstandingIOU)) { + return false; + } + + const iouReport = iouReports && iouReports[`${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`]; + if (!iouReport || !iouReport.ownerEmail) { + return false; + } + + if (iouReport.ownerEmail === currentUserEmail) { + return false; + } + + return report.hasOutstandingIOU; +} + +/** + * Takes several pieces of data from Onyx and evaluates if a report should be shown in the option list (either when searching + * for reports or the reports shown in the LHN). + * + * This logic is very specific and the order of the logic is very important. It should fail quickly in most cases and also + * filter out the majority of reports before filtering out very specific minority of reports. + * + * @param {Object} report + * @param {String} reportIDFromRoute + * @param {Boolean} isInGSDMode + * @param {String} currentUserLogin + * @param {Object} iouReports + * @param {String[]} betas + * @param {Object} policies + * @returns {boolean} + */ +function shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, currentUserLogin, iouReports, betas, policies) { + const isInDefaultMode = !isInGSDMode; + + // Exclude reports that have no data because there wouldn't be anything to show in the option item. + // This can happen if data is currently loading from the server or a report is in various stages of being created. + if (!report || !report.reportID || !report.participants || _.isEmpty(report.participants)) { + return false; + } + + // Include the currently viewed report. If we excluded the currently viewed report, then there + // would be no way to highlight it in the options list and it would be confusing to users because they lose + // a sense of context. + if (report.reportID === reportIDFromRoute) { + return true; + } + + // Include reports if they have a draft, are pinned, or have an outstanding IOU + // These are always relevant to the user no matter what view mode the user prefers + if (report.hasDraft || report.isPinned || hasOutstandingIOU(report, currentUserLogin, iouReports)) { + return true; + } + + // Include reports that have errors from trying to add a workspace + // If we excluded it, then the red-brock-road pattern wouldn't work for the user to resolve the error + if (report.errorFields && !_.isEmpty(report.errorFields.addWorkspaceRoom)) { + return true; + } + + // All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones + if (isInGSDMode) { + return isUnread(report); + } + + // Archived reports should always be shown when in default (most recent) mode. This is because you should still be able to access and search for the chats to find them. + if (isInDefaultMode && isArchivedRoom(report)) { + return true; + } + + // Include default rooms for free plan policies + if (isDefaultRoom(report) && getPolicyType(report, policies) === CONST.POLICY.TYPE.FREE) { + return true; + } + + // Include default rooms unless you're on the default room beta + if (isDefaultRoom(report) && !Permissions.canUseDefaultRooms(betas)) { + return false; + } + + // Include user created policy rooms if the user isn't on the policy rooms beta + if (isUserCreatedPolicyRoom(report) && !Permissions.canUsePolicyRooms(betas)) { + return false; + } + + // Include policy expense chats if the user isn't in the policy expense chat beta + if (isPolicyExpenseChat(report) && !Permissions.canUsePolicyExpenseChat(betas)) { + return false; + } + + return true; +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -870,6 +977,7 @@ export { isConciergeChatReport, hasExpensifyEmails, hasExpensifyGuidesEmails, + hasOutstandingIOU, canShowReportRecipientLocalTime, formatReportLastMessageText, chatIncludesConcierge, @@ -889,4 +997,5 @@ export { buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildOptimisticReportAction, + shouldReportBeInOptionList, }; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 9fa11976ce57..e46d4d12f56f 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -1,20 +1,18 @@ import Onyx from 'react-native-onyx'; import _ from 'underscore'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; import ONYXKEYS from '../ONYXKEYS'; import * as ReportUtils from './ReportUtils'; import * as Localize from './Localize'; import CONST from '../CONST'; import * as OptionsListUtils from './OptionsListUtils'; import * as CollectionUtils from './CollectionUtils'; -import Permissions from './Permissions'; // Note: It is very important that the keys subscribed to here are the same // keys that are connected to SidebarLinks withOnyx(). If there was a key missing from SidebarLinks and it's data was updated // for that key, then there would be no re-render and the options wouldn't reflect the new data because SidebarUtils.getOrderedReportIDs() wouldn't be triggered. // There are a couple of keys here which are OK to have stale data. iouReports for example, doesn't need to exist in withOnyx() because -// when IOUs change, it also triggers a change on the reports collection. Having redudant subscriptions causes more re-renders which should be avoided. +// when IOUs change, it also triggers a change on the reports collection. Having redundant subscriptions causes more re-renders which should be avoided. // Session also can remain stale because the only way for the current user to change is to sign out and sign in, which would clear out all the Onyx // data anyway and cause SidebarLinks to rerender. @@ -31,12 +29,6 @@ Onyx.connect({ callback: val => personalDetails = val, }); -let currentlyViewedReportID; -Onyx.connect({ - key: ONYXKEYS.CURRENTLY_VIEWED_REPORTID, - callback: val => currentlyViewedReportID = val, -}); - let priorityMode; Onyx.connect({ key: ONYXKEYS.NVP_PRIORITY_MODE, @@ -90,89 +82,56 @@ Onyx.connect({ }); /** + * @param {String} reportIDFromRoute * @returns {String[]} An array of reportIDs sorted in the proper order */ -function getOrderedReportIDs() { - const hideReadReports = priorityMode === CONST.PRIORITY_MODE.GSD; - const sortByTimestampDescending = priorityMode !== CONST.PRIORITY_MODE.GSD; - +function getOrderedReportIDs(reportIDFromRoute) { let recentReportOptions = []; const pinnedReportOptions = []; const iouDebtReportOptions = []; const draftReportOptions = []; - const filteredReports = _.filter(reports, (report) => { - if (!report || !report.reportID) { - return false; - } - - const isChatRoom = ReportUtils.isChatRoom(report); - const isDefaultRoom = ReportUtils.isDefaultRoom(report); - const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); - const participants = report.participants || []; + const isInGSDMode = priorityMode === CONST.PRIORITY_MODE.GSD; + const isInDefaultMode = !isInGSDMode; - // Skip this report if it has no participants and if it's not a type of report supported in the LHN - if (_.isEmpty(participants) && !isChatRoom && !isDefaultRoom && !isPolicyExpenseChat) { - return false; - } - - const hasDraftComment = report.hasDraft || false; - const iouReport = report.iouReportID && iouReports && iouReports[`${ONYXKEYS.COLLECTION.REPORT_IOUS}${report.iouReportID}`]; - const iouReportOwner = report.hasOutstandingIOU && iouReport - ? iouReport.ownerEmail - : ''; - - const reportContainsIOUDebt = iouReportOwner && iouReportOwner !== currentUserLogin; - const hasAddWorkspaceRoomError = report.errorFields && !_.isEmpty(report.errorFields.addWorkspaceRoom); - const shouldFilterReportIfEmpty = report.lastMessageTimestamp === 0 - - // We make exceptions for defaultRooms and policyExpenseChats so we can immediately - // highlight them in the LHN when they are created and have no messsages yet. We do - // not give archived rooms this exception since they do not need to be higlihted. - && !(!ReportUtils.isArchivedRoom(report) && (isDefaultRoom || isPolicyExpenseChat)) - - // Also make an exception for workspace rooms that failed to be added - && !hasAddWorkspaceRoomError; - - const shouldFilterReportIfRead = hideReadReports && !ReportUtils.isUnread(report); - const shouldFilterReport = shouldFilterReportIfEmpty || shouldFilterReportIfRead; - if (report.reportID !== currentlyViewedReportID - && !report.isPinned - && !hasDraftComment - && shouldFilterReport - && !reportContainsIOUDebt) { - return false; - } + // Filter out all the reports that shouldn't be displayed + const filteredReports = _.filter(reports, report => ReportUtils.shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, currentUserLogin, iouReports, betas, policies)); - // We let Free Plan default rooms to be shown in the App, or rooms that also have a Guide in them. - // It's the two exceptions to the beta, otherwise do not show policy rooms in product - if (ReportUtils.isDefaultRoom(report) - && !Permissions.canUseDefaultRooms(betas) - && ReportUtils.getPolicyType(report, policies) !== CONST.POLICY.TYPE.FREE - && !ReportUtils.hasExpensifyGuidesEmails(lodashGet(report, ['participants'], []))) { - return false; - } - - if (ReportUtils.isUserCreatedPolicyRoom(report) && !Permissions.canUsePolicyRooms(betas)) { - return false; - } + // Get all the display names for our reports in an easy to access property so we don't have to keep + // re-running the logic + const filteredReportsWithReportName = _.map(filteredReports, (report) => { + const personalDetailMap = OptionsListUtils.getPersonalDetailsForLogins(report.participants, personalDetails); + return { + ...report, + reportDisplayName: ReportUtils.getReportName(report, personalDetailMap, policies), + }; + }); - if (isPolicyExpenseChat && !Permissions.canUsePolicyExpenseChat(betas)) { - return false; + // Sorting the reports works like this: + // - When in default mode, reports will be ordered by most recently updated (in descending order) so that the most recently updated are at the top + // - When in GSD mode, reports are ordered by their display name so they are alphabetical (in ascending order) + // - Regardless of mode, all archived reports should remain at the bottom + const orderedReports = _.sortBy(filteredReportsWithReportName, (report) => { + if (ReportUtils.isArchivedRoom(report)) { + return isInDefaultMode + + // -Infinity is used here because there is no chance that a report will ever have an older timestamp than -Infinity and it ensures that archived reports + // will always be listed last + ? -Infinity + + // Similar logic is used for 'ZZZZZZZZZZZZZ' to reasonably assume that no report will ever have a report name that will be listed alphabetically after this, ensuring that + // archived reports will be listed last + : 'ZZZZZZZZZZZZZ'; } - return true; + return isInDefaultMode ? report.lastMessageTimestamp : report.reportDisplayName; }); - let orderedReports = _.sortBy(filteredReports, sortByTimestampDescending ? 'lastMessageTimestamp' : 'reportName'); - - if (sortByTimestampDescending) { + // Apply the decsending order to reports when in default mode + if (isInDefaultMode) { orderedReports.reverse(); } - // Move the archived Rooms to the last - orderedReports = _.sortBy(orderedReports, report => ReportUtils.isArchivedRoom(report)); - // Put all the reports into the different buckets for (let i = 0; i < orderedReports.length; i++) { const report = orderedReports[i]; @@ -186,7 +145,7 @@ function getOrderedReportIDs() { // If the active report has a draft, we do not put it in the group of draft reports because we want it to maintain it's current position. Otherwise the report's position // jumps around in the LHN and it's kind of confusing to the user to see the LHN reorder when they start typing a comment on a report. - } else if (report.hasDraft && report.reportID !== currentlyViewedReportID) { + } else if (report.hasDraft && report.reportID !== reportIDFromRoute) { draftReportOptions.push(report); } else { recentReportOptions.push(report); @@ -195,10 +154,7 @@ function getOrderedReportIDs() { // Prioritizing reports with draft comments, add them before the normal recent report options // and sort them by report name. - const sortedDraftReports = _.sortBy(draftReportOptions, (report) => { - const personalDetailMap = OptionsListUtils.getPersonalDetailsForLogins(report.participants, personalDetails); - return ReportUtils.getReportName(report, personalDetailMap, policies); - }); + const sortedDraftReports = _.sortBy(draftReportOptions, 'reportDisplayName'); recentReportOptions = sortedDraftReports.concat(recentReportOptions); // Prioritizing IOUs the user owes, add them before the normal recent report options and reports @@ -207,10 +163,7 @@ function getOrderedReportIDs() { recentReportOptions = sortedIOUReports.concat(recentReportOptions); // If we are prioritizing our pinned reports then shift them to the front and sort them by report name - const sortedPinnedReports = _.sortBy(pinnedReportOptions, (report) => { - const personalDetailMap = OptionsListUtils.getPersonalDetailsForLogins(report.participants, personalDetails); - return ReportUtils.getReportName(report, personalDetailMap, policies); - }); + const sortedPinnedReports = _.sortBy(pinnedReportOptions, 'reportDisplayName'); recentReportOptions = sortedPinnedReports.concat(recentReportOptions); return _.pluck(recentReportOptions, 'reportID'); @@ -281,6 +234,9 @@ function getOptionData(reportID) { const hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; const subtitle = ReportUtils.getChatRoomSubtitle(report, policies); + // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((personalDetailList || []).slice(0, 10), hasMultipleParticipants); + let lastMessageTextFromReport = ''; if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml})) { lastMessageTextFromReport = `[${Localize.translateLocal('common.attachment')}]`; @@ -306,6 +262,19 @@ function getOptionData(reportID) { if (result.isChatRoom || result.isPolicyExpenseChat) { result.alternateText = lastMessageText || subtitle; } else { + if (hasMultipleParticipants && !lastMessageText) { + // Here we get the beginning of chat history message and append the display name for each user, adding pronouns if there are any. + // We also add a fullstop after the final name, the word "and" before the final name and commas between all previous names. + lastMessageText = Localize.translate(preferredLocale, 'reportActionsView.beginningOfChatHistory') + + _.map(displayNamesWithTooltips, ({displayName, pronouns}, index) => { + const formattedText = _.isEmpty(pronouns) ? displayName : `${displayName} (${pronouns})`; + + if (index === displayNamesWithTooltips.length - 1) { return `${formattedText}.`; } + if (index === displayNamesWithTooltips.length - 2) { return `${formattedText} ${Localize.translate(preferredLocale, 'common.and')}`; } + if (index < displayNamesWithTooltips.length - 2) { return `${formattedText},`; } + }).join(' '); + } + result.alternateText = lastMessageText || Str.removeSMSDomain(personalDetail.login); } @@ -329,6 +298,7 @@ function getOptionData(reportID) { result.participantsList = personalDetailList; result.icons = ReportUtils.getIcons(report, personalDetails, policies, personalDetail.avatar); result.searchText = OptionsListUtils.getSearchText(report, reportName, personalDetailList, result.isChatRoom || result.isPolicyExpenseChat); + result.displayNamesWithTooltips = displayNamesWithTooltips; return result; } diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index e4b21ddea4be..4cf82b19d376 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -7,13 +7,14 @@ import * as ReportUtils from '../ReportUtils'; const reports = {}; /** - * Updates the title and favicon of the current browser tab - * and Mac OS or iOS dock icon with an unread indicator. + * Updates the title and favicon of the current browser tab and Mac OS or iOS dock icon with an unread indicator. + * Note: We are throttling this since listening for report changes can trigger many updates depending on how many reports + * a user has and how often they are updated. */ const throttledUpdatePageTitleAndUnreadCount = _.throttle(() => { const totalCount = _.filter(reports, ReportUtils.isUnread).length; updateUnread(totalCount); -}, 1000, {leading: false}); +}, 100, {leading: false}); let connectionID; diff --git a/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js b/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js index 75f6d3f31d08..c39afe29551b 100644 --- a/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js +++ b/src/libs/UnreadIndicatorUpdater/updateUnread/index.website.js @@ -10,6 +10,10 @@ import CONFIG from '../../../CONFIG'; */ function updateUnread(totalCount) { const hasUnread = totalCount !== 0; + + // There is a Chrome browser bug that causes the title to revert back to the previous when we are navigating back. Setting the title to an empty string + // seems to improve this issue. + document.title = ''; document.title = hasUnread ? `(${totalCount}) ${CONFIG.SITE_TITLE}` : CONFIG.SITE_TITLE; document.getElementById('favicon').href = hasUnread ? CONFIG.FAVICON.UNREAD : CONFIG.FAVICON.DEFAULT; } diff --git a/src/libs/Visibility/index.desktop.js b/src/libs/Visibility/index.desktop.js new file mode 100644 index 000000000000..04e5bb4ff9ca --- /dev/null +++ b/src/libs/Visibility/index.desktop.js @@ -0,0 +1,35 @@ +import ELECTRON_EVENTS from '../../../desktop/ELECTRON_EVENTS'; + +/** + * Detects whether the app is visible or not. Electron supports document.visibilityState, + * but switching to another app while Electron is partially occluded will not trigger a state of hidden + * so we ask the main process synchronously whether the BrowserWindow.isFocused() + * + * @returns {Boolean} + */ +function isVisible() { + return window.electron.sendSync(ELECTRON_EVENTS.REQUEST_VISIBILITY); +} + +/** + * Adds event listener for changes in visibility state + * + * @param {Function} callback + * + * @return {Function} removes the listener + */ +function onVisibilityChange(callback) { + // Deliberately strip callback argument to be consistent across implementations + window.electron.on(ELECTRON_EVENTS.FOCUS, () => callback()); + window.electron.on(ELECTRON_EVENTS.BLUR, () => callback()); + + return () => { + window.electron.removeAllListeners(ELECTRON_EVENTS.FOCUS); + window.electron.removeAllListeners(ELECTRON_EVENTS.BLUR); + }; +} + +export default { + isVisible, + onVisibilityChange, +}; diff --git a/src/libs/Visibility/index.js b/src/libs/Visibility/index.js index 58bb895e6aff..2d6c7d2f0906 100644 --- a/src/libs/Visibility/index.js +++ b/src/libs/Visibility/index.js @@ -1,20 +1,29 @@ -import ELECTRON_EVENTS from '../../../desktop/ELECTRON_EVENTS'; +import {AppState} from 'react-native'; /** - * Detects whether the app is visible or not. Electron supports - * document.visibilityState, but switching to another app while - * Electron is partially occluded will not trigger a state of hidden - * so we ask the main process synchronously whether the - * BrowserWindow.isFocused() + * Detects whether the app is visible or not. * * @returns {Boolean} */ function isVisible() { - return window.electron - ? window.electron.sendSync(ELECTRON_EVENTS.REQUEST_VISIBILITY) - : document.visibilityState === 'visible'; + return document.visibilityState === 'visible'; +} + +/** + * Adds event listener for changes in visibility state + * + * @param {Function} callback + * + * @return {Function} removes the listener + */ +function onVisibilityChange(callback) { + // Deliberately strip callback argument to be consistent across implementations + const subscription = AppState.addEventListener('change', () => callback()); + + return () => subscription.remove(); } export default { isVisible, + onVisibilityChange, }; diff --git a/src/libs/Visibility/index.native.js b/src/libs/Visibility/index.native.js index 5a2f1d74909f..c8b4f93048c1 100644 --- a/src/libs/Visibility/index.native.js +++ b/src/libs/Visibility/index.native.js @@ -8,6 +8,21 @@ import {AppState} from 'react-native'; */ const isVisible = () => AppState.currentState === 'active'; +/** + * Adds event listener for changes in visibility state + * + * @param {Function} callback + * + * @return {Function} removes the listener + */ +function onVisibilityChange(callback) { + // Deliberately strip callback argument to be consistent across implementations + const subscription = AppState.addEventListener('change', () => callback()); + + return () => subscription.remove(); +} + export default { isVisible, + onVisibilityChange, }; diff --git a/src/libs/__mocks__/Permissions.js b/src/libs/__mocks__/Permissions.js new file mode 100644 index 000000000000..50d093571d92 --- /dev/null +++ b/src/libs/__mocks__/Permissions.js @@ -0,0 +1,16 @@ +import _ from 'underscore'; +import CONST from '../../CONST'; + +/** + * This module is mocked in tests because all the permission methods call canUseAllBetas() and that will + * always return true because Environment.isDevelopment() is always true when running tests. It's not possible + * to mock canUseAllBetas() directly because it's not an exported method and we don't want to export it just + * so it can be mocked. + */ + +export default { + ...(jest.requireActual('../Permissions')), + canUseDefaultRooms: betas => _.contains(betas, CONST.BETAS.DEFAULT_ROOMS), + canUsePolicyRooms: betas => _.contains(betas, CONST.BETAS.POLICY_ROOMS), + canUsePolicyExpenseChat: betas => _.contains(betas, CONST.BETAS.POLICY_EXPENSE_CHAT), +}; diff --git a/src/libs/actions/ActiveClients.js b/src/libs/actions/ActiveClients.js index 2a3689b2c099..744944cfef1c 100644 --- a/src/libs/actions/ActiveClients.js +++ b/src/libs/actions/ActiveClients.js @@ -3,20 +3,13 @@ import ONYXKEYS from '../../ONYXKEYS'; /** * @param {Array} activeClients + * @return {Promise} */ function setActiveClients(activeClients) { - Onyx.set(ONYXKEYS.ACTIVE_CLIENTS, activeClients); -} - -/** - * @param {Number} clientID - * @returns {Promise} - */ -function addClient(clientID) { - return Onyx.merge(ONYXKEYS.ACTIVE_CLIENTS, [clientID]); + return Onyx.set(ONYXKEYS.ACTIVE_CLIENTS, activeClients); } export { + // eslint-disable-next-line import/prefer-default-export setActiveClients, - addClient, }; diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js index d667e8ee078c..faa7fe87f5d3 100644 --- a/src/libs/actions/App.js +++ b/src/libs/actions/App.js @@ -188,14 +188,31 @@ function setUpPoliciesAndNavigate(session) { const url = new URL(currentUrl); const exitTo = url.searchParams.get('exitTo'); + // Approved Accountants and Guides can enter a flow where they make a workspace for other users, + // and those are passed as a search parameter when using transition links + const ownerEmail = url.searchParams.get('ownerEmail'); + const makeMeAdmin = url.searchParams.get('makeMeAdmin'); + const shouldCreateFreePolicy = !isLoggingInAsNewUser && Str.startsWith(url.pathname, Str.normalizeUrl(ROUTES.TRANSITION_FROM_OLD_DOT)) && exitTo === ROUTES.WORKSPACE_NEW; if (shouldCreateFreePolicy) { - Policy.createWorkspace(); + Policy.createWorkspace(ownerEmail, makeMeAdmin); return; } if (!isLoggingInAsNewUser && exitTo) { + if (Navigation.isDrawerRoute(exitTo)) { + // The drawer navigation is only created after we have fetched reports from the server. + // Thus, if we use the standard navigation and try to navigate to a drawer route before + // the reports have been fetched, we will fail to navigate. + Navigation.isDrawerReady() + .then(() => { + // We must call dismissModal() to remove the /transition route from history + Navigation.dismissModal(); + Navigation.navigate(exitTo); + }); + return; + } Navigation.isNavigationReady() .then(() => { // We must call dismissModal() to remove the /transition route from history diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index f1fad43a63a9..53fe6511b5d1 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -44,7 +44,7 @@ function updatePlaidData(plaidData) { Onyx.merge(ONYXKEYS.PLAID_DATA, plaidData); } -function clearOnfido() { +function clearOnfidoToken() { Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, ''); } @@ -140,7 +140,8 @@ function addPersonalBankAccount(account, password) { key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, value: { isLoading: true, - error: '', + errors: null, + plaidAccountID: account.plaidAccountID, }, }, ], @@ -150,7 +151,7 @@ function addPersonalBankAccount(account, password) { key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, value: { isLoading: false, - error: '', + errors: null, shouldShowSuccess: true, }, }, @@ -161,7 +162,9 @@ function addPersonalBankAccount(account, password) { key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, value: { isLoading: false, - error: Localize.translateLocal('paymentsPage.addBankAccountFailure'), + errors: { + [DateUtils.getMicroseconds()]: Localize.translateLocal('paymentsPage.addBankAccountFailure'), + }, }, }, ], @@ -273,6 +276,22 @@ function updateCompanyInformationForBankAccount(bankAccount) { API.write('UpdateCompanyInformationForBankAccount', bankAccount, getVBBADataForOnyx()); } +/** + * Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided + * + * @param {Object} params + * + * // ACH Contract Step + * @param {Boolean} [params.ownsMoreThan25Percent] + * @param {Boolean} [params.hasOtherBeneficialOwners] + * @param {Boolean} [params.acceptTermsAndConditions] + * @param {Boolean} [params.certifyTrueInformation] + * @param {String} [params.beneficialOwners] + */ +function updateBeneficialOwnersForBankAccount(params) { + API.write('UpdateBeneficialOwnersForBankAccount', {...params}, getVBBADataForOnyx()); +} + /** * Create the bank account with manually entered data. * @@ -297,10 +316,11 @@ export { deletePaymentBankAccount, clearPersonalBankAccount, clearPlaid, - clearOnfido, + clearOnfidoToken, updatePersonalInformationForBankAccount, validateBankAccount, updateCompanyInformationForBankAccount, + updateBeneficialOwnersForBankAccount, connectBankAccountWithPlaid, updatePlaidData, }; diff --git a/src/libs/actions/CloseAccount.js b/src/libs/actions/CloseAccount.js index a42db97529ef..d8091f25bdb2 100644 --- a/src/libs/actions/CloseAccount.js +++ b/src/libs/actions/CloseAccount.js @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; /** * Clear CloseAccount error message to hide modal @@ -8,7 +9,15 @@ function clearError() { Onyx.merge(ONYXKEYS.CLOSE_ACCOUNT, {error: ''}); } +/** + * Set default Onyx data + */ +function setDefaultData() { + Onyx.merge(ONYXKEYS.CLOSE_ACCOUNT, {...CONST.DEFAULT_CLOSE_ACCOUNT_DATA}); +} + export { // eslint-disable-next-line import/prefer-default-export clearError, + setDefaultData, }; diff --git a/src/libs/actions/Composer.js b/src/libs/actions/Composer.js new file mode 100644 index 000000000000..9a75e3662cd8 --- /dev/null +++ b/src/libs/actions/Composer.js @@ -0,0 +1,14 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; + +/** + * @param {Boolean} shouldShowComposeInput + */ +function setShouldShowComposeInput(shouldShowComposeInput) { + Onyx.set(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, shouldShowComposeInput); +} + +export { + // eslint-disable-next-line import/prefer-default-export + setShouldShowComposeInput, +}; diff --git a/src/libs/actions/FormActions.js b/src/libs/actions/FormActions.js index eb087e673361..e81b8d28ace7 100644 --- a/src/libs/actions/FormActions.js +++ b/src/libs/actions/FormActions.js @@ -10,10 +10,10 @@ function setIsLoading(formID, isLoading) { /** * @param {String} formID - * @param {String} error + * @param {Object} errors */ -function setErrorMessage(formID, error) { - Onyx.merge(formID, {error}); +function setErrors(formID, errors) { + Onyx.merge(formID, {errors}); } /** @@ -21,11 +21,11 @@ function setErrorMessage(formID, error) { * @param {Object} draftValues */ function setDraftValues(formID, draftValues) { - Onyx.merge(`${formID}DraftValues`, draftValues); + Onyx.merge(`${formID}Draft`, draftValues); } export { setIsLoading, - setErrorMessage, + setErrors, setDraftValues, }; diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js index 1cba0623f81e..bb978b331e20 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.js @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import lodashGet from 'lodash/get'; import {Linking} from 'react-native'; +import _ from 'underscore'; import ONYXKEYS from '../../ONYXKEYS'; import Growl from '../Growl'; import * as Localize from '../Localize'; @@ -35,28 +36,36 @@ function showGrowlIfOffline() { * @param {String} url */ function openOldDotLink(url) { - if (showGrowlIfOffline()) { - return; - } - + /** + * @param {String} [shortLivedAuthToken] + * @returns {String} + */ function buildOldDotURL(shortLivedAuthToken) { const hasHashParams = url.indexOf('#') !== -1; const hasURLParams = url.indexOf('?') !== -1; + const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; + const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; + + const params = _.compact([authTokenParam, emailParam]).join('&'); + // If the URL contains # or ?, we can assume they don't need to have the `?` token to start listing url parameters. - return `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${url}${hasHashParams || hasURLParams ? '&' : '?'}authToken=${shortLivedAuthToken}&email=${encodeURIComponent(currentUserEmail)}`; + return `${CONFIG.EXPENSIFY.EXPENSIFY_URL}${url}${hasHashParams || hasURLParams ? '&' : '?'}${params}`; + } + + if (isNetworkOffline) { + Linking.openURL(buildOldDotURL()); + return; } - // We use makeRequestWithSideEffects here because we need to block until after we get the shortLivedAuthToken back from the server (link won't work without it!). + // If shortLivedAuthToken is not accessible, fallback to opening the link without the token. // eslint-disable-next-line rulesdir/no-api-side-effects-method API.makeRequestWithSideEffects( 'OpenOldDotLink', {}, {}, ).then((response) => { - if (response.jsonCode === 200) { - Linking.openURL(buildOldDotURL(response.shortLivedAuthToken)); - } else { - Growl.show(response.message, CONST.GROWL.WARNING); - } + Linking.openURL(buildOldDotURL(response.shortLivedAuthToken)); + }).catch(() => { + Linking.openURL(buildOldDotURL()); }); } diff --git a/src/libs/actions/NameValuePair.js b/src/libs/actions/NameValuePair.js index 8363b2b961c3..17563e8a8b27 100644 --- a/src/libs/actions/NameValuePair.js +++ b/src/libs/actions/NameValuePair.js @@ -1,7 +1,6 @@ import _ from 'underscore'; import Onyx from 'react-native-onyx'; import lodashGet from 'lodash/get'; -// eslint-disable-next-line import/no-cycle import * as DeprecatedAPI from '../deprecatedAPI'; /** diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js index 017bdf785f49..85d76ca7c1ef 100644 --- a/src/libs/actions/PaymentMethods.js +++ b/src/libs/actions/PaymentMethods.js @@ -126,7 +126,7 @@ function openPaymentsPage() { * */ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, isOptimisticData = true) { - return [ + const onxyData = [ { onyxMethod: CONST.ONYX.METHOD.MERGE, key: ONYXKEYS.USER_WALLET, @@ -136,7 +136,10 @@ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMet errors: null, }, }, - { + ]; + + if (previousPaymentMethod) { + onxyData.push({ onyxMethod: CONST.ONYX.METHOD.MERGE, key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.CARD_LIST, value: { @@ -144,8 +147,11 @@ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMet isDefault: !isOptimisticData, }, }, - }, - { + }); + } + + if (currentPaymentMethod) { + onxyData.push({ onyxMethod: CONST.ONYX.METHOD.MERGE, key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.CARD_LIST, value: { @@ -153,8 +159,10 @@ function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMet isDefault: isOptimisticData, }, }, - }, - ]; + }); + } + + return onxyData; } /** @@ -222,7 +230,7 @@ function addPaymentCard(params) { function clearDebitCardFormErrorAndSubmit() { Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, { isLoading: false, - error: null, + errors: null, }); } diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index ee8db8e212a8..903ec76b4839 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -6,9 +6,7 @@ import Str from 'expensify-common/lib/str'; import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; import * as API from '../API'; -// eslint-disable-next-line import/no-cycle import * as DeprecatedAPI from '../deprecatedAPI'; -// eslint-disable-next-line import/no-cycle import NameValuePair from './NameValuePair'; import * as LoginUtils from '../LoginUtils'; import * as ReportUtils from '../ReportUtils'; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 12efd891f05d..b51666f0e86f 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -6,17 +6,14 @@ import Str from 'expensify-common/lib/str'; import * as DeprecatedAPI from '../deprecatedAPI'; import * as API from '../API'; import ONYXKEYS from '../../ONYXKEYS'; -import Growl from '../Growl'; -import CONFIG from '../../CONFIG'; import CONST from '../../CONST'; import * as Localize from '../Localize'; import Navigation from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; import * as OptionsListUtils from '../OptionsListUtils'; -import * as Report from './Report'; -import * as Pusher from '../Pusher/pusher'; import DateUtils from '../DateUtils'; import * as ReportUtils from '../ReportUtils'; +import Log from '../Log'; const allPolicies = {}; Onyx.connect({ @@ -29,6 +26,13 @@ Onyx.connect({ allPolicies[key] = val; }, }); + +let lastAccessedWorkspacePolicyID = null; +Onyx.connect({ + key: ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, + callback: value => lastAccessedWorkspacePolicyID = value, +}); + let sessionEmail = ''; Onyx.connect({ key: ONYXKEYS.SESSION, @@ -87,6 +91,14 @@ function getSimplifiedPolicyObject(fullPolicyOrPolicySummary, isFromFullPolicy) }; } +/** + * Stores in Onyx the policy ID of the last workspace that was accessed by the user + * @param {String|null} policyID + */ +function updateLastAccessedWorkspace(policyID) { + Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); +} + /** * Used to update ALL of the policies at once. If a policy is present locally, but not in the policies object passed here it will be removed. * @param {Object} policyCollection - object of policy key and partial policy object @@ -111,8 +123,9 @@ function updateAllPolicies(policyCollection) { * Delete the workspace * * @param {String} policyID + * @param {Array} reports */ -function deleteWorkspace(policyID) { +function deleteWorkspace(policyID, reports) { const optimisticData = [ { onyxMethod: CONST.ONYX.METHOD.MERGE, @@ -122,13 +135,37 @@ function deleteWorkspace(policyID) { errors: null, }, }, + ..._.map(reports, ({reportID}) => ({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS.CLOSED, + }, + })), + ]; + + // Restore the old report stateNum and statusNum + const failureData = [ + ..._.map(reports, ({reportID, stateNum, statusNum}) => ({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum, + statusNum, + }, + })), ]; // We don't need success data since the push notification will update // the onyxData for all connected clients. - const failureData = []; const successData = []; API.write('DeleteWorkspace', {policyID}, {optimisticData, successData, failureData}); + + // Reset the lastAccessedWorkspacePolicyID + if (policyID === lastAccessedWorkspacePolicyID) { + updateLastAccessedWorkspace(null); + } } /** @@ -209,29 +246,21 @@ function removeMembers(members, policyID) { if (members.length === 0) { return; } - - const employeeListUpdate = {}; - _.each(members, login => employeeListUpdate[login] = null); - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_LIST}${policyID}`, employeeListUpdate); - - // Make the API call to remove a login from the policy - DeprecatedAPI.Policy_Employees_Remove({ + const membersListKey = `${ONYXKEYS.COLLECTION.POLICY_MEMBER_LIST}${policyID}`; + const optimisticData = [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: membersListKey, + value: _.object(members, Array(members.length).fill({pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})), + }]; + const failureData = [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: membersListKey, + value: _.object(members, Array(members.length).fill({errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.people.error.genericRemove')}})), + }]; + API.write('DeleteMembersFromWorkspace', { emailList: members.join(','), policyID, - }) - .then((data) => { - if (data.jsonCode === 200) { - return; - } - - // Rollback removal on failure - _.each(members, login => employeeListUpdate[login] = {}); - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_MEMBER_LIST}${policyID}`, employeeListUpdate); - - // Show the user feedback that the removal failed - const errorMessage = data.jsonCode === 666 ? data.message : Localize.translateLocal('workspace.people.genericFailureMessage'); - Growl.show(errorMessage, CONST.GROWL.ERROR, 5000); - }); + }, {optimisticData, failureData}); } /** @@ -645,39 +674,6 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new }, {optimisticData, successData, failureData}); } -/** - * Stores in Onyx the policy ID of the last workspace that was accessed by the user - * @param {String} policyID - */ -function updateLastAccessedWorkspace(policyID) { - Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); -} - -/** - * Subscribe to public-policyEditor-[policyID] events. - */ -function subscribeToPolicyEvents() { - _.each(allPolicies, (policy) => { - const pusherChannelName = `public-policyEditor-${policy.id}${CONFIG.PUSHER.SUFFIX}`; - Pusher.subscribe(pusherChannelName, 'policyEmployeeRemoved', ({removedEmails, policyExpenseChatIDs, defaultRoomChatIDs}) => { - // Refetch the policy expense chats to update their state and their actions to get the archive reason - if (!_.isEmpty(policyExpenseChatIDs)) { - Report.fetchChatReportsByIDs(policyExpenseChatIDs); - _.each(policyExpenseChatIDs, (reportID) => { - Report.reconnect(reportID); - }); - } - - // Remove the default chats if we are one of the users getting removed - if (removedEmails.includes(sessionEmail) && !_.isEmpty(defaultRoomChatIDs)) { - _.each(defaultRoomChatIDs, (chatID) => { - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatID}`, null); - }); - } - }); - }); -} - /** * Removes an error after trying to delete a member * @@ -719,10 +715,11 @@ function clearDeleteWorkspaceError(policyID) { /** * Generate a policy name based on an email and policy list. + * @param {String} [email] the email to base the workspace name on. If not passed, will use the logged in user's email instead * @returns {String} */ -function generateDefaultWorkspaceName() { - const emailParts = sessionEmail.split('@'); +function generateDefaultWorkspaceName(email = '') { + const emailParts = email ? email.split('@') : sessionEmail.split('@'); let defaultWorkspaceName = ''; if (!emailParts || emailParts.length !== 2) { return defaultWorkspaceName; @@ -747,8 +744,7 @@ function generateDefaultWorkspaceName() { // Check if this name already exists in the policies let suffix = 0; _.forEach(allPolicies, (policy) => { - // Get the name of the policy - const {name} = policy; + const name = lodashGet(policy, 'name', ''); if (name.toLowerCase().includes(defaultWorkspaceName.toLowerCase())) { suffix += 1; @@ -768,10 +764,13 @@ function generatePolicyID() { /** * Optimistically creates a new workspace and default workspace chats + * + * @param {String} [ownerEmail] Optional, the email of the account to make the owner of the policy + * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy */ -function createWorkspace() { +function createWorkspace(ownerEmail = '', makeMeAdmin = false) { const policyID = generatePolicyID(); - const workspaceName = generateDefaultWorkspaceName(); + const workspaceName = generateDefaultWorkspaceName(ownerEmail); const { announceChatReportID, @@ -790,6 +789,8 @@ function createWorkspace() { announceChatReportID, adminsChatReportID, expenseChatReportID, + ownerEmail, + makeMeAdmin, policyName: workspaceName, type: CONST.POLICY.TYPE.FREE, }, @@ -944,10 +945,20 @@ function createWorkspace() { } function openWorkspaceReimburseView(policyID) { + if (!policyID) { + Log.warn('openWorkspaceReimburseView invalid params', {policyID}); + return; + } + API.read('OpenWorkspaceReimburseView', {policyID}); } function openWorkspaceMembersPage(policyID, clientMemberEmails) { + if (!policyID || !clientMemberEmails) { + Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails}); + return; + } + API.read('OpenWorkspaceMembersPage', { policyID, clientMemberEmails: JSON.stringify(clientMemberEmails), @@ -955,6 +966,11 @@ function openWorkspaceMembersPage(policyID, clientMemberEmails) { } function openWorkspaceInvitePage(policyID, clientMemberEmails) { + if (!policyID || !clientMemberEmails) { + Log.warn('openWorkspaceInvitePage invalid params', {policyID, clientMemberEmails}); + return; + } + API.read('OpenWorkspaceInvitePage', { policyID, clientMemberEmails: JSON.stringify(clientMemberEmails), @@ -974,7 +990,6 @@ export { updateWorkspaceCustomUnit, updateCustomUnitRate, updateLastAccessedWorkspace, - subscribeToPolicyEvents, clearDeleteMemberError, clearAddMemberError, clearDeleteWorkspaceError, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 9008c209e53f..fc9b2e5a8518 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -41,12 +41,6 @@ Onyx.connect({ }, }); -let lastViewedReportID; -Onyx.connect({ - key: ONYXKEYS.CURRENTLY_VIEWED_REPORTID, - callback: val => lastViewedReportID = val ? Number(val) : null, -}); - const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -331,14 +325,6 @@ function fetchChatReportsByIDs(chatList, shouldRedirectIfInaccessible = false) { // Fetch the personal details if there are any PersonalDetails.getFromReportParticipants(_.values(simplifiedReports)); return simplifiedReports; - }) - .catch((err) => { - if (err.message !== CONST.REPORT.ERROR.INACCESSIBLE_REPORT) { - return; - } - - // eslint-disable-next-line no-use-before-define - handleInaccessibleReport(); }); } @@ -954,13 +940,6 @@ function handleReportChanged(report) { } } -/** - * @param {String} reportID - */ -function updateCurrentlyViewedReportID(reportID) { - Onyx.merge(ONYXKEYS.CURRENTLY_VIEWED_REPORTID, String(reportID)); -} - Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, callback: handleReportChanged, @@ -1086,6 +1065,7 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { isEdited: true, html: htmlForNewComment, text: textForNewComment, + type: originalReportAction.message[0].type, }], }, }; @@ -1212,48 +1192,6 @@ function navigateToConciergeChat() { Navigation.navigate(ROUTES.getReportRoute(conciergeChatReportID)); } -/** - * Handle the navigation when report is inaccessible - */ -function handleInaccessibleReport() { - Growl.error(Localize.translateLocal('notFound.chatYouLookingForCannotBeFound')); - navigateToConciergeChat(); -} - -/** - * Creates a policy room, fetches it, and navigates to it. - * @param {String} policyID - * @param {String} reportName - * @param {String} visibility - * @return {Promise} - */ -function createPolicyRoom(policyID, reportName, visibility) { - Onyx.set(ONYXKEYS.IS_LOADING_CREATE_POLICY_ROOM, true); - return DeprecatedAPI.CreatePolicyRoom({policyID, reportName, visibility}) - .then((response) => { - if (response.jsonCode === CONST.JSON_CODE.UNABLE_TO_RETRY) { - Growl.error(Localize.translateLocal('newRoomPage.growlMessageOnError')); - return; - } - - if (response.jsonCode !== CONST.JSON_CODE.SUCCESS) { - Growl.error(response.message); - return; - } - - return fetchChatReportsByIDs([response.reportID]); - }) - .then((chatReports) => { - const reportID = lodashGet(_.first(_.values(chatReports)), 'reportID', ''); - if (!reportID) { - Log.error('Unable to grab policy room after creation', reportID); - return; - } - Navigation.navigate(ROUTES.getReportRoute(reportID)); - }) - .finally(() => Onyx.set(ONYXKEYS.IS_LOADING_CREATE_POLICY_ROOM, false)); -} - /** * Add a policy report (workspace room) optimistically and navigate to it. * @@ -1263,7 +1201,7 @@ function createPolicyRoom(policyID, reportName, visibility) { */ function addPolicyReport(policy, reportName, visibility) { // The participants include the current user (admin) and the employees. Participants must not be empty. - const participants = [currentUserEmail, ...policy.employeeList]; + const participants = _.unique([currentUserEmail, ..._.pluck(policy.employeeList, 'email')]); const policyReport = ReportUtils.buildOptimisticChatReport( participants, reportName, @@ -1276,7 +1214,7 @@ function addPolicyReport(policy, reportName, visibility) { ); // Onyx.set is used on the optimistic data so that it is present before navigating to the workspace room. With Onyx.merge the workspace room reportID is not present when - // storeCurrentlyViewedReport is called on the ReportScreen, so fetchChatReportsByIDs is called which is unnecessary since the optimistic data will be stored in Onyx. + // fetchReportIfNeeded is called on the ReportScreen, so fetchChatReportsByIDs is called which is unnecessary since the optimistic data will be stored in Onyx. // If there was an error creating the room, then fetchChatReportsByIDs throws an error and the user is navigated away from the report instead of showing the RBR error message. // Therefore, Onyx.set is used instead of Onyx.merge. const optimisticData = [ @@ -1448,7 +1386,7 @@ function viewNewReportAction(reportID, action) { } // If we are currently viewing this report do not show a notification. - if (reportID === lastViewedReportID && Visibility.isVisible()) { + if (reportID === Navigation.getReportIDFromRoute() && Visibility.isVisible()) { Log.info('[LOCAL_NOTIFICATION] No notification because it was a comment for the current report'); return; } @@ -1484,7 +1422,7 @@ Onyx.connect({ initWithStoredValues: false, callback: (actions, key) => { // reportID can be derived from the Onyx key - const reportID = parseInt(key.split('_')[1], 10); + const reportID = key.split('_')[1]; if (!reportID) { return; } @@ -1537,16 +1475,13 @@ export { saveReportComment, broadcastUserIsTyping, togglePinnedState, - updateCurrentlyViewedReportID, editReportComment, saveReportActionDraft, deleteReportComment, getSimplifiedIOUReport, syncChatAndIOUReports, navigateToConciergeChat, - handleInaccessibleReport, setReportWithDraft, - createPolicyRoom, addPolicyReport, navigateToConciergeChatAndDeletePolicyReport, setIsComposerFullSize, diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 6bcaa9f7fdb5..13f1eadc5769 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -16,7 +16,6 @@ import * as Localize from '../../Localize'; import UnreadIndicatorUpdater from '../../UnreadIndicatorUpdater'; import Timers from '../../Timers'; import * as Pusher from '../../Pusher/pusher'; -import NetworkConnection from '../../NetworkConnection'; import * as User from '../User'; import * as Authentication from '../../Authentication'; import * as Welcome from '../Welcome'; @@ -41,10 +40,10 @@ Onyx.connect({ function setSuccessfulSignInData(data) { PushNotification.register(data.accountID); Onyx.merge(ONYXKEYS.SESSION, { - shouldShowComposeInput: true, errors: null, ..._.pick(data, 'authToken', 'accountID', 'email', 'encryptedAuthToken'), }); + Onyx.set(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT, true); } /** @@ -373,7 +372,6 @@ function clearSignInData() { */ function cleanupSession() { // We got signed out in this tab or another so clean up any subscriptions and timers - NetworkConnection.stopListeningForReconnect(); UnreadIndicatorUpdater.stopListeningForReportChanges(); PushNotification.deregister(); PushNotification.clearNotifications(); @@ -385,7 +383,8 @@ function cleanupSession() { function clearAccountMessages() { Onyx.merge(ONYXKEYS.ACCOUNT, { success: '', - errors: [], + errors: null, + message: null, isLoading: false, }); } @@ -566,13 +565,6 @@ function authenticatePusher(socketID, channelName, callback) { }); } -/** - * @param {Boolean} shouldShowComposeInput - */ -function setShouldShowComposeInput(shouldShowComposeInput) { - Onyx.merge(ONYXKEYS.SESSION, {shouldShowComposeInput}); -} - export { beginSignIn, updatePasswordAndSignin, @@ -589,7 +581,6 @@ export { validateEmail, authenticatePusher, reauthenticatePusher, - setShouldShowComposeInput, changePasswordAndSignIn, invalidateCredentials, invalidateAuthToken, diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.js index 6741fabfb4c8..d221ba62f6be 100644 --- a/src/libs/actions/SignInRedirect.js +++ b/src/libs/actions/SignInRedirect.js @@ -1,7 +1,6 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../ONYXKEYS'; import * as MainQueue from '../Network/MainQueue'; -// eslint-disable-next-line import/no-cycle import DateUtils from '../DateUtils'; import * as Localize from '../Localize'; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index 7554ce05a040..c1f11e361065 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -18,24 +18,15 @@ import * as Link from './Link'; import getSkinToneEmojiFromIndex from '../../components/EmojiPicker/getSkinToneEmojiFromIndex'; import * as SequentialQueue from '../Network/SequentialQueue'; import PusherUtils from '../PusherUtils'; -import DateUtils from '../DateUtils'; -let sessionAuthToken = ''; let currentUserAccountID = ''; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { - sessionAuthToken = lodashGet(val, 'authToken', ''); currentUserAccountID = lodashGet(val, 'accountID', ''); }, }); -let currentlyViewedReportID = ''; -Onyx.connect({ - key: ONYXKEYS.CURRENTLY_VIEWED_REPORTID, - callback: val => currentlyViewedReportID = val || '', -}); - /** * Changes a password for a given account * @@ -214,31 +205,22 @@ function setSecondaryLoginAndNavigate(login, password) { * @param {String} validateCode */ function validateLogin(accountID, validateCode) { - const isLoggedIn = !_.isEmpty(sessionAuthToken); - const redirectRoute = isLoggedIn ? ROUTES.getReportRoute(currentlyViewedReportID) : ROUTES.HOME; Onyx.merge(ONYXKEYS.ACCOUNT, {...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true}); - DeprecatedAPI.ValidateEmail({ + const optimisticData = [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + }, + }, + ]; + API.write('ValidateLogin', { accountID, validateCode, - }).then((response) => { - if (response.jsonCode === 200) { - const {email} = response; - - if (isLoggedIn) { - getUserDetails(); - } else { - // Let the user know we've successfully validated their login - const success = lodashGet(response, 'message', `Your secondary login ${email} has been validated.`); - Onyx.merge(ONYXKEYS.ACCOUNT, {success}); - } - } else { - Onyx.merge(ONYXKEYS.ACCOUNT, {errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('resendValidationForm.validationCodeFailedMessage')}}); - } - }).finally(() => { - Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: false}); - Navigation.navigate(redirectRoute); - }); + }, {optimisticData}); + Navigation.navigate(ROUTES.HOME); } /** diff --git a/src/libs/deprecatedAPI.js b/src/libs/deprecatedAPI.js index a51e217e89ab..4e074490c22c 100644 --- a/src/libs/deprecatedAPI.js +++ b/src/libs/deprecatedAPI.js @@ -3,7 +3,6 @@ import isViaExpensifyCashNative from './isViaExpensifyCashNative'; import requireParameters from './requireParameters'; import * as Request from './Request'; import * as Network from './Network'; -// eslint-disable-next-line import/no-cycle import * as Middleware from './Middleware'; import CONST from '../CONST'; @@ -154,14 +153,6 @@ function GetPolicySummaryList() { return Network.post(commandName, parameters); } -/** - * @returns {Promise} - */ -function GetRequestCountryCode() { - const commandName = 'GetRequestCountryCode'; - return Network.post(commandName); -} - /** * @param {Object} parameters * @param {String} parameters.name @@ -534,19 +525,6 @@ function GetReportSummaryList(parameters) { return Network.post(commandName, {...parameters, returnValueList: 'reportSummaryList'}); } -/** - * @param {Object} parameters - * @param {String} parameters.policyID - * @param {String} parameters.reportName - * @param {String} parameters.visibility - * @return {Promise} - */ -function CreatePolicyRoom(parameters) { - const commandName = 'CreatePolicyRoom'; - requireParameters(['policyID', 'reportName', 'visibility'], parameters, commandName); - return Network.post(commandName, parameters); -} - /** * Transfer Wallet balance and takes either the bankAccoundID or fundID * @param {Object} parameters @@ -578,7 +556,6 @@ export { ChangePassword, CreateChatReport, CreateLogin, - CreatePolicyRoom, DeleteLogin, Get, GetStatementPDF, @@ -586,7 +563,6 @@ export { GetFullPolicy, GetPolicySummaryList, GetReportSummaryList, - GetRequestCountryCode, Graphite_Timer, Inbox_CallUser, PayIOU, diff --git a/src/libs/hashCode.js b/src/libs/hashCode.js new file mode 100644 index 000000000000..0e6d0726962e --- /dev/null +++ b/src/libs/hashCode.js @@ -0,0 +1,19 @@ +/* eslint-disable no-bitwise */ +/** + * Simple string hashing function obtained from: https://stackoverflow.com/a/8831937/16434681 + * Returns a hash code from a string + * @param {String} str The string to hash. + * @return {Number} A 32bit integer (can be negative) + * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ + */ +function hashCode(str) { + let hash = 0; + for (let i = 0, len = str.length; i < len; i++) { + const chr = str.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +export default hashCode; diff --git a/src/libs/md5.js b/src/libs/md5.js deleted file mode 100644 index bb572097ca88..000000000000 --- a/src/libs/md5.js +++ /dev/null @@ -1,194 +0,0 @@ -/** - * md5 hash implementation - * http://www.myersdaily.org/joseph/javascript/md5-text.html - * - * Expensify modification: Wrap in a function to avoid global - * namespace pollution - * - */ -/* eslint-disable */ -function md5cycle(x, k) { - var a = x[0], - b = x[1], - c = x[2], - d = x[3]; - - a = ff(a, b, c, d, k[0], 7, -680876936); - d = ff(d, a, b, c, k[1], 12, -389564586); - c = ff(c, d, a, b, k[2], 17, 606105819); - b = ff(b, c, d, a, k[3], 22, -1044525330); - a = ff(a, b, c, d, k[4], 7, -176418897); - d = ff(d, a, b, c, k[5], 12, 1200080426); - c = ff(c, d, a, b, k[6], 17, -1473231341); - b = ff(b, c, d, a, k[7], 22, -45705983); - a = ff(a, b, c, d, k[8], 7, 1770035416); - d = ff(d, a, b, c, k[9], 12, -1958414417); - c = ff(c, d, a, b, k[10], 17, -42063); - b = ff(b, c, d, a, k[11], 22, -1990404162); - a = ff(a, b, c, d, k[12], 7, 1804603682); - d = ff(d, a, b, c, k[13], 12, -40341101); - c = ff(c, d, a, b, k[14], 17, -1502002290); - b = ff(b, c, d, a, k[15], 22, 1236535329); - - a = gg(a, b, c, d, k[1], 5, -165796510); - d = gg(d, a, b, c, k[6], 9, -1069501632); - c = gg(c, d, a, b, k[11], 14, 643717713); - b = gg(b, c, d, a, k[0], 20, -373897302); - a = gg(a, b, c, d, k[5], 5, -701558691); - d = gg(d, a, b, c, k[10], 9, 38016083); - c = gg(c, d, a, b, k[15], 14, -660478335); - b = gg(b, c, d, a, k[4], 20, -405537848); - a = gg(a, b, c, d, k[9], 5, 568446438); - d = gg(d, a, b, c, k[14], 9, -1019803690); - c = gg(c, d, a, b, k[3], 14, -187363961); - b = gg(b, c, d, a, k[8], 20, 1163531501); - a = gg(a, b, c, d, k[13], 5, -1444681467); - d = gg(d, a, b, c, k[2], 9, -51403784); - c = gg(c, d, a, b, k[7], 14, 1735328473); - b = gg(b, c, d, a, k[12], 20, -1926607734); - - a = hh(a, b, c, d, k[5], 4, -378558); - d = hh(d, a, b, c, k[8], 11, -2022574463); - c = hh(c, d, a, b, k[11], 16, 1839030562); - b = hh(b, c, d, a, k[14], 23, -35309556); - a = hh(a, b, c, d, k[1], 4, -1530992060); - d = hh(d, a, b, c, k[4], 11, 1272893353); - c = hh(c, d, a, b, k[7], 16, -155497632); - b = hh(b, c, d, a, k[10], 23, -1094730640); - a = hh(a, b, c, d, k[13], 4, 681279174); - d = hh(d, a, b, c, k[0], 11, -358537222); - c = hh(c, d, a, b, k[3], 16, -722521979); - b = hh(b, c, d, a, k[6], 23, 76029189); - a = hh(a, b, c, d, k[9], 4, -640364487); - d = hh(d, a, b, c, k[12], 11, -421815835); - c = hh(c, d, a, b, k[15], 16, 530742520); - b = hh(b, c, d, a, k[2], 23, -995338651); - - a = ii(a, b, c, d, k[0], 6, -198630844); - d = ii(d, a, b, c, k[7], 10, 1126891415); - c = ii(c, d, a, b, k[14], 15, -1416354905); - b = ii(b, c, d, a, k[5], 21, -57434055); - a = ii(a, b, c, d, k[12], 6, 1700485571); - d = ii(d, a, b, c, k[3], 10, -1894986606); - c = ii(c, d, a, b, k[10], 15, -1051523); - b = ii(b, c, d, a, k[1], 21, -2054922799); - a = ii(a, b, c, d, k[8], 6, 1873313359); - d = ii(d, a, b, c, k[15], 10, -30611744); - c = ii(c, d, a, b, k[6], 15, -1560198380); - b = ii(b, c, d, a, k[13], 21, 1309151649); - a = ii(a, b, c, d, k[4], 6, -145523070); - d = ii(d, a, b, c, k[11], 10, -1120210379); - c = ii(c, d, a, b, k[2], 15, 718787259); - b = ii(b, c, d, a, k[9], 21, -343485551); - - x[0] = add32(a, x[0]); - x[1] = add32(b, x[1]); - x[2] = add32(c, x[2]); - x[3] = add32(d, x[3]); - -} - -function cmn(q, a, b, x, s, t) { - a = add32(add32(a, q), add32(x, t)); - return add32((a << s) | (a >>> (32 - s)), b); -} - -function ff(a, b, c, d, x, s, t) { - return cmn((b & c) | ((~b) & d), a, b, x, s, t); -} - -function gg(a, b, c, d, x, s, t) { - return cmn((b & d) | (c & (~d)), a, b, x, s, t); -} - -function hh(a, b, c, d, x, s, t) { - return cmn(b ^ c ^ d, a, b, x, s, t); -} - -function ii(a, b, c, d, x, s, t) { - return cmn(c ^ (b | (~d)), a, b, x, s, t); -} - -function md51(s) { - var n = s.length, - state = [1732584193, -271733879, -1732584194, 271733878], - i; - for (i = 64; i <= s.length; i += 64) { - md5cycle(state, md5blk(s.substring(i - 64, i))); - } - s = s.substring(i - 64); - var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - for (i = 0; i < s.length; i++) - tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3); - tail[i >> 2] |= 0x80 << ((i % 4) << 3); - if (i > 55) { - md5cycle(state, tail); - for (i = 0; i < 16; i++) tail[i] = 0; - } - tail[14] = n * 8; - md5cycle(state, tail); - return state; -} - -/* there needs to be support for Unicode here, - * unless we pretend that we can redefine the MD-5 - * algorithm for multi-byte characters (perhaps - * by adding every four 16-bit characters and - * shortening the sum to 32 bits). Otherwise - * I suggest performing MD-5 as if every character - * was two bytes--e.g., 0040 0025 = @%--but then - * how will an ordinary MD-5 sum be matched? - * There is no way to standardize text to something - * like UTF-8 before transformation; speed cost is - * utterly prohibitive. The JavaScript standard - * itself needs to look at this: it should start - * providing access to strings as preformed UTF-8 - * 8-bit unsigned value arrays. - */ -function md5blk(s) { /* I figured global was faster. */ - var md5blks = [], - i; /* Andy King said do it this way. */ - for (i = 0; i < 64; i += 4) { - md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i + 1) << 8) + (s.charCodeAt(i + 2) << 16) + (s.charCodeAt(i + 3) << 24); - } - return md5blks; -} - -var hex_chr = '0123456789abcdef'.split(''); - -function rhex(n) { - var s = '', - j = 0; - for (; j < 4; j++) - s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] + hex_chr[(n >> (j * 8)) & 0x0F]; - return s; -} - -function hex(x) { - for (var i = 0; i < x.length; i++) - x[i] = rhex(x[i]); - return x.join(''); -} - -function md5(s) { - return hex(md51(s)); -} - -/* this function is much faster, - so if possible we use it. Some IEs - are the only ones I know of that - need the idiotic second function, - generated by an if clause. */ - -function add32(a, b) { - return (a + b) & 0xFFFFFFFF; -} - -if (md5('hello') != '5d41402abc4b2a76b9719d911017c592') { - function add32(x, y) { - var lsw = (x & 0xFFFF) + (y & 0xFFFF), - msw = (x >> 16) + (y >> 16) + (lsw >> 16); - return (msw << 16) | (lsw & 0xFFFF); - } -} -export default md5; diff --git a/src/libs/setSelection/index.js b/src/libs/setSelection/index.js new file mode 100644 index 000000000000..c7f24ae4a199 --- /dev/null +++ b/src/libs/setSelection/index.js @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000000..02d812d84cd4 --- /dev/null +++ b/src/libs/setSelection/index.native.js @@ -0,0 +1,7 @@ +export default function setSelection(textInput, start, end) { + if (!textInput) { + return; + } + + textInput.setSelection(start, end); +} diff --git a/src/libs/toggleReportActionComposeView/index.js b/src/libs/toggleReportActionComposeView/index.js index 65b8ee4d2401..70b580bfba44 100644 --- a/src/libs/toggleReportActionComposeView/index.js +++ b/src/libs/toggleReportActionComposeView/index.js @@ -1,9 +1,9 @@ -import * as Session from '../actions/Session'; +import * as Composer from '../actions/Composer'; export default (shouldShowComposeInput, isSmallScreenWidth = true) => { if (!isSmallScreenWidth) { return; } - Session.setShouldShowComposeInput(shouldShowComposeInput); + Composer.setShouldShowComposeInput(shouldShowComposeInput); }; diff --git a/src/libs/toggleReportActionComposeView/index.native.js b/src/libs/toggleReportActionComposeView/index.native.js index ddf5fdd1ce2e..edebae2cba5b 100644 --- a/src/libs/toggleReportActionComposeView/index.native.js +++ b/src/libs/toggleReportActionComposeView/index.native.js @@ -1,3 +1,3 @@ -import * as Session from '../actions/Session'; +import * as Composer from '../actions/Composer'; -export default shouldShowComposeInput => Session.setShouldShowComposeInput(shouldShowComposeInput); +export default shouldShowComposeInput => Composer.setShouldShowComposeInput(shouldShowComposeInput); diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 17bc7cea68a2..f6a747623647 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -20,20 +20,27 @@ import Icon from '../components/Icon'; import defaultTheme from '../styles/themes/default'; import Button from '../components/Button'; import FixedFooter from '../components/FixedFooter'; -import FormScrollView from '../components/FormScrollView'; -import FormAlertWithSubmitButton from '../components/FormAlertWithSubmitButton'; -import FormHelper from '../libs/FormHelper'; -import * as ReimbursementAccount from '../libs/actions/ReimbursementAccount'; +import Form from '../components/Form'; import TextInput from '../components/TextInput'; import canFocusInputOnScreenFocus from '../libs/canFocusInputOnScreenFocus/index.native'; import ROUTES from '../ROUTES'; const propTypes = { ...withLocalizePropTypes, + + /** The details about the Personal bank account we are adding saved in Onyx */ personalBankAccount: PropTypes.shape({ + /** An error message to display to the user */ error: PropTypes.string, + + /** Whether we should show the view that the bank account was successfully added */ shouldShowSuccess: PropTypes.bool, + + /** Whether the form is loading */ isLoading: PropTypes.bool, + + /** The account ID of the selected bank account from Plaid */ + plaidAccountID: PropTypes.string, }), }; @@ -42,6 +49,7 @@ const defaultProps = { error: '', shouldShowSuccess: false, isLoading: false, + plaidAccountID: '', }, }; @@ -49,20 +57,12 @@ class AddPersonalBankAccountPage extends React.Component { constructor(props) { super(props); - this.getErrorText = this.getErrorText.bind(this); - this.clearError = this.clearError.bind(this); this.validate = this.validate.bind(this); this.submit = this.submit.bind(this); this.state = { - selectedPlaidBankAccount: undefined, - password: '', + selectedPlaidAccountID: this.props.personalBankAccount.plaidAccountID, }; - - this.formHelper = new FormHelper({ - errorPath: 'personalBankAccount.errorFields', - setErrors: errorFields => ReimbursementAccount.setPersonalBankAccountFormValidationErrorFields(errorFields), - }); } componentDidMount() { @@ -70,61 +70,34 @@ class AddPersonalBankAccountPage extends React.Component { } /** + * @param {Object} values - form input values passed by the Form component + * @param {Object} values.password The password of the user adding the bank account, for security. * @returns {Object} */ - getErrors() { - return this.formHelper.getErrors(this.props); - } + validate(values) { + const errors = {}; - /** - * @param {String} fieldName - * @returns {String} - */ - getErrorText(fieldName) { - const errors = this.getErrors(); - if (!errors[fieldName]) { - return ''; + if (_.isEmpty(values.password)) { + errors.password = `${this.props.translate('common.password')} ${this.props.translate('common.isRequiredField')}.`; } - return this.props.translate(this.errorTranslationKeys[fieldName]); - } - - /** - * @param {String} path - */ - clearError(path) { - this.formHelper.clearError(this.props, path); + return errors; } /** - * @returns {Boolean} + * @param {Object} values - form input values passed by the Form component + * @param {Object} values.password The password of the user adding the bank account, for security. */ - validate() { - const errors = {}; - if (_.isUndefined(this.state.selectedPlaidBankAccount)) { - errors.selectedBank = true; - } - - if (this.props.isPasswordRequired && _.isEmpty(this.state.password)) { - errors.password = true; - } - - ReimbursementAccount.setPersonalBankAccountFormValidationErrorFields(errors); - return _.isEmpty(errors); - } - - submit() { - if (!this.validate()) { - return; - } + submit(values) { + const selectedPlaidBankAccount = _.findWhere(lodashGet(this.props.plaidData, 'bankAccounts', []), { + plaidAccountID: this.state.selectedPlaidAccountID, + }); - BankAccounts.addPersonalBankAccount(this.state.selectedPlaidBankAccount, this.state.password); + BankAccounts.addPersonalBankAccount(selectedPlaidBankAccount, values.password); } render() { const shouldShowSuccess = lodashGet(this.props, 'personalBankAccount.shouldShowSuccess', false); - const error = lodashGet(this.props, 'personalBankAccount.error', ''); - const isLoading = lodashGet(this.props, 'personalBankAccount.isLoading', false); return ( @@ -163,44 +136,36 @@ class AddPersonalBankAccountPage extends React.Component { ) : ( - - +
+ <> { - this.setState({ - selectedPlaidBankAccount: params.selectedPlaidBankAccount, - }); + onSelect={(selectedPlaidAccountID) => { + this.setState({selectedPlaidAccountID}); }} onExitPlaid={Navigation.goBack} receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} + selectedPlaidAccountID={this.state.selectedPlaidAccountID} /> - {!_.isUndefined(this.state.selectedPlaidBankAccount) && ( - - this.setState({password: text})} - errorText={this.getErrorText('password')} - hasError={this.getErrors().password} - /> - - )} - - {!_.isUndefined(this.state.selectedPlaidBankAccount) && ( - - )} - + )} + + )}
); @@ -216,5 +181,8 @@ export default compose( personalBankAccount: { key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, }, + plaidData: { + key: ONYXKEYS.PLAID_DATA, + }, }), )(AddPersonalBankAccountPage); diff --git a/src/pages/EnablePayments/ActivateStep.js b/src/pages/EnablePayments/ActivateStep.js index 21b28063bdfb..51d56fb0cac1 100644 --- a/src/pages/EnablePayments/ActivateStep.js +++ b/src/pages/EnablePayments/ActivateStep.js @@ -35,69 +35,55 @@ const defaultProps = { }, }; -class ActivateStep extends React.Component { - constructor(props) { - super(props); +const ActivateStep = (props) => { + const isGoldWallet = props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD; + const illustration = isGoldWallet ? Illustrations.TadaBlue : Illustrations.ReceiptsSearchYellow; + const continueButtonText = props.walletTerms.chatReportID ? props.translate('activateStep.continueToPayment') : props.translate('activateStep.continueToTransfer'); - this.renderGoldWalletActivationStep = this.renderGoldWalletActivationStep.bind(this); - } - - renderGoldWalletActivationStep() { - // The text of the "Continue" button depends on whether the action comes from an IOU (i.e. with an attached chat), or a balance transfer - const continueButtonText = this.props.walletTerms.chatReportID ? this.props.translate('activateStep.continueToPayment') : this.props.translate('activateStep.continueToTransfer'); - return ( - <> + return ( + <> + Navigation.dismissModal()} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> + - {this.props.translate('activateStep.activatedTitle')} + {props.translate(`activateStep.${isGoldWallet ? 'activated' : 'checkBackLater'}Title`)} - {this.props.translate('activateStep.activatedMessage')} + {props.translate(`activateStep.${isGoldWallet ? 'activated' : 'checkBackLater'}Message`)} - -